A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
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