···5151export STORAGE_ROOT_DIR=/tmp/atcr-hold
5252export HOLD_OWNER=did:plc:your-did-here
5353./bin/atcr-hold
5454-# Check logs for OAuth URL, visit in browser to complete registration
5454+# Hold starts immediately with embedded PDS
5555```
56565757## Architecture Overview
···757576762. **Hold Service** (`cmd/hold`) - Optional BYOS component
7777 - Lightweight HTTP server for presigned URLs
7878+ - Embedded PDS with captain + crew records
7879 - Supports S3, Storj, Minio, filesystem, etc.
7979- - Authorization based on PDS records (hold.public, crew records)
8080- - Auto-registration via OAuth
8080+ - Authorization based on captain record (public, allowAllCrew)
8181+ - Self-describing via DID resolution
8182 - Configured entirely via environment variables
828383843. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth
···357358358359Write access:
359360- Hold owner OR crew members only
360360-- Verified via `io.atcr.hold.crew` records in owner's PDS
361361+- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
361362362363Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet).
363364···484485- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
485486- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
486487- `HOLD_PUBLIC` - Allow public reads (default: false)
487487-- `HOLD_OWNER` - DID for auto-registration (optional)
488488+- `HOLD_OWNER` - DID for captain record creation (optional)
489489+- `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false)
488490489491**Credential Helper**:
490492- Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store)
···539541- Client methods are consistent across authorization, token exchange, and refresh flows
540542541543**Adding BYOS support for a user**:
542542-1. User sets environment variables (storage credentials, public URL)
543543-2. User runs hold service with `HOLD_OWNER` set - auto-registration via OAuth
544544-3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS
545545-4. AppView automatically queries PDS and routes blobs to user's storage
546546-5. No AppView changes needed - fully decentralized
544544+1. User sets environment variables (storage credentials, public URL, HOLD_OWNER)
545545+2. User runs hold service - creates captain + crew records in embedded PDS
546546+3. Hold creates `io.atcr.hold.captain` + `io.atcr.hold.crew` records
547547+4. User sets sailor profile `defaultHold` to point to their hold
548548+5. AppView automatically queries hold's PDS and routes blobs to user's storage
549549+6. No AppView changes needed - fully decentralized
547550548551**Supporting a new storage backend**:
5495521. Ensure driver is registered in `cmd/hold/main.go` imports
+3-3
docs/EMBEDDED_PDS.md
···560560- ✅ Discoverable via service type
561561562562**OAuth implications:**
563563-- OAuth registration flow no longer needed (hold is self-describing)
564564-- OAuth code kept for backward compatibility with legacy registration records
565565-- Future: Remove OAuth after migration period
563563+- ✅ OAuth registration removed completely (hold is self-describing)
564564+- Hold creates captain + crew records in its own embedded PDS
565565+- No cross-PDS writes or OAuth flows needed
566566567567### 7. Multi-Tenancy
568568
+138
pkg/appview/db/hold_store.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "time"
77+)
88+99+// HoldCaptainRecord represents a cached captain record from a hold's PDS
1010+type HoldCaptainRecord struct {
1111+ HoldDID string
1212+ OwnerDID string
1313+ Public bool
1414+ AllowAllCrew bool
1515+ DeployedAt string
1616+ Region string
1717+ Provider string
1818+ UpdatedAt time.Time
1919+}
2020+2121+// GetCaptainRecord retrieves a captain record from the cache
2222+// Returns nil if not found (cache miss)
2323+func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
2424+ query := `
2525+ SELECT hold_did, owner_did, public, allow_all_crew,
2626+ deployed_at, region, provider, updated_at
2727+ FROM hold_captain_records
2828+ WHERE hold_did = ?
2929+ `
3030+3131+ var record HoldCaptainRecord
3232+ var deployedAt, region, provider sql.NullString
3333+3434+ err := db.QueryRow(query, holdDID).Scan(
3535+ &record.HoldDID,
3636+ &record.OwnerDID,
3737+ &record.Public,
3838+ &record.AllowAllCrew,
3939+ &deployedAt,
4040+ ®ion,
4141+ &provider,
4242+ &record.UpdatedAt,
4343+ )
4444+4545+ if err == sql.ErrNoRows {
4646+ return nil, nil // Cache miss - not an error
4747+ }
4848+4949+ if err != nil {
5050+ return nil, fmt.Errorf("failed to query captain record: %w", err)
5151+ }
5252+5353+ // Handle nullable fields
5454+ if deployedAt.Valid {
5555+ record.DeployedAt = deployedAt.String
5656+ }
5757+ if region.Valid {
5858+ record.Region = region.String
5959+ }
6060+ if provider.Valid {
6161+ record.Provider = provider.String
6262+ }
6363+6464+ return &record, nil
6565+}
6666+6767+// UpsertCaptainRecord inserts or updates a captain record in the cache
6868+func UpsertCaptainRecord(db *sql.DB, record *HoldCaptainRecord) error {
6969+ query := `
7070+ INSERT INTO hold_captain_records (
7171+ hold_did, owner_did, public, allow_all_crew,
7272+ deployed_at, region, provider, updated_at
7373+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
7474+ ON CONFLICT(hold_did) DO UPDATE SET
7575+ owner_did = excluded.owner_did,
7676+ public = excluded.public,
7777+ allow_all_crew = excluded.allow_all_crew,
7878+ deployed_at = excluded.deployed_at,
7979+ region = excluded.region,
8080+ provider = excluded.provider,
8181+ updated_at = excluded.updated_at
8282+ `
8383+8484+ _, err := db.Exec(query,
8585+ record.HoldDID,
8686+ record.OwnerDID,
8787+ record.Public,
8888+ record.AllowAllCrew,
8989+ nullString(record.DeployedAt),
9090+ nullString(record.Region),
9191+ nullString(record.Provider),
9292+ record.UpdatedAt,
9393+ )
9494+9595+ if err != nil {
9696+ return fmt.Errorf("failed to upsert captain record: %w", err)
9797+ }
9898+9999+ return nil
100100+}
101101+102102+// ListHoldDIDs returns all known hold DIDs from the cache
103103+func ListHoldDIDs(db *sql.DB) ([]string, error) {
104104+ query := `
105105+ SELECT hold_did
106106+ FROM hold_captain_records
107107+ ORDER BY updated_at DESC
108108+ `
109109+110110+ rows, err := db.Query(query)
111111+ if err != nil {
112112+ return nil, fmt.Errorf("failed to query hold DIDs: %w", err)
113113+ }
114114+ defer rows.Close()
115115+116116+ var holdDIDs []string
117117+ for rows.Next() {
118118+ var holdDID string
119119+ if err := rows.Scan(&holdDID); err != nil {
120120+ return nil, fmt.Errorf("failed to scan hold DID: %w", err)
121121+ }
122122+ holdDIDs = append(holdDIDs, holdDID)
123123+ }
124124+125125+ if err := rows.Err(); err != nil {
126126+ return nil, fmt.Errorf("error iterating hold DIDs: %w", err)
127127+ }
128128+129129+ return holdDIDs, nil
130130+}
131131+132132+// nullString converts a string to sql.NullString
133133+func nullString(s string) sql.NullString {
134134+ if s == "" {
135135+ return sql.NullString{Valid: false}
136136+ }
137137+ return sql.NullString{String: s, Valid: true}
138138+}
···11+description: Add hold_captain_records table for caching hold security settings
22+query: |
33+ CREATE TABLE IF NOT EXISTS hold_captain_records (
44+ hold_did TEXT PRIMARY KEY,
55+ owner_did TEXT NOT NULL,
66+ public BOOLEAN NOT NULL,
77+ allow_all_crew BOOLEAN NOT NULL,
88+ deployed_at TEXT,
99+ region TEXT,
1010+ provider TEXT,
1111+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1212+ );
1313+ CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
+12
pkg/appview/db/schema.go
···167167);
168168CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository);
169169CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did);
170170+171171+CREATE TABLE IF NOT EXISTS hold_captain_records (
172172+ hold_did TEXT PRIMARY KEY,
173173+ owner_did TEXT NOT NULL,
174174+ public BOOLEAN NOT NULL,
175175+ allow_all_crew BOOLEAN NOT NULL,
176176+ deployed_at TEXT,
177177+ region TEXT,
178178+ provider TEXT,
179179+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
180180+);
181181+CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
170182`
171183172184// InitDB initializes the SQLite database with the schema
+4-1
pkg/appview/storage/routing_repository.go
···5454 // Ensure blob store is created first (needed for label extraction during push)
5555 blobStore := r.Blobs(ctx)
56565757- r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did, blobStore, r.database)
5757+ // Resolve hold endpoint URL to DID
5858+ holdDID := atproto.ResolveHoldDIDFromURL(r.storageEndpoint)
5959+6060+ r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, holdDID, r.did, blobStore, r.database)
5861 }
59626063 // After any manifest operation, cache the hold endpoint for blob fetches
+34-3
pkg/atproto/lexicon.go
···4141 // Digest is the content digest (e.g., "sha256:abc123...")
4242 Digest string `json:"digest"`
43434444- // HoldEndpoint is the hold service endpoint where blobs are stored
4444+ // HoldDID is the DID of the hold service where blobs are stored
4545+ // This is the primary reference for hold resolution
4646+ // e.g., "did:web:hold01.atcr.io"
4747+ HoldDID string `json:"holdDid,omitempty"`
4848+4949+ // HoldEndpoint is the hold service endpoint URL where blobs are stored (DEPRECATED)
5050+ // Kept for backward compatibility with manifests created before DID migration
5151+ // New manifests should use HoldDID instead
4552 // This is a historical reference that doesn't change even if user's default hold changes
4646- HoldEndpoint string `json:"holdEndpoint"`
5353+ HoldEndpoint string `json:"holdEndpoint,omitempty"`
47544855 // MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json")
4956 MediaType string `json:"mediaType"`
···261268 // Type should be "io.atcr.sailor.profile"
262269 Type string `json:"$type"`
263270264264- // DefaultHold is the default hold endpoint for blob storage
271271+ // DefaultHold is the default hold DID for blob storage
272272+ // Can be a DID (e.g., "did:web:hold01.atcr.io") or legacy URL
273273+ // URLs are migrated to DIDs on user login
265274 // If null/empty, user has opted out of defaults
266275 DefaultHold string `json:"defaultHold,omitempty"`
267276···340349341350 return parts[0], parts[1], nil
342351}
352352+353353+// ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID
354354+// For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io
355355+func ResolveHoldDIDFromURL(holdURL string) string {
356356+ // Handle empty URLs
357357+ if holdURL == "" {
358358+ return ""
359359+ }
360360+361361+ // Parse URL to get hostname
362362+ holdURL = strings.TrimPrefix(holdURL, "http://")
363363+ holdURL = strings.TrimPrefix(holdURL, "https://")
364364+ holdURL = strings.TrimSuffix(holdURL, "/")
365365+366366+ // Extract hostname (remove path if present)
367367+ parts := strings.Split(holdURL, "/")
368368+ hostname := parts[0]
369369+370370+ // Convert to did:web
371371+ // did:web uses hostname directly (port included if non-standard)
372372+ return "did:web:" + hostname
373373+}
+34-5
pkg/atproto/manifest_store.go
···2323type ManifestStore struct {
2424 client *Client
2525 repository string
2626- holdEndpoint string // Hold service endpoint where blobs are stored (for push)
2626+ holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated)
2727+ holdDID string // Hold service DID (primary reference)
2728 did string // User's DID for cache key
2829 lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull)
2930 blobStore distribution.BlobStore // Blob store for fetching config during push
···3132}
32333334// NewManifestStore creates a new ATProto-backed manifest store
3434-func NewManifestStore(client *Client, repository string, holdEndpoint string, did string, blobStore distribution.BlobStore, database DatabaseMetrics) *ManifestStore {
3535+func NewManifestStore(client *Client, repository string, holdEndpoint string, holdDID string, did string, blobStore distribution.BlobStore, database DatabaseMetrics) *ManifestStore {
3536 return &ManifestStore{
3637 client: client,
3738 repository: repository,
3839 holdEndpoint: holdEndpoint,
4040+ holdDID: holdDID,
3941 did: did,
4042 blobStore: blobStore,
4143 database: database,
···7375 }
74767577 // Store the hold endpoint for subsequent blob requests during pull
7878+ // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
7679 // The routing repository will cache this for concurrent blob fetches
7777- s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
8080+ if manifestRecord.HoldDID != "" {
8181+ // New format: DID reference
8282+ // Convert did:web back to URL for blob fetching
8383+ // TODO: Routing repository should handle DID→URL conversion
8484+ // For now, fall back to HoldEndpoint if available
8585+ if manifestRecord.HoldEndpoint != "" {
8686+ s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
8787+ } else {
8888+ // Convert did:web:hold.example.com → https://hold.example.com
8989+ s.lastFetchedHoldEndpoint = didToURL(manifestRecord.HoldDID)
9090+ }
9191+ } else if manifestRecord.HoldEndpoint != "" {
9292+ // Legacy format: URL reference
9393+ s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
9494+ }
78957996 var ociManifest []byte
8097···127144 return "", fmt.Errorf("failed to create manifest record: %w", err)
128145 }
129146130130- // Set the blob reference and hold endpoint
147147+ // Set the blob reference, hold DID, and hold endpoint
131148 manifestRecord.ManifestBlob = blobRef
132132- manifestRecord.HoldEndpoint = s.holdEndpoint
149149+ manifestRecord.HoldDID = s.holdDID // Primary reference (DID)
150150+ manifestRecord.HoldEndpoint = s.holdEndpoint // Legacy reference (URL) for backward compat
133151134152 // Extract Dockerfile labels from config blob and add to annotations
135153 if s.blobStore != nil && manifestRecord.Config.Digest != "" {
···276294277295 return configJSON.Config.Labels, nil
278296}
297297+298298+// didToURL converts a did:web DID to an HTTPS URL
299299+// e.g., did:web:hold.example.com → https://hold.example.com
300300+func didToURL(didWeb string) string {
301301+ if !strings.HasPrefix(didWeb, "did:web:") {
302302+ return didWeb // Not a did:web, return as-is
303303+ }
304304+305305+ hostname := strings.TrimPrefix(didWeb, "did:web:")
306306+ return "https://" + hostname
307307+}
+63
pkg/auth/oauth/server.go
···11111212 "atcr.io/pkg/appview/db"
1313 "atcr.io/pkg/atproto"
1414+ indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
1415 "github.com/bluesky-social/indigo/atproto/syntax"
1516)
1617···314315 }
315316316317 fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did)
318318+319319+ // Handle profile migration and crew registration
320320+ s.migrateProfileAndRegisterCrew(ctx, client, did, session)
321321+}
322322+323323+// migrateProfileAndRegisterCrew handles URL→DID migration and crew registration
324324+func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atproto.Client, did string, session *indigooauth.ClientSession) {
325325+ // Get user's sailor profile
326326+ profile, err := atproto.GetProfile(ctx, client)
327327+ if err != nil {
328328+ fmt.Printf("WARNING [oauth/server]: Failed to get profile for %s: %v\n", did, err)
329329+ return
330330+ }
331331+332332+ if profile == nil || profile.DefaultHold == "" {
333333+ // No profile or no default hold configured
334334+ return
335335+ }
336336+337337+ // Check if defaultHold is a URL (needs migration)
338338+ var holdDID string
339339+ if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
340340+ fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold)
341341+342342+ // Resolve URL to DID
343343+ holdDID = resolveHoldDIDFromURL(profile.DefaultHold)
344344+345345+ // Update profile with DID
346346+ profile.DefaultHold = holdDID
347347+ if err := atproto.UpdateProfile(ctx, client, profile); err != nil {
348348+ fmt.Printf("WARNING [oauth/server]: Failed to update profile with hold DID for %s: %v\n", did, err)
349349+ // Continue anyway - crew registration might still work
350350+ } else {
351351+ fmt.Printf("DEBUG [oauth/server]: Updated profile with hold DID: %s\n", holdDID)
352352+ }
353353+ } else {
354354+ // Already a DID
355355+ holdDID = profile.DefaultHold
356356+ }
357357+358358+ // TODO: Request crew membership at the hold
359359+ // This requires understanding how to make authenticated HTTP requests with indigo's ClientSession
360360+ // For now, crew registration will happen on first push when appview validates access
361361+ fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID)
362362+ _ = session // TODO: use session for crew registration
363363+}
364364+365365+// resolveHoldDIDFromURL converts a hold endpoint URL to a DID
366366+// For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io
367367+func resolveHoldDIDFromURL(holdURL string) string {
368368+ // Parse URL to get hostname
369369+ holdURL = strings.TrimPrefix(holdURL, "http://")
370370+ holdURL = strings.TrimPrefix(holdURL, "https://")
371371+ holdURL = strings.TrimSuffix(holdURL, "/")
372372+373373+ // Extract hostname (remove path if present)
374374+ parts := strings.Split(holdURL, "/")
375375+ hostname := parts[0]
376376+377377+ // Convert to did:web
378378+ // did:web uses hostname directly (port included if non-standard)
379379+ return "did:web:" + hostname
317380}
318381319382// HTML templates