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

395 lines
10 KiB
Markdown

# 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>/<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:
```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)