diff --git a/CLAUDE.md b/CLAUDE.md index fe20a9f..eaa33e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,19 @@ go build -o bin/oauth-helper ./cmd/oauth-helper # Run tests go test ./... +# Run tests for specific package +go test ./pkg/atproto/... +go test ./pkg/appview/storage/... + +# Run specific test +go test -run TestManifestStore ./pkg/atproto/... + # Run with race detector go test -race ./... +# Run tests with verbose output +go test -v ./... + # Update dependencies go mod tidy @@ -101,32 +111,45 @@ ATCR uses **distribution/distribution** as a library and extends it through midd 2. HTTP Request → /v2/alice/myapp/manifests/latest 3. Registry Middleware (pkg/appview/middleware/registry.go) → Resolves "alice" to DID and PDS endpoint - → Queries alice's sailor profile for defaultHold + → Queries alice's sailor profile for defaultHold (returns DID if set) → If not set, checks alice's io.atcr.hold records - → Falls back to AppView's default_storage_endpoint - → Stores DID/PDS/storage endpoint in context + → Falls back to AppView's default_hold_did + → Stores DID/PDS/hold DID in RegistryContext 4. Routing Repository (pkg/appview/storage/routing_repository.go) → Creates RoutingRepository → Returns ATProto ManifestStore for manifests - → Returns ProxyBlobStore for blobs -5. Blob PUT → Resolved hold service (redirects to S3/storage) -6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdEndpoint) + → Returns ProxyBlobStore for blobs (routes to hold DID) +5. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints: + a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID) + b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part) + c. PUT to S3 presigned URL (or PUT /xrpc/io.atcr.hold.uploadPart for buffered mode) + d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload) +6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint) + → Manifest also uploaded to PDS blob storage (ATProto CID format) ``` #### Push with BYOS (Bring Your Own Storage) ``` 1. Client: docker push atcr.io/alice/myapp:latest 2. Registry Middleware resolves alice → did:plc:alice123 -3. Hold discovery via findStorageEndpoint(): - a. Check alice's sailor profile for defaultHold - b. If not set, check alice's io.atcr.hold records - c. Fall back to AppView's default_storage_endpoint -4. Found: alice's profile has defaultHold = "https://alice-storage.fly.dev" -5. Routing Repository returns ProxyBlobStore(alice-storage.fly.dev) -6. ProxyBlobStore calls alice-storage.fly.dev for presigned URL -7. Storage service validates alice's DID, generates S3 presigned URL -8. Client redirected to upload blob directly to alice's S3/Storj -9. Manifest stored in alice's PDS with holdEndpoint = "https://alice-storage.fly.dev" +3. Hold discovery via findHoldDID(): + a. Check alice's sailor profile for defaultHold (returns DID if set) + b. If not set, check alice's io.atcr.hold records (legacy) + c. Fall back to AppView's default_hold_did +4. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev" +5. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev) +6. ProxyBlobStore: + a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution) + b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth + c. Calls hold XRPC endpoints with service token authentication: + - POST /xrpc/io.atcr.hold.initiateUpload + - POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL) + - PUT to S3 presigned URL (direct upload to alice's S3/Storj) + - POST /xrpc/io.atcr.hold.completeUpload +7. Hold service validates service token, checks crew membership, generates presigned URLs +8. Manifest stored in alice's PDS with: + - holdDid = "did:web:alice-storage.fly.dev" (primary) + - holdEndpoint = "https://alice-storage.fly.dev" (backward compat) ``` #### Pull Flow @@ -134,15 +157,22 @@ ATCR uses **distribution/distribution** as a library and extends it through midd 1. Client: docker pull atcr.io/alice/myapp:latest 2. GET /v2/alice/myapp/manifests/latest 3. AppView fetches manifest from alice's PDS -4. Manifest contains holdEndpoint = "https://alice-storage.fly.dev" -5. Hold endpoint cached: (alice's DID, "myapp") → "https://alice-storage.fly.dev" +4. Manifest contains: + - holdDid = "did:web:alice-storage.fly.dev" (primary reference) + - holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback) +5. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev" + TTL: 10 minutes (covers typical pull operations) 6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123 -7. AppView checks cache, routes to hold from manifest (not re-discovered) -8. ProxyBlobStore calls alice-storage.fly.dev for presigned download URL -9. Client redirected to download blob directly from alice's S3 +7. AppView checks cache, routes to hold DID from manifest (not re-discovered) +8. ProxyBlobStore: + a. Resolves hold DID → https://alice-storage.fly.dev + b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth + c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET + d. Hold returns presigned download URL in JSON response +9. Client redirected to download blob directly from alice's S3 via presigned URL ``` -**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold. +**Key insight:** Pull uses the historical `holdDid` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold. Hold cache (10min TTL) avoids re-querying PDS for each blob during the same pull operation. ### Name Resolution @@ -269,12 +299,14 @@ Later (subsequent docker push): - Uses XRPC protocol (com.atproto.repo.*) **lexicon.go**: ATProto record schemas -- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdEndpoint` field) +- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdDid` + `holdEndpoint` fields) - `TagRecord`: Tag pointing to manifest digest -- `HoldRecord`: Storage hold definition (for BYOS) -- `HoldCrewRecord`: Hold crew membership/permissions -- `SailorProfileRecord`: User profile with `defaultHold` preference -- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold`, `io.atcr.hold.crew`, `io.atcr.sailor.profile` +- `HoldRecord`: Storage hold definition (LEGACY - for old BYOS model) +- `HoldCrewRecord`: Hold crew membership (LEGACY - stored in owner's PDS) +- `CaptainRecord`: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self") +- `CrewRecord`: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member) +- `SailorProfileRecord`: User profile with `defaultHold` preference (can be DID or URL) +- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold` (legacy), `io.atcr.hold.crew` (used by both legacy and new models), `io.atcr.hold.captain` (new), `io.atcr.sailor.profile` **profile.go**: Sailor profile management - `EnsureProfile()`: Creates profile with default hold on first authentication @@ -289,26 +321,29 @@ Later (subsequent docker push): #### Storage Layer (`pkg/appview/storage/`) **routing_repository.go**: Routes content by type -- `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction) +- `Manifests()` → returns ATProto ManifestStore (caches instance for hold DID extraction) - `Blobs()` → checks hold cache for pull, uses discovery for push - - Pull: Uses cached `holdEndpoint` from manifest (historical reference) - - Push: Uses discovery-based endpoint from `findStorageEndpoint()` - - Always returns ProxyBlobStore (routes to hold service) + - Pull: Uses cached `holdDid` from manifest (historical reference) + - Push: Uses discovery-based DID from `findHoldDID()` in middleware + - Always returns ProxyBlobStore (routes to hold service via DID) - Implements `distribution.Repository` interface +- Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc. -**hold_cache.go**: In-memory hold endpoint cache -- Caches `(DID, repository) → holdEndpoint` for pull operations +**hold_cache.go**: In-memory hold DID cache +- Caches `(DID, repository) → holdDid` for pull operations - TTL: 10 minutes (covers typical pull operations) - Cleanup: Background goroutine runs every 5 minutes - **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar -- Prevents expensive ATProto lookups on every blob request +- Prevents expensive PDS manifest lookups on every blob request during pull -**proxy_blob_store.go**: External storage proxy -- Calls user's storage service for presigned URLs -- Issues HTTP redirects for blob uploads/downloads +**proxy_blob_store.go**: External storage proxy (routes to hold via XRPC) +- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution) +- Gets service tokens from user's PDS (`com.atproto.server.getServiceAuth`) +- Calls hold XRPC endpoints with service token authentication: + - Multipart upload: initiateUpload, getPartUploadUrl, uploadPart, completeUpload, abortUpload + - Blob read: com.atproto.sync.getBlob (returns presigned download URL) - Implements full `distribution.BlobStore` interface -- Supports multipart uploads for large blobs -- Used when user has `io.atcr.hold` record +- Supports both presigned URL mode (S3 direct) and buffered mode (proxy via hold) #### AppView Web UI (`pkg/appview/`) @@ -348,46 +383,73 @@ The AppView includes a web interface for browsing the registry: #### Hold Service (`cmd/hold/`) -Lightweight standalone service for BYOS (Bring Your Own Storage): +Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS: **Architecture:** -- Reuses distribution's storage driver factory -- Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem -- Authorization follows ATProto's public-by-default model -- Generates presigned URLs (15min expiry) or proxies uploads/downloads +- **Embedded PDS**: Each hold has a full ATProto PDS for storing captain + crew records +- **DID**: Hold identified by did:web (e.g., `did:web:hold01.atcr.io`) +- **Storage**: Reuses distribution's storage driver factory (S3, Storj, Minio, Azure, GCS, filesystem) +- **Authorization**: Based on captain + crew records in embedded PDS +- **Blob operations**: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC **Authorization Model:** Read access: - **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users -- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users only (any ATCR user) +- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read permission Write access: -- Hold owner OR crew members only +- Hold owner OR crew members with blob:write permission - Verified via `io.atcr.hold.crew` records in hold's embedded PDS -Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet). - -**Embedded PDS Endpoints:** - -Each hold service includes an embedded PDS (Personal Data Server) that stores captain + crew records: +**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`): +Standard ATProto sync endpoints: - `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file - `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision - `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events - `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS) +- `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL + +Repository management: +- `GET /xrpc/com.atproto.repo.describeRepo?repo={did}` - Repository metadata +- `GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}` - Get record +- `GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}` - List records (supports pagination) +- `POST /xrpc/com.atproto.repo.deleteRecord` - Delete record (owner/crew admin only) +- `POST /xrpc/com.atproto.repo.uploadBlob` - Upload ATProto blob (owner/crew admin only) + +DID resolution: - `GET /.well-known/did.json` - DID document (did:web resolution) -- Standard ATProto repo endpoints (getRecord, listRecords, etc.) +- `GET /.well-known/atproto-did` - DID for handle resolution -The `subscribeRepos` endpoint broadcasts #commit events whenever crew membership changes, allowing AppViews to monitor hold access control in real-time. +Crew management: +- `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership (authenticated users) -**Configuration:** Environment variables (see `.env.example`) -- `HOLD_PUBLIC_URL` - Public URL of hold service (required) +**OCI Multipart Upload Endpoints** (`pkg/hold/oci/xrpc.go`): + +All require blob:write permission via service token authentication: +- `POST /xrpc/io.atcr.hold.initiateUpload` - Start multipart upload session +- `POST /xrpc/io.atcr.hold.getPartUploadUrl` - Get presigned URL for uploading a part +- `PUT /xrpc/io.atcr.hold.uploadPart` - Direct buffered part upload (alternative to presigned URLs) +- `POST /xrpc/io.atcr.hold.completeUpload` - Finalize multipart upload and move to final location +- `POST /xrpc/io.atcr.hold.abortUpload` - Cancel multipart upload and cleanup temp data + +**AppView-to-Hold Authentication:** +- AppView uses service tokens from user's PDS (`com.atproto.server.getServiceAuth`) +- Service tokens are scoped to specific hold DIDs and include the user's DID +- Hold validates tokens and checks crew membership for authorization +- Tokens cached for 50 seconds (valid for 60 seconds from PDS) + +**Configuration:** Environment variables (see `.env.hold.example`) +- `HOLD_PUBLIC_URL` - Public URL of hold service (required, used for did:web generation) - `STORAGE_DRIVER` - Storage driver type (s3, filesystem) - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials - `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration - `HOLD_PUBLIC` - Allow public reads (default: false) -- `HOLD_OWNER` - DID for auto-registration (optional) +- `HOLD_OWNER` - DID for captain record creation (optional) +- `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false) +- `HOLD_DATABASE_PATH` - Path for embedded PDS database (required) +- `HOLD_DATABASE_KEY_PATH` - Path for PDS signing keys (optional, generated if missing) **Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. @@ -399,17 +461,29 @@ Manifests are stored as records with this structure: "$type": "io.atcr.manifest", "repository": "myapp", "digest": "sha256:abc123...", - "holdEndpoint": "https://hold1.alice.com", + "holdDid": "did:web:hold01.atcr.io", + "holdEndpoint": "https://hold1.atcr.io", "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "digest": "sha256:...", "size": 1234 }, "layers": [ { "digest": "sha256:...", "size": 5678 } ], + "manifestBlob": { + "$type": "blob", + "ref": { "$link": "bafyrei..." }, + "mimeType": "application/vnd.oci.image.manifest.v1+json", + "size": 1234 + }, "createdAt": "2025-09-30T..." } ``` +**Key fields:** +- `holdDid` - DID of the hold service where blobs are stored (PRIMARY reference, new) +- `holdEndpoint` - HTTP URL of hold service (DEPRECATED, kept for backward compatibility) +- `manifestBlob` - Reference to manifest blob in ATProto blob storage (CID format) + Record key = manifest digest (without algorithm prefix) Collection = `io.atcr.manifest` @@ -425,7 +499,7 @@ ATCR uses a "sailor profile" to manage user preferences for hold (storage) selec ```json { "$type": "io.atcr.sailor.profile", - "defaultHold": "https://hold1.alice.com", + "defaultHold": "did:web:hold1.alice.com", "createdAt": "2025-10-02T...", "updatedAt": "2025-10-02T..." } @@ -433,14 +507,15 @@ ATCR uses a "sailor profile" to manage user preferences for hold (storage) selec **Profile Management:** - Created automatically on first authentication (OAuth or Basic Auth) -- If AppView has `default_storage_endpoint` configured, profile gets that as `defaultHold` +- `defaultHold` can be a DID (preferred, e.g., `did:web:hold01.atcr.io`) or legacy URL +- If AppView has `default_hold_did` configured, profile gets that as `defaultHold` - Users can update their profile to change default hold (future: via UI) - Setting `defaultHold` to null opts out of defaults (use own holds or AppView default) -**Hold Resolution Priority** (in `findStorageEndpoint()`): -1. **Profile's `defaultHold`** - User's explicit preference -2. **User's `io.atcr.hold` records** - User's own holds -3. **AppView's `default_storage_endpoint`** - Fallback default +**Hold Resolution Priority** (in `findHoldDID()` in middleware): +1. **Profile's `defaultHold`** - User's explicit preference (DID or URL) +2. **User's `io.atcr.hold` records** - User's own holds (legacy BYOS model) +3. **AppView's `default_hold_did`** - Fallback default (configured in middleware) This ensures: - Users can join shared holds by setting their profile's `defaultHold` @@ -472,7 +547,7 @@ See `.env.appview.example` for all available options. Key environment variables: **Server:** - `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`) - `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev) -- `ATCR_DEFAULT_HOLD` - Default hold endpoint for blob storage (REQUIRED) +- `ATCR_DEFAULT_HOLD_DID` - Default hold DID for blob storage (REQUIRED, e.g., `did:web:hold01.atcr.io`) **Authentication:** - `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`) @@ -537,12 +612,12 @@ When writing tests: **Modifying storage routing**: 1. Edit `pkg/appview/storage/routing_repository.go` 2. Update `Blobs()` method to change routing logic -3. Consider context values: `storage.endpoint`, `atproto.did` +3. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.) **Changing name resolution**: 1. Modify `pkg/atproto/resolver.go` for DID/handle resolution 2. Update `pkg/appview/middleware/registry.go` if changing routing logic -3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records +3. Remember: `findHoldDID()` checks sailor profile, then `io.atcr.hold` records (legacy), then default hold DID **Working with OAuth client**: - Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes @@ -582,13 +657,20 @@ When writing tests: ## Important Context Values -When working with the codebase, these context values are used for routing: +When working with the codebase, routing information is passed via the `RegistryContext` struct (`pkg/appview/storage/context.go`): -- `atproto.did` - Resolved DID for the user (e.g., `did:plc:alice123`) -- `atproto.pds` - User's PDS endpoint (e.g., `https://bsky.social`) -- `atproto.identity` - Original identity string (handle or DID) -- `storage.endpoint` - Storage service URL (if user has `io.atcr.registry` record) -- `auth.did` - Authenticated DID from validated token +- `DID` - User's DID (e.g., `did:plc:alice123`) +- `PDSEndpoint` - User's PDS endpoint (e.g., `https://bsky.social`) +- `HoldDID` - Hold service DID (e.g., `did:web:hold01.atcr.io`) +- `Repository` - Image repository name (e.g., `myapp`) +- `ATProtoClient` - Client for calling user's PDS with OAuth/Basic Auth +- `Refresher` - OAuth token refresher for service token requests +- `Database` - Database for metrics tracking +- `Authorizer` - Hold authorizer for access control + +Legacy context keys (deprecated): +- `hold.did` - Hold DID (now in RegistryContext) +- `auth.did` - Authenticated DID from validated token (now in auth middleware) ## Documentation References diff --git a/README.md b/README.md index ce55e2a..e3788b5 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,23 @@ atcr.io/did:plc:xyz123/myapp:latest 1. **AppView** - Registry API + web UI - Serves OCI Distribution API (Docker push/pull) - Resolves handles/DIDs to PDS endpoints - - Routes manifests to PDS, blobs to storage + - Routes manifests to user's PDS, blobs to hold services - Web interface for browsing/search -2. **Hold Service** - Storage service (optional BYOS) +2. **Hold Service** - Storage service with embedded PDS (optional BYOS) + - Each hold has a full ATProto PDS for access control (captain + crew records) + - Identified by did:web (e.g., `did:web:hold01.atcr.io`) - Generates presigned URLs for S3/Storj/Minio/etc. - - Users can deploy their own storage + - Users can deploy their own storage and control access via crew membership 3. **Credential Helper** - Client authentication - ATProto OAuth with DPoP - Automatic authentication on first push/pull **Storage model:** -- Manifests → ATProto records (small JSON) -- Blobs → S3 or BYOS (large binaries) +- Manifests → ATProto records in user's PDS (small JSON, includes `holdDid` reference) +- Blobs → Hold services via XRPC multipart upload (large binaries, stored in S3/etc.) +- AppView uses service tokens to communicate with holds on behalf of users ## Features diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index c86fc1b..8d7599a 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -26,6 +26,7 @@ import ( "atcr.io/pkg/appview" "atcr.io/pkg/appview/db" uihandlers "atcr.io/pkg/appview/handlers" + "atcr.io/pkg/appview/holdhealth" "atcr.io/pkg/appview/jetstream" "github.com/gorilla/mux" ) @@ -72,6 +73,22 @@ func serveRegistry(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to initialize UI database - required for session storage") } + // Initialize hold health checker + fmt.Println("Initializing hold health checker...") + cacheTTL := 15 * time.Minute // Cache TTL from user requirements + healthChecker := holdhealth.NewChecker(cacheTTL) + + // Start background health check worker + refreshInterval := 5 * time.Minute // Refresh every 5 minutes + dbAdapter := holdhealth.NewDBAdapter(uiDatabase) + healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval) + + // Create context for worker lifecycle management + workerCtx, workerCancel := context.WithCancel(context.Background()) + defer workerCancel() // Ensure context is cancelled on all exit paths + healthWorker.Start(workerCtx) + fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)") + // Initialize OAuth components fmt.Println("Initializing OAuth components...") @@ -132,8 +149,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error { middleware.SetGlobalAuthorizer(holdAuthorizer) fmt.Println("Hold authorizer initialized with database caching") - // Initialize UI routes with OAuth app, refresher, and device store - uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID) + // Initialize UI routes with OAuth app, refresher, device store, and health checker + uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker) // Create OAuth server oauthServer := oauth.NewServer(oauthApp) @@ -256,6 +273,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error { select { case <-stop: fmt.Println("Shutting down registry server...") + + // Stop health worker first + fmt.Println("Stopping hold health worker...") + healthWorker.Stop() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -263,6 +285,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error { return fmt.Errorf("server shutdown error: %w", err) } case err := <-errChan: + // Stop health worker on error (workerCancel called by defer) + healthWorker.Stop() return fmt.Errorf("server error: %w", err) } @@ -320,7 +344,8 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro // database: read-write connection for auth and writes // readOnlyDB: read-only connection for public queries (search, user pages, etc.) // defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io") -func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string) (*template.Template, *mux.Router) { +// healthChecker: hold endpoint health checker +func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker) (*template.Template, *mux.Router) { // Check if UI is enabled uiEnabled := os.Getenv("ATCR_UI_ENABLED") if uiEnabled == "false" { @@ -356,9 +381,10 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S router.Handle("/api/recent-pushes", middleware.OptionalAuth(sessionStore, database)( &uihandlers.RecentPushesHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), + DB: readOnlyDB, + Templates: templates, + RegistryURL: uihandlers.TrimRegistryURL(baseURL), + HealthChecker: healthChecker, }, )).Methods("GET") @@ -428,6 +454,11 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S }, )).Methods("GET") + // Manifest health check API endpoint (HTMX polling) + router.Handle("/api/manifest-health", &uihandlers.ManifestHealthHandler{ + HealthChecker: healthChecker, + }).Methods("GET") + router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)( &uihandlers.UserPageHandler{ DB: readOnlyDB, @@ -438,11 +469,12 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S router.Handle("/r/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( &uihandlers.RepositoryPageHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - Directory: oauthApp.Directory(), - Refresher: refresher, + DB: readOnlyDB, + Templates: templates, + RegistryURL: uihandlers.TrimRegistryURL(baseURL), + Directory: oauthApp.Directory(), + Refresher: refresher, + HealthChecker: healthChecker, }, )).Methods("GET") diff --git a/docs/BYOS.md b/docs/BYOS.md index 3e4dfe4..187a1c9 100644 --- a/docs/BYOS.md +++ b/docs/BYOS.md @@ -2,216 +2,138 @@ ## Overview -ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. This allows users to: -- Deploy their own storage service backed by S3/Storj/Minio/filesystem -- Control who can use their storage (public or private) -- Keep blob data in their own infrastructure while manifests remain in their ATProto PDS +ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. Users can: +- Deploy their own hold service with embedded PDS +- Control access via crew membership in the hold's PDS +- Keep blob data in their own S3/Storj/Minio while manifests stay in their user PDS ## Architecture ``` -┌─────────────────────────────────────────────┐ -│ ATCR AppView (API) │ -│ - Manifests → ATProto PDS │ -│ - Auth & token validation │ -│ - Blob routing (issues redirects) │ -│ - Profile management │ -└─────────────────┬───────────────────────────┘ - │ - │ Hold discovery priority: - │ 1. io.atcr.sailor.profile.defaultHold - │ 2. io.atcr.hold records - │ 3. AppView default_storage_endpoint - ▼ -┌─────────────────────────────────────────────┐ -│ User's PDS │ -│ - io.atcr.sailor.profile (hold preference) │ -│ - io.atcr.hold records (own holds) │ -│ - io.atcr.manifest records (with holdEP) │ -└─────────────────┬───────────────────────────┘ - │ - │ Redirects to hold - ▼ -┌─────────────────────────────────────────────┐ -│ Storage Service (Hold) │ -│ - Blob storage (S3/Storj/Minio/filesystem) │ -│ - Presigned URL generation │ -│ - Authorization (DID-based) │ -└─────────────────────────────────────────────┘ +┌──────────────────────────────────────────┐ +│ ATCR AppView (API) │ +│ - Manifests → User's PDS │ +│ - Auth & service token management │ +│ - Blob routing via XRPC │ +│ - Profile management │ +└────────────┬─────────────────────────────┘ + │ + │ Hold discovery priority: + │ 1. io.atcr.sailor.profile.defaultHold (DID) + │ 2. io.atcr.hold records (legacy) + │ 3. AppView default_hold_did + ▼ +┌──────────────────────────────────────────┐ +│ User's PDS │ +│ - io.atcr.sailor.profile (hold DID) │ +│ - io.atcr.manifest (with holdDid) │ +└────────────┬─────────────────────────────┘ + │ + │ Service token from user's PDS + ▼ +┌──────────────────────────────────────────┐ +│ Hold Service (did:web:hold.example.com) │ +│ ├── Embedded PDS │ +│ │ ├── Captain record (ownership) │ +│ │ └── Crew records (access control) │ +│ ├── XRPC multipart upload endpoints │ +│ └── Storage driver (S3/Storj/etc.) │ +└──────────────────────────────────────────┘ ``` -## ATProto Records +## Hold Service Components -### io.atcr.sailor.profile +Each hold is a full ATProto actor with: +- **DID**: `did:web:hold.example.com` (hold's identity) +- **Embedded PDS**: Stores captain + crew records (shared data) +- **Storage backend**: S3, Storj, Minio, filesystem, etc. +- **XRPC endpoints**: Standard ATProto + custom OCI multipart upload -**NEW:** User profile for hold selection preferences. Created automatically on first authentication. +### Records in Hold's PDS + +**Captain record** (`io.atcr.hold.captain/self`): +```json +{ + "$type": "io.atcr.hold.captain", + "owner": "did:plc:alice123", + "public": false, + "deployedAt": "2025-10-14T...", + "region": "iad", + "provider": "fly.io" +} +``` + +**Crew records** (`io.atcr.hold.crew/{rkey}`): +```json +{ + "$type": "io.atcr.hold.crew", + "member": "did:plc:bob456", + "role": "admin", + "permissions": ["blob:read", "blob:write"], + "addedAt": "2025-10-14T..." +} +``` + +### Sailor Profile (User's PDS) + +Users set their preferred hold in their sailor profile: ```json { "$type": "io.atcr.sailor.profile", - "defaultHold": "https://team-hold.example.com", - "createdAt": "2025-10-02T12:00:00Z", - "updatedAt": "2025-10-02T12:00:00Z" + "defaultHold": "did:web:hold.example.com", + "createdAt": "2025-10-02T...", + "updatedAt": "2025-10-02T..." } ``` -**Record key:** Always `"self"` (only one profile per user) - -**Behavior:** -- Created automatically when user first authenticates (OAuth or Basic Auth) -- If AppView has `default_storage_endpoint`, profile gets that as initial `defaultHold` -- User can update to join shared holds or use their own hold -- Set `defaultHold` to `null` to opt out of defaults (use own hold or AppView default) - -**This solves the multi-hold problem:** Users who are crew members of multiple holds can explicitly choose which one to use via their profile. - -### io.atcr.hold - -Users create a hold record in their PDS to configure their own storage: - -```json -{ - "$type": "io.atcr.hold", - "endpoint": "https://alice-storage.example.com", - "owner": "did:plc:alice123", - "public": false, - "createdAt": "2025-10-01T12:00:00Z" -} -``` - -### io.atcr.hold.crew - -Hold owners can add crew members (for shared storage): - -```json -{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:alice/io.atcr.hold/my-storage", - "member": "did:plc:bob456", - "role": "write", - "addedAt": "2025-10-01T12:00:00Z" -} -``` - -**Note:** Crew records are stored in the **hold owner's PDS**, not the crew member's PDS. This ensures the hold owner maintains full control over access. - -## Storage Service - -### Deployment - -The storage service is a lightweight HTTP server that: -1. Accepts presigned URL requests -2. Verifies DID authorization -3. Generates presigned URLs for S3/Storj/etc -4. Returns URLs to AppView for client redirect +## Deployment ### Configuration -The hold service is configured entirely via environment variables. See `.env.example` for all options. - -**Required environment variables:** +Hold service is configured entirely via environment variables: ```bash -# Hold service public URL (REQUIRED) -HOLD_PUBLIC_URL=https://storage.example.com +# Hold identity (REQUIRED) +HOLD_PUBLIC_URL=https://hold.example.com +HOLD_OWNER=did:plc:your-did-here -# Storage driver type +# Storage backend STORAGE_DRIVER=s3 - -# For S3/Minio AWS_ACCESS_KEY_ID=your_access_key AWS_SECRET_ACCESS_KEY=your_secret_key AWS_REGION=us-east-1 S3_BUCKET=my-blobs -# For Storj (optional - custom S3 endpoint) -# S3_ENDPOINT=https://gateway.storjshare.io +# Access control +HOLD_PUBLIC=false # Require authentication for reads +HOLD_ALLOW_ALL_CREW=false # Only explicit crew members can write -# For filesystem storage -# STORAGE_DRIVER=filesystem -# STORAGE_ROOT_DIR=/var/lib/atcr-storage +# Embedded PDS +HOLD_DATABASE_PATH=/var/lib/atcr-hold/hold.db +HOLD_DATABASE_KEY_PATH=/var/lib/atcr-hold/keys ``` -**Authorization:** - -ATCR follows ATProto's public-by-default model with gated anonymous access: - -**Read Access:** -- **Public hold** (`HOLD_PUBLIC=true`): Anonymous reads allowed (no authentication) -- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication (any ATCR user with sailor.profile) - -**Write Access:** -- Always requires authentication -- Must be hold owner OR crew member (verified via `io.atcr.hold.crew` records in owner's PDS) - -**Key Points:** -- "Private" just means "no anonymous access" - not "limited user access" -- Any authenticated ATCR user can read from private holds -- Crew membership only controls WRITE access, not READ access -- This aligns with ATProto's public records model (no private PDS records yet) - -### Running +### Running Locally ```bash # Build -go build -o atcr-hold ./cmd/hold +go build -o bin/atcr-hold ./cmd/hold -# Set environment variables (or use .env file) -export HOLD_PUBLIC_URL=https://storage.example.com -export STORAGE_DRIVER=s3 -export AWS_ACCESS_KEY_ID=... -export AWS_SECRET_ACCESS_KEY=... -export AWS_REGION=us-east-1 -export S3_BUCKET=my-blobs +# Run (with env vars or .env file) +export HOLD_PUBLIC_URL=http://localhost:8080 +export HOLD_OWNER=did:plc:your-did-here +export STORAGE_DRIVER=filesystem +export STORAGE_ROOT_DIR=/tmp/atcr-hold +export HOLD_DATABASE_PATH=/tmp/atcr-hold/hold.db -# Run -./atcr-hold +./bin/atcr-hold ``` -**Registration (required):** - -The hold service must be registered in a PDS to be discoverable by the AppView. - -**Standard registration workflow:** - -1. Set `HOLD_OWNER` to your DID: - ```bash - export HOLD_OWNER=did:plc:your-did-here - ``` - -2. Start the hold service: - ```bash - ./atcr-hold - ``` - -3. **Check the logs** for the OAuth authorization URL: - ``` - ================================================================================ - OAUTH AUTHORIZATION REQUIRED - ================================================================================ - - Please visit this URL to authorize the hold service: - - https://bsky.app/authorize?client_id=... - - Waiting for authorization... - ================================================================================ - ``` - -4. Visit the URL in your browser and authorize - -5. The hold service will: - - Exchange the authorization code for a token - - Create `io.atcr.hold` record in your PDS - - Create `io.atcr.hold.crew` record (making you the owner) - - Save registration state - -6. On subsequent runs, the service checks if already registered and skips OAuth - -**Alternative methods:** - -- **Manual API registration**: Call `POST /register` with your own OAuth token -- **Completely manual**: Create PDS records yourself using any ATProto client +On first run, the hold service creates: +- Captain record in embedded PDS (making you the owner) +- Crew record for owner with all permissions +- DID document at `/.well-known/did.json` ### Deploy to Fly.io @@ -223,11 +145,11 @@ primary_region = "ord" [env] HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev" - HOLD_SERVER_ADDR = ":8080" STORAGE_DRIVER = "s3" AWS_REGION = "us-east-1" S3_BUCKET = "my-blobs" HOLD_PUBLIC = "false" + HOLD_ALLOW_ALL_CREW = "false" [http_service] internal_port = 8080 @@ -250,268 +172,196 @@ fly deploy fly secrets set AWS_ACCESS_KEY_ID=... fly secrets set AWS_SECRET_ACCESS_KEY=... fly secrets set HOLD_OWNER=did:plc:your-did-here - -# Check logs for OAuth URL on first run -fly logs - -# Visit the OAuth URL shown in logs to authorize -# The hold service will register itself in your PDS ``` ## Request Flow ### Push with BYOS -1. **Docker push** `atcr.io/alice/myapp:latest` -2. **AppView** resolves `alice` → `did:plc:alice123` -3. **AppView** discovers hold via priority logic: - - Check alice's `io.atcr.sailor.profile` for `defaultHold` - - If not set, check alice's `io.atcr.hold` records - - Fall back to AppView's `default_storage_endpoint` -4. **Found:** `alice.profile.defaultHold = "https://team-hold.example.com"` -5. **AppView** → team-hold: POST `/put-presigned-url` - ```json - { - "did": "did:plc:alice123", - "digest": "sha256:abc123...", - "size": 1048576 - } - ``` -6. **Hold service**: - - Verifies alice is authorized (checks crew records) - - Generates S3 presigned upload URL (15min expiry) - - Returns: `{"url": "https://s3.../blob?signature=..."}` -7. **AppView** → Docker: `307 Redirect` to presigned URL -8. **Docker** → S3: PUT blob directly (no proxy) -9. **Manifest** stored in alice's PDS with `holdEndpoint: "https://team-hold.example.com"` +``` +1. Client: docker push atcr.io/alice/myapp:latest + +2. AppView resolves alice → did:plc:alice123 + +3. AppView discovers hold DID: + - Check alice's sailor profile for defaultHold + - Returns: "did:web:alice-storage.fly.dev" + +4. AppView gets service token from alice's PDS: + GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:alice-storage.fly.dev + Response: { "token": "eyJ..." } + +5. AppView initiates multipart upload to hold: + POST https://alice-storage.fly.dev/xrpc/io.atcr.hold.initiateUpload + Authorization: Bearer {serviceToken} + Body: { "digest": "sha256:abc..." } + Response: { "uploadId": "xyz" } + +6. For each part: + - AppView: POST /xrpc/io.atcr.hold.getPartUploadUrl + - Hold validates service token, checks crew membership + - Hold returns: { "url": "https://s3.../presigned" } + - Client uploads directly to S3 presigned URL + +7. AppView completes upload: + POST /xrpc/io.atcr.hold.completeUpload + Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] } + +8. Manifest stored in alice's PDS: + - holdDid: "did:web:alice-storage.fly.dev" + - holdEndpoint: "https://alice-storage.fly.dev" (backward compat) +``` ### Pull with BYOS -1. **Docker pull** `atcr.io/alice/myapp:latest` -2. **AppView** fetches manifest from alice's PDS -3. **Manifest** contains `holdEndpoint: "https://team-hold.example.com"` -4. **AppView** caches: `(alice's DID, "myapp") → "https://team-hold.example.com"` (10min TTL) -5. **Docker** requests blobs: GET `/v2/alice/myapp/blobs/sha256:abc123` -6. **AppView** uses **cached hold from manifest** (not re-discovered) -7. **AppView** → team-hold: POST `/get-presigned-url` -8. **Hold service** returns presigned download URL -9. **AppView** → Docker: `307 Redirect` -10. **Docker** → S3: GET blob directly +``` +1. Client: docker pull atcr.io/alice/myapp:latest -**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from where they were originally pushed, even if alice later changes her profile's `defaultHold`. +2. AppView fetches manifest from alice's PDS -## Default Registry +3. Manifest contains: + - holdDid: "did:web:alice-storage.fly.dev" -The AppView can run its own storage service as the default: +4. AppView caches hold DID for 10 minutes (covers pull operation) -### AppView config +5. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123 -```yaml -middleware: - - name: registry - options: - atproto-resolver: - default_storage_endpoint: https://storage.atcr.io +6. AppView uses cached hold DID from manifest + +7. AppView gets service token from alice's PDS + +8. AppView calls hold XRPC: + GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123 + Authorization: Bearer {serviceToken} + Response: { "url": "https://s3.../presigned-download" } + +9. AppView redirects client to presigned S3 URL + +10. Client downloads directly from S3 ``` -### Default hold service config +**Key insight:** Pull uses the `holdDid` stored in the manifest, ensuring blobs are fetched from where they were originally pushed. + +## Access Control + +### Read Access + +- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + authenticated users +- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users with crew membership + +### Write Access + +- Hold owner (captain) OR crew members only +- Verified via `io.atcr.hold.crew` records in hold's embedded PDS +- Service token proves user identity (from user's PDS) + +### Authorization Flow + +```go +1. AppView gets service token from user's PDS +2. AppView sends request to hold with service token +3. Hold validates service token (checks it's from user's PDS) +4. Hold extracts user's DID from token +5. Hold checks crew records in its embedded PDS +6. If crew member found → allow, else → deny +``` + +## Managing Crew Members + +### Add Crew Member + +Use ATProto client to create crew record in hold's PDS: ```bash -# Accept any authenticated DID -HOLD_PUBLIC=false # Requires authentication +# Via XRPC (if hold supports it) +POST https://hold.example.com/xrpc/io.atcr.hold.requestCrew +Authorization: Bearer {userOAuthToken} -# Or allow public reads -HOLD_PUBLIC=true # Public reads, auth required for writes +# Or manually via captain's OAuth to hold's PDS +atproto put-record \ + --pds https://hold.example.com \ + --collection io.atcr.hold.crew \ + --rkey "{memberDID}" \ + --value '{ + "$type": "io.atcr.hold.crew", + "member": "did:plc:bob456", + "role": "admin", + "permissions": ["blob:read", "blob:write"] + }' ``` -This provides free-tier shared storage for users who don't want to deploy their own. +### Remove Crew Member -## Storage Drivers Supported +```bash +atproto delete-record \ + --pds https://hold.example.com \ + --collection io.atcr.hold.crew \ + --rkey "{memberDID}" +``` -The storage service uses distribution's storage drivers: +## Storage Drivers +Hold service supports all distribution storage drivers: - **S3** - AWS S3, Minio, Storj (via S3 gateway) - **Filesystem** - Local disk (for testing) - **Azure** - Azure Blob Storage - **GCS** - Google Cloud Storage - **Swift** - OpenStack Swift -- **OSS** - Alibaba Cloud OSS - -## Quotas - -Quotas are NOT implemented in the storage service. Instead, use: - -- **S3**: Bucket policies, lifecycle rules -- **Storj**: Project limits in Storj dashboard -- **Minio**: Quota enforcement features -- **Filesystem**: Disk quotas at OS level - -## Security - -### Authorization - -Authorization is based on ATProto's public-by-default model: - -**Read Authorization:** -- **Public hold** (`public: true` in hold record): - - Anonymous users: ✅ Allowed - - Any authenticated user: ✅ Allowed - -- **Private hold** (`public: false` in hold record): - - Anonymous users: ❌ 401 Unauthorized - - Any authenticated ATCR user: ✅ Allowed (no crew membership required) - -**Write Authorization:** -- Anonymous users: ❌ 401 Unauthorized -- Authenticated non-crew: ❌ 403 Forbidden -- Authenticated crew member: ✅ Allowed -- Hold owner: ✅ Allowed - -**Implementation:** -- Hold service queries owner's PDS for `io.atcr.hold.crew` records -- Crew records are public ATProto records (read without authentication) -- "Private" holds only gate anonymous access, not authenticated user access -- This reflects ATProto's current limitation: no private PDS records - -### Presigned URLs - -- 15 minute expiry -- Client uploads/downloads directly to storage -- No data flows through AppView or hold service - -### Private Holds - -"Private" holds gate anonymous access while remaining accessible to authenticated users: - -**What "Private" Means:** -- `HOLD_PUBLIC=false` prevents anonymous reads -- Any authenticated ATCR user can still read -- This aligns with ATProto's public records model - -**Write Control:** -- Only hold owner and crew members can write -- Crew membership managed via `io.atcr.hold.crew` records in owner's PDS -- Removing crew member immediately revokes write access - -**Future: True Private Access** -- When ATProto adds private PDS records, ATCR can support truly private repos -- For now, "private" = "authenticated-only access" - -## Example: Personal Storage - -Alice wants to use her own Storj account: - -1. **Set environment variables**: - ```bash - export HOLD_PUBLIC_URL=https://alice-storage.fly.dev - export HOLD_OWNER=did:plc:alice123 - export STORAGE_DRIVER=s3 - export AWS_ACCESS_KEY_ID=your_storj_access_key - export AWS_SECRET_ACCESS_KEY=your_storj_secret_key - export S3_ENDPOINT=https://gateway.storjshare.io - export S3_BUCKET=alice-blobs - ``` - -2. **Deploy hold service** to Fly.io - auto-registration creates hold + crew record - -3. **Push images** - AppView automatically routes to her storage ## Example: Team Hold -A company wants shared storage for their team: +```bash +# 1. Deploy hold service +export HOLD_PUBLIC_URL=https://team-hold.fly.dev +export HOLD_OWNER=did:plc:admin +export HOLD_PUBLIC=false # Private +export STORAGE_DRIVER=s3 +export AWS_ACCESS_KEY_ID=... +export S3_BUCKET=team-blobs -1. **Deploy hold service** with S3 credentials and auto-registration: - ```bash - export HOLD_PUBLIC_URL=https://company-hold.fly.dev - export HOLD_OWNER=did:plc:admin - export HOLD_PUBLIC=false - export STORAGE_DRIVER=s3 - export AWS_ACCESS_KEY_ID=... - export AWS_SECRET_ACCESS_KEY=... - export S3_BUCKET=company-blobs - ``` +fly deploy -2. **Hold service auto-registers** on first run, creating: - - Hold record in admin's PDS - - Crew record making admin the owner +# 2. Hold auto-creates captain + crew records on first run -3. **Admin adds crew members** via ATProto client or manually: - ```bash - # Using atproto client - atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "company-did:plc:engineer1" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:admin/io.atcr.hold/company", - "member": "did:plc:engineer1", - "role": "write" - }' - ``` +# 3. Admin adds team members via hold's PDS (requires OAuth) +# (TODO: Implement crew management UI/CLI) -4. **Team members set their profile** to use the shared hold: - ```bash - # Engineer updates their sailor profile - atproto put-record \ - --collection io.atcr.sailor.profile \ - --rkey "self" \ - --value '{ - "$type": "io.atcr.sailor.profile", - "defaultHold": "https://company-hold.fly.dev" - }' - ``` +# 4. Team members set their sailor profile: +atproto put-record \ + --collection io.atcr.sailor.profile \ + --rkey "self" \ + --value '{ + "$type": "io.atcr.sailor.profile", + "defaultHold": "did:web:team-hold.fly.dev" + }' -5. **Hold service queries PDS** for crew records to authorize writes -6. **Engineers push/pull** using `atcr.io/engineer1/myapp` - blobs go to company hold +# 5. Team members can now push/pull using team hold +``` ## Limitations -1. **No resume/partial uploads** - Storage service doesn't track upload state -2. **No advanced features** - Just basic put/get, no deduplication logic -3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis) -4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client) +### Current IAM Challenges -## Performance Optimization: S3 Presigned URLs +See [EMBEDDED_PDS.md](./EMBEDDED_PDS.md#iam-challenges) for detailed discussion. -**Status:** Planned implementation (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md)) +**Known issues:** +1. **RPC permission format**: Service tokens don't work with IP-based DIDs in local dev +2. **Dynamic hold discovery**: AppView can't dynamically OAuth arbitrary holds from sailor profiles +3. **Manual profile management**: No UI for updating sailor profile (must use ATProto client) -Currently, hold services act as proxies for blob data. With presigned URLs: - -- **Downloads:** Docker → S3 direct (via 307 redirect) -- **Uploads:** Docker → AppView → S3 (via presigned URL) -- **Hold service bandwidth:** Reduced by 99.98% (only orchestration) - -**Benefits:** -- Hold services can run on minimal infrastructure ($5/month instances) -- Direct S3 transfers at maximum speed -- Scales to arbitrarily large images -- Works with Storj, MinIO, Backblaze B2, Cloudflare R2 - -See [PRESIGNED_URLS.md](./PRESIGNED_URLS.md) for complete technical details and implementation guide. +**Workaround:** Use hostname-based DIDs (`did:web:hold.example.com`) and public holds for now. ## Future Improvements -1. **S3 Presigned URLs** - Implement direct S3 URLs (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md)) -2. **Automatic failover** - Multiple storage endpoints, fallback to default -3. **Storage analytics** - Track usage per DID -4. **Quota integration** - Optional quota tracking in storage service -5. **Profile management UI** - Web interface for users to manage their sailor profile -6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments - -## Comparison to Default Storage - -| Feature | Default (Shared S3) | BYOS | -|---------|---------------------|------| -| Setup | None required | Deploy storage service | -| Cost | Free (with quota) | User pays for S3/Storj | -| Control | Limited | Full control | -| Performance | Shared | Dedicated | -| Quotas | Enforced by AppView | User managed | -| Privacy | Blobs in shared bucket | Blobs in user's bucket | +1. **Crew management UI** - Web interface for adding/removing crew members +2. **Dynamic OAuth** - Support for arbitrary BYOS holds without pre-configuration +3. **Hold migration** - Tools for moving blobs between holds +4. **Storage analytics** - Track usage per user/repository +5. **Distributed cache** - Redis for hold DID cache in multi-instance deployments ## References +- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Embedded PDS architecture and IAM details - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon) - [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/) - [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) -- [Storj Documentation](https://docs.storj.io/) diff --git a/docs/CREW_ACCESS_CONTROL.md b/docs/CREW_ACCESS_CONTROL.md index 7c99efc..b541852 100644 --- a/docs/CREW_ACCESS_CONTROL.md +++ b/docs/CREW_ACCESS_CONTROL.md @@ -2,1230 +2,249 @@ ## Overview -ATCR uses a crew-based access control system for hold (storage) services. Hold owners can grant write access to other users by creating crew records in their PDS. This document describes the scalable access control system that supports: +ATCR uses crew-based access control for hold (storage) services. Crew records are stored in the **hold's embedded PDS** (not the owner's or user's PDS), making the hold a self-contained ATProto actor with its own access control. -- **Individual access** - Explicit DID-based crew membership -- **Wildcard access** - Allow all authenticated users -- **Pattern-based access** - Match users by handle patterns (e.g., `*.example.com`) -- **Access revocation** - Bar (ban) specific users or patterns +## Current Implementation -## Problem Statement +### Records in Hold's PDS -The original crew system required one `io.atcr.hold.crew` record per user. This doesn't scale for: - -1. **Public/shared holds** - Thousands of users would need individual crew records -2. **Community holds** - PDS operators want to allow all their users -3. **Default registries** - AppView operators want to allow all authenticated users -4. **Access revocation** - No way to selectively remove access from wildcard/pattern grants - -## Design Goals - -1. **Preserve ATProto semantics** - Keep `member` as DID type for backlinks -2. **Scalable** - Support thousands of users with minimal records -3. **Flexible patterns** - Support wildcards, handle globs, future regex -4. **Clear semantics** - Separate allow/deny (crew vs barred) -5. **Backward compatible** - Existing crew records work unchanged -6. **Performance** - Minimize PDS queries, enable caching - -## Record Schemas - -### io.atcr.hold.crew (Updated) - -Crew membership grants write access to a hold. Stored in the **hold owner's PDS**. +**Captain record** - Hold ownership (single record at `io.atcr.hold.captain/self`): +```json +{ + "$type": "io.atcr.hold.captain", + "owner": "did:plc:alice123", + "public": false, + "deployedAt": "2025-10-14T...", + "region": "iad", + "provider": "fly.io" +} +``` +**Crew records** - Access control (one per member at `io.atcr.hold.crew/{rkey}`): ```json { "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/shared", - "member": "did:plc:alice123", // Optional: Explicit DID (for backlinks) - "memberPattern": "*.bsky.social", // Optional: Pattern matching - "role": "write", - "createdAt": "2025-10-13T12:00:00Z" + "member": "did:plc:bob456", + "role": "admin", + "permissions": ["blob:read", "blob:write"], + "addedAt": "2025-10-14T..." } ``` -**Fields:** +### Authorization Logic -- `hold` (string, at-uri, required) - AT-URI of the hold record -- `member` (string, did, optional) - Explicit DID for individual access (enables backlinks) -- `memberPattern` (string, optional) - Pattern for matching multiple users -- `role` (string, required) - Role: `"owner"` or `"write"` -- `expiresAt` (string, datetime, optional) - Optional expiration -- `createdAt` (string, datetime, required) - Creation timestamp - -**Validation:** Exactly one of `member` or `memberPattern` must be set. - -**Pattern syntax:** - -- `"*"` - Matches all authenticated users -- `"*.domain.com"` - Matches handles ending with `.domain.com` -- `"subdomain.*"` - Matches handles starting with `subdomain.` -- `"*.bsky.*"` - Matches handles containing `.bsky.` - -**Examples:** - -```json -// Explicit DID (current behavior, preserved) -{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/team", - "member": "did:plc:alice123", - "role": "write", - "createdAt": "2025-10-13T12:00:00Z" -} - -// Allow all authenticated users (public hold) -{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/shared", - "memberPattern": "*", - "role": "write", - "createdAt": "2025-10-13T12:00:00Z" -} - -// Allow all users from a community -{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/community", - "memberPattern": "*.my-community.social", - "role": "write", - "createdAt": "2025-10-13T12:00:00Z" -} - -// Allow specific subdomain -{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/corp", - "memberPattern": "*.eng.company.com", - "role": "write", - "createdAt": "2025-10-13T12:00:00Z" -} -``` - -### io.atcr.hold.crew.barred (New) - -Barred list revokes access for specific users or patterns. Overrides crew membership. Stored in the **hold owner's PDS**. - -```json -{ - "$type": "io.atcr.hold.crew.barred", - "hold": "at://did:plc:owner/io.atcr.hold/shared", - "member": "did:plc:spammer", // Optional: Explicit DID - "memberPattern": "*.spam-instance.com", // Optional: Pattern matching - "reason": "spam/abuse/policy violation", - "barredAt": "2025-10-13T12:00:00Z" -} -``` - -**Fields:** - -- `hold` (string, at-uri, required) - AT-URI of the hold record -- `member` (string, did, optional) - Explicit DID to bar -- `memberPattern` (string, optional) - Pattern for barring multiple users -- `reason` (string, optional) - Human-readable reason for access revocation -- `barredAt` (string, datetime, required) - When user was barred - -**Validation:** Exactly one of `member` or `memberPattern` must be set. - -**Pattern syntax:** Same as crew patterns (wildcards, handle globs). - -**Limitations:** Handle-based barring can be circumvented by users changing their handle or acquiring a new domain. However, this requires significant effort (purchasing domains, changing identity), making it an acceptable deterrent for most abuse cases. DID-based barring is permanent (until user creates new DID). - -**Examples:** - -```json -// Bar specific user -{ - "$type": "io.atcr.hold.crew.barred", - "hold": "at://did:plc:owner/io.atcr.hold/shared", - "member": "did:plc:badactor", - "reason": "Terms of service violation", - "barredAt": "2025-10-13T12:00:00Z" -} - -// Bar all users from a spam PDS -{ - "$type": "io.atcr.hold.crew.barred", - "hold": "at://did:plc:owner/io.atcr.hold/shared", - "memberPattern": "*.spam-pds.com", - "reason": "Spam instance", - "barredAt": "2025-10-13T14:30:00Z" -} - -// Bar pattern of suspicious accounts -{ - "$type": "io.atcr.hold.crew.barred", - "hold": "at://did:plc:owner/io.atcr.hold/shared", - "memberPattern": "bot*", - "reason": "Automated account abuse", - "barredAt": "2025-10-13T15:00:00Z" -} -``` - -## Authorization Logic - -Write authorization follows this priority order: +Write authorization follows this priority: ``` -isAuthorizedWrite(did, handle): - 1. If DID is hold owner → ALLOW - 2. If DID or handle matches barred list → DENY - 3. If DID explicitly in crew list → ALLOW - 4. If handle matches crew pattern → ALLOW - 5. Default → DENY +isAuthorizedWrite(userDID): + 1. If userDID == captain.owner → ALLOW + 2. If crew record exists for userDID → ALLOW + 3. Default → DENY ``` -**Detailed algorithm:** +Read authorization depends on `HOLD_PUBLIC` setting: +- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users can read +- **Private hold** (`HOLD_PUBLIC=false`): Requires crew membership for reads -```go -func (s *HoldService) isAuthorizedWrite(did string) bool { - // 1. Check if owner - if did == s.config.Registration.OwnerDID { - return true // Owner always has access - } +### Configuration - // 2. Resolve handle from DID - handle, err := resolveHandle(did) - if err != nil { - log.Printf("Failed to resolve handle for DID %s: %v", did, err) - handle = "" // Continue without handle matching - } - - // 3. Check barred list (explicit deny overrides everything) - barred, err := s.isBarred(did, handle) - if err != nil { - log.Printf("Error checking barred status: %v", err) - return false // Fail secure - } - if barred { - return false // Explicitly barred - } - - // 4. Check crew list (explicit allow) - crew, err := s.isCrewMember(did, handle) - if err != nil { - log.Printf("Error checking crew status: %v", err) - return false // Fail secure - } - - return crew // Allow if crew member, deny otherwise -} - -func (s *HoldService) isBarred(did, handle string) (bool, error) { - records := listBarredRecords() - - for _, record := range records { - // Check explicit DID match - if record.Member != "" && record.Member == did { - return true, nil - } - - // Check pattern match (if handle available) - if record.MemberPattern != "" && handle != "" { - if matchPattern(record.MemberPattern, handle) { - return true, nil - } - } - } - - return false, nil -} - -func (s *HoldService) isCrewMember(did, handle string) (bool, error) { - records := listCrewRecords() - - for _, record := range records { - // Check explicit DID match - if record.Member != "" && record.Member == did { - return true, nil - } - - // Check pattern match (if handle available) - if record.MemberPattern != "" && handle != "" { - if matchPattern(record.MemberPattern, handle) { - return true, nil - } - } - } - - return false, nil -} +```bash +# Access control environment variables +HOLD_PUBLIC=false # Require authentication for reads +HOLD_ALLOW_ALL_CREW=false # Only explicit crew members can write ``` -**Pattern matching:** +### Crew Management -```go -func matchPattern(pattern, handle string) bool { - if pattern == "*" { - return true // Wildcard matches all - } +Crew records are managed by the hold captain (owner) using standard ATProto operations on the hold's embedded PDS: - // Convert glob pattern to regex - // *.example.com → ^.*\.example\.com$ - // subdomain.* → ^subdomain\..*$ - // *.bsky.* → ^.*\.bsky\..*$ - - regex := globToRegex(pattern) - matched, _ := regexp.MatchString(regex, handle) - return matched -} +**Add crew member:** +```bash +# Via hold's PDS (requires captain's OAuth) +atproto put-record \ + --pds https://hold.example.com \ + --collection io.atcr.hold.crew \ + --rkey "{memberDID}" \ + --value '{ + "$type": "io.atcr.hold.crew", + "member": "did:plc:bob456", + "role": "admin", + "permissions": ["blob:read", "blob:write"], + "addedAt": "2025-10-14T12:00:00Z" + }' ``` +**Remove crew member:** +```bash +atproto delete-record \ + --pds https://hold.example.com \ + --collection io.atcr.hold.crew \ + --rkey "{memberDID}" +``` + +**List crew members:** +```bash +# Via XRPC +GET https://hold.example.com/xrpc/com.atproto.repo.listRecords?repo={holdDID}&collection=io.atcr.hold.crew +``` + +## Authentication Flow + +``` +1. User pushes image to atcr.io/alice/myapp + +2. AppView gets service token from alice's PDS: + GET /xrpc/com.atproto.server.getServiceAuth?aud={holdDID} + Response: { "token": "..." } + +3. AppView calls hold with service token: + POST /xrpc/io.atcr.hold.initiateUpload + Authorization: Bearer {serviceToken} + +4. Hold validates service token: + - Checks token is from alice's PDS + - Extracts alice's DID from token + +5. Hold checks crew membership: + - Queries its own PDS: com.atproto.repo.getRecord + - Collection: io.atcr.hold.crew + - Record key: alice's DID + +6. If crew record found → allow upload + Else → deny with 403 Forbidden +``` + +**Trust model:** "Trust but verify" +- User OAuth'd to AppView (proves identity) +- Service token from user's PDS (proves AppView is acting on behalf of user) +- Crew record in hold's PDS (proves user has access to this hold) + ## Use Cases -### 1. Public Hold (Allow All Users) - -**Goal:** Shared storage for any authenticated ATCR user. - -**Setup:** -```bash -# Create crew record with wildcard -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "all-users" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/public", - "memberPattern": "*", - "role": "write" - }' -``` - -**Result:** All authenticated users can push. Owner can selectively bar bad actors. - -### 2. Community Hold (PDS-Specific) - -**Goal:** Storage for all users from a specific community/PDS. - -**Setup:** -```bash -# Allow all community members -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "community-hold" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/community", - "memberPattern": "*.my-community.social", - "role": "write" - }' -``` - -**Result:** Anyone with a `@someone.my-community.social` handle can push. - -### 3. Team Hold with Selective Banning - -**Goal:** Shared team storage, but remove access from former employees. - -**Setup:** -```bash -# Allow team domain -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "team-hold" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/team", - "memberPattern": "*.company.com", - "role": "write" - }' - -# Bar former employee -atproto put-record \ - --collection io.atcr.hold.crew.barred \ - --rkey "bar-former-employee" \ - --value '{ - "$type": "io.atcr.hold.crew.barred", - "hold": "at://did:plc:owner/io.atcr.hold/team", - "member": "did:plc:former-employee", - "reason": "No longer with company" - }' -``` - -**Result:** All `@*.company.com` users can push, except the explicitly barred DID. - -### 4. Anti-Spam with Barred Patterns - -**Goal:** Public hold with protection against known spam instances. - -**Setup:** -```bash -# Allow all users -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "public-hold" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/public", - "memberPattern": "*", - "role": "write" - }' - -# Bar spam instance -atproto put-record \ - --collection io.atcr.hold.crew.barred \ - --rkey "bar-spam-pds" \ - --value '{ - "$type": "io.atcr.hold.crew.barred", - "hold": "at://did:plc:owner/io.atcr.hold/public", - "memberPattern": "*.known-spam.com", - "reason": "Spam source" - }' -``` - -**Result:** Everyone can push except users from `*.known-spam.com`. - -### 5. Mixed Access (Explicit + Patterns) - -**Goal:** Team pattern plus individual guests. - -**Setup:** -```bash -# Team pattern -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "team-pattern" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/team", - "memberPattern": "*.company.com", - "role": "write" - }' - -# Individual contractor -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "contractor-alice" \ - --value '{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/team", - "member": "did:plc:alice-contractor", - "role": "write" - }' -``` - -**Result:** Team members + specific contractor all have access. - -## Implementation Details - -### Code Changes Required - -**Files to modify:** - -1. **`lexicons/io/atcr/hold/crew.json`** - - Make `member` optional (remove from `required`) - - Add `memberPattern` field (string, optional) - - Update description - -2. **`lexicons/io/atcr/hold/crew/barred.json`** (new file) - - Define new lexicon for barred records - - Same structure as crew (member + memberPattern) - - Add `reason` field - -3. **`pkg/atproto/lexicon.go`** - - Update `HoldCrewRecord` struct (add `MemberPattern` field, make `Member` pointer for optional) - - Add `BarredRecord` struct - - Add `NewBarredRecord()` constructor - - Add `BarredCollection` constant - -4. **`pkg/hold/authorization.go`** - - Update `isCrewMember()` to check patterns - - Add `isBarred()` function - - Add `resolveHandle()` helper (DID → handle lookup) - - Add `matchPattern()` helper (glob matching) - - Update `isAuthorizedWrite()` to check barred first - -5. **`pkg/hold/registration.go`** - - Add `HOLD_ALLOW_ALL_CREW` env var handling - - Check env var on every startup (not just first registration) - - Reconcile desired state (env) vs actual state (PDS) - - Create/delete wildcard crew record as needed - -### Pattern Matching Implementation - -```go -// pkg/hold/patterns.go (new file) - -package hold - -import ( - "regexp" - "strings" -) - -// matchPattern checks if a handle matches a pattern -func matchPattern(pattern, handle string) bool { - if pattern == "*" { - return true - } - - // Convert glob to regex - regex := globToRegex(pattern) - matched, err := regexp.MatchString(regex, handle) - if err != nil { - return false - } - return matched -} - -// globToRegex converts a glob pattern to a regex -// *.example.com → ^.*\.example\.com$ -// subdomain.* → ^subdomain\..*$ -// *.bsky.* → ^.*\.bsky\..*$ -func globToRegex(pattern string) string { - // Escape special regex characters except * - escaped := regexp.QuoteMeta(pattern) - - // Replace escaped \* with .* - regex := strings.ReplaceAll(escaped, "\\*", ".*") - - // Anchor to start and end - return "^" + regex + "$" -} -``` - -### Handle Resolution - -```go -// pkg/hold/resolve.go - -package hold - -import ( - "context" - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -// resolveHandle resolves a DID to its current handle -func resolveHandle(did string) (string, error) { - ctx := context.Background() - directory := identity.DefaultDirectory() - - didParsed, err := syntax.ParseDID(did) - if err != nil { - return "", err - } - - ident, err := directory.LookupDID(ctx, didParsed) - if err != nil { - return "", err - } - - return ident.Handle.String(), nil -} -``` - -### Caching Considerations - -**Problem:** Pattern matching requires handle resolution, which adds latency. - -**Solution:** Cache handle lookups with TTL. - -```go -type handleCache struct { - mu sync.RWMutex - cache map[string]cacheEntry // did → handle -} - -type cacheEntry struct { - handle string - expiresAt time.Time -} - -const handleCacheTTL = 10 * time.Minute - -func (c *handleCache) get(did string) (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - entry, ok := c.cache[did] - if !ok || time.Now().After(entry.expiresAt) { - return "", false - } - return entry.handle, true -} - -func (c *handleCache) set(did, handle string) { - c.mu.Lock() - defer c.mu.Unlock() - - c.cache[did] = cacheEntry{ - handle: handle, - expiresAt: time.Now().Add(handleCacheTTL), - } -} -``` - -**Trade-offs:** -- **Cache hit:** Authorization instant -- **Cache miss:** One additional PDS lookup (acceptable for writes) -- **TTL:** 10 minutes balances freshness vs performance - -### HOLD_ALLOW_ALL_CREW Environment Variable - -**Purpose:** Automatically manage wildcard crew access via environment variable. - -**Behavior:** Checked on **every startup** (not just first registration): - -1. **Read env var:** `HOLD_ALLOW_ALL_CREW` (true/false) -2. **Query PDS:** Check for crew record with rkey `"allow-all"` and `memberPattern: "*"` -3. **Reconcile state:** - - If env=`true` and record missing → **Create wildcard crew record** (requires OAuth) - - If env=`false` (or unset) and record exists → **Delete wildcard crew record** (requires OAuth) - - Otherwise → No action needed - -**Well-known record key:** `"allow-all"` (used exclusively for the managed wildcard record) - -**Implementation:** - -```go -// pkg/hold/config.go -type Config struct { - Registration struct { - OwnerDID string - AllowAllCrew bool // HOLD_ALLOW_ALL_CREW - } - // ... -} - -// pkg/hold/registration.go -func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error { - desiredState := s.config.Registration.AllowAllCrew - - // Query PDS for "allow-all" crew record - actualState, err := s.hasAllowAllCrewRecord() - if err != nil { - return fmt.Errorf("failed to check allow-all crew record: %w", err) - } - - // States match - nothing to do - if desiredState == actualState { - log.Printf("Allow-all crew state matches desired state: %v", desiredState) - return nil - } - - // State mismatch - need to reconcile - if desiredState && !actualState { - // Need to create wildcard crew record - log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)") - return s.createAllowAllCrewRecord(callbackHandler) - } - - if !desiredState && actualState { - // Need to delete wildcard crew record - log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW removed/false)") - return s.deleteAllowAllCrewRecord(callbackHandler) - } - - return nil -} - -func (s *HoldService) hasAllowAllCrewRecord() (bool, error) { - ownerDID := s.config.Registration.OwnerDID - if ownerDID == "" { - return false, fmt.Errorf("hold owner DID not configured") - } - - ctx := context.Background() - - // Resolve owner's PDS - pdsEndpoint, err := s.resolveOwnerPDS(ownerDID) - if err != nil { - return false, err - } - - // Query for specific rkey - client := atproto.NewClient(pdsEndpoint, ownerDID, "") - record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all") - - if err != nil { - // Record doesn't exist - return false, nil - } - - // Verify it's the wildcard record (memberPattern: "*") - var crewRecord atproto.HoldCrewRecord - if err := json.Unmarshal(record.Value, &crewRecord); err != nil { - return false, err - } - - // Check if it's the exact wildcard pattern - return crewRecord.MemberPattern == "*", nil -} - -func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { - // This requires OAuth - reuse registration OAuth flow - // Need authenticated client to create record - - ownerDID := s.config.Registration.OwnerDID - pdsEndpoint, err := s.resolveOwnerPDS(ownerDID) - if err != nil { - return err - } - - // Get handle for OAuth - handle, err := resolveHandleFromDID(ownerDID) - if err != nil { - return err - } - - // Run OAuth flow (similar to registration) - ctx := context.Background() - result, err := oauth.InteractiveFlowWithCallback( - ctx, - s.config.Server.PublicURL, - handle, - s.getCrewManagementScopes(), - func(handler http.HandlerFunc) error { - *callbackHandler = handler - return nil - }, - func(authURL string) error { - log.Printf("\n%s", strings.Repeat("=", 80)) - log.Printf("OAUTH REQUIRED: Creating allow-all crew record") - log.Printf("%s", strings.Repeat("=", 80)) - log.Printf("\nVisit: %s\n", authURL) - log.Printf("Waiting for authorization...") - log.Printf("%s\n", strings.Repeat("=", 80)) - return nil - }, - ) - if err != nil { - return err - } - - // Create authenticated client - apiClient := result.Session.APIClient() - client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient) - - // Get hold URI (need to know which hold to grant access to) - holdURI, err := s.getHoldURI() - if err != nil { - return err - } - - // Create wildcard crew record - crewRecord := atproto.HoldCrewRecord{ - Type: atproto.HoldCrewCollection, - Hold: holdURI, - MemberPattern: ptr("*"), // Wildcard - allow all - Role: "write", - CreatedAt: time.Now(), - } - - _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", &crewRecord) - if err != nil { - return fmt.Errorf("failed to create allow-all crew record: %w", err) - } - - log.Printf("✓ Created allow-all crew record (allows all authenticated users)") - return nil -} - -func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { - // Similar OAuth flow for deletion - // Only delete if it's the exact wildcard pattern (safety check) - - isWildcard, err := s.hasAllowAllCrewRecord() - if err != nil { - return err - } - - if !isWildcard { - log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion") - return nil - } - - // OAuth flow (same as create) - ownerDID := s.config.Registration.OwnerDID - pdsEndpoint, err := s.resolveOwnerPDS(ownerDID) - if err != nil { - return err - } - - handle, err := resolveHandleFromDID(ownerDID) - if err != nil { - return err - } - - ctx := context.Background() - result, err := oauth.InteractiveFlowWithCallback( - ctx, - s.config.Server.PublicURL, - handle, - s.getCrewManagementScopes(), - func(handler http.HandlerFunc) error { - *callbackHandler = handler - return nil - }, - func(authURL string) error { - log.Printf("\n%s", strings.Repeat("=", 80)) - log.Printf("OAUTH REQUIRED: Deleting allow-all crew record") - log.Printf("%s", strings.Repeat("=", 80)) - log.Printf("\nVisit: %s\n", authURL) - log.Printf("Waiting for authorization...") - log.Printf("%s\n", strings.Repeat("=", 80)) - return nil - }, - ) - if err != nil { - return err - } - - // Create authenticated client - apiClient := result.Session.APIClient() - client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient) - - // Delete the record - err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all") - if err != nil { - return fmt.Errorf("failed to delete allow-all crew record: %w", err) - } - - log.Printf("✓ Deleted allow-all crew record") - return nil -} - -func (s *HoldService) getCrewManagementScopes() []string { - return []string{ - "atproto", - fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), - fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), - fmt.Sprintf("repo:%s?action=delete", atproto.HoldCrewCollection), - } -} - -// Helper for pointer -func ptr(s string) *string { - return &s -} -``` - -**Startup sequence:** - -```go -// cmd/hold/main.go -func main() { - // ... load config ... - - holdService := hold.NewHoldService(config) - - // Register HTTP routes - var oauthCallbackHandler http.HandlerFunc - http.HandleFunc("/auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) { - if oauthCallbackHandler != nil { - oauthCallbackHandler(w, r) - } else { - http.Error(w, "OAuth callback not initialized", http.StatusInternalServerError) - } - }) - - // Auto-register hold (if HOLD_OWNER set) - if config.Registration.OwnerDID != "" { - err := holdService.AutoRegister(&oauthCallbackHandler) - if err != nil { - log.Fatalf("Failed to register hold: %v", err) - } - - // Reconcile allow-all crew record - err = holdService.ReconcileAllowAllCrew(&oauthCallbackHandler) - if err != nil { - log.Fatalf("Failed to reconcile allow-all crew: %v", err) - } - } - - // Start server... -} -``` - -**Key properties:** - -1. **Idempotent:** Safe to run on every startup -2. **Well-known rkey:** Uses `"allow-all"` exclusively for managed record -3. **Safety:** Only deletes if `memberPattern` is exactly `"*"` (won't touch custom patterns like `*.example.com`) -4. **OAuth required:** Both create and delete operations need authentication -5. **Reuses infrastructure:** Same OAuth flow as registration - -**Example configurations:** +### 1. Personal Hold (Private) ```bash -# Public hold - allow all users -HOLD_ALLOW_ALL_CREW=true - -# Private hold - explicit crew only +# Owner only +HOLD_PUBLIC=false HOLD_ALLOW_ALL_CREW=false -# (or omit the variable entirely) +# No additional crew records needed - captain has implicit access ``` -**Edge cases handled:** - -- Record exists with different pattern → Won't delete (safety) -- OAuth fails → Service won't start (explicit failure) -- PDS unreachable → Startup fails (can't verify state) -- Record exists but env unset → Deletes wildcard (opt-in behavior) - -**Custom patterns preserved:** - -Hold owners can still manually create pattern-based crew records with different rkeys: +### 2. Team Hold (Shared) ```bash -# Manually created pattern (rkey: "community") -atproto put-record \ - --collection io.atcr.hold.crew \ - --rkey "community" \ - --value '{ - "memberPattern": "*.my-community.social", - "role": "write" - }' +# Multiple team members +HOLD_PUBLIC=false +HOLD_ALLOW_ALL_CREW=false + +# Captain adds crew members: +# - did:plc:alice (admin) +# - did:plc:bob (member) +# - did:plc:charlie (member) ``` -The `HOLD_ALLOW_ALL_CREW` management **only touches** the `"allow-all"` rkey with exact `memberPattern: "*"`. +### 3. Public Hold (Community) -## Migration Path +```bash +# Allow any authenticated user (TODO: Implement HOLD_ALLOW_ALL_CREW) +HOLD_PUBLIC=true +HOLD_ALLOW_ALL_CREW=true +``` -**Backward Compatibility:** Fully compatible with existing deployments. +## Planned Features -1. **Existing crew records work unchanged** - - Records with `member` (DID) continue to work - - No changes needed to existing records +### Pattern-Based Access Control -2. **Opt-in patterns** - - Hold owners can add pattern-based crew records - - Mix explicit DIDs and patterns freely +**Status:** Planned but not yet implemented. -3. **Barred list is optional** - - Only needed for selective access revocation - - Empty barred list = no blocking - -4. **Lexicon evolution** - - Making `member` optional is backward compatible (existing records still have it) - - Adding `memberPattern` is additive (old clients ignore it) - -## Future Enhancements - -### 1. PDS-Based Access Control - -**Goal:** Allow/bar users based on their PDS (not handle). - -**Challenge:** ATProto doesn't give PDSes stable identifiers. PDS endpoints are mutable URLs. - -**Potential Solutions:** - -#### Option A: PDS DID Standard (if ATProto adds it) - -If ATProto introduces PDS DIDs: +**Concept:** Allow crew records with pattern matching instead of explicit DIDs: ```json { "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/community", - "memberPattern": "pds:did:plc:pds-id", + "memberPattern": "*.example.com", "role": "write" } ``` -#### Option B: Accept PDS URL Mutability +**Use cases:** +- `"*"` - Allow all authenticated users +- `"*.company.com"` - Allow all users from company domain +- `"*.community.social"` - Allow all community members -Store PDS URLs with understanding they can change: +**Implementation needed:** +- Add `memberPattern` field to crew record schema (make `member` optional) +- Add handle resolution (DID → handle lookup) +- Add pattern matching logic +- Update authorization to check patterns + +### Barred List (Access Revocation) + +**Status:** Planned but not yet implemented. + +**Concept:** Explicit deny list that overrides crew membership: ```json { - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/community", - "memberPattern": "pds:https://my-community.social", - "role": "write" + "$type": "io.atcr.hold.crew.barred", + "member": "did:plc:former-employee", + "reason": "No longer with company", + "barredAt": "2025-10-13T12:00:00Z" } ``` -**Trade-off:** User migration bypasses access control, but this requires effort. +**Priority:** Barred list checked before crew list. -#### Option C: PDS Trust Lists (Federated Model) +### HOLD_ALLOW_ALL_CREW -Reference curated lists of trusted PDSes: +**Status:** Environment variable exists but full implementation pending. -```json -{ - "$type": "io.atcr.hold.crew", - "hold": "at://did:plc:owner/io.atcr.hold/community", - "memberPattern": "trust-list:at://did:plc:curator/trust.list/vetted-pds", - "role": "write" -} +**Concept:** Automatically create/manage wildcard crew record via env var: + +```bash +HOLD_ALLOW_ALL_CREW=true # Creates crew record with memberPattern: "*" ``` -**Status:** Experimental. Requires additional standards. +**Implementation needed:** +- Auto-create wildcard crew record on startup if env=true +- Auto-delete wildcard crew record if env changes to false +- Use well-known rkey "allow-all" for managed record -### 2. Advanced Pattern Matching +## Architecture Notes -**Goal:** Support more sophisticated patterns. +### Why Hold's Embedded PDS? -**Potential patterns:** +**Key insight:** Crew records are **shared data** about the hold, not user-specific data. -- **Regex:** `memberPattern: "regex:^eng-.*@company.com$"` -- **Multiple patterns:** `memberPattern: ["*.example.com", "*.other.com"]` -- **NOT patterns:** `memberPattern: "!*.spam.com"` (everything except) +**Benefits:** +- **Self-contained**: Hold is independent ATProto actor +- **Portable**: Hold can move without coordinating with user PDSs +- **Discoverable**: Query hold's PDS to see who has access +- **Standard**: Uses normal ATProto sync endpoints (subscribeRepos, getRecord, listRecords) -**Implementation:** Extend `matchPattern()` function with pattern type detection. +**Comparison:** +- **User's PDS**: Stores user-specific data (manifests, sailor profile) +- **Hold's PDS**: Stores hold-specific data (captain, crew, configuration) +- Clear separation of concerns -### 3. Temporary Access +### Security Considerations -**Goal:** Time-limited crew membership. +1. **Public Records**: Crew records are public (anyone can see who has access to a hold) +2. **Service Tokens**: Hold trusts user's PDS to issue valid service tokens +3. **DID-Based**: Crew membership is DID-based (permanent), not handle-based +4. **Captain Control**: Only captain can modify crew records (via OAuth to hold's PDS) -**Current support:** `expiresAt` field already in schema (optional). +## Future Improvements -**Enhancement:** Hold service automatically checks expiration during authorization: - -```go -if record.ExpiresAt != nil && time.Now().After(*record.ExpiresAt) { - continue // Skip expired crew record -} -``` - -### 4. Role-Based Access Control (RBAC) - -**Goal:** Fine-grained permissions beyond read/write. - -**Potential roles:** -- `"read"` - Pull only -- `"write"` - Push + pull -- `"admin"` - Manage crew records -- `"owner"` - Full control - -**Current status:** `role` field exists but only `"owner"` and `"write"` are used. - -### 5. Audit Logging - -**Goal:** Track access grants/denials for compliance. - -**Implementation:** -- Log crew checks to structured log -- Include: DID, handle, result (allow/deny), reason -- Optional: Write to ATProto audit log record - -## Security Considerations - -### 1. Public Records - -**Consideration:** Crew and barred records are public ATProto records. - -**Implications:** -- Anyone can see who has access to a hold -- Anyone can see who is barred (and why) -- Similar to Bluesky block lists being public - -**Mitigation:** This is intentional transparency. Hold owners should use generic reasons in barred records if privacy is a concern. - -### 2. Handle Changes - -**Consideration:** Handles can change, but DIDs are permanent. - -**Implications:** -- Pattern matching based on handles can be bypassed by changing handle -- DID-based rules are more stable -- However, changing handles or acquiring new domains requires significant effort: - - Purchasing new domain names ($10-100+/year) - - Updating identity across platforms - - Loss of established reputation/identity - -**Recommendation:** -- Use DID-based crew/barred records for critical access control (permanent) -- Use pattern-based rules for convenience and community management -- The effort required to bypass handle patterns makes them an acceptable deterrent -- Combine both approaches for defense in depth - -### 3. PDS Migration - -**Consideration:** Users can migrate to different PDSes. - -**Implications:** -- PDS-based patterns (future) can be bypassed by migration -- Handle patterns persist across PDS migration (if handle stays same) - -**Recommendation:** Accept this as inherent trade-off. Migration requires user effort and is acceptable "escape hatch." - -### 4. Pattern Matching Performance - -**Consideration:** Complex patterns could cause ReDoS (regex denial of service). - -**Mitigation:** -- Limit pattern complexity (only basic globs in v1) -- Cache handle lookups to minimize repeated work -- Set timeout on pattern matching operations - -### 5. Barred List Circumvention - -**Consideration:** Barred users might create new DIDs. - -**Mitigation:** -- This is fundamental to decentralized identity (users control DIDs) -- Hold owners can add new DIDs to barred list as discovered -- Pattern-based barring (handle/PDS patterns) provides broader coverage - -## Testing Strategy - -### Unit Tests - -**Pattern matching:** -```go -func TestMatchPattern(t *testing.T) { - tests := []struct{ - pattern string - handle string - want bool - }{ - {"*", "anything.com", true}, - {"*.example.com", "alice.example.com", true}, - {"*.example.com", "bob.other.com", false}, - {"eng.*", "eng.company.com", true}, - {"eng.*", "sales.company.com", false}, - } - // ... -} -``` - -**Authorization logic:** -```go -func TestIsAuthorizedWrite(t *testing.T) { - // Test: owner always allowed - // Test: explicit crew member allowed - // Test: pattern match allowed - // Test: barred user denied - // Test: barred pattern denied - // Test: barred overrides crew -} -``` - -### Integration Tests - -1. **Create hold with wildcard crew** → verify any user can write -2. **Add barred record** → verify barred user rejected -3. **Pattern-based crew** → verify matching handles allowed -4. **Mixed access** → verify explicit + pattern both work -5. **Handle resolution failure** → verify fallback to DID-only matching - -### Performance Tests - -1. **Large crew list** (1000+ records) → measure query time -2. **Complex patterns** → measure pattern matching time -3. **Handle cache** → verify cache hit rate -4. **Concurrent requests** → verify no race conditions +1. **Crew management UI** - Web interface for adding/removing crew members +2. **Pattern-based matching** - Implement `memberPattern` field +3. **Barred list** - Implement access revocation +4. **Role-based permissions** - Fine-grained permissions beyond read/write +5. **Temporary access** - Time-limited crew membership (`expiresAt` field) +6. **Audit logging** - Track access grants/denials ## References +- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Embedded PDS architecture details +- [BYOS.md](./BYOS.md) - BYOS deployment and usage - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon) -- [Bluesky Block Lists](https://bsky.app/profile/bsky.app/post/3l7wzyc6i622o) (analogous public records) -- [Go Glob Matching](https://pkg.go.dev/path/filepath#Match) -- [OAuth Scopes](https://atproto.com/specs/oauth#scopes) (for crew management permissions) - -## Appendix: Lexicon Definitions - -### lexicons/io/atcr/hold/crew.json (Updated) - -```json -{ - "lexicon": 1, - "id": "io.atcr.hold.crew", - "defs": { - "main": { - "type": "record", - "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns.", - "key": "any", - "record": { - "type": "object", - "required": ["hold", "role", "createdAt"], - "properties": { - "hold": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')" - }, - "member": { - "type": "string", - "format": "did", - "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set." - }, - "memberPattern": { - "type": "string", - "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set." - }, - "role": { - "type": "string", - "description": "Member's role/permissions. 'owner' = hold owner, 'write' = can push blobs.", - "knownValues": ["owner", "write"] - }, - "expiresAt": { - "type": "string", - "format": "datetime", - "description": "Optional expiration for this membership" - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "Membership creation timestamp" - } - } - } - } - } -} -``` - -### lexicons/io/atcr/hold/crew/barred.json (New) - -```json -{ - "lexicon": 1, - "id": "io.atcr.hold.crew.barred", - "defs": { - "main": { - "type": "record", - "description": "Barred (banned) list for a storage hold. Users/patterns in this list are denied write access, overriding crew membership. Stored in the hold owner's PDS.", - "key": "any", - "record": { - "type": "object", - "required": ["hold", "barredAt"], - "properties": { - "hold": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the hold record" - }, - "member": { - "type": "string", - "format": "did", - "description": "DID of user to bar. Exactly one of 'member' or 'memberPattern' must be set." - }, - "memberPattern": { - "type": "string", - "description": "Pattern for barring multiple users. Supports wildcards: '*.spam.com', 'bot*', etc. Exactly one of 'member' or 'memberPattern' must be set." - }, - "reason": { - "type": "string", - "maxLength": 300, - "description": "Optional human-readable reason for barring (e.g., 'spam', 'abuse', 'policy violation')" - }, - "barredAt": { - "type": "string", - "format": "datetime", - "description": "When the user/pattern was barred" - } - } - } - } - } -} -``` - -## Summary - -This design enables scalable, flexible access control for ATCR holds while: - -- **Preserving ATProto semantics** (DID backlinks, public records) -- **Supporting massive scale** (one record for thousands of users) -- **Enabling selective revocation** (barred list) -- **Maintaining backward compatibility** (existing records work unchanged) -- **Planning for future enhancements** (PDS-based filtering when possible) - ---- - -**Note on terminology:** "Barred" is an ironic reversal of the idiom "no holds barred" (meaning "without restrictions"). In wrestling, when all holds are allowed, it's unrestricted. In ATCR, being "barred from a hold" means you're restricted from access. The pun works in reverse! 🥁 diff --git a/docs/EMBEDDED_PDS.md b/docs/EMBEDDED_PDS.md index 0ddf74e..23471fe 100644 --- a/docs/EMBEDDED_PDS.md +++ b/docs/EMBEDDED_PDS.md @@ -1,280 +1,51 @@ # Embedded PDS Architecture for Hold Services -This document explores the evolution of ATCR's hold service architecture toward becoming an embedded ATProto PDS (Personal Data Server). +This document describes ATCR's hold service architecture using embedded ATProto PDS (Personal Data Server) for access control and federation. ## Motivation -### Comparison to Other ATProto Projects +### The Fragmentation Problem Several ATProto projects face similar challenges with large data storage: -| Project | Large Data | Metadata | Current Solution | -|---------|-----------|----------|------------------| +| Project | Large Data | Metadata | Solution | +|---------|-----------|----------|----------| | **tangled.org** | Git objects | Issues, PRs, comments | External knot storage | | **stream.place** | Video segments | Stream info, chat | Embedded "static PDS" | -| **ATCR** | Container blobs | Manifests, comments, builds | External hold service | +| **ATCR** | Container blobs | Manifests, comments, builds | Embedded PDS in hold service | -**Common problem:** Large binary data can't realistically live in user PDSs, but interaction metadata gets fragmented across different users' PDSs. +**Common problem:** Large binary data can't realistically live in user PDSs, but application metadata needs a federated home. -**Emerging pattern:** Application-specific storage services with embedded minimal PDS implementations. +**ATCR's approach:** Each hold service is a full ATProto actor with its own embedded PDS for **shared data** (captain + crew records, not user-specific data). This PDS stores access control and metadata about the hold itself. -### The Fragmentation Problem +## Current Architecture -#### Tangled.org Example -``` -user/myproject repository -├── Git data → Knot (external storage) -├── Issues → Created by @alice → Lives in alice's PDS -├── PRs → Created by @bob → Lives in bob's PDS -└── Comments → Created by @charlie → Lives in charlie's PDS -``` - -**Problems:** -- Repo owner can't export all issues/PRs easily -- No single source of truth for repo metadata -- Interaction history fragmented across PDSs -- Can't encrypt repo data while maintaining collaboration - -#### ATCR's Similar Challenge -``` -atcr.io/alice/myapp -├── Manifests → alice's PDS -├── Blobs → Hold service (external) -└── Future: Comments, builds, attestations → Where? -``` - -### Stream.place's Approach - -Stream.place built a **minimal "static PDS"** embedded in their application with just the XRPC endpoints they need: -- `com.atproto.repo.describeRepo` -- `com.atproto.sync.subscribeRepos` -- Minimal read methods - -**Why:** Avoid rate-limiting Bluesky's infrastructure with video segments while staying ATProto-native. - -## Current Hold Service Architecture - -The current hold service is intentionally minimal: +### Hold Service Components ``` -Hold Service = - - OAuth token validation (call user's PDS) - - Generate presigned S3 URLs - - Return HTTP redirects - - Optional crew membership checks +Hold Service (did:web:hold01.atcr.io) +├── Embedded PDS (SQLite carstore) - Shared data only +│ ├── Captain record (ownership metadata) +│ ├── Crew records (access control) +│ └── ATProto sync/repo endpoints +├── OCI multipart upload (XRPC) +│ ├── io.atcr.hold.initiateUpload +│ ├── io.atcr.hold.getPartUploadUrl +│ ├── io.atcr.hold.uploadPart +│ ├── io.atcr.hold.completeUpload +│ └── io.atcr.hold.abortUpload +└── Storage driver (S3, filesystem, etc.) ``` -**Endpoints:** -- `POST /get-presigned-url` → S3 download URL -- `POST /put-presigned-url` → S3 upload URL -- `GET /blobs/{digest}` → Proxy fallback -- `PUT /blobs/{digest}` → Proxy fallback -- `GET /health` → Health check +**Important distinction:** +- **Hold's embedded PDS** = Shared data (crew members, hold configuration) +- **User's PDS** = User-specific data (manifests, sailor profile, personal records) +- Hold's PDS does NOT store user-specific container data (that stays in user's own PDS) -**Resource footprint:** -- Single Go binary (~20MB) -- No database (stateless) -- No PDS (validates against user's PDS) -- Minimal memory/CPU (just signing URLs) -- S3 does all the heavy lifting - -This is already **as cheap as possible** for what it does - just an OAuth validation + URL signing service. - -## Why Not Force Blobs into User PDSs? - -### Size Considerations - -**PDS blob limits:** Default ~50MB (Bluesky may be lower) - -**Container layer sizes:** -- Alpine base: ~5MB ✓ -- Config blobs: ~1-5KB ✓ -- Small Go binaries: 10-30MB ✓ -- Node.js base: 100-200MB ✗ -- Python base: 50-100MB ✗ -- ML models: 500MB - 10GB ✗ -- Large datasets: huge ✗ - -**Reality:** Many/most layers exceed 50MB. A split-brain approach would be the norm, not the exception. - -### Split-Brain Complexity - -```go -func (s *SplitBlobStore) Create(ctx context.Context, options ...) { - // Challenges: - // 1. Monolithic uploads: Size known upfront ✓ - // 2. Chunked uploads: Size unknown until complete ✗ - // 3. Resumable uploads: State management across PDS/hold ✗ - // 4. Mount/cross-repo: Which backend to check? ✗ -} -``` - -Detection works for simple cases but breaks down with: -- Multipart/chunked uploads (no size until complete) -- Resumable uploads (stateful across boundaries) -- Cross-repository blob mounts (which backend?) - -### Pragmatic Decision - -**Accept the trade-off:** -- Blobs in holds (practical for large data) -- Manifests in user's PDS (ownership of metadata) -- Focus on making holds easy to deploy and migrate - -Users still own the **important part** - the manifest is the source of truth for what the image is. - -## Embedded PDS Vision - -### Key Insight: Hold is the PDS - -Because blobs are **content-addressed** and **deduplicated globally**, there isn't a singular owner of blob data. Multiple images share the same base layer blobs. - -**Therefore:** The **hold itself** is the PDS (with identity `did:web:hold1.example.com`), not individual image repositories. - -### Proposed Architecture - -``` -Hold Service = Minimal PDS (did:web:hold1.example.com) -├── Standard ATProto blob endpoints: -│ ├── com.atproto.sync.uploadBlob -│ ├── com.atproto.sync.getBlob -│ └── Blob storage → S3 (like normal PDS) -├── Custom XRPC methods: -│ ├── io.atcr.hold.delegateAccess (IAM) -│ ├── io.atcr.hold.getUploadUrl (optimization) -│ ├── io.atcr.hold.getDownloadUrl (optimization) -│ ├── io.atcr.hold.exportImage (data portability) -│ └── io.atcr.hold.getStats (metadata) -└── Records (hold's own PDS): - ├── io.atcr.hold.captain (single record: ownership & metadata) - ├── io.atcr.hold.crew/* (crew membership & permissions) - └── io.atcr.hold.config (hold configuration) -``` - -### Benefits - -1. **ATProto-native**: Uses standard XRPC, not custom REST API -2. **Discoverable**: Hold's DID document advertises capabilities -3. **Portable**: Users can export images via XRPC -4. **Standardized**: Blob operations use ATProto conventions -5. **Future-proof**: Can add more XRPC methods as needed -6. **Interoperable**: Works with ATProto tooling - -## Implementation Details - -### 1. SHA256 to CID Mapping - -ATProto uses CIDs (Content Identifiers) for blobs, while OCI uses SHA256 digests. However, CIDs support SHA256 as the hash function. - -**Key insight:** We can construct CIDs directly from SHA256 digests with no additional storage needed! - -```go -// pkg/hold/cid.go -func DigestToCID(digest string) (cid.Cid, error) { - // sha256:abc123... → raw bytes - hash := parseDigest(digest) - - // Construct CIDv1 with sha256 codec - return cid.NewCidV1( - cid.Raw, // codec - multihash.SHA2_256, // hash function - hash, // hash bytes - ) -} - -func CIDToDigest(c cid.Cid) string { - // Decode multihash → sha256:abc... - mh := c.Hash() - return fmt.Sprintf("sha256:%x", mh) -} -``` - -**Mapping:** -``` -OCI digest: sha256:abc123... -ATProto CID: bafybei... (CIDv1 with sha256, base32 encoded) -Storage path: s3://bucket/blobs/sha256/ab/abc123... -``` - -Blobs stay in distribution's layout, we just compute CID on-the-fly. **No mapping records needed.** - -### 2. Storage: Distribution Layout with PDS Interface - -The hold's blob storage uses distribution's driver directly - no encoding or transformation: - -```go -type HoldBlobStore struct { - storageDriver storagedriver.StorageDriver // S3, filesystem, etc -} - -// Implements ATProto blob interface -func (h *HoldBlobStore) UploadBlob(ctx context.Context, data io.Reader) (cid.Cid, error) { - // 1. Compute sha256 while reading - digest, size := computeDigest(data) - - // 2. Store at distribution's path: blobs/sha256/ab/abc123... - path := h.blobPath(digest) - h.storageDriver.PutContent(ctx, path, data) - - // 3. Return CID (computed from sha256) - return DigestToCID(digest), nil -} - -func (h *HoldBlobStore) GetBlob(ctx context.Context, c cid.Cid) (io.Reader, error) { - // 1. Convert CID → sha256 digest - digest := CIDToDigest(c) - - // 2. Fetch from distribution's path - path := h.blobPath(digest) - return h.storageDriver.Reader(ctx, path, 0) -} -``` - -Storage continues to use distribution's existing S3 layout. The PDS interface is just a wrapper. - -### 3. Authentication & IAM - -**Challenge:** ATProto operations are authenticated AS the account owner. For hold operations, we need actions to be performed AS the hold (not individual users), but authorized BY crew members. - -**Important context:** AppView manages the user's OAuth session. When users authenticate via the credential helper, they actually authenticate through AppView's web interface. AppView obtains and stores the user's OAuth token and DPoP key. The credential helper only receives a registry JWT. - -**Proposed: DPoP Proof Delegation (Standard ATProto Federation)** - -``` -1. User authenticates via AppView (OAuth flow) - - AppView obtains: OAuth token, refresh token, DPoP key, DID - - AppView stores these in its token storage - - Credential helper receives: Registry JWT only - -2. When AppView needs blob access, it calls hold: - POST /xrpc/io.atcr.hold.delegateAccess - Headers: Authorization: DPoP - DPoP: - Body: { - "userDid": "did:plc:alice123", - "purpose": "blob-upload", - "duration": 900 - } - -3. Hold validates (standard ATProto token validation): - - Verify DPoP proof signature matches token's bound key - - Call user's PDS: com.atproto.server.getSession (validates token) - - Extract user's DID from validated session - - Check user's DID in hold's crew records - - If authorized, issue temporary token for blob operations - -4. AppView uses delegated token for blob operations: - POST /xrpc/com.atproto.sync.uploadBlob - Headers: Authorization: DPoP - DPoP: -``` - -**This is standard ATProto federation** - services pass OAuth tokens with DPoP proofs between each other. Hold independently validates tokens against the user's PDS, so there's no trust relationship required. - -**Records stored in hold's PDS:** +### Records Structure +**Captain record** (hold ownership, single record at `io.atcr.hold.captain/self`): ```json -// io.atcr.hold.captain (single record - hold metadata) { "$type": "io.atcr.hold.captain", "owner": "did:plc:alice123", @@ -283,332 +54,69 @@ Storage continues to use distribution's existing S3 layout. The PDS interface is "region": "iad", "provider": "fly.io" } +``` -// io.atcr.hold.crew/* (access control records) +**Crew records** (access control, one per member at `io.atcr.hold.crew/{rkey}`): +```json { "$type": "io.atcr.hold.crew", - "member": "did:plc:alice123", + "member": "did:plc:bob456", "role": "admin", - "permissions": ["blob:read", "blob:write", "crew:manage"], + "permissions": ["blob:read", "blob:write"], "addedAt": "2025-10-14T..." } ``` -**Semantic separation:** -- **Captain record** = Hold ownership and metadata (who owns it, where it's deployed) -- **Crew records** = Access control (who can use it, what permissions they have) +### ATProto PDS Endpoints -**Security considerations:** -- User's OAuth token is exposed to hold during delegation -- However, hold independently validates it (can't be forged) -- Tokens are short-lived (15min typical) -- Hold only accepts tokens for crew members -- Hold validates DPoP binding (requires private key) -- Standard ATProto security model +Standard ATProto sync endpoints: +- `GET /xrpc/com.atproto.sync.getRepo` - Download repository as CAR file +- `GET /xrpc/com.atproto.sync.getBlob` - Get blob or presigned download URL +- `GET /xrpc/com.atproto.sync.subscribeRepos` - Real-time crew changes +- `GET /xrpc/com.atproto.sync.listRepos` - List repositories -### 4. Presigned URLs for Optimized Egress +Repository management: +- `GET /xrpc/com.atproto.repo.describeRepo` - Repository metadata +- `GET /xrpc/com.atproto.repo.getRecord` - Get specific record (captain/crew) +- `GET /xrpc/com.atproto.repo.listRecords` - List crew members +- `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership -While standard ATProto blob endpoints work, direct S3 access is more efficient. Hold can expose custom XRPC methods: +DID resolution: +- `GET /.well-known/did.json` - DID document (did:web resolution) +- `GET /.well-known/atproto-did` - DID for handle resolution + +### OCI Multipart Upload Flow + +``` +1. AppView gets service token from user's PDS: + GET /xrpc/com.atproto.server.getServiceAuth?aud={holdDID} + Response: { "token": "eyJ..." } + +2. AppView initiates multipart upload: + POST /xrpc/io.atcr.hold.initiateUpload + Authorization: Bearer {serviceToken} + Body: { "digest": "sha256:abc..." } + Response: { "uploadId": "xyz" } + +3. For each part: + POST /xrpc/io.atcr.hold.getPartUploadUrl + Body: { "uploadId": "xyz", "partNumber": 1 } + Response: { "url": "https://s3.../presigned" } + +4. Upload part to S3 presigned URL: + PUT {presignedURL} + Body: [part data] + +5. Complete upload: + POST /xrpc/io.atcr.hold.completeUpload + Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] } +``` + +## Implementation Details + +### Storage: Indigo Carstore with SQLite ```go -// io.atcr.hold.getUploadUrl - Get presigned upload URL -type GetUploadUrlRequest struct { - Digest string // sha256:abc... - Size int64 -} - -type GetUploadUrlResponse struct { - UploadURL string // Presigned S3 URL - ExpiresAt time.Time -} - -// io.atcr.hold.getDownloadUrl - Get presigned download URL -type GetDownloadUrlRequest struct { - Digest string -} - -type GetDownloadUrlResponse struct { - DownloadURL string // Presigned S3 URL - ExpiresAt time.Time -} -``` - -**AppView uses optimized path:** -```go -func (a *ATProtoBlobStore) ServeBlob(ctx, w, r, dgst) error { - // Try optimized presigned URL endpoint - resp, err := a.client.GetDownloadUrl(ctx, dgst) - if err == nil { - // Redirect directly to S3 - http.Redirect(w, r, resp.DownloadURL, http.StatusTemporaryRedirect) - return nil - } - - // Fallback: Standard ATProto blob endpoint (proxied) - reader, _ := a.client.GetBlob(ctx, holdDID, cid) - io.Copy(w, reader) -} -``` - -**Best of both worlds:** Standard ATProto interface + S3 optimization for bandwidth efficiency. - -### 5. Image Export for Portability - -Custom XRPC method enables users to export entire images: - -```go -// io.atcr.hold.exportImage - Export all blobs for an image -type ExportImageRequest struct { - Manifest *oci.Manifest // User provides manifest -} - -type ExportImageResponse struct { - ArchiveURL string // Presigned S3 URL to tar.gz - ExpiresAt time.Time -} - -// Implementation: -// 1. Extract all blob digests from manifest (config + layers) -// 2. Create tar.gz with all blobs -// 3. Upload to S3 temp location -// 4. Return presigned download URL (15min expiry) -``` - -Users can request all blobs for their images and migrate to different holds. - -## Changes Required - -### AppView Changes - -**Current:** -```go -type ProxyBlobStore struct { - holdURL string // HTTP endpoint -} - -func (p *ProxyBlobStore) ServeBlob(...) { - // POST /put-presigned-url - // Return redirect -} -``` - -**New:** -```go -type ATProtoBlobStore struct { - holdDID string // did:web:hold1.example.com - holdURL string // Resolved from DID document - client *atproto.Client // XRPC client - delegatedToken string // From io.atcr.hold.delegateAccess -} - -func (a *ATProtoBlobStore) ServeBlob(ctx, w, r, dgst) error { - // Try optimized: io.atcr.hold.getDownloadUrl - // Fallback: com.atproto.sync.getBlob -} -``` - -### Hold Service Changes - -Transform from simple HTTP server to minimal PDS: - -```go -// cmd/hold/main.go -func main() { - // Storage driver (unchanged) - storageDriver := buildStorageDriver() - - // NEW: Embedded PDS - pds := hold.NewEmbeddedPDS(hold.Config{ - DID: "did:web:hold1.example.com", - BlobStore: storageDriver, - Collections: []string{ - "io.atcr.hold.crew", - "io.atcr.hold.config", - }, - }) - - // Serve XRPC endpoints - mux.Handle("/xrpc/", pds.Handler()) - - // Legacy endpoints (optional for backwards compat) - // mux.Handle("/get-presigned-url", legacyHandler) -} -``` - -## Open Questions - -### 1. Docker Hub Size Limits - -**Research findings:** Docker Hub has soft limits around 10-20GB per layer, with practical issues beyond that. No hard-coded enforcement. - -**For ATCR:** Hold services can theoretically support larger blobs if S3 and network infrastructure allows. May want configurable limits to prevent abuse. - -### 2. Token Delegation Security Model - -**Recommended approach:** DPoP proof delegation (standard ATProto federation pattern) - -Open questions: -- How long should delegated tokens last? (15min like presigned URLs?) -- Should delegation be per-operation or session-based? -- Do we need audit logs for delegated operations? -- Can AppView cache delegated tokens across requests? -- Should we implement token refresh for long-running operations? - -### 3. Migration Path - -- Do we support both HTTP and XRPC APIs during transition? -- How do existing manifests with `holdEndpoint: "https://..."` migrate to `holdDid: "did:web:..."`? -- Can AppView auto-detect if hold supports XRPC vs legacy? - -### 4. PDS Implementation Scope - -**Minimal endpoints needed:** -- `com.atproto.sync.uploadBlob` -- `com.atproto.sync.getBlob` -- `com.atproto.repo.describeRepo` (discovery) -- Custom XRPC methods (delegation, presigned URLs, export) - -**Not needed:** -- `com.atproto.repo.*` (no user repos) -- `com.atproto.server.*` (no user sessions) -- Most sync/admin endpoints - -Can we build a reusable "static PDS" library for apps like ATCR, tangled.org, stream.place? - -### 5. Crew Management - -- How are crew members added/removed? -- UI in AppView? CLI tool? Direct XRPC calls? -- Can crew members delegate to other crew members? -- Role hierarchy (owner > admin > member)? - -### 6. Hold Discovery & Registration - -**Decision: No registration records needed in owner's PDS.** - -Since holds are ATProto actors with did:web identity, they are self-describing: - -**Hold's PDS contains everything:** -``` -did:web:hold01.atcr.io -├── io.atcr.hold.captain → { owner: "did:plc:alice123", ... } -└── io.atcr.hold.crew/* → Access control records -``` - -**DID Document with Multiple Services:** - -Holds expose multiple service endpoints to distinguish themselves from generic PDSs: - -```json -{ - "@context": ["https://www.w3.org/ns/did/v1", ...], - "id": "did:web:hold01.atcr.io", - "service": [ - { - "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", - "serviceEndpoint": "https://hold01.atcr.io" - }, - { - "id": "#atcr_hold", - "type": "AtcrHoldService", - "serviceEndpoint": "https://hold01.atcr.io" - } - ] -} -``` - -**Service semantics:** -- **`#atproto_pds`** - Standard ATProto PDS operations (crew queries, record sync) -- **`#atcr_hold`** - ATCR-specific operations (blob storage, presigned URLs) - -**Discovery patterns:** - -1. **Direct deployment** - Owner deploys hold, knows the DID -2. **Sailor profiles** - Users reference holds by DID in their profile -3. **DID resolution** - `did:web:hold01.atcr.io` → `https://hold01.atcr.io/.well-known/did.json` -4. **Service lookup** - Check for `#atcr_hold` service to identify ATCR holds -5. **Crew queries** - AppView queries hold's PDS directly via `#atproto_pds` endpoint - -**AppView resolution flow:** -```go -// 1. Get hold DID from sailor profile -holdDID := profile.DefaultHold // "did:web:hold01.atcr.io" - -// 2. Resolve DID document -didDoc := resolveDidWeb(holdDID) - -// 3. Extract service endpoints -pdsEndpoint := didDoc.GetService("#atproto_pds") // XRPC operations -holdEndpoint := didDoc.GetService("#atcr_hold") // Blob operations - -// 4. Query crew list via PDS endpoint -crew := xrpcClient.ListRecords(pdsEndpoint, "io.atcr.hold.crew") - -// 5. Check if user has access -hasAccess := crew.Contains(userDID) -``` - -**No need for reverse lookup** (owner → holds). Users know their holds because they deployed them. - -**Benefits:** -- ✅ Single source of truth (hold's PDS) -- ✅ No cross-PDS writes during registration -- ✅ Self-describing ATProto actors -- ✅ Standard DID resolution patterns -- ✅ Clear service semantics (PDS vs ATCR-specific) -- ✅ Discoverable via service type - -**OAuth implications:** -- ✅ OAuth registration removed completely (hold is self-describing) -- Hold creates captain + crew records in its own embedded PDS -- No cross-PDS writes or OAuth flows needed - -### 7. Multi-Tenancy - -Could one hold PDS serve multiple "logical holds" for different organizations? - -``` -did:web:hold-provider.com/org1 -did:web:hold-provider.com/org2 -``` - -Or should each hold be a separate deployment? - -### 8. Blob Deduplication - -Current behavior: Global deduplication (same layer shared across all images). - -With embedded PDS: -- Does dedup stay global across all crew/users? -- Or is it per-hold (isolated storage)? -- How do we track blob references for garbage collection? - -### 9. Cost Model - -- Who pays for S3 storage/egress? -- Hold operator? Image owner? Per-pull? -- How to implement metering/billing via XRPC? - -### 10. Disaster Recovery - -- How to backup hold's PDS (crew records, config)? -- Can holds replicate to other holds? -- Image export handles blobs - what about metadata? - -## Implementation Plan - -### Phase 1: Basic PDS with Carstore ✅ COMPLETED - -**Implementation: Using indigo's carstore with SQLite + DeltaSession** - -```go -import ( - "github.com/bluesky-social/indigo/carstore" - "github.com/bluesky-social/indigo/models" - "github.com/bluesky-social/indigo/repo" -) - type HoldPDS struct { did string carstore carstore.CarStore @@ -617,160 +125,16 @@ type HoldPDS struct { dbPath string uid models.Uid // User ID for carstore (fixed: 1) } - -func NewHoldPDS(ctx context.Context, did, dbPath string) (*HoldPDS, error) { - // Create SQLite-backed carstore - sqlStore, err := carstore.NewSqliteStore(dbPath) - sqlStore.Open(dbPath) - cs := sqlStore.CarStore() - - // For single-hold use, fixed UID - uid := models.Uid(1) - - // Create DeltaSession (provides blockstore interface) - session, err := cs.NewDeltaSession(ctx, uid, nil) - - // Create repo with session as blockstore - r := repo.NewRepo(ctx, did, session) - - return &HoldPDS{ - did: did, - carstore: cs, - session: session, - repo: r, - dbPath: dbPath, - uid: uid, - }, nil -} ``` -**Key learnings:** -- ✅ Carstore provides blockstore via `DeltaSession` (not direct access) -- ✅ `models.Uid` is the user ID type (we use fixed UID(1)) -- ✅ DeltaSession needs to be a pointer (`*carstore.DeltaSession`) -- ✅ `repo.NewRepo()` accepts the session directly as blockstore - -**Storage:** -- Single file: `/var/lib/atcr-hold/hold.db` (SQLite) +**Storage location:** Single SQLite file (`/var/lib/atcr-hold/hold.db`) - Contains MST nodes, records, commits in carstore tables -- Proper indigo repo/MST implementation (production-tested) +- Handles compaction/cleanup automatically +- Migration path to Postgres if needed (same carstore API) -**Why SQLite carstore:** -- ✅ Single file persistence (like appview's SQLite) -- ✅ Official indigo storage backend -- ✅ Handles compaction/cleanup automatically -- ✅ Migration path to Postgres/Scylla if needed -- ✅ Easy to replicate (Litestream, LiteFS, rsync) -- ✅ CAR import/export support built-in +### Key Implementation Lessons -**Scale considerations:** -- SQLite carstore marked "experimental" but suitable for single-hold use -- MST designed for massive scale (O(log n) operations) -- 1000 crew records = ~1-2MB database (trivial) -- Bluesky PDSs use carstore for millions of records -- If needed: migrate to Postgres-backed carstore (same API) - -### Hold as Proper ATProto User - -**Decision:** Make holds full ATProto actors for discoverability and ecosystem integration. - -**What this enables:** -- Hold becomes discoverable via ATProto directory -- Can have profile (`app.bsky.actor.profile`) -- Can post status updates (`app.bsky.feed.post`) -- Users can follow holds -- Social proof/reputation via ATProto social graph - -**MVP Scope:** -We're building the minimal PDS needed for discoverability, not a full social client: -- ✅ Signing keys (ES256K via `atproto/atcrypto`) -- ✅ DID document (did:web at `/.well-known/did.json`) -- ✅ Standard XRPC endpoints (`describeRepo`, `getRecord`, `listRecords`) -- ✅ Profile record (`app.bsky.actor.profile`) -- ⏸️ Posting functionality (later - other services can read our records) - -**Key insight:** Other ATProto services will "just work" as long as they can retrieve records from the hold's PDS. We don't need to implement full social features for the hold to participate in the ecosystem. - -### Crew Management: Captain + Individual Records - -**Decision: Captain record (ownership) + Individual crew records (access control)** - -```json -// io.atcr.hold.captain (single record - hold metadata) -{ - "$type": "io.atcr.hold.captain", - "owner": "did:plc:alice123", - "public": false, - "deployedAt": "2025-10-14T...", - "region": "iad", - "provider": "fly.io" -} - -// io.atcr.hold.crew/{rkey} (access control) -{ - "$type": "io.atcr.hold.crew", - "member": "did:plc:alice123", - "role": "admin", // or "member" - "permissions": ["blob:read", "blob:write"], - "addedAt": "2025-10-14T..." -} - -// io.atcr.hold.config/policy (optional) -{ - "$type": "io.atcr.hold.config", - "access": "public", // or "allowlist" - "allowAny": true, // public: allow any authenticated user - "requireAuth": true, // require authentication (no anonymous) - "maxUsers": 1000 // optional limit -} -``` - -**Semantic separation:** -- **Captain record** = Who owns/deployed the hold (billing, deletion, migration rights) -- **Crew records** = Who can use the hold (access control, permissions) -- **Config record** = Hold-wide policies - -**Authorization logic:** -```go -func (p *HoldPDS) CheckAccess(ctx context.Context, userDID string) (bool, error) { - policy := p.GetPolicy(ctx) - - if policy.Access == "public" && policy.AllowAny { - // Public hold - any authenticated ATCR user allowed - // No individual crew record needed - return true, nil - } - - if policy.Access == "allowlist" { - // Check explicit crew membership - _, err := p.GetCrewMember(ctx, userDID) - return err == nil, nil - } - - return false, nil -} -``` - -**Benefits of individual records:** -- Auditability (track who has access) -- Per-user permissions (admin vs member) -- Explicit revocation capabilities -- Analytics (usage tracking) -- Rate limiting (per-user quotas) -- subscribeRepos events on crew changes - -**Use cases:** -- **Public community hold:** `access: "public", allowAny: true` - no crew records needed -- **Private team hold:** `access: "allowlist"` - explicit crew membership -- **Hybrid:** Public access + explicit admin crew records for elevated permissions - -### Phase 2: XRPC Endpoints Implementation ✅ COMPLETED - -**Critical Implementation Lessons Learned:** - -#### 1. Custom Record Types Require Manual CBOR Decoding - -Indigo's `repo.GetRecord()` uses its lexicon decoder which only knows about built-in ATProto types. For custom types, you must use `GetRecordBytes()` and decode manually: +#### 1. Custom Record Types Need Manual CBOR Decoding ```go // ❌ WRONG - Fails with "unrecognized lexicon type" @@ -782,22 +146,11 @@ var crewRecord CrewRecord err = crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)) ``` -**Why:** Indigo's lexicon system doesn't know about `io.atcr.hold.crew` or other custom types. +Indigo's lexicon system doesn't know about custom types like `io.atcr.hold.crew`. -#### 2. JSON Struct Tags Must Match CBOR Tags Exactly - -For CID verification to work, JSON and CBOR encodings must produce identical bytes: +#### 2. JSON and CBOR Struct Tags Must Match ```go -// ❌ WRONG - JSON uses capital field names (Member, Role) -type CrewRecord struct { - Type string `cborgen:"$type"` - Member string `cborgen:"member"` - Role string `cborgen:"role"` - Permissions []string `cborgen:"permissions"` - AddedAt string `cborgen:"addedAt"` -} - // ✅ CORRECT - JSON tags match CBOR tags type CrewRecord struct { Type string `json:"$type" cborgen:"$type"` @@ -808,20 +161,11 @@ type CrewRecord struct { } ``` -**Why:** Verification code CBOR-encodes the JSON record and compares the CID. Mismatched field names produce different bytes and thus different CIDs. +CID verification requires identical bytes from JSON and CBOR encodings. #### 3. MST ForEach Returns Full Paths -The `repo.ForEach()` callback receives full collection paths, not just record keys: - ```go -// ❌ WRONG - Prepends collection prefix again -err := repo.ForEach(ctx, "io.atcr.hold.crew", func(k string, v cid.Cid) error { - // k is already "io.atcr.hold.crew/3m37dr2ddit22" - path := fmt.Sprintf("%s/%s", collection, k) // Double path! - return nil -}) - // ✅ CORRECT - Extract just the rkey err := repo.ForEach(ctx, "io.atcr.hold.crew", func(k string, v cid.Cid) error { // k = "io.atcr.hold.crew/3m37dr2ddit22" @@ -831,102 +175,179 @@ err := repo.ForEach(ctx, "io.atcr.hold.crew", func(k string, v cid.Cid) error { }) ``` -#### 4. All Record Endpoints Must Return CIDs +#### 4. CAR Files Must Include Full MST Path -Per ATProto spec, `com.atproto.repo.getRecord` and `listRecords` must include the record's CID: - -```go -// ✅ CORRECT - Include CID in response -response := map[string]any{ - "uri": fmt.Sprintf("at://%s/%s/%s", did, collection, rkey), - "cid": recordCID.String(), // Required! - "value": record, -} -``` - -**Why:** Clients need the CID to verify record integrity via `com.atproto.sync.getRecord`. - -#### 5. sync.getRecord CAR Files Must Include Full MST Path - -The `com.atproto.sync.getRecord` endpoint must return a CAR file with ALL blocks needed to verify the record: - -```go -// ❌ WRONG - Only includes the record block -blk, _ := repo.Blockstore().Get(ctx, recordCID) -// Write single block to CAR - -// ✅ CORRECT - Capture all accessed blocks -loggingBS := util.NewLoggingBstore(session) -tempRepo, _ := repo.OpenRepo(ctx, loggingBS, repoHead) -_, _, _ = tempRepo.GetRecordBytes(ctx, path) -blocks := loggingBS.GetLoggedBlocks() // Commit + MST nodes + record -// Write all blocks to CAR -``` - -**Components included:** -1. **Commit block** - Repo head with signature, data root, version -2. **MST tree nodes** - Path from root to record (log N depth) +For `com.atproto.sync.getRecord`, return CAR with: +1. **Commit block** - Repo head with signature +2. **MST tree nodes** - Path from root to record 3. **Record block** - The actual record data -**Why:** Clients need the full Merkle path to cryptographically verify the record against the repo head. +Use `util.NewLoggingBstore()` to capture all accessed blocks. -#### 6. CAR Root Must Be Repo Head, Not Record CID +## IAM Challenges -The CAR file's root CID must be the repo head (commit), not the record: +### Current Implementation: Service Tokens + +AppView uses `com.atproto.server.getServiceAuth` to get tokens for calling holds: ```go -// ❌ WRONG - Uses record CID as root -header := &car.CarHeader{ - Roots: []cid.Cid{recordCID}, - Version: 1, -} +// AppView requests service token from user's PDS +GET /xrpc/com.atproto.server.getServiceAuth?aud={holdDID}&lxm=com.atproto.repo.getRecord -// ✅ CORRECT - Uses repo head as root -repoHead, _ := carstore.GetUserRepoHead(ctx, uid) -header := &car.CarHeader{ - Roots: []cid.Cid{repoHead}, // Commit CID - Version: 1, -} +// PDS returns short-lived token (60 seconds) +{ "token": "eyJ..." } + +// AppView uses token to authenticate to hold +Authorization: Bearer eyJ... ``` -**Why:** The CAR represents a slice of the repo from head to record, not just the record itself. +### Known Issues -#### 7. Empty Collections Should Return Empty Arrays +#### 1. RPC Permission Format with IP Addresses -Handle empty collections gracefully instead of returning errors: +**Problem:** Service token RPC permissions don't work with IP addresses in the audience (`aud`) field: -```go -// ✅ CORRECT - Return empty array for missing collection -err := repo.ForEach(ctx, collection, func(k string, v cid.Cid) error { - // ... -}) -if err != nil { - if err.Error() == "mst: not found" { - return []*CrewMemberWithKey{}, nil // Empty collection - } - return nil, err // Real error -} +``` +Error: RPC permission format invalid +Permission: rpc:com.atproto.repo.getRecord?aud=172.28.0.3:8080#atcr_hold +Issue: IP address with port not supported in aud field ``` -**Why:** ATProto expects empty arrays for non-existent collections, not 404 errors. +**Impact:** Local development with IP-based hold DIDs (e.g., `did:web:172.28.0.3:8080`) fails. -### Next Steps +**Workaround:** Falls back to unauthenticated requests (works for public holds only) or use hostname-based DIDs. -1. ~~**Add indigo dependencies**~~ ✅ -2. ~~**Implement HoldPDS with carstore**~~ ✅ -3. ~~**Add crew management**~~ ✅ -4. ~~**Implement standard PDS endpoints**~~ ✅ -5. ~~**Add DID document**~~ ✅ -6. **Custom XRPC methods** - getUploadUrl, getDownloadUrl (presigned URLs) -7. **Wire up in cmd/hold** - Serve XRPC alongside existing HTTP -8. **Test basic operations** - Add/list crew, policy checks -9. **Design delegation/IAM** - Token exchange for authenticated operations -10. **Implement AppView XRPC client** - Support PDS-based holds +#### 2. Dynamic Hold Discovery Limitation + +**Problem:** AppView can only OAuth a user's default hold (configured in AppView), not dynamically discovered holds from sailor profiles. + +**Current limitation:** +- User sets `defaultHold = "did:web:alice-storage.fly.dev"` in sailor profile +- AppView discovers hold DID when user pushes +- AppView tries to get service token for alice's hold from user's PDS +- BUT: User never OAuth'd through alice's hold, only through AppView's default hold +- Result: No service token available, can't authenticate to alice's hold + +**Why this matters:** +- Users can't seamlessly use BYOS (Bring Your Own Storage) +- Hold references in sailor profiles are non-functional +- Limits portability and decentralization goals + +#### 3. Trust Model: "Trust but Verify" + +**Current approach:** +1. User OAuth's to AppView (credential helper flow) +2. Hold has crew member record for user (authorization) +3. AppView requests service token from user's PDS (proof) +4. Hold validates service token from user's PDS (verification) + +**Philosophy:** "Trust but verify" +- IF user OAuth'd to AppView AND hold has crew member record for user → generally trust +- BUT don't want AppView to lie → need proof from user's PDS that it's actually them +- Service tokens provide this proof (user's PDS says "yes, I authorized this") + +**Challenge:** Service tokens work for this model, but scope/permission format issues (see #1, #2) make it fragile in practice. + +### Potential Solutions + +#### Option A: Direct User-to-Hold Authentication + +Users authenticate directly to holds (bypassing AppView service tokens). + +**Pros:** +- ✅ Clear trust model (user ↔ hold) +- ✅ Works with any hold (BYOS friendly) +- ✅ No OAuth scope issues + +**Cons:** +- ❌ Multiple OAuth flows (user's PDS + each hold) +- ❌ Complex credential management +- ❌ Poor UX (authenticate to each hold separately) + +#### Option B: AppView as OAuth Client + +AppView pre-registers with holds and uses its own credentials (not user's). + +**Pros:** +- ✅ No OAuth scope issues +- ✅ Single OAuth flow for user +- ✅ Simpler credential management + +**Cons:** +- ❌ Holds must trust AppView (centralization) +- ❌ Doesn't work for unknown holds +- ❌ Requires registration process + +#### Option C: Public Hold API + +Simplify by making holds public for reads, auth only for writes. + +**Pros:** +- ✅ No OAuth complexity for reads +- ✅ Works offline (no PDS dependency) + +**Cons:** +- ❌ Private holds still need auth +- ❌ Not standard ATProto pattern + +#### Option D: Hybrid Service Token + API Key + +Use service tokens when available, fall back to API keys for BYOS holds. + +**Pros:** +- ✅ Optimal for default holds +- ✅ BYOS works with API keys +- ✅ Backward compatible + +**Cons:** +- ❌ Two auth mechanisms +- ❌ Not pure ATProto + +### Recommended Approach + +**Short-term (MVP):** +1. Public holds (no auth needed for reads) +2. Default hold with service tokens (AppView-managed) +3. Document BYOS limitation + +**Medium-term:** +1. Hybrid approach (service tokens + API key fallback) +2. Clear security model for hold operators + +**Long-term:** +1. Explore direct user-to-hold OAuth +2. Credential helper manages multiple hold sessions +3. Auto-discover and authenticate to new holds + +### Understanding getServiceAuth + +**Purpose:** `com.atproto.server.getServiceAuth` gives a JWT to a service with access to specific functions in the user's PDS. It's a **temporary grant to a service outside of what you OAuth'd to**. + +**How ATCR uses it:** +- User OAuth's to AppView (gets broad access to their account) +- AppView needs to prove to hold that user authorized it +- AppView calls user's PDS: "give me a token scoped for this hold" +- User's PDS issues service token with narrow scope (e.g., `rpc:com.atproto.repo.getRecord?aud={holdDID}`) +- AppView presents this token to hold as proof + +**Industry usage:** +- `getServiceAuth` appears to be the intended pattern for inter-service auth +- Not widely used yet (ATProto ecosystem is young) +- Most apps use `transition:generic` scope for everything (too broad, not ideal) +- RPC permission scopes are finicky and not well documented + +### Open Questions + +1. **RPC permission format:** Can the `aud` field in RPC permissions support IP addresses? Is this a spec limitation or implementation bug? +2. **Scope granularity:** What's the right balance between `transition:generic` (too broad) and fine-grained RPC scopes (finicky)? +3. **Dynamic discovery + auth:** How should AppView authenticate to arbitrary holds discovered from sailor profiles without pre-registration? +4. **Service token caching:** Should service tokens be cached across multiple requests? Current: 50 second cache, is this optimal? ## References - **Stream.place embedded PDS:** https://streamplace.leaflet.pub/3lut7mgni5s2k/l-quote/6_318-6_554#6 - **ATProto OAuth spec:** https://atproto.com/specs/oauth - **ATProto XRPC spec:** https://atproto.com/specs/xrpc +- **ATProto Service Auth:** https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth - **CID spec:** https://github.com/multiformats/cid - **OCI Distribution Spec:** https://github.com/opencontainers/distribution-spec diff --git a/docs/IMAGE_SIGNING.md b/docs/IMAGE_SIGNING.md new file mode 100644 index 0000000..b61bed0 --- /dev/null +++ b/docs/IMAGE_SIGNING.md @@ -0,0 +1,733 @@ +# Image Signing with ATProto + +ATCR can support cryptographic signing of container images to ensure authenticity and integrity. This document explores different approaches and recommends a design based on Notary v2's plugin architecture adapted for ATProto. + +## Background: Why Not Cosign? + +[Sigstore Cosign](https://github.com/sigstore/cosign) is the most popular OCI image signing tool, but has several incompatibilities with ATProto: + +### 1. Key Format Mismatch + +**ATProto PDS keys:** +- Format: secp256k1 (K256) for signing +- Purpose: ATProto record signatures, DID authentication +- Access: Private keys never leave the PDS server +- Standard: ATProto specification + +**Cosign expected keys:** +- Format: ECDSA P-256, RSA, or Ed25519 +- Purpose: Image signing (not ATProto records) +- Access: User-controlled private keys +- Standard: Sigstore/PKIX + +**Problem:** Can't use PDS keys directly for Cosign signing - wrong curve, wrong access model, wrong security boundary. + +### 2. No Direct PDS Key Access + +**Security model:** +- PDS private keys are server-side secrets +- Never exposed to clients (even authenticated users) +- Used only by PDS for ATProto operations +- Exposing them would compromise entire account security + +**Cosign requirement:** +- Needs access to private key for signing operations +- Expects user-controlled keys or KMS integration + +**Problem:** Can't sign images client-side with PDS keys without fundamentally breaking ATProto security model. + +### 3. Keyless Signing Complexity + +Cosign supports "keyless" signing via OIDC + Fulcio CA: + +**What it requires:** +- OIDC identity provider (Google, GitHub, etc.) +- Fulcio certificate authority (issues short-lived certs) +- Rekor transparency log (immutable signature log) +- All infrastructure managed by Sigstore + +**ATProto adaptation would need:** +- **OIDC bridge**: Make ATProto DIDs look like OIDC identities + - Map `did:plc:alice123` → OIDC claims + - PDS as OIDC provider? (not in spec) + - Requires custom OIDC server wrapping ATProto auth +- **Fulcio adaptation**: Issue certs based on ATProto identities + - Deploy and manage CA infrastructure + - Handle DID resolution in cert issuance + - Trust anchor distribution +- **Rekor instance**: Public transparency log for signatures + - High availability requirements + - Storage and indexing at scale + - Replication and backup + +**Problem:** Too much infrastructure for ATCR to host and manage. Defeats the purpose of decentralized architecture. + +### 4. Signature Storage + +**Cosign storage:** +- OCI registry artifacts (signatures as ORAS manifests) +- Stored alongside images in registry + +**ATCR ideal:** +- Signatures in ATProto records (user's PDS) +- Discoverable via ATProto queries +- Integrated with ATProto's existing signature/verification model + +**Problem:** Would need to patch Cosign or run dual storage (OCI + ATProto) which creates consistency issues. + +### Conclusion: Cosign Doesn't Fit + +While Cosign is excellent for traditional registries, forcing it into ATProto would require: +- Breaking ATProto security model (exposing PDS keys), OR +- Building massive OIDC/Fulcio/Rekor infrastructure, OR +- Running parallel storage systems with consistency problems + +**Better approach:** Use a more flexible signing framework designed for extensibility. + +## Notary v2: Plugin-Based Architecture + +[Notary v2](https://notaryproject.dev/) (also called "Notation" or "Notary Project") is a CNCF signature specification with a plugin architecture that fits ATProto better. + +### Why Notary v2? + +**Flexible plugin system:** +- **Trust store plugins**: Custom key resolution (e.g., from ATProto records) +- **Signature plugins**: Custom signature storage (e.g., in PDS) +- **Verification plugins**: Custom verification logic +- Plugins written in any language, communicate via stdio + +**Multiple key types supported:** +- ECDSA, RSA, Ed25519 out of box +- Can support custom key types via plugins +- Signature envelope format is extensible + +**Designed for extensibility:** +- Not tied to specific PKI (unlike Cosign/Sigstore) +- Trust policies are configurable +- Storage backend is pluggable +- Works with custom identity systems + +**Standard CLI:** +- `notation sign` / `notation verify` commands +- Users don't need to learn new tools +- Integration with Docker/containerd + +### Notary v2 Architecture + +``` +┌─────────────────────┐ +│ notation CLI │ User signs/verifies images +└──────────┬──────────┘ + │ + ├─────────────────────────────────────┐ + │ │ +┌──────────▼─────────┐ ┌───────────▼──────────┐ +│ Signing Plugin │ │ Trust Store Plugin │ +│ │ │ │ +│ - Read private key │ │ - Resolve DID → PDS │ +│ - Generate sig │ │ - Fetch public keys │ +│ - Store in PDS │ │ - Verify trust │ +└────────────────────┘ └──────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ User's PDS (ATProto) │ +│ │ +│ io.atcr.signing.key (public keys) │ +│ io.atcr.signature (signatures) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Proposed Design: ATProto Signing + +### Key Management + +**Separate signing keys from PDS keys:** + +1. **User generates signing key pair locally:** + ```bash + notation key generate --id alice-signing-key --type ecdsa + # Or: --type ed25519, --type rsa + ``` + +2. **Public key published to ATProto:** + ```json + { + "$type": "io.atcr.signing.key", + "keyId": "alice-signing-key", + "keyType": "ecdsa-p256", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...", + "validFrom": "2025-10-20T12:00:00Z", + "expiresAt": "2026-10-20T12:00:00Z", + "revoked": false, + "createdAt": "2025-10-20T12:00:00Z" + } + ``` + +3. **Private key stored locally:** + - Docker credential store + - OS keychain (macOS Keychain, Windows Credential Manager) + - File with restrictive permissions + - Hardware security module (future) + +**Why separate keys?** +- ✅ No need to access PDS private keys +- ✅ Standard key formats (ECDSA, Ed25519, RSA) +- ✅ User controls key lifecycle +- ✅ Can use hardware tokens (YubiKey, etc.) +- ✅ Security boundary separation (signing ≠ identity) +- ✅ Key rotation without changing DID + +### Signing Flow + +``` +1. User: notation sign atcr.io/alice/myapp:latest --key alice-signing-key + +2. notation-atproto plugin: + a. Resolve image → manifest digest + b. Read private key from local keystore + c. Generate signature over manifest digest + d. Get OAuth token for alice's PDS + e. Create signature record in alice's PDS + +3. Signature stored in alice's PDS: + { + "$type": "io.atcr.signature", + "repository": "alice/myapp", + "digest": "sha256:abc123...", + "signature": "MEUCIQDx...", // base64 signature bytes + "keyId": "alice-signing-key", + "signatureAlgorithm": "ecdsa-p256-sha256", + "signedAt": "2025-10-20T12:34:56Z" + } + +4. Record key: sha256 of (digest + keyId) for deduplication +``` + +### Verification Flow + +``` +1. User: notation verify atcr.io/alice/myapp:latest + +2. notation-atproto plugin: + a. Resolve "alice" → did:plc:alice123 → pds.alice.com + b. Fetch manifest digest: sha256:abc123 + c. Query alice's PDS for signatures: + GET /xrpc/com.atproto.repo.listRecords? + repo=did:plc:alice123& + collection=io.atcr.signature + d. Filter records matching digest: sha256:abc123 + e. For each signature: + - Fetch public key from io.atcr.signing.key record + - Check key not revoked, not expired + - Verify signature bytes over digest + - Check trust policy (is this key trusted?) + +3. Trust policy evaluation: + - Signature valid cryptographically? ✓ + - Key belongs to image owner (alice)? ✓ + - Key not revoked? ✓ + - Key not expired? ✓ + - Trust policy satisfied? ✓ + +4. Output: Verification succeeded ✓ +``` + +### Trust Policies + +Notary v2 uses trust policies to define what signatures are required: + +```json +{ + "version": "1.0", + "trustPolicies": [ + { + "name": "atcr-images", + "registryScopes": ["atcr.io/*/*"], + "signatureVerification": { + "level": "strict" + }, + "trustStores": ["atproto:default"], + "trustedIdentities": [ + "did:plc:*" // Trust any ATProto DID + ] + } + ] +} +``` + +**Policy options:** +- `level: strict` - Signature required, verification must pass +- `level: permissive` - Signature optional, but verified if present +- `level: audit` - Signature logged but doesn't block +- `level: skip` - No verification + +**Trust store resolution:** +- `atproto:default` - Use ATProto plugin to resolve keys +- Plugin queries user's PDS for `io.atcr.signing.key` records +- Verifies key is owned by the image owner (DID match) + +### ATProto Records + +**io.atcr.signing.key** - Public signing keys + +```json +{ + "$type": "io.atcr.signing.key", + "keyId": "alice-signing-key", + "keyType": "ecdsa-p256", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZI...", + "validFrom": "2025-10-20T12:00:00Z", + "expiresAt": "2026-10-20T12:00:00Z", + "revoked": false, + "purpose": ["image-signing"], + "createdAt": "2025-10-20T12:00:00Z" +} +``` + +**Record key:** `keyId` (user-chosen identifier) + +**Fields:** +- `keyId`: Unique identifier for this key +- `keyType`: Algorithm (ecdsa-p256, ed25519, rsa-2048, rsa-4096) +- `publicKey`: PEM-encoded public key +- `validFrom`: Key becomes valid at this time +- `expiresAt`: Key expires at this time (null = no expiry) +- `revoked`: Key has been revoked (true/false) +- `purpose`: Array of purposes (image-signing, sbom-signing, etc.) + +**io.atcr.signature** - Image signatures + +```json +{ + "$type": "io.atcr.signature", + "repository": "alice/myapp", + "digest": "sha256:abc123...", + "signature": "MEUCIQDxH7...", + "keyId": "alice-signing-key", + "signatureAlgorithm": "ecdsa-p256-sha256", + "signedAt": "2025-10-20T12:34:56Z", + "createdAt": "2025-10-20T12:34:56Z" +} +``` + +**Record key:** SHA256 hash of `(digest || keyId)` for deduplication + +**Fields:** +- `repository`: Image repository (alice/myapp) +- `digest`: Manifest digest being signed +- `signature`: Base64-encoded signature bytes +- `keyId`: Reference to signing key record +- `signatureAlgorithm`: Algorithm used for signing +- `signedAt`: When signature was created + +### Plugin Implementation + +**notation-atproto** - Notary v2 plugin for ATProto + +**Trust store plugin:** +```go +// Implements: notation trust store plugin spec +// https://notaryproject.dev/docs/user-guides/how-to/plugin-management/ + +type ATProtoTrustStore struct { + resolver *atproto.Resolver + client *atproto.Client +} + +// GetKeys resolves public keys for a given identity (DID) +func (t *ATProtoTrustStore) GetKeys(did string) ([]PublicKey, error) { + // 1. Resolve DID → PDS endpoint + pds, err := t.resolver.ResolvePDS(did) + + // 2. Query PDS for io.atcr.signing.key records + records, err := t.client.ListRecords(pds, did, "io.atcr.signing.key") + + // 3. Filter active keys (not revoked, not expired) + keys := []PublicKey{} + for _, record := range records { + if !record.Revoked && !record.Expired() { + keys = append(keys, ParsePublicKey(record.PublicKey)) + } + } + + return keys, nil +} +``` + +**Signature store plugin:** +```go +// Store signature in user's PDS +func (s *ATProtoSignatureStore) StoreSignature(sig Signature) error { + // 1. Get OAuth token for user's PDS + token, err := s.oauthClient.GetToken() + + // 2. Create signature record + record := SignatureRecord{ + Type: "io.atcr.signature", + Repository: sig.Repository, + Digest: sig.Digest, + Signature: base64.Encode(sig.Bytes), + KeyId: sig.KeyId, + SignatureAlgorithm: sig.Algorithm, + SignedAt: time.Now(), + } + + // 3. Generate record key (hash of digest + keyId) + rkey := sha256.Sum256([]byte(sig.Digest + sig.KeyId)) + + // 4. Write to PDS + err = s.client.PutRecord(pds, did, "io.atcr.signature", hex.Encode(rkey), record) + + return err +} + +// Retrieve signatures for a digest +func (s *ATProtoSignatureStore) GetSignatures(did, digest string) ([]Signature, error) { + // Query PDS for matching signatures + records, err := s.client.ListRecords(pds, did, "io.atcr.signature") + + // Filter by digest + sigs := []Signature{} + for _, record := range records { + if record.Digest == digest { + sigs = append(sigs, ParseSignature(record)) + } + } + + return sigs, nil +} +``` + +**Plugin installation:** +```bash +# Install notation CLI +brew install notation + +# Install ATProto plugin +notation plugin install notation-atproto --version v1.0.0 + +# Configure trust policy +cat > ~/.config/notation/trustpolicy.json < +notation-atproto signature inspect +``` + +**Helper utilities:** +- Bulk re-signing for key rotation +- Signature audit logs +- Trust policy generators +- Key lifecycle management + +### Phase 3: AppView Integration (2-3 weeks) + +**Web UI features:** +- Display signature status on repository pages +- Show signing keys for users +- Signature verification badges +- Key management interface + +**API endpoints:** +- `GET /v2/alice/myapp/signatures` - List signatures for image +- `GET /v2/alice/keys` - List user's signing keys +- `POST /v2/alice/keys/revoke` - Revoke key via web UI + +### Phase 4: Advanced Features (ongoing) + +**Hardware token support:** +- YubiKey integration +- TPM-backed keys +- Hardware-backed keystores + +**Timestamp verification:** +- Trusted timestamp authorities +- Prove signature was created at specific time +- Long-term signature validity + +**SBOM signing:** +- Sign SBOMs with same keys +- Link SBOM signatures to image signatures +- Unified verification workflow + +## Comparison: Cosign vs Notary v2 for ATCR + +| Feature | Cosign | Notary v2 | Winner | +|---------|--------|-----------|--------| +| **ATProto integration** | Requires OIDC bridge | Plugin system | ✅ Notary | +| **Key format flexibility** | Limited | Extensible | ✅ Notary | +| **Custom storage** | OCI only | Pluggable | ✅ Notary | +| **Infrastructure needs** | Fulcio + Rekor | None | ✅ Notary | +| **Keyless signing** | Yes (complex) | No | ⚠️ Cosign* | +| **Ecosystem maturity** | High | Medium | ⚠️ Cosign* | +| **CLI simplicity** | Very simple | Simple | ⚠️ Cosign* | +| **Plugin development** | N/A | Required | ⚠️ Mixed | + +*Cosign advantages don't outweigh ATProto incompatibilities + +**Recommendation: Notary v2 with ATProto plugin** + +## References + +- [Notary v2 Specification](https://notaryproject.dev/) +- [Notation CLI](https://github.com/notaryproject/notation) +- [Notary Plugin Specification](https://notaryproject.dev/docs/user-guides/how-to/plugin-management/) +- [Sigstore Cosign](https://github.com/sigstore/cosign) (for comparison) +- [ATProto Specification](https://atproto.com/) +- [OCI Artifacts](https://github.com/opencontainers/artifacts) +- [RFC 7515 - JSON Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (signature formats) diff --git a/docs/SAILOR.md b/docs/SAILOR.md index cdde2d9..e6daf8a 100644 --- a/docs/SAILOR.md +++ b/docs/SAILOR.md @@ -1,101 +1,394 @@ -User approved Claude's plan: - ⎿ Implement Sailor Profile & Hold Discovery System +# Sailor Profile System - Summary +## Overview - Add io.atcr.sailor.profile record type to manage user's default hold preference, and update manifest to store historical hold endpoint reference. This enables transparent hold - routing while preserving image ownership semantics. +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 - Changes Required +## Concepts - 1. Create Sailor Profile Lexicon +**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 - File: lexicons/io/atcr/sailor/profile.json - - New record type: io.atcr.sailor.profile - - Fields: defaultHold (string, nullable), createdAt, updatedAt +**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 - 2. Update Manifest Lexicon +## Sailor Profile Record - File: lexicons/io/atcr/manifest.json - - Add holdEndpoint field (string, required) - - This is historical reference (immutable per manifest) +```json +{ + "$type": "io.atcr.sailor.profile", + "defaultHold": "did:web:hold.example.com", + "createdAt": "2025-10-02T12:00:00Z", + "updatedAt": "2025-10-02T12:00:00Z" +} +``` - 3. Update Go Types +**Fields:** +- `defaultHold` (string, optional) - Hold DID or URL (auto-normalized to DID) +- `createdAt` (datetime, required) - Profile creation timestamp +- `updatedAt` (datetime, required) - Last update timestamp - File: pkg/atproto/lexicon.go - - Add SailorProfileCollection = "io.atcr.sailor.profile" - - Add SailorProfileRecord struct - - Add NewSailorProfileRecord() constructor - - Update ManifestRecord struct to include HoldEndpoint field +**Record key:** Always `"self"` (only one profile per user) - 4. Create Profile Management +**Collection:** `io.atcr.sailor.profile` - File: pkg/atproto/profile.go (new file) - - EnsureProfile(ctx, client, defaultHoldDID) function - - Logic: check if profile exists, create with default if not +## Profile Management - 5. Update Auth Handlers +### Automatic Creation - Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go - - Call EnsureProfile() after token validation - - Use authenticated client (has write access to user's PDS) - - Pass AppView's default_hold_did config (format: "did:web:hold01.atcr.io") +Profiles are created automatically on first authentication: - 6. Update Hold Resolution +```go +// During OAuth login or Basic Auth token exchange +func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { + // ... OAuth flow ... - File: pkg/middleware/registry.go - - Update findStorageEndpoint() priority: - a. Check io.atcr.sailor.profile.defaultHold - b. If null (opted out): check user's io.atcr.hold, then AppView default - c. If no profile: check user's io.atcr.hold, then AppView default + // Create ATProto client with user's OAuth session + client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) - 7. Store Hold in Manifest + // Ensure profile exists (creates with AppView's default if not) + err := atproto.EnsureProfile(ctx, client, appViewDefaultHoldDID) +} +``` - File: pkg/atproto/manifest_store.go - - When creating manifest, include resolved holdEndpoint - - Pass hold endpoint through context or parameter +**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` - 8. Update Pull to Use Manifest Hold +### Web UI Management - File: pkg/atproto/manifest_store.go and pkg/storage/routing_repository.go - - On pull, extract holdEndpoint from manifest - - Route blob requests to that hold (not via discovery) +Users can update their profile via the settings page (`/settings`): - 9. Update Documentation +**View current profile:** +``` +GET /settings +→ Shows current defaultHold value +``` - Files: CLAUDE.md, docs/BYOS.md, .env.example - - Document sailor profile concept - - Explain hold resolution priority - - Update examples for shared holds - - Document how crew members configure profile +**Update defaultHold:** +``` +POST /api/settings/update-hold +Form data: hold_endpoint=did:web:team-hold.fly.dev - Benefits +→ Updates sailor profile in user's PDS +→ Returns success confirmation +``` - - ✅ URL structure remains atcr.io// (ownership clear) - - ✅ Hold is transparent infrastructure (like S3 region) - - ✅ Supports personal, shared, and public holds - - ✅ Historical reference in manifest (pull works even if defaults change) - - ✅ User can opt-out (set defaultHold to null) - - ✅ Future: UI for self-service profile management +**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 -Progress Summary +Users can also manage their profile using standard ATProto tools: - ✅ Completed: - 1. Created io.atcr.sailor.profile lexicon - 2. Updated io.atcr.manifest lexicon with holdEndpoint field - 3. Updated Go types in pkg/atproto/lexicon.go - 4. Created profile management in pkg/atproto/profile.go - 5. Updated /auth/exchange handler to manage profile +**Get profile:** +```bash +atproto get-record \ + --collection io.atcr.sailor.profile \ + --rkey self +``` - ⏳ In Progress: - - Need to update /auth/token handler similarly (add defaultHoldDID parameter and profile management) - - Fix compilation error in extractDefaultHoldDID() - should use configuration.Middleware type not any +**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" + }' +``` - 🔜 Remaining: - - Update findStorageEndpoint() for new priority logic (check profile → own hold → default) - - Update manifest_store.go to include holdEndpoint when creating manifests - - Update pull flow to use manifest holdEndpoint - - Update documentation +**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" + }' +``` - The architecture is solid - just need to finish the token handler update and fix the type issue in the config extraction. Would you like me to continue? +## 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 = 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) diff --git a/docs/SBOM_SCANNING.md b/docs/SBOM_SCANNING.md new file mode 100644 index 0000000..aba241f --- /dev/null +++ b/docs/SBOM_SCANNING.md @@ -0,0 +1,568 @@ +# SBOM Scanning + +ATCR supports optional Software Bill of Materials (SBOM) generation for container images stored in holds. This feature enables automated security scanning and vulnerability analysis while maintaining the decentralized architecture. + +## Overview + +When enabled, holds automatically generate SBOMs for uploaded container images in the background. The scanning process: + +- **Async execution**: Scanning happens after upload completes (non-blocking) +- **ORAS artifacts**: SBOMs stored as OCI Registry as Storage (ORAS) artifacts +- **ATProto integration**: Scan results stored as `io.atcr.manifest` records in hold's embedded PDS +- **Tool agnostic**: Results accessible via XRPC, ATProto queries, and direct blob URLs +- **Opt-in**: Disabled by default, enabled per-hold via configuration + +### Default Scanner: Syft + +ATCR uses [Anchore Syft](https://github.com/anchore/syft) for SBOM generation: +- Industry-standard SBOM generator +- Supports SPDX and CycloneDX formats +- Comprehensive package detection (OS packages, language libraries, etc.) +- Active maintenance and CVE database updates + +Future enhancements may include [Grype](https://github.com/anchore/grype) for vulnerability scanning and [Trivy](https://github.com/aquasecurity/trivy) for comprehensive security analysis. + +## Trust Model + +### Same Trust as Docker Hub + +SBOM scanning follows the same trust model as Docker Hub or other centralized registries: + +**Docker Hub model:** +- Docker Hub scans your image on their infrastructure +- Results stored in their database +- You trust Docker Hub's scanner version and scan integrity + +**ATCR hold model:** +- Hold scans image on their infrastructure +- Results stored in hold's embedded PDS +- You trust hold operator's scanner version and scan integrity + +The security comes from **reproducibility** and **transparency**, not storage location: +- Anyone can re-scan the same digest and verify results +- Multiple holds scanning the same image provide independent verification +- Scanner version and scan timestamp are recorded in ATProto records + +### Why Hold's PDS? + +Scan results are stored in the **hold's embedded PDS** rather than the user's PDS: + +**Advantages:** +1. **No OAuth expiry issues**: Hold owns its PDS, no service tokens needed +2. **Hold-scoped metadata**: Scanner version, scan time, hold configuration +3. **Multiple perspectives**: Different holds can scan the same image independently +4. **Simpler auth**: Hold writes directly to its own PDS +5. **Keeps user PDS lean**: Potentially large SBOM data doesn't bloat user's repo + +**Security properties:** +- Same trust level as trusting hold to serve correct blobs +- DID signatures prove which hold generated the SBOM +- Reproducible scans enable independent verification +- Multiple holds scanning same digest → compare results for tampering detection + +## ORAS Manifest Format + +SBOMs are stored as ORAS artifacts that reference their subject image using the OCI referrers specification. + +### Example Manifest Record + +```json +{ + "$type": "io.atcr.manifest", + "repository": "alice/myapp", + "digest": "sha256:4a5e...", + "holdDid": "did:web:hold01.atcr.io", + "holdEndpoint": "https://hold01.atcr.io", + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/spdx+json", + "subject": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:abc123...", + "size": 1234 + }, + "config": { + "mediaType": "application/vnd.oci.empty.v1+json", + "digest": "sha256:44136f...", + "size": 2 + }, + "layers": [ + { + "mediaType": "application/spdx+json", + "digest": "sha256:def456...", + "size": 5678, + "annotations": { + "org.opencontainers.image.title": "sbom.spdx.json" + } + } + ], + "manifestBlob": { + "$type": "blob", + "ref": { "$link": "bafyrei..." }, + "mimeType": "application/vnd.oci.image.manifest.v1+json", + "size": 789 + }, + "ownerDid": "did:plc:alice123", + "scannedAt": "2025-10-20T12:34:56.789Z", + "scannerVersion": "syft-v1.0.0", + "createdAt": "2025-10-20T12:34:56.789Z" +} +``` + +### Key Fields + +- `artifactType`: Distinguishes SBOM artifact from regular image manifest + - `application/spdx+json` for SPDX format + - `application/vnd.cyclonedx+json` for CycloneDX format +- `subject`: Reference to the original image manifest +- `ownerDid`: DID of the image owner (for multi-tenant holds) +- `scannedAt`: ISO 8601 timestamp of when scan completed +- `scannerVersion`: Tool version for reproducibility tracking + +### SBOM Blob + +The actual SBOM document is stored as a blob in the hold's storage backend and referenced in the manifest's `layers` array. The blob contains the full SPDX or CycloneDX JSON document. + +## Configuration + +SBOM scanning is configured via environment variables on the hold service. + +### Environment Variables + +```bash +# Enable SBOM scanning (opt-in) +HOLD_SBOM_ENABLED=true + +# Number of concurrent scan workers (default: 2) +# Higher values = faster scanning, more CPU/memory usage +HOLD_SBOM_WORKERS=4 + +# SBOM output format (default: spdx-json) +# Options: spdx-json, cyclonedx-json +HOLD_SBOM_FORMAT=spdx-json + +# Future: Enable vulnerability scanning with Grype +# HOLD_VULN_ENABLED=true +``` + +### Example Configuration + +```bash +# .env.hold +HOLD_PUBLIC_URL=https://hold01.atcr.io +STORAGE_DRIVER=s3 +S3_BUCKET=my-hold-blobs +HOLD_OWNER=did:plc:xyz123 +HOLD_DATABASE_PATH=/var/lib/atcr/hold.db + +# Enable SBOM scanning +HOLD_SBOM_ENABLED=true +HOLD_SBOM_WORKERS=2 +HOLD_SBOM_FORMAT=spdx-json +``` + +## Scanning Workflow + +### 1. Upload Completes + +When a container image is successfully pushed to a hold: + +``` +1. Client: docker push atcr.io/alice/myapp:latest +2. AppView routes blobs to hold service +3. Hold receives multipart upload via XRPC +4. Hold completes upload and stores blobs +5. Hold checks: HOLD_SBOM_ENABLED=true? +6. If yes: enqueue scan job (non-blocking) +7. Upload completes immediately +``` + +### 2. Background Scanning + +Scan workers process jobs from the queue: + +``` +1. Worker pulls job from queue +2. Extracts image layers from storage +3. Runs Syft on extracted filesystem +4. Generates SBOM in configured format +5. Uploads SBOM blob to storage +6. Creates ORAS manifest record in hold's PDS +7. Job complete +``` + +### 3. Result Storage + +SBOM results are stored in two places: + +1. **SBOM blob**: Full JSON document in hold's blob storage +2. **ORAS manifest**: Metadata record in hold's embedded PDS + - Collection: `io.atcr.manifest` + - Record key: SBOM manifest digest + - Contains reference to subject image + +## Accessing SBOMs + +Multiple methods for discovering and retrieving SBOM data. + +### 1. XRPC Query Endpoint + +Query for SBOMs by image digest: + +```bash +# Get SBOM for a specific image +curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?\ + digest=sha256:abc123&\ + ownerDid=did:plc:alice123&\ + repository=alice/myapp" + +# Response: ORAS manifest JSON +{ + "manifest": { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/spdx+json", + "subject": { "digest": "sha256:abc123...", ... }, + "layers": [ { "digest": "sha256:def456...", ... } ] + }, + "scannedAt": "2025-10-20T12:34:56.789Z", + "scannerVersion": "syft-v1.0.0" +} +``` + +### 2. ATProto Repository Queries + +Use standard ATProto XRPC to list all SBOMs: + +```bash +# List all SBOM manifests in hold's PDS +curl "https://hold01.atcr.io/xrpc/com.atproto.repo.listRecords?\ + repo=did:web:hold01.atcr.io&\ + collection=io.atcr.manifest" + +# Filter by artifactType (requires AppView indexing) +# Returns all SBOM artifacts +``` + +### 3. Direct SBOM Blob Download + +Download the full SBOM JSON file: + +```bash +# Get SBOM blob CID from manifest layers[0].digest +SBOM_DIGEST="sha256:def456..." + +# Request presigned download URL +curl "https://hold01.atcr.io/xrpc/com.atproto.sync.getBlob?\ + did=did:web:hold01.atcr.io&\ + cid=$SBOM_DIGEST" + +# Response: presigned S3 URL or direct blob +{ + "url": "https://s3.amazonaws.com/bucket/blob?signature=...", + "expiresAt": "2025-10-20T12:49:56Z" +} + +# Download SBOM JSON +curl "$URL" > sbom.spdx.json +``` + +### 4. ORAS CLI Integration + +Use the ORAS CLI to discover and pull SBOMs: + +```bash +# Discover referrers (SBOMs) for an image +oras discover atcr.io/alice/myapp:latest + +# Output shows SBOM artifacts: +# digest: sha256:abc123... +# referrers: +# - artifactType: application/spdx+json +# digest: sha256:4a5e... + +# Pull SBOM artifact +oras pull atcr.io/alice/myapp@sha256:4a5e... + +# Downloads sbom.spdx.json to current directory +``` + +### 5. AppView Web UI (Future) + +Future enhancement: AppView web interface will display SBOM information on repository pages: + +- Link to SBOM JSON download +- Vulnerability count (if Grype enabled) +- Scanner version and scan timestamp +- Comparison across multiple holds + +## Tool Integration + +### SPDX/CycloneDX Tools + +Any tool that understands SPDX or CycloneDX formats can consume the SBOMs: + +**Example tools:** +- [OSV Scanner](https://github.com/google/osv-scanner) - Vulnerability scanning +- [Grype](https://github.com/anchore/grype) - Vulnerability scanning +- [Dependency-Track](https://dependencytrack.org/) - Software composition analysis +- [SBOM Quality Score](https://github.com/eBay/sbom-scorecard) - SBOM completeness + +**Usage:** +```bash +# Download SBOM +curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?..." | \ + jq -r '.manifest.layers[0].digest' | \ + # ... fetch blob ... > sbom.spdx.json + +# Scan with OSV +osv-scanner --sbom sbom.spdx.json + +# Scan with Grype +grype sbom:./sbom.spdx.json +``` + +### OCI Registry API + +ORAS manifests are fully OCI-compliant and discoverable via standard registry APIs: + +```bash +# Discover referrers for an image +curl -H "Accept: application/vnd.oci.image.index.v1+json" \ + "https://atcr.io/v2/alice/myapp/referrers/sha256:abc123" + +# Returns referrers index with SBOM manifests +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:4a5e...", + "artifactType": "application/spdx+json" + } + ] +} +``` + +### Programmatic Access + +Use the ATProto SDK to query SBOMs: + +```go +import "github.com/bluesky-social/indigo/atproto" + +// List all SBOMs for a hold +records, err := client.RepoListRecords(ctx, + "did:web:hold01.atcr.io", + "io.atcr.manifest", + 100, // limit + "", // cursor +) + +// Filter for SBOM artifacts +for _, record := range records.Records { + manifest := record.Value.(ManifestRecord) + if manifest.ArtifactType == "application/spdx+json" { + // Process SBOM manifest + } +} +``` + +## Future Enhancements + +### Vulnerability Scanning (Grype) + +Add vulnerability scanning to SBOM generation: + +```bash +# Configuration +HOLD_VULN_ENABLED=true +HOLD_VULN_DB_UPDATE_INTERVAL=24h + +# Extended manifest with vulnerability count +{ + "artifactType": "application/spdx+json", + "annotations": { + "io.atcr.vuln.critical": "2", + "io.atcr.vuln.high": "15", + "io.atcr.vuln.medium": "42", + "io.atcr.vuln.low": "8", + "io.atcr.vuln.scannedWith": "grype-v0.74.0", + "io.atcr.vuln.dbVersion": "2025-10-20" + } +} +``` + +### Multi-Scanner Support (Trivy) + +Support multiple scanner backends: + +```bash +HOLD_SBOM_SCANNER=trivy # syft (default), trivy, grype +HOLD_TRIVY_SCAN_TYPE=os,library,config,secret +``` + +### Multi-Hold Verification + +Compare SBOMs from different holds for the same image: + +```bash +# Alice pushes to hold1 and hold2 +docker push atcr.io/alice/myapp:latest + +# Both holds scan independently +# Compare results: +atcr-cli compare-sboms \ + --image atcr.io/alice/myapp:latest \ + --holds hold1.atcr.io,hold2.atcr.io + +# Output: Package count differences, version mismatches, etc. +``` + +### Signature Verification (Cosign) + +Sign SBOMs with Sigstore Cosign: + +```bash +HOLD_SBOM_SIGN=true +HOLD_COSIGN_KEY_PATH=/var/lib/atcr/cosign.key + +# SBOM artifacts get signed +# Verification: +cosign verify --key cosign.pub atcr.io/alice/myapp@sha256:4a5e... +``` + +## Security Considerations + +### Reproducibility + +SBOMs should be reproducible for the same image digest: + +**Best practices:** +- Pin scanner versions in production holds +- Record scanner version in manifest annotations +- Document vulnerability database versions +- Re-scan periodically to catch new CVEs + +**Validation:** +```bash +# Compare SBOMs from different holds +diff <(curl hold1/sbom.json | jq -S) \ + <(curl hold2/sbom.json | jq -S) + +# Differences indicate: +# - Different scanner versions +# - Different scan times (new CVEs discovered) +# - Potential tampering (investigate) +``` + +### Multiple Hold Verification + +Running multiple holds provides defense in depth: + +1. User pushes to hold1 (uses hold1 by default) +2. User also pushes to hold2 (backup/verification) +3. Both holds scan independently +4. Compare SBOM results: + - Similar results = confidence in accuracy + - Divergent results = investigate discrepancy + +### Transparency + +Hold operators should publish scanning policies: + +- Scanner version and update schedule +- Vulnerability database update frequency +- SBOM format and schema version +- Data retention policies + +### Trust Anchors + +Users can verify scanner integrity: + +1. **Scanner version**: Check `scannerVersion` field matches expected version +2. **DID signature**: ATProto record signed by hold's DID +3. **Timestamp**: Check `scannedAt` for stale scans +4. **Reproducibility**: Re-scan locally and compare results + +## Example Workflows + +### Enable Scanning on Your Hold + +```bash +# 1. Configure hold with SBOM enabled +cat > .env.hold < 5GB, many parts) -- [ ] Concurrent uploads -- [ ] Upload resume after network failure -- [ ] Legacy endpoint backward compatibility -- [ ] AppView XRPC client integration -- [ ] Performance comparison (XRPC vs legacy) - -## Migration Timeline - -1. **Week 1**: Implement unified uploadBlob handler (Phase 1-2) -2. **Week 2**: Update AppView client, feature flag (Phase 3) -3. **Week 3**: Deploy to dev/staging, test both paths (Phase 4) -4. **Week 4**: Roll out to production (gradual) -5. **Week 5-6**: Monitor, verify all holds migrated -6. **Week 7**: Remove legacy endpoints (Phase 5) - -## References - -- ATProto uploadBlob spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob -- XRPC conventions: https://atproto.com/specs/xrpc -- Existing multipart implementation: pkg/hold/multipart.go -- Blob store adapter: pkg/hold/blobstore_adapter.go diff --git a/pkg/appview/db/migrations/0005_normalize_hold_endpoint_to_did.yaml b/pkg/appview/db/migrations/0005_normalize_hold_endpoint_to_did.yaml new file mode 100644 index 0000000..7ad2a64 --- /dev/null +++ b/pkg/appview/db/migrations/0005_normalize_hold_endpoint_to_did.yaml @@ -0,0 +1,19 @@ +description: Normalize hold_endpoint column to store DIDs instead of URLs +query: | + -- Convert any URL-formatted hold_endpoint values to DID format + -- This ensures all hold identifiers are stored consistently as did:web:hostname + + -- Convert HTTPS URLs to did:web: format + -- https://hold.example.com → did:web:hold.example.com + UPDATE manifests + SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9) + WHERE hold_endpoint LIKE 'https://%'; + + -- Convert HTTP URLs to did:web: format + -- http://172.28.0.3:8080 → did:web:172.28.0.3:8080 + UPDATE manifests + SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8) + WHERE hold_endpoint LIKE 'http://%'; + + -- Entries already in did:web: format are left unchanged + -- did:web:hold.example.com → did:web:hold.example.com (no change) diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index 53c24e2..ba1e878 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -65,17 +65,19 @@ type Tag struct { // Push represents a combined tag and manifest for the recent pushes view type Push struct { - DID string - Handle string - Repository string - Tag string - Digest string - Title string - Description string - IconURL string - StarCount int - PullCount int - CreatedAt time.Time + DID string + Handle string + Repository string + Tag string + Digest string + Title string + Description string + IconURL string + StarCount int + PullCount int + CreatedAt time.Time + HoldEndpoint string // Hold endpoint for health checking + Reachable bool // Whether the hold endpoint is reachable } // Repository represents an aggregated view of a user's repository @@ -156,4 +158,6 @@ type ManifestWithMetadata struct { Platforms []PlatformInfo PlatformCount int IsManifestList bool + Reachable bool // Whether the hold endpoint is reachable + Pending bool // Whether health check is still in progress } diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 6973cae..80c2799 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -44,7 +44,8 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, COALESCE(m.icon_url, ''), COALESCE(rs.pull_count, 0), COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), - t.created_at + t.created_at, + m.hold_endpoint FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest @@ -70,7 +71,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, var pushes []Push for rows.Next() { var p Push - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil { return nil, 0, err } pushes = append(pushes, p) @@ -113,7 +114,8 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err COALESCE(m.icon_url, ''), COALESCE(rs.pull_count, 0), COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), - t.created_at + t.created_at, + m.hold_endpoint FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest @@ -136,7 +138,7 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err var pushes []Push for rows.Next() { var p Push - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil { return nil, 0, err } pushes = append(pushes, p) diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go index 4deb4dc..6704ca1 100644 --- a/pkg/appview/db/schema.go +++ b/pkg/appview/db/schema.go @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS manifests ( did TEXT NOT NULL, repository TEXT NOT NULL, digest TEXT NOT NULL, - hold_endpoint TEXT NOT NULL, + hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com) schema_version INTEGER NOT NULL, media_type TEXT NOT NULL, config_digest TEXT, diff --git a/pkg/appview/handlers/home.go b/pkg/appview/handlers/home.go index f916b28..585837e 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -7,6 +7,7 @@ import ( "strconv" "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/holdhealth" ) // HomeHandler handles the home page @@ -54,9 +55,10 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // RecentPushesHandler handles the HTMX request for recent pushes type RecentPushesHandler struct { - DB *sql.DB - Templates *template.Template - RegistryURL string + DB *sql.DB + Templates *template.Template + RegistryURL string + HealthChecker *holdhealth.Checker } func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -78,6 +80,35 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } + // Check health status and filter out unreachable manifests for home page + // Use GetCachedStatus only (no blocking) - background worker keeps cache fresh + if h.HealthChecker != nil { + reachablePushes := []db.Push{} + for i := range pushes { + if pushes[i].HoldEndpoint != "" { + // Use cached status only - don't block on health checks + cached := h.HealthChecker.GetCachedStatus(pushes[i].HoldEndpoint) + if cached != nil { + pushes[i].Reachable = cached.Reachable + // Only show reachable pushes on home page + if cached.Reachable { + reachablePushes = append(reachablePushes, pushes[i]) + } + } else { + // No cached status - optimistically show it (background worker will check) + pushes[i].Reachable = true + reachablePushes = append(reachablePushes, pushes[i]) + } + } + } + pushes = reachablePushes + } else { + // If no health checker, assume all are reachable (backward compatibility) + for i := range pushes { + pushes[i].Reachable = true + } + } + data := struct { PageData Pushes []db.Push diff --git a/pkg/appview/handlers/manifest_health.go b/pkg/appview/handlers/manifest_health.go new file mode 100644 index 0000000..4185132 --- /dev/null +++ b/pkg/appview/handlers/manifest_health.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "context" + "net/http" + "net/url" + "time" + + "atcr.io/pkg/appview/holdhealth" +) + +// ManifestHealthHandler handles HTMX polling for manifest health status +type ManifestHealthHandler struct { + HealthChecker *holdhealth.Checker +} + +func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Get endpoint from query parameter + endpoint := r.URL.Query().Get("endpoint") + if endpoint == "" { + http.Error(w, "endpoint parameter required", http.StatusBadRequest) + return + } + + // Decode URL-encoded endpoint + endpoint, err := url.QueryUnescape(endpoint) + if err != nil { + http.Error(w, "invalid endpoint parameter", http.StatusBadRequest) + return + } + + // Try to get cached status first (instant if background worker has checked it) + cached := h.HealthChecker.GetCachedStatus(endpoint) + if cached != nil { + // Cache hit - return final status + h.renderBadge(w, endpoint, cached.Reachable, false) + return + } + + // Cache miss - perform quick check with 2 second timeout + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint) + + if ctx.Err() == context.DeadlineExceeded { + // Still pending - render "Checking..." badge with HTMX retry + h.renderBadge(w, endpoint, false, true) + } else if err != nil { + // Error - mark as unreachable + h.renderBadge(w, endpoint, false, false) + } else { + // Success + h.renderBadge(w, endpoint, reachable, false) + } +} + +// renderBadge renders the appropriate badge HTML snippet +func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool) { + w.Header().Set("Content-Type", "text/html") + + if pending { + // Still checking - render badge with HTMX retry after 3 seconds + retryURL := "/api/manifest-health?endpoint=" + url.QueryEscape(endpoint) + w.Write([]byte(`🔄 Checking...`)) + } else if !reachable { + // Unreachable - render offline badge + w.Write([]byte(`⚠️ Offline`)) + } else { + // Reachable - no badge (empty response) + w.Write([]byte(``)) + } +} diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 8d37c26..f2e932d 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -1,12 +1,16 @@ package handlers import ( + "context" "database/sql" "html/template" "log" "net/http" + "sync" + "time" "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/holdhealth" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" @@ -16,11 +20,12 @@ import ( // RepositoryPageHandler handles the public repository page type RepositoryPageHandler struct { - DB *sql.DB - Templates *template.Template - RegistryURL string - Directory identity.Directory - Refresher *oauth.Refresher + DB *sql.DB + Templates *template.Template + RegistryURL string + Directory identity.Directory + Refresher *oauth.Refresher + HealthChecker *holdhealth.Checker } func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -54,6 +59,69 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } + // Check health status for each manifest's hold endpoint (concurrent with 1s timeout) + if h.HealthChecker != nil { + // Create context with 1 second deadline for fast-fail + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) + defer cancel() + + var wg sync.WaitGroup + var mu sync.Mutex + + for i := range manifests { + if manifests[i].HoldEndpoint == "" { + // No hold endpoint, mark as unreachable + manifests[i].Reachable = false + manifests[i].Pending = false + continue + } + + wg.Add(1) + go func(idx int) { + defer wg.Done() + + endpoint := manifests[idx].HoldEndpoint + + // Try to get cached status first (instant) + if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil { + mu.Lock() + manifests[idx].Reachable = cached.Reachable + manifests[idx].Pending = false + mu.Unlock() + return + } + + // Perform health check with timeout context + reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint) + + mu.Lock() + if ctx.Err() == context.DeadlineExceeded { + // Timeout - mark as pending for HTMX polling + manifests[idx].Reachable = false + manifests[idx].Pending = true + } else if err != nil { + // Error - mark as unreachable + manifests[idx].Reachable = false + manifests[idx].Pending = false + } else { + // Success + manifests[idx].Reachable = reachable + manifests[idx].Pending = false + } + mu.Unlock() + }(i) + } + + // Wait for all checks to complete or timeout + wg.Wait() + } else { + // If no health checker, assume all are reachable (backward compatibility) + for i := range manifests { + manifests[i].Reachable = true + manifests[i].Pending = false + } + } + if len(tagsWithPlatforms) == 0 && len(manifests) == 0 { http.Error(w, "Repository not found", http.StatusNotFound) return @@ -100,13 +168,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request data := struct { PageData - Owner *db.User // Repository owner - Repository *db.Repository // Repository summary - Tags []db.TagWithPlatforms // Tags with platform info - Manifests []db.ManifestWithMetadata // Top-level manifests only - StarCount int - IsStarred bool - IsOwner bool // Whether current user owns this repository + Owner *db.User // Repository owner + Repository *db.Repository // Repository summary + Tags []db.TagWithPlatforms // Tags with platform info + Manifests []db.ManifestWithMetadata // Top-level manifests only + StarCount int + IsStarred bool + IsOwner bool // Whether current user owns this repository }{ PageData: NewPageData(r, h.RegistryURL), Owner: owner, diff --git a/pkg/appview/holdhealth/checker.go b/pkg/appview/holdhealth/checker.go new file mode 100644 index 0000000..81eae66 --- /dev/null +++ b/pkg/appview/holdhealth/checker.go @@ -0,0 +1,179 @@ +package holdhealth + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "atcr.io/pkg/appview" +) + +// HealthStatus represents the health status of a hold endpoint +type HealthStatus struct { + Reachable bool + LastChecked time.Time + LastError error +} + +// Checker manages health checking for hold endpoints +type Checker struct { + client *http.Client + cache map[string]*HealthStatus + cacheMu sync.RWMutex + cacheTTL time.Duration + cleanupMu sync.Mutex +} + +// NewChecker creates a new health checker with the specified cache TTL +func NewChecker(cacheTTL time.Duration) *Checker { + return NewCheckerWithTimeout(cacheTTL, 2*time.Second) +} + +// NewCheckerWithTimeout creates a new health checker with custom timeout +// Useful for testing with shorter timeouts +func NewCheckerWithTimeout(cacheTTL, httpTimeout time.Duration) *Checker { + return &Checker{ + client: &http.Client{ + Timeout: httpTimeout, + }, + cache: make(map[string]*HealthStatus), + cacheTTL: cacheTTL, + } +} + +// CheckHealth performs an HTTP health check on the hold endpoint +// Accepts either DID (did:web:host) or URL (https://host) format +// Checks {endpoint}/xrpc/_health and returns true if reachable +func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error) { + // Convert DID to HTTP URL if needed + // did:web:hold.example.com → https://hold.example.com + // https://hold.example.com → https://hold.example.com (passthrough) + httpURL := appview.ResolveHoldURL(endpoint) + + // Build health check URL + healthURL := httpURL + "/xrpc/_health" + + // Create request with context + req, err := http.NewRequestWithContext(ctx, "GET", healthURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + // Perform request + resp, err := c.client.Do(req) + if err != nil { + return false, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, nil + } + + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) +} + +// GetStatus returns the cached health status for an endpoint +// If the cache is expired or missing, it performs an on-demand check +func (c *Checker) GetStatus(ctx context.Context, endpoint string) *HealthStatus { + // Check cache first + c.cacheMu.RLock() + status, exists := c.cache[endpoint] + c.cacheMu.RUnlock() + + // If cached and not expired, return it + if exists && time.Since(status.LastChecked) < c.cacheTTL { + return status + } + + // On-demand check + reachable, err := c.CheckHealth(ctx, endpoint) + + // Update cache + newStatus := &HealthStatus{ + Reachable: reachable, + LastChecked: time.Now(), + LastError: err, + } + + c.cacheMu.Lock() + c.cache[endpoint] = newStatus + c.cacheMu.Unlock() + + return newStatus +} + +// GetCachedStatus returns the cached status without performing a check +// Returns nil if no cached status exists +func (c *Checker) GetCachedStatus(endpoint string) *HealthStatus { + c.cacheMu.RLock() + defer c.cacheMu.RUnlock() + + status, exists := c.cache[endpoint] + if !exists { + return nil + } + + // Return nil if expired + if time.Since(status.LastChecked) > c.cacheTTL { + return nil + } + + return status +} + +// SetStatus manually sets the health status for an endpoint +// Used by the background worker to update cache +func (c *Checker) SetStatus(endpoint string, reachable bool, err error) { + status := &HealthStatus{ + Reachable: reachable, + LastChecked: time.Now(), + LastError: err, + } + + c.cacheMu.Lock() + c.cache[endpoint] = status + c.cacheMu.Unlock() +} + +// Cleanup removes stale cache entries (older than 30 minutes) +func (c *Checker) Cleanup() { + c.cleanupMu.Lock() + defer c.cleanupMu.Unlock() + + c.cacheMu.Lock() + defer c.cacheMu.Unlock() + + cutoff := time.Now().Add(-30 * time.Minute) + for endpoint, status := range c.cache { + if status.LastChecked.Before(cutoff) { + delete(c.cache, endpoint) + } + } +} + +// GetCacheStats returns cache statistics for debugging +func (c *Checker) GetCacheStats() map[string]any { + c.cacheMu.RLock() + defer c.cacheMu.RUnlock() + + reachable := 0 + unreachable := 0 + + for _, status := range c.cache { + if status.Reachable { + reachable++ + } else { + unreachable++ + } + } + + return map[string]any{ + "total": len(c.cache), + "reachable": reachable, + "unreachable": unreachable, + } +} diff --git a/pkg/appview/holdhealth/checker_test.go b/pkg/appview/holdhealth/checker_test.go new file mode 100644 index 0000000..cb4cd6c --- /dev/null +++ b/pkg/appview/holdhealth/checker_test.go @@ -0,0 +1,253 @@ +package holdhealth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewChecker(t *testing.T) { + cacheTTL := 15 * time.Minute + checker := NewChecker(cacheTTL) + + if checker == nil { + t.Fatal("NewChecker returned nil") + } + + if checker.cacheTTL != cacheTTL { + t.Errorf("Expected cache TTL %v, got %v", cacheTTL, checker.cacheTTL) + } + + if checker.cache == nil { + t.Error("Cache map not initialized") + } +} + +func TestCheckHealth_Success(t *testing.T) { + // Create test server that returns 200 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/_health" { + t.Errorf("Expected path /xrpc/_health, got %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"version": "1.0.0"}`)) + })) + defer server.Close() + + checker := NewChecker(15 * time.Minute) + ctx := context.Background() + + reachable, err := checker.CheckHealth(ctx, server.URL) + if err != nil { + t.Errorf("CheckHealth returned error: %v", err) + } + + if !reachable { + t.Error("Expected hold to be reachable") + } +} + +func TestCheckHealth_WithDID(t *testing.T) { + // Create test server that returns 200 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/_health" { + t.Errorf("Expected path /xrpc/_health, got %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"version": "1.0.0"}`)) + })) + defer server.Close() + + checker := NewChecker(15 * time.Minute) + ctx := context.Background() + + // Test with DID format (did:web:host) + // Extract host:port from test server URL + // http://127.0.0.1:12345 → did:web:127.0.0.1:12345 + serverURL := server.URL + didFormat := "did:web:" + serverURL[7:] // Remove "http://" + + reachable, err := checker.CheckHealth(ctx, didFormat) + if err != nil { + t.Errorf("CheckHealth with DID returned error: %v", err) + } + + if !reachable { + t.Error("Expected hold to be reachable with DID format") + } +} + +func TestCheckHealth_Failure(t *testing.T) { + // Create test server that returns 500 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + checker := NewChecker(15 * time.Minute) + ctx := context.Background() + + reachable, err := checker.CheckHealth(ctx, server.URL) + if err == nil { + t.Error("Expected error for 500 status code") + } + + if reachable { + t.Error("Expected hold to be unreachable") + } +} + +func TestCheckHealth_Timeout(t *testing.T) { + // Create test server that delays longer than client timeout + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) // Longer than 100ms test timeout + })) + defer server.Close() + + // Use custom timeout of 100ms for faster test + checker := NewCheckerWithTimeout(15*time.Minute, 100*time.Millisecond) + ctx := context.Background() + + reachable, err := checker.CheckHealth(ctx, server.URL) + if err == nil { + t.Error("Expected timeout error") + } + + if reachable { + t.Error("Expected hold to be unreachable due to timeout") + } +} + +func TestGetStatus_CacheHit(t *testing.T) { + checker := NewChecker(15 * time.Minute) + endpoint := "https://example.com" + + // Manually set cached status + checker.SetStatus(endpoint, true, nil) + + // Get status should return cached value + status := checker.GetStatus(context.Background(), endpoint) + if status == nil { + t.Fatal("GetStatus returned nil") + } + + if !status.Reachable { + t.Error("Expected cached status to be reachable") + } + + if status.LastError != nil { + t.Errorf("Expected no error, got %v", status.LastError) + } +} + +func TestGetStatus_CacheMiss(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + checker := NewChecker(15 * time.Minute) + + // Get status should perform check on cache miss + status := checker.GetStatus(context.Background(), server.URL) + if status == nil { + t.Fatal("GetStatus returned nil") + } + + if !status.Reachable { + t.Error("Expected status to be reachable") + } +} + +func TestGetStatus_CacheExpiry(t *testing.T) { + // Create checker with very short TTL + checker := NewChecker(100 * time.Millisecond) + endpoint := "https://example.com" + + // Set cached status + checker.SetStatus(endpoint, true, nil) + + // Wait for cache to expire + time.Sleep(150 * time.Millisecond) + + // GetCachedStatus should return nil for expired entry + status := checker.GetCachedStatus(endpoint) + if status != nil { + t.Error("Expected nil for expired cache entry") + } +} + +func TestSetStatus(t *testing.T) { + checker := NewChecker(15 * time.Minute) + endpoint := "https://example.com" + + // Set status + checker.SetStatus(endpoint, true, nil) + + // Verify it was set + status := checker.GetCachedStatus(endpoint) + if status == nil { + t.Fatal("Status not found in cache") + } + + if !status.Reachable { + t.Error("Expected status to be reachable") + } +} + +func TestCleanup(t *testing.T) { + checker := NewChecker(1 * time.Minute) + + // Add old entry (simulate old timestamp by manually setting it) + endpoint := "https://example.com" + checker.cache[endpoint] = &HealthStatus{ + Reachable: true, + LastChecked: time.Now().Add(-31 * time.Minute), // 31 minutes ago + } + + // Add recent entry + recentEndpoint := "https://recent.com" + checker.SetStatus(recentEndpoint, true, nil) + + // Run cleanup + checker.Cleanup() + + // Old entry should be removed + if checker.GetCachedStatus(endpoint) != nil { + t.Error("Expected old entry to be cleaned up") + } + + // Recent entry should remain + if checker.GetCachedStatus(recentEndpoint) == nil { + t.Error("Expected recent entry to remain after cleanup") + } +} + +func TestGetCacheStats(t *testing.T) { + checker := NewChecker(15 * time.Minute) + + // Add some entries + checker.SetStatus("https://reachable1.com", true, nil) + checker.SetStatus("https://reachable2.com", true, nil) + checker.SetStatus("https://unreachable1.com", false, nil) + + stats := checker.GetCacheStats() + + total, ok := stats["total"].(int) + if !ok || total != 3 { + t.Errorf("Expected total=3, got %v", stats["total"]) + } + + reachable, ok := stats["reachable"].(int) + if !ok || reachable != 2 { + t.Errorf("Expected reachable=2, got %v", stats["reachable"]) + } + + unreachable, ok := stats["unreachable"].(int) + if !ok || unreachable != 1 { + t.Errorf("Expected unreachable=1, got %v", stats["unreachable"]) + } +} diff --git a/pkg/appview/holdhealth/worker.go b/pkg/appview/holdhealth/worker.go new file mode 100644 index 0000000..03f2315 --- /dev/null +++ b/pkg/appview/holdhealth/worker.go @@ -0,0 +1,169 @@ +package holdhealth + +import ( + "context" + "database/sql" + "fmt" + "log" + "sync" + "time" +) + +// DBQuerier interface for database queries (allows mocking in tests) +type DBQuerier interface { + GetUniqueHoldEndpoints() ([]string, error) +} + +// Worker runs background health checks for hold endpoints +type Worker struct { + checker *Checker + db DBQuerier + refreshTicker *time.Ticker + cleanupTicker *time.Ticker + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewWorker creates a new background worker +func NewWorker(checker *Checker, db DBQuerier, refreshInterval time.Duration) *Worker { + return &Worker{ + checker: checker, + db: db, + refreshTicker: time.NewTicker(refreshInterval), + cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes + stopChan: make(chan struct{}), + } +} + +// Start begins the background worker +func (w *Worker) Start(ctx context.Context) { + w.wg.Add(1) + go func() { + defer w.wg.Done() + + log.Println("Hold health worker: Starting background health checks") + + // Perform initial check immediately + w.refreshAllHolds(ctx) + + for { + select { + case <-ctx.Done(): + log.Println("Hold health worker: Context cancelled, stopping") + return + case <-w.stopChan: + log.Println("Hold health worker: Stop signal received") + return + case <-w.refreshTicker.C: + w.refreshAllHolds(ctx) + case <-w.cleanupTicker.C: + log.Println("Hold health worker: Running cache cleanup") + w.checker.Cleanup() + } + } + }() +} + +// Stop gracefully stops the worker +func (w *Worker) Stop() { + close(w.stopChan) + w.refreshTicker.Stop() + w.cleanupTicker.Stop() + w.wg.Wait() + log.Println("Hold health worker: Stopped") +} + +// refreshAllHolds queries the database for unique hold endpoints and refreshes their health status +func (w *Worker) refreshAllHolds(ctx context.Context) { + log.Println("Hold health worker: Starting refresh cycle") + + // Get unique hold endpoints from database + endpoints, err := w.db.GetUniqueHoldEndpoints() + if err != nil { + log.Printf("Hold health worker: Failed to fetch hold endpoints: %v", err) + return + } + + if len(endpoints) == 0 { + log.Println("Hold health worker: No hold endpoints to check") + return + } + + log.Printf("Hold health worker: Checking %d unique hold endpoints", len(endpoints)) + + // Check health concurrently with rate limiting + // Use a semaphore to limit concurrent requests (max 10 at a time) + sem := make(chan struct{}, 10) + var wg sync.WaitGroup + + reachable := 0 + unreachable := 0 + var statsMu sync.Mutex + + for _, endpoint := range endpoints { + wg.Add(1) + + go func(ep string) { + defer wg.Done() + + // Acquire semaphore + sem <- struct{}{} + defer func() { <-sem }() + + // Check health + isReachable, err := w.checker.CheckHealth(ctx, ep) + + // Update cache + w.checker.SetStatus(ep, isReachable, err) + + // Update stats + statsMu.Lock() + if isReachable { + reachable++ + } else { + unreachable++ + log.Printf("Hold health worker: Hold unreachable: %s (error: %v)", ep, err) + } + statsMu.Unlock() + }(endpoint) + } + + // Wait for all checks to complete + wg.Wait() + + log.Printf("Hold health worker: Refresh complete - %d reachable, %d unreachable", reachable, unreachable) +} + +// DBAdapter wraps sql.DB to implement DBQuerier interface +type DBAdapter struct { + db *sql.DB +} + +// NewDBAdapter creates a new database adapter +func NewDBAdapter(db *sql.DB) *DBAdapter { + return &DBAdapter{db: db} +} + +// GetUniqueHoldEndpoints queries the database for unique hold endpoints +func (a *DBAdapter) GetUniqueHoldEndpoints() ([]string, error) { + rows, err := a.db.Query(`SELECT DISTINCT hold_endpoint FROM manifests WHERE hold_endpoint != ''`) + if err != nil { + return nil, fmt.Errorf("failed to query hold endpoints: %w", err) + } + defer rows.Close() + + var endpoints []string + for rows.Next() { + var endpoint string + if err := rows.Scan(&endpoint); err != nil { + return nil, fmt.Errorf("failed to scan endpoint: %w", err) + } + endpoints = append(endpoints, endpoint) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + return endpoints, nil +} diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index 292afe5..d8462b3 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -1044,6 +1044,61 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { margin-top: 0.5rem; } +/* Offline manifest badge */ +.offline-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--warning-bg); + color: var(--warning); + border: 1px solid var(--warning); + border-radius: 4px; + font-size: 0.85rem; + font-weight: 600; + margin-left: 0.5rem; +} + +/* Checking manifest badge (health check in progress) */ +.checking-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + background: #e3f2fd; + color: #1976d2; + border: 1px solid #1976d2; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 600; + margin-left: 0.5rem; +} + +/* Hide offline manifests by default */ +.manifest-item[data-reachable="false"] { + display: none; +} + +/* Show offline manifests when toggle is checked */ +.manifests-list.show-offline .manifest-item[data-reachable="false"] { + display: block; + opacity: 0.6; +} + +/* Show offline images toggle styling */ +.show-offline-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + user-select: none; +} + +.show-offline-toggle input[type="checkbox"] { + cursor: pointer; +} + +.show-offline-toggle span { + font-size: 0.9rem; + color: var(--border-dark); +} + .manifest-detail-label { font-weight: 500; color: var(--secondary); diff --git a/pkg/appview/static/js/app.js b/pkg/appview/static/js/app.js index ddb6ca1..c1be9f9 100644 --- a/pkg/appview/static/js/app.js +++ b/pkg/appview/static/js/app.js @@ -243,3 +243,41 @@ async function loadStarCount(handle, repository) { console.error('Error loading star count:', err); } } + +// Toggle offline manifests visibility +function toggleOfflineManifests() { + const checkbox = document.getElementById('show-offline-toggle'); + const manifestsList = document.querySelector('.manifests-list'); + + if (!checkbox || !manifestsList) return; + + // Store preference in localStorage + localStorage.setItem('showOfflineManifests', checkbox.checked); + + // Toggle visibility of offline manifests + if (checkbox.checked) { + manifestsList.classList.add('show-offline'); + } else { + manifestsList.classList.remove('show-offline'); + } +} + +// Restore offline manifests toggle state on page load +document.addEventListener('DOMContentLoaded', () => { + const checkbox = document.getElementById('show-offline-toggle'); + if (!checkbox) return; + + // Restore state from localStorage + const showOffline = localStorage.getItem('showOfflineManifests') === 'true'; + checkbox.checked = showOffline; + + // Apply initial state + const manifestsList = document.querySelector('.manifests-list'); + if (manifestsList) { + if (showOffline) { + manifestsList.classList.add('show-offline'); + } else { + manifestsList.classList.remove('show-offline'); + } + } +}); diff --git a/pkg/appview/storage/proxy_blob_store.go b/pkg/appview/storage/proxy_blob_store.go index 9c98df4..b9e931e 100644 --- a/pkg/appview/storage/proxy_blob_store.go +++ b/pkg/appview/storage/proxy_blob_store.go @@ -8,10 +8,10 @@ import ( "io" "net/http" "net/url" - "strings" "sync" "time" + "atcr.io/pkg/appview" "atcr.io/pkg/atproto" "github.com/distribution/distribution/v3" "github.com/opencontainers/go-digest" @@ -199,21 +199,10 @@ func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error { return nil } -// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests -// did:web:hold01.atcr.io → https://hold01.atcr.io -// did:web:172.28.0.3:8080 → http://172.28.0.3:8080 +// resolveHoldURL converts a hold identifier (DID or URL) to an HTTP URL +// Deprecated: Use appview.ResolveHoldURL instead func resolveHoldURL(holdDID string) string { - hostname := strings.TrimPrefix(holdDID, "did:web:") - - // Use HTTP for localhost/IP addresses with ports, HTTPS for domains - if strings.Contains(hostname, ":") || - strings.Contains(hostname, "127.0.0.1") || - strings.Contains(hostname, "localhost") || - // Check if it's an IP address (contains only digits and dots) - (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { - return "http://" + hostname - } - return "https://" + hostname + return appview.ResolveHoldURL(holdDID) } // Stat returns the descriptor for a blob diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 0cb88e3..dd58db6 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -140,11 +140,17 @@
-

Manifests

+
+

Manifests

+ +
{{ if .Manifests }}
{{ range .Manifests }} -
+
{{ if .IsManifestList }} @@ -152,6 +158,16 @@ {{ else }} 📄 Image {{ end }} + {{ if .Pending }} + + 🔄 Checking... + + {{ else if not .Reachable }} + ⚠️ Offline + {{ end }} {{ .Manifest.Digest }}
diff --git a/pkg/appview/utils.go b/pkg/appview/utils.go new file mode 100644 index 0000000..7d71371 --- /dev/null +++ b/pkg/appview/utils.go @@ -0,0 +1,33 @@ +package appview + +import "strings" + +// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL +// Handles both formats for backward compatibility: +// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io +// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080 +// - URL format: https://hold.example.com → https://hold.example.com (passthrough) +func ResolveHoldURL(holdIdentifier string) string { + // If it's already a URL (has scheme), return as-is + if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") { + return holdIdentifier + } + + // If it's a DID, convert to URL + if strings.HasPrefix(holdIdentifier, "did:web:") { + hostname := strings.TrimPrefix(holdIdentifier, "did:web:") + + // Use HTTP for localhost/IP addresses with ports, HTTPS for domains + if strings.Contains(hostname, ":") || + strings.Contains(hostname, "127.0.0.1") || + strings.Contains(hostname, "localhost") || + // Check if it's an IP address (contains only digits and dots in first part) + (len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') { + return "http://" + hostname + } + return "https://" + hostname + } + + // Fallback: assume it's a hostname and use HTTPS + return "https://" + holdIdentifier +} diff --git a/pkg/appview/utils_test.go b/pkg/appview/utils_test.go new file mode 100644 index 0000000..3587e7a --- /dev/null +++ b/pkg/appview/utils_test.go @@ -0,0 +1,61 @@ +package appview + +import "testing" + +func TestResolveHoldURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "DID with HTTPS domain", + input: "did:web:hold.example.com", + expected: "https://hold.example.com", + }, + { + name: "DID with HTTP and port (IP)", + input: "did:web:172.28.0.3:8080", + expected: "http://172.28.0.3:8080", + }, + { + name: "DID with HTTP and port (localhost)", + input: "did:web:127.0.0.1:8080", + expected: "http://127.0.0.1:8080", + }, + { + name: "DID with localhost", + input: "did:web:localhost:8080", + expected: "http://localhost:8080", + }, + { + name: "Already HTTPS URL (passthrough)", + input: "https://hold.example.com", + expected: "https://hold.example.com", + }, + { + name: "Already HTTP URL (passthrough)", + input: "http://172.28.0.3:8080", + expected: "http://172.28.0.3:8080", + }, + { + name: "Plain hostname (fallback to HTTPS)", + input: "hold.example.com", + expected: "https://hold.example.com", + }, + { + name: "DID with subdomain", + input: "did:web:hold01.atcr.io", + expected: "https://hold01.atcr.io", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ResolveHoldURL(tt.input) + if result != tt.expected { + t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/pkg/atproto/profile_test.go b/pkg/atproto/profile_test.go index ed92251..6750756 100644 --- a/pkg/atproto/profile_test.go +++ b/pkg/atproto/profile_test.go @@ -149,15 +149,15 @@ func TestEnsureProfile_Exists(t *testing.T) { // TestGetProfile tests retrieving a user's profile func TestGetProfile(t *testing.T) { tests := []struct { - name string - serverResponse string - serverStatus int - wantProfile *SailorProfileRecord - wantNil bool - wantErr bool - expectMigration bool // Whether URL-to-DID migration should happen - originalHoldURL string - expectedHoldDID string + name string + serverResponse string + serverStatus int + wantProfile *SailorProfileRecord + wantNil bool + wantErr bool + expectMigration bool // Whether URL-to-DID migration should happen + originalHoldURL string + expectedHoldDID string }{ { name: "profile with DID (no migration needed)", @@ -359,10 +359,10 @@ func TestGetProfile_MigrationLocking(t *testing.T) { // TestUpdateProfile tests updating a user's profile func TestUpdateProfile(t *testing.T) { tests := []struct { - name string - profile *SailorProfileRecord - wantNormalized string // Expected defaultHold after normalization - wantErr bool + name string + profile *SailorProfileRecord + wantNormalized string // Expected defaultHold after normalization + wantErr bool }{ { name: "update with DID",