10 KiB
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
defaultHoldpreference (DID or URL) - Created automatically on first authentication
- Managed via web UI or ATProto client
Hold Discovery Priority:
- User's sailor profile
defaultHold(if set) - User's own hold records (
io.atcr.hold) - legacy - AppView's
default_hold_didconfiguration
Sailor Profile Record
{
"$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 timestampupdatedAt(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:
// 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
defaultHoldset 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
updatedAttimestamp
ATProto Client Management
Users can also manage their profile using standard ATProto tools:
Get profile:
atproto get-record \
--collection io.atcr.sailor.profile \
--rkey self
Update profile:
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):
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):
{
"defaultHold": "https://hold.example.com"
}
New format (preferred):
{
"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()):
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:
{
"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:
{
"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:
{
"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):
{
"defaultHold": ""
}
Behavior:
- Skips profile's defaultHold (set to empty/null)
- Falls back to
io.atcr.holdrecords 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>/<image>:<tag>
- 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:
{
"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
# 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.holdrecords can set profile.defaultHold - Profile takes priority over hold records
Future Improvements
- Multi-hold support - Set different holds for different repositories
- Hold suggestions - Recommend holds based on geography/cost
- Hold migration tools - Move blobs between holds
- Profile templates - Pre-configured profiles for teams
- Hold analytics - Show storage usage per hold in UI
References
- BYOS.md - BYOS deployment and hold management
- EMBEDDED_PDS.md - Hold's embedded PDS architecture
- CREW_ACCESS_CONTROL.md - Crew membership and permissions
- ATProto Lexicon Spec