mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-29 12:36:58 +00:00
395 lines
10 KiB
Markdown
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)
|