# Sailor Profile System ## Overview The sailor profile system allows users to choose which hold (storage service) to use for their container images. This enables: - **Personal holds** - Use your own S3/Storj/Minio storage - **Shared holds** - Join a team or community hold - **Default holds** - Use AppView's default storage (free tier) - **Transparent infrastructure** - Hold choice doesn't affect image URL ## Concepts **Sailor Profile** (`io.atcr.sailor.profile`): - Record stored in user's PDS - Contains `defaultHold` preference (DID or URL) - Created automatically on first authentication - Managed via web UI or ATProto client **Hold Discovery Priority**: 1. User's sailor profile `defaultHold` (if set) 2. User's own hold records (`io.atcr.hold`) - legacy 3. AppView's `default_hold_did` configuration ## Sailor Profile Record ```json { "$type": "io.atcr.sailor.profile", "defaultHold": "did:web:hold.example.com", "createdAt": "2025-10-02T12:00:00Z", "updatedAt": "2025-10-02T12:00:00Z" } ``` **Fields:** - `defaultHold` (string, optional) - Hold DID or URL (auto-normalized to DID) - `createdAt` (datetime, required) - Profile creation timestamp - `updatedAt` (datetime, required) - Last update timestamp **Record key:** Always `"self"` (only one profile per user) **Collection:** `io.atcr.sailor.profile` ## Profile Management ### Automatic Creation Profiles are created automatically on first authentication: ```go // During OAuth login or Basic Auth token exchange func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { // ... OAuth flow ... // Create ATProto client with user's OAuth session client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) // Ensure profile exists (creates with AppView's default if not) err := atproto.EnsureProfile(ctx, client, appViewDefaultHoldDID) } ``` **Behavior:** - If profile exists → no-op - If profile doesn't exist → creates with `defaultHold` set to AppView's default - If AppView has no default configured → creates with empty `defaultHold` ### Web UI Management Users can update their profile via the settings page (`/settings`): **View current profile:** ``` GET /settings → Shows current defaultHold value ``` **Update defaultHold:** ``` POST /api/settings/update-hold Form data: hold_endpoint=did:web:team-hold.fly.dev → Updates sailor profile in user's PDS → Returns success confirmation ``` **Implementation** (`pkg/appview/handlers/settings.go`): - Requires OAuth session (user must be logged in) - Fetches existing profile or creates new one - Normalizes URLs to DIDs automatically - Updates `updatedAt` timestamp ### ATProto Client Management Users can also manage their profile using standard ATProto tools: **Get profile:** ```bash atproto get-record \ --collection io.atcr.sailor.profile \ --rkey self ``` **Update profile:** ```bash atproto put-record \ --collection io.atcr.sailor.profile \ --rkey self \ --value '{ "$type": "io.atcr.sailor.profile", "defaultHold": "did:web:my-hold.example.com", "updatedAt": "2025-10-20T12:00:00Z" }' ``` **Clear default hold** (opt out): ```bash atproto put-record \ --collection io.atcr.sailor.profile \ --rkey self \ --value '{ "$type": "io.atcr.sailor.profile", "defaultHold": "", "updatedAt": "2025-10-20T12:00:00Z" }' ``` ## URL-to-DID Migration The system automatically migrates old URL-based `defaultHold` values to DID format for consistency: **Old format (deprecated):** ```json { "defaultHold": "https://hold.example.com" } ``` **New format (preferred):** ```json { "defaultHold": "did:web:hold.example.com" } ``` **Migration behavior:** - `GetProfile()` detects URL format automatically - Converts URL → DID transparently (strips protocol, converts to `did:web:`) - Persists migration to PDS in background goroutine - Uses locks to prevent duplicate migrations - Completely transparent to user **Why DIDs?** - **Portable**: DIDs work offline, URLs require DNS - **Canonical**: One DID per hold, multiple URLs possible - **Standard**: ATProto uses DIDs for identity ## Hold Discovery Flow When a user pushes an image, AppView discovers which hold to use: ``` 1. User: docker push atcr.io/alice/myapp:latest 2. AppView resolves alice → did:plc:alice123 3. AppView calls findHoldDID(did, pdsEndpoint): a. Query alice's PDS for io.atcr.sailor.profile/self b. If profile.defaultHold is set → use it c. Else check alice's io.atcr.hold records (legacy) d. Else use AppView's default_hold_did 4. Found: alice.profile.defaultHold = "did:web:team-hold.fly.dev" 5. AppView uses team-hold.fly.dev for blob storage 6. Manifest stored in alice's PDS includes: - holdDid: "did:web:team-hold.fly.dev" (for future pulls) - holdEndpoint: "https://team-hold.fly.dev" (backward compat) ``` **Implementation** (`pkg/appview/middleware/registry.go:findHoldDID()`): ```go func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string { client := atproto.NewClient(pdsEndpoint, did, "") // 1. Check sailor profile profile, err := atproto.GetProfile(ctx, client) if profile != nil && profile.DefaultHold != "" { return profile.DefaultHold // DID or URL (auto-normalized) } // 2. Check own hold records (legacy) records, _ := client.ListRecords(ctx, "io.atcr.hold", 10) for _, record := range records { // Return first hold's endpoint if holdRecord.Endpoint != "" { return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint) } } // 3. Use AppView default return nr.defaultHoldDID } ``` ## Use Cases ### 1. Default Hold (Free Tier) User doesn't need to do anything: ``` 1. User authenticates to atcr.io 2. Profile created with defaultHold = AppView's default 3. User pushes images → blobs go to default hold ``` **Profile:** ```json { "defaultHold": "did:web:hold01.atcr.io" } ``` ### 2. Join Team Hold User joins a shared team hold: ``` 1. Team admin deploys hold service (did:web:team-hold.fly.dev) 2. Team admin adds user to crew (via hold's PDS) 3. User updates profile: - Via web UI: /settings → set hold to "did:web:team-hold.fly.dev" - Or via ATProto client: put-record 4. User pushes images → blobs go to team hold ``` **Profile:** ```json { "defaultHold": "did:web:team-hold.fly.dev" } ``` **Benefits:** - Team pays for storage (not individual users) - Centralized access control - Shared bandwidth limits ### 3. Personal Hold (BYOS) User deploys their own hold: ``` 1. User deploys hold service to Fly.io (did:web:alice-hold.fly.dev) 2. Hold auto-creates captain + crew records on first run 3. User updates profile to use their hold 4. User pushes images → blobs go to personal hold ``` **Profile:** ```json { "defaultHold": "did:web:alice-hold.fly.dev" } ``` **Benefits:** - Full control over storage - Choose storage provider (S3, Storj, Minio, etc.) - No quotas/limits (except what you pay for) ### 4. Opt Out of Defaults User wants to use only their own hold records (legacy model): ```json { "defaultHold": "" } ``` **Behavior:** - Skips profile's defaultHold (set to empty/null) - Falls back to `io.atcr.hold` records in user's PDS - If no hold records found → uses AppView default ## Architecture Notes ### Why Sailor Profile? **Problem solved:** - Users can be crew members of multiple holds - Need explicit way to choose which hold to use - Want to support both personal and shared holds **Without sailor profile:** ``` Alice is crew of: - team-hold.fly.dev (team storage) - community-hold.fly.dev (community storage) Which one should AppView use? 🤔 ``` **With sailor profile:** ``` Alice sets profile.defaultHold = "did:web:team-hold.fly.dev" → AppView knows to use team hold → Alice can change anytime via settings ``` ### Image Ownership vs Hold Choice **Key insight:** Image ownership stays with the user, hold is just infrastructure. **URL structure:** `atcr.io//:` - Owner = Alice (clear ownership) - Hold = Team storage (infrastructure detail) **Analogy:** Like choosing an S3 region - Your files, your ownership - Region is just where bits live - Can move regions without changing ownership ### Historical Hold References Manifests store `holdDid` for immutable blob location tracking: ```json { "digest": "sha256:abc123", "holdDid": "did:web:team-hold.fly.dev", "holdEndpoint": "https://team-hold.fly.dev", "layers": [...] } ``` **Why store hold in manifest?** - Pull uses historical reference (not re-discovered) - Image stays pullable even if user changes defaultHold - Blobs fetched from where they were originally pushed - Immutable references (manifests don't change) **Hold cache:** - In-memory cache: `(userDID, repository) → holdDid` - TTL: 10 minutes (covers typical pull operation) - Avoids re-querying PDS for every blob ## Configuration ### AppView Configuration ```bash # Default hold for new users ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io # Test mode: fallback to default if user's hold unreachable ATCR_TEST_MODE=false ``` **Test mode behavior:** - Checks if user's defaultHold is reachable (HTTP/HTTPS) - Falls back to AppView default if unreachable - Useful for local development (prevents errors from unreachable holds) ### Legacy Support **Old hold registration model** (`io.atcr.hold` records in user's PDS): - Still supported for backward compatibility - Checked if profile.defaultHold is empty - New deployments should use sailor profiles instead **Migration path:** - Existing holds continue to work - Users with `io.atcr.hold` records can set profile.defaultHold - Profile takes priority over hold records ## Future Improvements 1. **Multi-hold support** - Set different holds for different repositories 2. **Hold suggestions** - Recommend holds based on geography/cost 3. **Hold migration tools** - Move blobs between holds 4. **Profile templates** - Pre-configured profiles for teams 5. **Hold analytics** - Show storage usage per hold in UI ## References - [BYOS.md](./BYOS.md) - BYOS deployment and hold management - [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Hold's embedded PDS architecture - [CREW_ACCESS_CONTROL.md](./CREW_ACCESS_CONTROL.md) - Crew membership and permissions - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)