Files
at-container-registry/docs/SAILOR.md

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 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

{
  "$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:

// 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:

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.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>/<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.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