A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

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

at refactor 802 lines 38 kB view raw view rendered
1# CLAUDE.md 2 3This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 5## Project Overview 6 7ATCR (ATProto Container Registry) is an OCI-compliant container registry that uses the AT Protocol for manifest storage and S3 for blob storage. This creates a decentralized container registry where manifests are stored in users' Personal Data Servers (PDS) while layers are stored in S3. 8 9## Build Commands 10 11```bash 12# Build all binaries 13# create go builds in the bin/ directory 14go build -o bin/atcr-appview ./cmd/appview 15go build -o bin/atcr-hold ./cmd/hold 16go build -o bin/docker-credential-atcr ./cmd/credential-helper 17go build -o bin/oauth-helper ./cmd/oauth-helper 18 19# Run tests 20go test ./... 21 22# Run tests for specific package 23go test ./pkg/atproto/... 24go test ./pkg/appview/storage/... 25 26# Run specific test 27go test -run TestManifestStore ./pkg/atproto/... 28 29# Run with race detector 30go test -race ./... 31 32# Run tests with verbose output 33go test -v ./... 34 35# Update dependencies 36go mod tidy 37 38# Build Docker images 39docker build -t atcr.io/appview:latest . 40docker build -f Dockerfile.hold -t atcr.io/hold:latest . 41 42# Or use docker-compose 43docker-compose up -d 44 45# Run locally (AppView) - configure via env vars (see .env.appview.example) 46export ATCR_HTTP_ADDR=:5000 47export ATCR_DEFAULT_HOLD=http://127.0.0.1:8080 48./bin/atcr-appview serve 49 50# Or use .env file: 51cp .env.appview.example .env.appview 52# Edit .env.appview with your settings 53source .env.appview 54./bin/atcr-appview serve 55 56# Legacy mode (still supported): 57# ./bin/atcr-appview serve config/config.yml 58 59# Run hold service (configure via env vars - see .env.hold.example) 60export HOLD_PUBLIC_URL=http://127.0.0.1:8080 61export STORAGE_DRIVER=filesystem 62export STORAGE_ROOT_DIR=/tmp/atcr-hold 63export HOLD_OWNER=did:plc:your-did-here 64./bin/atcr-hold 65# Hold starts immediately with embedded PDS 66 67# Request Bluesky relay crawl (makes your PDS discoverable) 68./deploy/request-crawl.sh hold01.atcr.io 69# Or specify a different relay: 70./deploy/request-crawl.sh hold01.atcr.io https://custom-relay.example.com/xrpc/com.atproto.sync.requestCrawl 71``` 72 73## Architecture Overview 74 75### Core Design 76 77ATCR uses **distribution/distribution** as a library and extends it through middleware to route different types of content to different storage backends: 78 79- **Manifests** → ATProto PDS (small JSON metadata, stored as `io.atcr.manifest` records) 80- **Blobs/Layers** → S3 or user-deployed storage (large binary data) 81- **Authentication** → ATProto OAuth with DPoP + Docker credential helpers 82 83### Three-Component Architecture 84 851. **AppView** (`cmd/appview`) - OCI Distribution API server 86 - Resolves identities (handle/DID → PDS endpoint) 87 - Routes manifests to user's PDS 88 - Routes blobs to storage endpoint (default or BYOS) 89 - Validates OAuth tokens via PDS 90 - Issues registry JWTs 91 922. **Hold Service** (`cmd/hold`) - Optional BYOS component 93 - Lightweight HTTP server for presigned URLs 94 - Embedded PDS with captain + crew records 95 - Supports S3, Storj, Minio, filesystem, etc. 96 - Authorization based on captain record (public, allowAllCrew) 97 - Self-describing via DID resolution 98 - Configured entirely via environment variables 99 1003. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth 101 - Implements Docker credential helper protocol 102 - ATProto OAuth flow with DPoP 103 - Token caching and refresh 104 - Exchanges OAuth token for registry JWT 105 106### Request Flow 107 108#### Push with Default Storage 109``` 1101. Client: docker push atcr.io/alice/myapp:latest 1112. HTTP Request → /v2/alice/myapp/manifests/latest 1123. Registry Middleware (pkg/appview/middleware/registry.go) 113 → Resolves "alice" to DID and PDS endpoint 114 → Queries alice's sailor profile for defaultHold (returns DID if set) 115 → If not set, checks alice's io.atcr.hold records 116 → Falls back to AppView's default_hold_did 117 → Stores DID/PDS/hold DID in RegistryContext 1184. Routing Repository (pkg/appview/storage/routing_repository.go) 119 → Creates RoutingRepository 120 → Returns ATProto ManifestStore for manifests 121 → Returns ProxyBlobStore for blobs (routes to hold DID) 1225. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints: 123 a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID) 124 b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part) 125 c. PUT to S3 presigned URL (or PUT /xrpc/io.atcr.hold.uploadPart for buffered mode) 126 d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload) 1276. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint) 128 → Manifest also uploaded to PDS blob storage (ATProto CID format) 129``` 130 131#### Push with BYOS (Bring Your Own Storage) 132``` 1331. Client: docker push atcr.io/alice/myapp:latest 1342. Registry Middleware resolves alice → did:plc:alice123 1353. Hold discovery via findHoldDID(): 136 a. Check alice's sailor profile for defaultHold (returns DID if set) 137 b. If not set, check alice's io.atcr.hold records (legacy) 138 c. Fall back to AppView's default_hold_did 1394. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev" 1405. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev) 1416. ProxyBlobStore: 142 a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution) 143 b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth 144 c. Calls hold XRPC endpoints with service token authentication: 145 - POST /xrpc/io.atcr.hold.initiateUpload 146 - POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL) 147 - PUT to S3 presigned URL (direct upload to alice's S3/Storj) 148 - POST /xrpc/io.atcr.hold.completeUpload 1497. Hold service validates service token, checks crew membership, generates presigned URLs 1508. Manifest stored in alice's PDS with: 151 - holdDid = "did:web:alice-storage.fly.dev" (primary) 152 - holdEndpoint = "https://alice-storage.fly.dev" (backward compat) 153``` 154 155#### Pull Flow 156``` 1571. Client: docker pull atcr.io/alice/myapp:latest 1582. GET /v2/alice/myapp/manifests/latest 1593. AppView fetches manifest from alice's PDS 1604. Manifest contains: 161 - holdDid = "did:web:alice-storage.fly.dev" (primary reference) 162 - holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback) 1635. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev" 164 TTL: 10 minutes (covers typical pull operations) 1656. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123 1667. AppView checks cache, routes to hold DID from manifest (not re-discovered) 1678. ProxyBlobStore: 168 a. Resolves hold DID → https://alice-storage.fly.dev 169 b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth 170 c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET 171 d. Hold returns presigned download URL in JSON response 1729. Client redirected to download blob directly from alice's S3 via presigned URL 173``` 174 175**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. 176 177### Name Resolution 178 179Names follow the pattern: `atcr.io/<identity>/<image>:<tag>` 180 181Where `<identity>` can be: 182- **Handle**: `alice.bsky.social` → resolved via .well-known/atproto-did 183- **DID**: `did:plc:xyz123` → resolved via PLC directory 184 185Resolution happens in `pkg/atproto/resolver.go`: 1861. Handle → DID (via DNS/HTTPS) 1872. DID → PDS endpoint (via DID document) 188 189### Middleware System 190 191ATCR uses middleware and routing to handle requests: 192 193#### 1. Registry Middleware (`pkg/appview/middleware/registry.go`) 194- Wraps `distribution.Namespace` 195- Intercepts `Repository(name)` calls 196- Performs name resolution (alice → did:plc:xyz → pds.example.com) 197- Queries PDS for `io.atcr.hold` records to find storage endpoint 198- Stores resolved identity and storage endpoint in context 199 200#### 2. Auth Middleware (`pkg/appview/middleware/auth.go`) 201- Validates JWT tokens from Docker clients 202- Extracts DID from token claims 203- Injects authenticated identity into context 204 205#### 3. Routing Repository (`pkg/appview/storage/routing_repository.go`) 206- Implements `distribution.Repository` 207- Returns custom `Manifests()` and `Blobs()` implementations 208- Routes manifests to ATProto, blobs to S3 or BYOS 209- **IMPORTANT**: RoutingRepository is created fresh on EVERY request (no caching) 210 - Each Docker layer upload is a separate HTTP request (possibly different process) 211 - OAuth sessions can be refreshed/invalidated between requests 212 - The OAuth refresher already caches sessions efficiently (in-memory + DB) 213 - Previous caching of repositories with stale ATProtoClient caused "invalid refresh token" errors 214 215### Authentication Architecture 216 217#### Token Types and Flows 218 219ATCR uses three distinct token types in its authentication flow: 220 221**1. OAuth Tokens (Access + Refresh)** 222- **Issued by:** User's PDS via OAuth flow 223- **Stored in:** AppView database (`oauth_sessions` table) 224- **Cached in:** Refresher's in-memory map (per-DID) 225- **Used for:** AppView → User's PDS communication (write manifests, read profiles) 226- **Managed by:** Indigo library with DPoP (automatic refresh) 227- **Lifetime:** Access ~2 hours, Refresh ~90 days (PDS controlled) 228 229**2. Registry JWTs** 230- **Issued by:** AppView after OAuth login 231- **Stored in:** Docker credential helper (`~/.atcr/credential-helper-token.json`) 232- **Used for:** Docker client → AppView authentication 233- **Lifetime:** 15 minutes (configurable via `ATCR_TOKEN_EXPIRATION`) 234- **Format:** JWT with DID claim 235 236**3. Service Tokens** 237- **Issued by:** User's PDS via `com.atproto.server.getServiceAuth` 238- **Stored in:** AppView memory (in-memory cache with ~50s TTL) 239- **Used for:** AppView → Hold service authentication (acting on behalf of user) 240- **Lifetime:** 60 seconds (PDS controlled), cached for 50s 241- **Required:** OAuth session to obtain (catch-22 solved by Refresher) 242 243**Token Flow Diagram:** 244``` 245┌─────────────┐ ┌──────────────┐ 246│ Docker │ ─── Registry JWT ──────────────→ │ AppView │ 247│ Client │ │ │ 248└─────────────┘ └──────┬───────┘ 249250 │ OAuth tokens 251 │ (access + refresh) 252253 ┌──────────────┐ 254 │ User's PDS │ 255 └──────┬───────┘ 256257 │ Service token 258 │ (via getServiceAuth) 259260 ┌──────────────┐ 261 │ Hold Service │ 262 └──────────────┘ 263``` 264 265#### ATProto OAuth with DPoP 266 267ATCR implements the full ATProto OAuth specification with mandatory security features: 268 269**Required Components:** 270- **DPoP** (RFC 9449) - Cryptographic proof-of-possession for every request 271- **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange 272- **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception 273 274**Key Components** (`pkg/auth/oauth/`): 275 2761. **Client** (`client.go`) - OAuth client configuration and session management 277 - **ClientApp setup:** 278 - `NewClientApp()` - Creates configured `*oauth.ClientApp` (uses indigo directly, no wrapper) 279 - Uses `NewLocalhostConfig()` for localhost (public client) 280 - Uses `NewPublicConfig()` for production (upgraded to confidential with P-256 key) 281 - `GetDefaultScopes()` - Returns ATCR-specific OAuth scopes 282 - `ScopesMatch()` - Compares scope lists (order-independent) 283 - **Session management (Refresher):** 284 - `NewRefresher()` - Creates session cache manager for AppView 285 - **Purpose:** In-memory cache for `*oauth.ClientSession` objects (performance optimization) 286 - **Why needed:** Saves 1-2 DB queries per request (~2ms) with minimal code complexity 287 - Per-DID locking prevents concurrent database loads 288 - Calls `ClientApp.ResumeSession()` on cache miss 289 - Indigo handles token refresh automatically (transparent to ATCR) 290 - **Performance:** Essential for high-traffic deployments, negligible for low-traffic 291 - **Architecture:** Single file containing both ClientApp helpers and Refresher (combined from previous two-file structure) 292 2932. **Keys** (`keys.go`) - P-256 key management for confidential clients 294 - `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk 295 - Follows hold service pattern: auto-generation, 0600 permissions, /var/lib/atcr/oauth/ 296 - `GenerateKeyID()` - derives key ID from public key hash 297 - `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API 298 - **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys) 299 3003. **Storage** - Persists OAuth sessions 301 - `db/oauth_store.go` - SQLite-backed storage for AppView (in UI database) 302 - `store.go` - File-based storage for CLI tools (`~/.atcr/oauth-sessions.json`) 303 - Implements indigo's `ClientAuthStore` interface 304 3054. **Server** (`server.go`) - OAuth authorization endpoints for AppView 306 - `GET /auth/oauth/authorize` - starts OAuth flow 307 - `GET /auth/oauth/callback` - handles OAuth callback 308 - Uses `ClientApp` methods directly (no wrapper) 309 3105. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools 311 - Used by credential helper and hold service registration 312 - Two-phase callback setup ensures PAR metadata availability 313 314**Client Configuration:** 315- **Localhost:** Always public client (no client authentication) 316 - Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based) 317 - No P-256 key generation 318- **Production:** Confidential client with P-256 private key (if key exists) 319 - Client ID: `{baseURL}/client-metadata.json` (metadata endpoint) 320 - Key path: `/var/lib/atcr/oauth/client.key` (auto-generated on first run) 321 - Key algorithm: ES256 (P-256, not K-256) 322 - Upgraded via `config.SetClientSecret(key, keyID)` 323 324**Authentication Flow:** 325``` 3261. User configures Docker to use the credential helper (adds to config.json) 3272. On first docker push/pull, Docker calls credential helper 3283. Credential helper opens browser → AppView OAuth page 3294. AppView handles OAuth flow: 330 - Resolves handle → DID → PDS endpoint 331 - Discovers OAuth server metadata from PDS 332 - PAR request with DPoP header → get request_uri 333 - User authorizes in browser 334 - AppView exchanges code for OAuth token with DPoP proof 335 - AppView stores: OAuth session (tokens managed by indigo library with DPoP), DID, handle 3365. AppView shows device approval page: "Can [device] push to your account?" 3376. User approves device 3387. AppView issues registry JWT with validated DID 3398. AppView returns JSON token to credential helper (via callback or browser display) 3409. Credential helper saves registry JWT locally 34110. Helper returns registry JWT to Docker 342 343Later (subsequent docker push): 34411. Docker calls credential helper 34512. Helper returns cached registry JWT (or re-authenticates if expired) 346``` 347 348**Key distinction:** The credential helper never manages OAuth tokens directly. AppView owns the OAuth session (including DPoP handling via indigo library) and issues registry JWTs to the credential helper. AppView needs the OAuth session for: 349- Writing manifests to user's PDS (with DPoP authentication) 350- Getting service tokens from user's PDS (with DPoP authentication) 351- Service tokens are then used to authenticate to hold services (Bearer tokens, not DPoP) 352 353**Security:** 354- Tokens validated against authoritative source (user's PDS) 355- No trust in client-provided identity information 356- DPoP binds tokens to specific client key 357- 15-minute token expiry for registry JWTs 358- **Confidential clients** (production): Client authentication via P-256 private key JWT assertion 359 - Prevents client impersonation attacks 360 - Key stored in `/var/lib/atcr/oauth/client.key` with 0600 permissions 361 - Automatically generated on first run 362- **Public clients** (localhost): No client authentication (development only) 363 364### Key Components 365 366#### ATProto Integration (`pkg/atproto/`) 367 368**resolver.go**: DID and handle resolution 369- `ResolveIdentity()`: alice → did:plc:xyz → pds.example.com 370- `ResolveHandle()`: Uses .well-known/atproto-did 371- `ResolvePDS()`: Parses DID document for PDS endpoint 372 373**client.go**: ATProto PDS client 374- `PutRecord()`: Store manifest as ATProto record 375- `GetRecord()`: Retrieve manifest from PDS 376- `DeleteRecord()`: Remove manifest 377- Uses XRPC protocol (com.atproto.repo.*) 378 379**lexicon.go**: ATProto record schemas 380- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdDid` + `holdEndpoint` fields) 381- `TagRecord`: Tag pointing to manifest digest 382- `HoldRecord`: Storage hold definition (LEGACY - for old BYOS model) 383- `HoldCrewRecord`: Hold crew membership (LEGACY - stored in owner's PDS) 384- `CaptainRecord`: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self") 385- `CrewRecord`: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member) 386- `SailorProfileRecord`: User profile with `defaultHold` preference (can be DID or URL) 387- 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` 388 389**profile.go**: Sailor profile management 390- `EnsureProfile()`: Creates profile with default hold on first authentication 391- `GetProfile()`: Retrieves user's profile from PDS 392- `UpdateProfile()`: Updates user's profile 393 394**manifest_store.go**: Implements `distribution.ManifestService` 395- Stores OCI manifests as ATProto records 396- Digest-based addressing (sha256:abc123 → record key) 397- Converts between OCI and ATProto formats 398 399#### Storage Layer (`pkg/appview/storage/`) 400 401**routing_repository.go**: Routes content by type 402- `Manifests()` → returns ATProto ManifestStore (caches instance for hold DID extraction) 403- `Blobs()` → checks hold cache for pull, uses discovery for push 404 - Pull: Uses cached `holdDid` from manifest (historical reference) 405 - Push: Uses discovery-based DID from `findHoldDID()` in middleware 406 - Always returns ProxyBlobStore (routes to hold service via DID) 407- Implements `distribution.Repository` interface 408- Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc. 409 410**Database-based hold DID lookups**: 411- Queries SQLite `manifests` table for hold DID (indexed, fast) 412- No in-memory caching needed - database IS the cache 413- Persistent across restarts, multi-instance safe 414- Pull operations use hold DID from latest manifest (historical reference) 415- Push operations use fresh discovery from profile/default 416- Function: `db.GetLatestHoldDIDForRepo(did, repository)` in `pkg/appview/db/queries.go` 417 418**proxy_blob_store.go**: External storage proxy (routes to hold via XRPC) 419- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution) 420- Gets service tokens from user's PDS (`com.atproto.server.getServiceAuth`) 421- Calls hold XRPC endpoints with service token authentication: 422 - Multipart upload: initiateUpload, getPartUploadUrl, uploadPart, completeUpload, abortUpload 423 - Blob read: com.atproto.sync.getBlob (returns presigned download URL) 424- Implements full `distribution.BlobStore` interface 425- Supports both presigned URL mode (S3 direct) and buffered mode (proxy via hold) 426 427#### AppView Web UI (`pkg/appview/`) 428 429The AppView includes a web interface for browsing the registry: 430 431**Features:** 432- Repository browsing and search 433- Star/favorite repositories 434- Pull count tracking 435- User profiles and settings 436- OAuth-based authentication for web users 437 438**Database Layer** (`pkg/appview/db/`): 439- SQLite database for metadata (stars, pulls, repository info) 440- Schema migrations via SQL files in `pkg/appview/db/schema.go` 441- Stores: OAuth sessions, device flows, repository metadata 442- **NOTE:** Simple SQLite for MVP. For production multi-instance: use PostgreSQL 443 444**Jetstream Integration** (`pkg/appview/jetstream/`): 445- Consumes ATProto Jetstream for real-time updates 446- Backfills repository records from PDS 447- Indexes manifests, tags, and repository metadata 448- Worker processes incoming events 449 450**Web Handlers** (`pkg/appview/handlers/`): 451- `home.go` - Landing page 452- `repository.go` - Repository detail pages 453- `search.go` - Search functionality 454- `auth.go` - OAuth login/logout for web 455- `settings.go` - User settings management 456- `api.go` - JSON API endpoints 457 458**Static Assets** (`pkg/appview/static/`, `pkg/appview/templates/`): 459- Templates use Go html/template 460- JavaScript in `static/js/app.js` 461- Minimal CSS for clean UI 462 463#### Hold Service (`cmd/hold/`) 464 465Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS: 466 467**Architecture:** 468- **Embedded PDS**: Each hold has a full ATProto PDS for storing captain + crew records 469- **DID**: Hold identified by did:web (e.g., `did:web:hold01.atcr.io`) 470- **Storage**: Reuses distribution's storage driver factory (S3, Storj, Minio, Azure, GCS, filesystem) 471- **Authorization**: Based on captain + crew records in embedded PDS 472- **Blob operations**: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC 473 474**Authorization Model:** 475 476Read access: 477- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users 478- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read OR blob:write permission 479- **Note:** `blob:write` implicitly grants `blob:read` access (can't push without pulling) 480 481Write access: 482- Hold owner OR crew members with blob:write permission 483- Verified via `io.atcr.hold.crew` records in hold's embedded PDS 484 485**Permission Matrix:** 486 487| User Type | Public Read | Private Read | Write | Crew Admin | 488|-----------|-------------|--------------|-------|------------| 489| Anonymous | Yes | No | No | No | 490| Owner (captain) | Yes | Yes | Yes | Yes (implied) | 491| Crew (blob:read only) | Yes | Yes | No | No | 492| Crew (blob:write only) | Yes | Yes* | Yes | No | 493| Crew (blob:read + blob:write) | Yes | Yes | Yes | No | 494| Crew (crew:admin) | Yes | Yes | Yes | Yes | 495| Authenticated non-crew | Yes | No | No | No | 496 497*`blob:write` implicitly grants `blob:read` access 498 499**Authorization Error Format:** 500 501All authorization failures use consistent structured errors (`pkg/hold/pds/auth.go`): 502``` 503access denied for [action]: [reason] (required: [permission(s)]) 504``` 505 506Examples: 507- `access denied for blob:read: user is not a crew member (required: blob:read or blob:write)` 508- `access denied for blob:write: crew member lacks permission (required: blob:write)` 509- `access denied for crew:admin: user is not a crew member (required: crew:admin)` 510 511**Shared Error Constants** (`pkg/hold/pds/auth.go`): 512- `ErrMissingAuthHeader` - Missing Authorization header 513- `ErrInvalidAuthFormat` - Invalid Authorization header format 514- `ErrInvalidAuthScheme` - Invalid scheme (expected Bearer or DPoP) 515- `ErrInvalidJWTFormat` - Malformed JWT 516- `ErrMissingISSClaim` / `ErrMissingSubClaim` - Missing JWT claims 517- `ErrTokenExpired` - Token has expired 518 519**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`): 520 521Standard ATProto sync endpoints: 522- `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file 523- `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision 524- `GET /xrpc/com.atproto.sync.getRepoStatus?did={did}` - Get repository hosting status and current revision 525- `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events 526- `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS) 527- `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL 528 529Repository management: 530- `GET /xrpc/com.atproto.repo.describeRepo?repo={did}` - Repository metadata 531- `GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}` - Get record 532- `GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}` - List records (supports pagination) 533- `POST /xrpc/com.atproto.repo.deleteRecord` - Delete record (owner/crew admin only) 534- `POST /xrpc/com.atproto.repo.uploadBlob` - Upload ATProto blob (owner/crew admin only) 535 536DID resolution: 537- `GET /.well-known/did.json` - DID document (did:web resolution) 538- `GET /.well-known/atproto-did` - DID for handle resolution 539 540Crew management: 541- `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership (authenticated users) 542 543**OCI Multipart Upload Endpoints** (`pkg/hold/oci/xrpc.go`): 544 545All require blob:write permission via service token authentication: 546- `POST /xrpc/io.atcr.hold.initiateUpload` - Start multipart upload session 547- `POST /xrpc/io.atcr.hold.getPartUploadUrl` - Get presigned URL for uploading a part 548- `PUT /xrpc/io.atcr.hold.uploadPart` - Direct buffered part upload (alternative to presigned URLs) 549- `POST /xrpc/io.atcr.hold.completeUpload` - Finalize multipart upload and move to final location 550- `POST /xrpc/io.atcr.hold.abortUpload` - Cancel multipart upload and cleanup temp data 551 552**AppView-to-Hold Authentication:** 553- AppView uses service tokens from user's PDS (`com.atproto.server.getServiceAuth`) 554- Service tokens are scoped to specific hold DIDs and include the user's DID 555- Hold validates tokens and checks crew membership for authorization 556- Tokens cached for 50 seconds (valid for 60 seconds from PDS) 557 558**Configuration:** Environment variables (see `.env.hold.example`) 559- `HOLD_PUBLIC_URL` - Public URL of hold service (required, used for did:web generation) 560- `STORAGE_DRIVER` - Storage driver type (s3, filesystem) 561- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials 562- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration 563- `HOLD_PUBLIC` - Allow public reads (default: false) 564- `HOLD_OWNER` - DID for captain record creation (optional) 565- `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false) 566- `HOLD_DATABASE_PATH` - Path for embedded PDS database (required) 567- `HOLD_DATABASE_KEY_PATH` - Path for PDS signing keys (optional, generated if missing) 568 569**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. 570 571### ATProto Storage Model 572 573Manifests are stored as records with this structure: 574```json 575{ 576 "$type": "io.atcr.manifest", 577 "repository": "myapp", 578 "digest": "sha256:abc123...", 579 "holdDid": "did:web:hold01.atcr.io", 580 "holdEndpoint": "https://hold1.atcr.io", 581 "schemaVersion": 2, 582 "mediaType": "application/vnd.oci.image.manifest.v1+json", 583 "config": { "digest": "sha256:...", "size": 1234 }, 584 "layers": [ 585 { "digest": "sha256:...", "size": 5678 } 586 ], 587 "manifestBlob": { 588 "$type": "blob", 589 "ref": { "$link": "bafyrei..." }, 590 "mimeType": "application/vnd.oci.image.manifest.v1+json", 591 "size": 1234 592 }, 593 "createdAt": "2025-09-30T..." 594} 595``` 596 597**Key fields:** 598- `holdDid` - DID of the hold service where blobs are stored (PRIMARY reference, new) 599- `holdEndpoint` - HTTP URL of hold service (DEPRECATED, kept for backward compatibility) 600- `manifestBlob` - Reference to manifest blob in ATProto blob storage (CID format) 601 602Record key = manifest digest (without algorithm prefix) 603Collection = `io.atcr.manifest` 604 605### Sailor Profile System 606 607ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture: 608- **Sailors** = Registry users 609- **Captains** = Hold owners 610- **Crew** = Hold members with access 611- **Holds** = Storage endpoints (BYOS) 612 613**Profile Record** (`io.atcr.sailor.profile`): 614```json 615{ 616 "$type": "io.atcr.sailor.profile", 617 "defaultHold": "did:web:hold1.alice.com", 618 "createdAt": "2025-10-02T...", 619 "updatedAt": "2025-10-02T..." 620} 621``` 622 623**Profile Management:** 624- Created automatically on first authentication (OAuth or Basic Auth) 625- `defaultHold` can be a DID (preferred, e.g., `did:web:hold01.atcr.io`) or legacy URL 626- If AppView has `default_hold_did` configured, profile gets that as `defaultHold` 627- Users can update their profile to change default hold (future: via UI) 628- Setting `defaultHold` to null opts out of defaults (use own holds or AppView default) 629 630**Hold Resolution Priority** (in `findHoldDID()` in middleware): 6311. **Profile's `defaultHold`** - User's explicit preference (DID or URL) 6322. **User's `io.atcr.hold` records** - User's own holds (legacy BYOS model) 6333. **AppView's `default_hold_did`** - Fallback default (configured in middleware) 634 635This ensures: 636- Users can join shared holds by setting their profile's `defaultHold` 637- Users can opt out of defaults (set `defaultHold` to null) 638- URL structure remains `atcr.io/<owner>/<image>` (ownership-based, not hold-based) 639- Hold choice is transparent infrastructure (like choosing an S3 region) 640 641### Key Design Decisions 642 6431. **No fork of distribution**: Uses distribution as library, extends via middleware 6442. **Hybrid storage**: Manifests in ATProto (small), blobs in S3 or BYOS (cheap, scalable) 6453. **Content addressing**: Manifests stored by digest, blobs deduplicated globally 6464. **ATProto-native**: Manifests are first-class ATProto records, discoverable via AT Protocol 6475. **OCI compliant**: Fully compatible with Docker/containerd/podman 6486. **Account-agnostic AppView**: Server validates any user's token, queries their PDS for config 6497. **BYOS architecture**: Users can deploy their own storage service, AppView just routes 6508. **OAuth with DPoP**: Full ATProto OAuth implementation with mandatory DPoP proofs 6519. **Sailor profile system**: User preferences for hold selection, transparent to image ownership 65210. **Historical hold references**: Manifests store `holdEndpoint` for immutable blob location tracking 653 654### Configuration 655 656**AppView configuration** (environment variables): 657 658Both AppView and Hold service follow the same pattern: **zero config files, all configuration via environment variables**. 659 660See `.env.appview.example` for all available options. Key environment variables: 661 662**Server:** 663- `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`) 664- `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev) 665- `ATCR_DEFAULT_HOLD_DID` - Default hold DID for blob storage (REQUIRED, e.g., `did:web:hold01.atcr.io`) 666 667**Authentication:** 668- `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`) 669- `ATCR_TOKEN_EXPIRATION` - JWT expiration in seconds (default: 300) 670 671**UI:** 672- `ATCR_UI_ENABLED` - Enable web interface (default: true) 673- `ATCR_UI_DATABASE_PATH` - SQLite database path (default: `/var/lib/atcr/ui.db`) 674 675**Jetstream:** 676- `JETSTREAM_URL` - ATProto event stream URL 677- `ATCR_BACKFILL_ENABLED` - Enable periodic sync (default: false) 678 679**Legacy:** `config/config.yml` is still supported but deprecated. Use environment variables instead. 680 681**Hold Service configuration** (environment variables): 682 683See `.env.hold.example` for all available options. Key environment variables: 684- `HOLD_PUBLIC_URL` - Public URL of hold service (REQUIRED) 685- `STORAGE_DRIVER` - Storage backend (s3, filesystem) 686- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials 687- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration 688- `HOLD_PUBLIC` - Allow public reads (default: false) 689- `HOLD_OWNER` - DID for captain record creation (optional) 690- `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false) 691 692**Credential Helper**: 693- Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store) 694- Contains: Registry JWT issued by AppView (NOT OAuth tokens) 695- OAuth session managed entirely by AppView 696 697### Development Notes 698 699**General:** 700- Middleware is in `pkg/appview/middleware/` (auth.go, registry.go) 701- Storage routing is in `pkg/appview/storage/` (routing_repository.go, proxy_blob_store.go) 702- Hold DID lookups use database queries (no in-memory caching) 703- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` 704- Hold service reuses distribution's driver factory for multi-backend support 705 706**OAuth implementation:** 707- Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration 708- Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity 709- All ATCR components use standardized `/auth/oauth/callback` path 710- Client ID generation (localhost query-based vs production metadata URL) handled internally 711 712### Testing Strategy 713 714When writing tests: 715- Mock ATProto client for manifest operations 716- Mock S3 driver for blob operations 717- Test name resolution independently 718- Integration tests require real PDS + S3 719 720### Common Tasks 721 722**Adding a new ATProto record type**: 7231. Define schema in `pkg/atproto/lexicon.go` 7242. Add collection constant (e.g., `MyCollection = "io.atcr.my-type"`) 7253. Add constructor function (e.g., `NewMyRecord()`) 7264. Update client methods if needed 727 728**Modifying storage routing**: 7291. Edit `pkg/appview/storage/routing_repository.go` 7302. Update `Blobs()` method to change routing logic 7313. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.) 732 733**Changing name resolution**: 7341. Modify `pkg/atproto/resolver.go` for DID/handle resolution 7352. Update `pkg/appview/middleware/registry.go` if changing routing logic 7363. Remember: `findHoldDID()` checks sailor profile, then `io.atcr.hold` records (legacy), then default hold DID 737 738**Working with OAuth client**: 739- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes 740- For AppView server/refresher: use `NewClient(baseURL)` or `NewClientWithKey(baseURL, storedKey)` 741- For custom scopes: call `client.SetScopes(customScopes)` after initialization 742- Standard callback path: `/auth/oauth/callback` (used by all ATCR components) 743- Client methods are consistent across authorization, token exchange, and refresh flows 744 745**Adding BYOS support for a user**: 7461. User sets environment variables (storage credentials, public URL, HOLD_OWNER) 7472. User runs hold service - creates captain + crew records in embedded PDS 7483. Hold creates `io.atcr.hold.captain` + `io.atcr.hold.crew` records 7494. User sets sailor profile `defaultHold` to point to their hold 7505. AppView automatically queries hold's PDS and routes blobs to user's storage 7516. No AppView changes needed - fully decentralized 752 753**Supporting a new storage backend**: 7541. Ensure driver is registered in `cmd/hold/main.go` imports 7552. Distribution supports: S3, Azure, GCS, Swift, filesystem, OSS 7563. For custom drivers: implement `storagedriver.StorageDriver` interface 7574. Add case to `buildStorageConfig()` in `cmd/hold/main.go` 7585. Update `.env.example` with new driver's env vars 759 760**Working with the database**: 761- **Base schema** defined in `pkg/appview/db/schema.sql` - source of truth for fresh installations 762- **Migrations** in `pkg/appview/db/migrations/*.yaml` - only for ALTER/UPDATE/DELETE on existing databases 763- **Queries** in `pkg/appview/db/queries.go` 764- **Stores** for OAuth, devices, sessions in separate files 765- **Execution order**: schema.sql first, then migrations (automatically on startup) 766- **Database path** configurable via `ATCR_UI_DATABASE_PATH` env var 767- **Adding new tables**: Add to `schema.sql` only (no migration needed) 768- **Altering tables**: Create migration AND update `schema.sql` to keep them in sync 769 770**Adding web UI features**: 771- Add handler in `pkg/appview/handlers/` 772- Register route in `cmd/appview/serve.go` 773- Create template in `pkg/appview/templates/pages/` 774- Use existing auth middleware for protected routes 775- API endpoints return JSON, pages return HTML 776 777## Important Context Values 778 779When working with the codebase, routing information is passed via the `RegistryContext` struct (`pkg/appview/storage/context.go`): 780 781- `DID` - User's DID (e.g., `did:plc:alice123`) 782- `PDSEndpoint` - User's PDS endpoint (e.g., `https://bsky.social`) 783- `HoldDID` - Hold service DID (e.g., `did:web:hold01.atcr.io`) 784- `Repository` - Image repository name (e.g., `myapp`) 785- `ATProtoClient` - Client for calling user's PDS with OAuth/Basic Auth 786- `Refresher` - OAuth token refresher for service token requests 787- `Database` - Database for metrics tracking 788- `Authorizer` - Hold authorizer for access control 789 790Legacy context keys (deprecated): 791- `hold.did` - Hold DID (now in RegistryContext) 792- `auth.did` - Authenticated DID from validated token (now in auth middleware) 793 794## Documentation References 795 796- **BYOS Architecture**: See `docs/BYOS.md` for complete BYOS documentation 797- **OAuth Implementation**: See `docs/OAUTH.md` for OAuth/DPoP flow details 798- **ATProto Spec**: https://atproto.com/specs/oauth 799- **OCI Distribution Spec**: https://github.com/opencontainers/distribution-spec 800- **DPoP RFC**: https://datatracker.ietf.org/doc/html/rfc9449 801- **PAR RFC**: https://datatracker.ietf.org/doc/html/rfc9126 802- **PKCE RFC**: https://datatracker.ietf.org/doc/html/rfc7636