A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

begin migration from owner based identification to hold based in appview

+314 -22
+13 -10
CLAUDE.md
··· 51 51 export STORAGE_ROOT_DIR=/tmp/atcr-hold 52 52 export HOLD_OWNER=did:plc:your-did-here 53 53 ./bin/atcr-hold 54 - # Check logs for OAuth URL, visit in browser to complete registration 54 + # Hold starts immediately with embedded PDS 55 55 ``` 56 56 57 57 ## Architecture Overview ··· 75 75 76 76 2. **Hold Service** (`cmd/hold`) - Optional BYOS component 77 77 - Lightweight HTTP server for presigned URLs 78 + - Embedded PDS with captain + crew records 78 79 - Supports S3, Storj, Minio, filesystem, etc. 79 - - Authorization based on PDS records (hold.public, crew records) 80 - - Auto-registration via OAuth 80 + - Authorization based on captain record (public, allowAllCrew) 81 + - Self-describing via DID resolution 81 82 - Configured entirely via environment variables 82 83 83 84 3. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth ··· 357 358 358 359 Write access: 359 360 - Hold owner OR crew members only 360 - - Verified via `io.atcr.hold.crew` records in owner's PDS 361 + - Verified via `io.atcr.hold.crew` records in hold's embedded PDS 361 362 362 363 Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet). 363 364 ··· 484 485 - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials 485 486 - `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration 486 487 - `HOLD_PUBLIC` - Allow public reads (default: false) 487 - - `HOLD_OWNER` - DID for auto-registration (optional) 488 + - `HOLD_OWNER` - DID for captain record creation (optional) 489 + - `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false) 488 490 489 491 **Credential Helper**: 490 492 - Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store) ··· 539 541 - Client methods are consistent across authorization, token exchange, and refresh flows 540 542 541 543 **Adding BYOS support for a user**: 542 - 1. User sets environment variables (storage credentials, public URL) 543 - 2. User runs hold service with `HOLD_OWNER` set - auto-registration via OAuth 544 - 3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS 545 - 4. AppView automatically queries PDS and routes blobs to user's storage 546 - 5. No AppView changes needed - fully decentralized 544 + 1. User sets environment variables (storage credentials, public URL, HOLD_OWNER) 545 + 2. User runs hold service - creates captain + crew records in embedded PDS 546 + 3. Hold creates `io.atcr.hold.captain` + `io.atcr.hold.crew` records 547 + 4. User sets sailor profile `defaultHold` to point to their hold 548 + 5. AppView automatically queries hold's PDS and routes blobs to user's storage 549 + 6. No AppView changes needed - fully decentralized 547 550 548 551 **Supporting a new storage backend**: 549 552 1. Ensure driver is registered in `cmd/hold/main.go` imports
+3 -3
docs/EMBEDDED_PDS.md
··· 560 560 - ✅ Discoverable via service type 561 561 562 562 **OAuth implications:** 563 - - OAuth registration flow no longer needed (hold is self-describing) 564 - - OAuth code kept for backward compatibility with legacy registration records 565 - - Future: Remove OAuth after migration period 563 + - ✅ OAuth registration removed completely (hold is self-describing) 564 + - Hold creates captain + crew records in its own embedded PDS 565 + - No cross-PDS writes or OAuth flows needed 566 566 567 567 ### 7. Multi-Tenancy 568 568
+138
pkg/appview/db/hold_store.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "time" 7 + ) 8 + 9 + // HoldCaptainRecord represents a cached captain record from a hold's PDS 10 + type HoldCaptainRecord struct { 11 + HoldDID string 12 + OwnerDID string 13 + Public bool 14 + AllowAllCrew bool 15 + DeployedAt string 16 + Region string 17 + Provider string 18 + UpdatedAt time.Time 19 + } 20 + 21 + // GetCaptainRecord retrieves a captain record from the cache 22 + // Returns nil if not found (cache miss) 23 + func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) { 24 + query := ` 25 + SELECT hold_did, owner_did, public, allow_all_crew, 26 + deployed_at, region, provider, updated_at 27 + FROM hold_captain_records 28 + WHERE hold_did = ? 29 + ` 30 + 31 + var record HoldCaptainRecord 32 + var deployedAt, region, provider sql.NullString 33 + 34 + err := db.QueryRow(query, holdDID).Scan( 35 + &record.HoldDID, 36 + &record.OwnerDID, 37 + &record.Public, 38 + &record.AllowAllCrew, 39 + &deployedAt, 40 + &region, 41 + &provider, 42 + &record.UpdatedAt, 43 + ) 44 + 45 + if err == sql.ErrNoRows { 46 + return nil, nil // Cache miss - not an error 47 + } 48 + 49 + if err != nil { 50 + return nil, fmt.Errorf("failed to query captain record: %w", err) 51 + } 52 + 53 + // Handle nullable fields 54 + if deployedAt.Valid { 55 + record.DeployedAt = deployedAt.String 56 + } 57 + if region.Valid { 58 + record.Region = region.String 59 + } 60 + if provider.Valid { 61 + record.Provider = provider.String 62 + } 63 + 64 + return &record, nil 65 + } 66 + 67 + // UpsertCaptainRecord inserts or updates a captain record in the cache 68 + func UpsertCaptainRecord(db *sql.DB, record *HoldCaptainRecord) error { 69 + query := ` 70 + INSERT INTO hold_captain_records ( 71 + hold_did, owner_did, public, allow_all_crew, 72 + deployed_at, region, provider, updated_at 73 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 74 + ON CONFLICT(hold_did) DO UPDATE SET 75 + owner_did = excluded.owner_did, 76 + public = excluded.public, 77 + allow_all_crew = excluded.allow_all_crew, 78 + deployed_at = excluded.deployed_at, 79 + region = excluded.region, 80 + provider = excluded.provider, 81 + updated_at = excluded.updated_at 82 + ` 83 + 84 + _, err := db.Exec(query, 85 + record.HoldDID, 86 + record.OwnerDID, 87 + record.Public, 88 + record.AllowAllCrew, 89 + nullString(record.DeployedAt), 90 + nullString(record.Region), 91 + nullString(record.Provider), 92 + record.UpdatedAt, 93 + ) 94 + 95 + if err != nil { 96 + return fmt.Errorf("failed to upsert captain record: %w", err) 97 + } 98 + 99 + return nil 100 + } 101 + 102 + // ListHoldDIDs returns all known hold DIDs from the cache 103 + func ListHoldDIDs(db *sql.DB) ([]string, error) { 104 + query := ` 105 + SELECT hold_did 106 + FROM hold_captain_records 107 + ORDER BY updated_at DESC 108 + ` 109 + 110 + rows, err := db.Query(query) 111 + if err != nil { 112 + return nil, fmt.Errorf("failed to query hold DIDs: %w", err) 113 + } 114 + defer rows.Close() 115 + 116 + var holdDIDs []string 117 + for rows.Next() { 118 + var holdDID string 119 + if err := rows.Scan(&holdDID); err != nil { 120 + return nil, fmt.Errorf("failed to scan hold DID: %w", err) 121 + } 122 + holdDIDs = append(holdDIDs, holdDID) 123 + } 124 + 125 + if err := rows.Err(); err != nil { 126 + return nil, fmt.Errorf("error iterating hold DIDs: %w", err) 127 + } 128 + 129 + return holdDIDs, nil 130 + } 131 + 132 + // nullString converts a string to sql.NullString 133 + func nullString(s string) sql.NullString { 134 + if s == "" { 135 + return sql.NullString{Valid: false} 136 + } 137 + return sql.NullString{String: s, Valid: true} 138 + }
+13
pkg/appview/db/migrations/0002_add_hold_captain_records.yaml
··· 1 + description: Add hold_captain_records table for caching hold security settings 2 + query: | 3 + CREATE TABLE IF NOT EXISTS hold_captain_records ( 4 + hold_did TEXT PRIMARY KEY, 5 + owner_did TEXT NOT NULL, 6 + public BOOLEAN NOT NULL, 7 + allow_all_crew BOOLEAN NOT NULL, 8 + deployed_at TEXT, 9 + region TEXT, 10 + provider TEXT, 11 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 + ); 13 + CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
+12
pkg/appview/db/schema.go
··· 167 167 ); 168 168 CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository); 169 169 CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did); 170 + 171 + CREATE TABLE IF NOT EXISTS hold_captain_records ( 172 + hold_did TEXT PRIMARY KEY, 173 + owner_did TEXT NOT NULL, 174 + public BOOLEAN NOT NULL, 175 + allow_all_crew BOOLEAN NOT NULL, 176 + deployed_at TEXT, 177 + region TEXT, 178 + provider TEXT, 179 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 180 + ); 181 + CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); 170 182 ` 171 183 172 184 // InitDB initializes the SQLite database with the schema
+4 -1
pkg/appview/storage/routing_repository.go
··· 54 54 // Ensure blob store is created first (needed for label extraction during push) 55 55 blobStore := r.Blobs(ctx) 56 56 57 - r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did, blobStore, r.database) 57 + // Resolve hold endpoint URL to DID 58 + holdDID := atproto.ResolveHoldDIDFromURL(r.storageEndpoint) 59 + 60 + r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, holdDID, r.did, blobStore, r.database) 58 61 } 59 62 60 63 // After any manifest operation, cache the hold endpoint for blob fetches
+34 -3
pkg/atproto/lexicon.go
··· 41 41 // Digest is the content digest (e.g., "sha256:abc123...") 42 42 Digest string `json:"digest"` 43 43 44 - // HoldEndpoint is the hold service endpoint where blobs are stored 44 + // HoldDID is the DID of the hold service where blobs are stored 45 + // This is the primary reference for hold resolution 46 + // e.g., "did:web:hold01.atcr.io" 47 + HoldDID string `json:"holdDid,omitempty"` 48 + 49 + // HoldEndpoint is the hold service endpoint URL where blobs are stored (DEPRECATED) 50 + // Kept for backward compatibility with manifests created before DID migration 51 + // New manifests should use HoldDID instead 45 52 // This is a historical reference that doesn't change even if user's default hold changes 46 - HoldEndpoint string `json:"holdEndpoint"` 53 + HoldEndpoint string `json:"holdEndpoint,omitempty"` 47 54 48 55 // MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json") 49 56 MediaType string `json:"mediaType"` ··· 261 268 // Type should be "io.atcr.sailor.profile" 262 269 Type string `json:"$type"` 263 270 264 - // DefaultHold is the default hold endpoint for blob storage 271 + // DefaultHold is the default hold DID for blob storage 272 + // Can be a DID (e.g., "did:web:hold01.atcr.io") or legacy URL 273 + // URLs are migrated to DIDs on user login 265 274 // If null/empty, user has opted out of defaults 266 275 DefaultHold string `json:"defaultHold,omitempty"` 267 276 ··· 340 349 341 350 return parts[0], parts[1], nil 342 351 } 352 + 353 + // ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID 354 + // For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io 355 + func ResolveHoldDIDFromURL(holdURL string) string { 356 + // Handle empty URLs 357 + if holdURL == "" { 358 + return "" 359 + } 360 + 361 + // Parse URL to get hostname 362 + holdURL = strings.TrimPrefix(holdURL, "http://") 363 + holdURL = strings.TrimPrefix(holdURL, "https://") 364 + holdURL = strings.TrimSuffix(holdURL, "/") 365 + 366 + // Extract hostname (remove path if present) 367 + parts := strings.Split(holdURL, "/") 368 + hostname := parts[0] 369 + 370 + // Convert to did:web 371 + // did:web uses hostname directly (port included if non-standard) 372 + return "did:web:" + hostname 373 + }
+34 -5
pkg/atproto/manifest_store.go
··· 23 23 type ManifestStore struct { 24 24 client *Client 25 25 repository string 26 - holdEndpoint string // Hold service endpoint where blobs are stored (for push) 26 + holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 + holdDID string // Hold service DID (primary reference) 27 28 did string // User's DID for cache key 28 29 lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) 29 30 blobStore distribution.BlobStore // Blob store for fetching config during push ··· 31 32 } 32 33 33 34 // NewManifestStore creates a new ATProto-backed manifest store 34 - func NewManifestStore(client *Client, repository string, holdEndpoint string, did string, blobStore distribution.BlobStore, database DatabaseMetrics) *ManifestStore { 35 + func NewManifestStore(client *Client, repository string, holdEndpoint string, holdDID string, did string, blobStore distribution.BlobStore, database DatabaseMetrics) *ManifestStore { 35 36 return &ManifestStore{ 36 37 client: client, 37 38 repository: repository, 38 39 holdEndpoint: holdEndpoint, 40 + holdDID: holdDID, 39 41 did: did, 40 42 blobStore: blobStore, 41 43 database: database, ··· 73 75 } 74 76 75 77 // Store the hold endpoint for subsequent blob requests during pull 78 + // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format) 76 79 // The routing repository will cache this for concurrent blob fetches 77 - s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint 80 + if manifestRecord.HoldDID != "" { 81 + // New format: DID reference 82 + // Convert did:web back to URL for blob fetching 83 + // TODO: Routing repository should handle DID→URL conversion 84 + // For now, fall back to HoldEndpoint if available 85 + if manifestRecord.HoldEndpoint != "" { 86 + s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint 87 + } else { 88 + // Convert did:web:hold.example.com → https://hold.example.com 89 + s.lastFetchedHoldEndpoint = didToURL(manifestRecord.HoldDID) 90 + } 91 + } else if manifestRecord.HoldEndpoint != "" { 92 + // Legacy format: URL reference 93 + s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint 94 + } 78 95 79 96 var ociManifest []byte 80 97 ··· 127 144 return "", fmt.Errorf("failed to create manifest record: %w", err) 128 145 } 129 146 130 - // Set the blob reference and hold endpoint 147 + // Set the blob reference, hold DID, and hold endpoint 131 148 manifestRecord.ManifestBlob = blobRef 132 - manifestRecord.HoldEndpoint = s.holdEndpoint 149 + manifestRecord.HoldDID = s.holdDID // Primary reference (DID) 150 + manifestRecord.HoldEndpoint = s.holdEndpoint // Legacy reference (URL) for backward compat 133 151 134 152 // Extract Dockerfile labels from config blob and add to annotations 135 153 if s.blobStore != nil && manifestRecord.Config.Digest != "" { ··· 276 294 277 295 return configJSON.Config.Labels, nil 278 296 } 297 + 298 + // didToURL converts a did:web DID to an HTTPS URL 299 + // e.g., did:web:hold.example.com → https://hold.example.com 300 + func didToURL(didWeb string) string { 301 + if !strings.HasPrefix(didWeb, "did:web:") { 302 + return didWeb // Not a did:web, return as-is 303 + } 304 + 305 + hostname := strings.TrimPrefix(didWeb, "did:web:") 306 + return "https://" + hostname 307 + }
+63
pkg/auth/oauth/server.go
··· 11 11 12 12 "atcr.io/pkg/appview/db" 13 13 "atcr.io/pkg/atproto" 14 + indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 15 16 ) 16 17 ··· 314 315 } 315 316 316 317 fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did) 318 + 319 + // Handle profile migration and crew registration 320 + s.migrateProfileAndRegisterCrew(ctx, client, did, session) 321 + } 322 + 323 + // migrateProfileAndRegisterCrew handles URL→DID migration and crew registration 324 + func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atproto.Client, did string, session *indigooauth.ClientSession) { 325 + // Get user's sailor profile 326 + profile, err := atproto.GetProfile(ctx, client) 327 + if err != nil { 328 + fmt.Printf("WARNING [oauth/server]: Failed to get profile for %s: %v\n", did, err) 329 + return 330 + } 331 + 332 + if profile == nil || profile.DefaultHold == "" { 333 + // No profile or no default hold configured 334 + return 335 + } 336 + 337 + // Check if defaultHold is a URL (needs migration) 338 + var holdDID string 339 + if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { 340 + fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold) 341 + 342 + // Resolve URL to DID 343 + holdDID = resolveHoldDIDFromURL(profile.DefaultHold) 344 + 345 + // Update profile with DID 346 + profile.DefaultHold = holdDID 347 + if err := atproto.UpdateProfile(ctx, client, profile); err != nil { 348 + fmt.Printf("WARNING [oauth/server]: Failed to update profile with hold DID for %s: %v\n", did, err) 349 + // Continue anyway - crew registration might still work 350 + } else { 351 + fmt.Printf("DEBUG [oauth/server]: Updated profile with hold DID: %s\n", holdDID) 352 + } 353 + } else { 354 + // Already a DID 355 + holdDID = profile.DefaultHold 356 + } 357 + 358 + // TODO: Request crew membership at the hold 359 + // This requires understanding how to make authenticated HTTP requests with indigo's ClientSession 360 + // For now, crew registration will happen on first push when appview validates access 361 + fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID) 362 + _ = session // TODO: use session for crew registration 363 + } 364 + 365 + // resolveHoldDIDFromURL converts a hold endpoint URL to a DID 366 + // For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io 367 + func resolveHoldDIDFromURL(holdURL string) string { 368 + // Parse URL to get hostname 369 + holdURL = strings.TrimPrefix(holdURL, "http://") 370 + holdURL = strings.TrimPrefix(holdURL, "https://") 371 + holdURL = strings.TrimSuffix(holdURL, "/") 372 + 373 + // Extract hostname (remove path if present) 374 + parts := strings.Split(holdURL, "/") 375 + hostname := parts[0] 376 + 377 + // Convert to did:web 378 + // did:web uses hostname directly (port included if non-standard) 379 + return "did:web:" + hostname 317 380 } 318 381 319 382 // HTML templates