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.

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#