A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
fork

Configure Feed

Select the types of activity you want to include in your feed.

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)