A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1# Sailor Profile System
2
3## Overview
4
5The sailor profile system allows users to choose which hold (storage service) to use for their container images. This enables:
6- **Personal holds** - Use your own S3/Storj/Minio storage
7- **Shared holds** - Join a team or community hold
8- **Default holds** - Use AppView's default storage (free tier)
9- **Transparent infrastructure** - Hold choice doesn't affect image URL
10
11## Concepts
12
13**Sailor Profile** (`io.atcr.sailor.profile`):
14- Record stored in user's PDS
15- Contains `defaultHold` preference (DID or URL)
16- Created automatically on first authentication
17- Managed via web UI or ATProto client
18
19**Hold Discovery Priority**:
201. User's sailor profile `defaultHold` (if set)
212. User's own hold records (`io.atcr.hold`) - legacy
223. AppView's `default_hold_did` configuration
23
24## Sailor Profile Record
25
26```json
27{
28 "$type": "io.atcr.sailor.profile",
29 "defaultHold": "did:web:hold.example.com",
30 "createdAt": "2025-10-02T12:00:00Z",
31 "updatedAt": "2025-10-02T12:00:00Z"
32}
33```
34
35**Fields:**
36- `defaultHold` (string, optional) - Hold DID or URL (auto-normalized to DID)
37- `createdAt` (datetime, required) - Profile creation timestamp
38- `updatedAt` (datetime, required) - Last update timestamp
39
40**Record key:** Always `"self"` (only one profile per user)
41
42**Collection:** `io.atcr.sailor.profile`
43
44## Profile Management
45
46### Automatic Creation
47
48Profiles are created automatically on first authentication:
49
50```go
51// During OAuth login or Basic Auth token exchange
52func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
53 // ... OAuth flow ...
54
55 // Create ATProto client with user's OAuth session
56 client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
57
58 // Ensure profile exists (creates with AppView's default if not)
59 err := atproto.EnsureProfile(ctx, client, appViewDefaultHoldDID)
60}
61```
62
63**Behavior:**
64- If profile exists → no-op
65- If profile doesn't exist → creates with `defaultHold` set to AppView's default
66- If AppView has no default configured → creates with empty `defaultHold`
67
68### Web UI Management
69
70Users can update their profile via the settings page (`/settings`):
71
72**View current profile:**
73```
74GET /settings
75→ Shows current defaultHold value
76```
77
78**Update defaultHold:**
79```
80POST /api/settings/update-hold
81Form data: hold_endpoint=did:web:team-hold.fly.dev
82
83→ Updates sailor profile in user's PDS
84→ Returns success confirmation
85```
86
87**Implementation** (`pkg/appview/handlers/settings.go`):
88- Requires OAuth session (user must be logged in)
89- Fetches existing profile or creates new one
90- Normalizes URLs to DIDs automatically
91- Updates `updatedAt` timestamp
92
93### ATProto Client Management
94
95Users can also manage their profile using standard ATProto tools:
96
97**Get profile:**
98```bash
99atproto get-record \
100 --collection io.atcr.sailor.profile \
101 --rkey self
102```
103
104**Update profile:**
105```bash
106atproto put-record \
107 --collection io.atcr.sailor.profile \
108 --rkey self \
109 --value '{
110 "$type": "io.atcr.sailor.profile",
111 "defaultHold": "did:web:my-hold.example.com",
112 "updatedAt": "2025-10-20T12:00:00Z"
113 }'
114```
115
116**Clear default hold** (opt out):
117```bash
118atproto put-record \
119 --collection io.atcr.sailor.profile \
120 --rkey self \
121 --value '{
122 "$type": "io.atcr.sailor.profile",
123 "defaultHold": "",
124 "updatedAt": "2025-10-20T12:00:00Z"
125 }'
126```
127
128## URL-to-DID Migration
129
130The system automatically migrates old URL-based `defaultHold` values to DID format for consistency:
131
132**Old format (deprecated):**
133```json
134{
135 "defaultHold": "https://hold.example.com"
136}
137```
138
139**New format (preferred):**
140```json
141{
142 "defaultHold": "did:web:hold.example.com"
143}
144```
145
146**Migration behavior:**
147- `GetProfile()` detects URL format automatically
148- Converts URL → DID transparently (strips protocol, converts to `did:web:`)
149- Persists migration to PDS in background goroutine
150- Uses locks to prevent duplicate migrations
151- Completely transparent to user
152
153**Why DIDs?**
154- **Portable**: DIDs work offline, URLs require DNS
155- **Canonical**: One DID per hold, multiple URLs possible
156- **Standard**: ATProto uses DIDs for identity
157
158## Hold Discovery Flow
159
160When a user pushes an image, AppView discovers which hold to use:
161
162```
1631. User: docker push atcr.io/alice/myapp:latest
164
1652. AppView resolves alice → did:plc:alice123
166
1673. AppView calls findHoldDID(did, pdsEndpoint):
168 a. Query alice's PDS for io.atcr.sailor.profile/self
169 b. If profile.defaultHold is set → use it
170 c. Else check alice's io.atcr.hold records (legacy)
171 d. Else use AppView's default_hold_did
172
1734. Found: alice.profile.defaultHold = "did:web:team-hold.fly.dev"
174
1755. AppView uses team-hold.fly.dev for blob storage
176
1776. Manifest stored in alice's PDS includes:
178 - holdDid: "did:web:team-hold.fly.dev" (for future pulls)
179 - holdEndpoint: "https://team-hold.fly.dev" (backward compat)
180```
181
182**Implementation** (`pkg/appview/middleware/registry.go:findHoldDID()`):
183
184```go
185func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
186 client := atproto.NewClient(pdsEndpoint, did, "")
187
188 // 1. Check sailor profile
189 profile, err := atproto.GetProfile(ctx, client)
190 if profile != nil && profile.DefaultHold != "" {
191 return profile.DefaultHold // DID or URL (auto-normalized)
192 }
193
194 // 2. Check own hold records (legacy)
195 records, _ := client.ListRecords(ctx, "io.atcr.hold", 10)
196 for _, record := range records {
197 // Return first hold's endpoint
198 if holdRecord.Endpoint != "" {
199 return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
200 }
201 }
202
203 // 3. Use AppView default
204 return nr.defaultHoldDID
205}
206```
207
208## Use Cases
209
210### 1. Default Hold (Free Tier)
211
212User doesn't need to do anything:
213
214```
2151. User authenticates to atcr.io
2162. Profile created with defaultHold = AppView's default
2173. User pushes images → blobs go to default hold
218```
219
220**Profile:**
221```json
222{
223 "defaultHold": "did:web:hold01.atcr.io"
224}
225```
226
227### 2. Join Team Hold
228
229User joins a shared team hold:
230
231```
2321. Team admin deploys hold service (did:web:team-hold.fly.dev)
2332. Team admin adds user to crew (via hold's PDS)
2343. User updates profile:
235 - Via web UI: /settings → set hold to "did:web:team-hold.fly.dev"
236 - Or via ATProto client: put-record
2374. User pushes images → blobs go to team hold
238```
239
240**Profile:**
241```json
242{
243 "defaultHold": "did:web:team-hold.fly.dev"
244}
245```
246
247**Benefits:**
248- Team pays for storage (not individual users)
249- Centralized access control
250- Shared bandwidth limits
251
252### 3. Personal Hold (BYOS)
253
254User deploys their own hold:
255
256```
2571. User deploys hold service to Fly.io (did:web:alice-hold.fly.dev)
2582. Hold auto-creates captain + crew records on first run
2593. User updates profile to use their hold
2604. User pushes images → blobs go to personal hold
261```
262
263**Profile:**
264```json
265{
266 "defaultHold": "did:web:alice-hold.fly.dev"
267}
268```
269
270**Benefits:**
271- Full control over storage
272- Choose storage provider (S3, Storj, Minio, etc.)
273- No quotas/limits (except what you pay for)
274
275### 4. Opt Out of Defaults
276
277User wants to use only their own hold records (legacy model):
278
279```json
280{
281 "defaultHold": ""
282}
283```
284
285**Behavior:**
286- Skips profile's defaultHold (set to empty/null)
287- Falls back to `io.atcr.hold` records in user's PDS
288- If no hold records found → uses AppView default
289
290## Architecture Notes
291
292### Why Sailor Profile?
293
294**Problem solved:**
295- Users can be crew members of multiple holds
296- Need explicit way to choose which hold to use
297- Want to support both personal and shared holds
298
299**Without sailor profile:**
300```
301Alice is crew of:
302- team-hold.fly.dev (team storage)
303- community-hold.fly.dev (community storage)
304
305Which one should AppView use? 🤔
306```
307
308**With sailor profile:**
309```
310Alice sets profile.defaultHold = "did:web:team-hold.fly.dev"
311→ AppView knows to use team hold
312→ Alice can change anytime via settings
313```
314
315### Image Ownership vs Hold Choice
316
317**Key insight:** Image ownership stays with the user, hold is just infrastructure.
318
319**URL structure:** `atcr.io/<owner>/<image>:<tag>`
320- Owner = Alice (clear ownership)
321- Hold = Team storage (infrastructure detail)
322
323**Analogy:** Like choosing an S3 region
324- Your files, your ownership
325- Region is just where bits live
326- Can move regions without changing ownership
327
328### Historical Hold References
329
330Manifests store `holdDid` for immutable blob location tracking:
331
332```json
333{
334 "digest": "sha256:abc123",
335 "holdDid": "did:web:team-hold.fly.dev",
336 "holdEndpoint": "https://team-hold.fly.dev",
337 "layers": [...]
338}
339```
340
341**Why store hold in manifest?**
342- Pull uses historical reference (not re-discovered)
343- Image stays pullable even if user changes defaultHold
344- Blobs fetched from where they were originally pushed
345- Immutable references (manifests don't change)
346
347**Hold cache:**
348- In-memory cache: `(userDID, repository) → holdDid`
349- TTL: 10 minutes (covers typical pull operation)
350- Avoids re-querying PDS for every blob
351
352## Configuration
353
354### AppView Configuration
355
356```bash
357# Default hold for new users
358ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io
359
360# Test mode: fallback to default if user's hold unreachable
361ATCR_TEST_MODE=false
362```
363
364**Test mode behavior:**
365- Checks if user's defaultHold is reachable (HTTP/HTTPS)
366- Falls back to AppView default if unreachable
367- Useful for local development (prevents errors from unreachable holds)
368
369### Legacy Support
370
371**Old hold registration model** (`io.atcr.hold` records in user's PDS):
372- Still supported for backward compatibility
373- Checked if profile.defaultHold is empty
374- New deployments should use sailor profiles instead
375
376**Migration path:**
377- Existing holds continue to work
378- Users with `io.atcr.hold` records can set profile.defaultHold
379- Profile takes priority over hold records
380
381## Future Improvements
382
3831. **Multi-hold support** - Set different holds for different repositories
3842. **Hold suggestions** - Recommend holds based on geography/cost
3853. **Hold migration tools** - Move blobs between holds
3864. **Profile templates** - Pre-configured profiles for teams
3875. **Hold analytics** - Show storage usage per hold in UI
388
389## References
390
391- [BYOS.md](./BYOS.md) - BYOS deployment and hold management
392- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Hold's embedded PDS architecture
393- [CREW_ACCESS_CONTROL.md](./CREW_ACCESS_CONTROL.md) - Crew membership and permissions
394- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)