A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
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└─────────────┘ └──────┬───────┘
249 │
250 │ OAuth tokens
251 │ (access + refresh)
252 ↓
253 ┌──────────────┐
254 │ User's PDS │
255 └──────┬───────┘
256 │
257 │ Service token
258 │ (via getServiceAuth)
259 ↓
260 ┌──────────────┐
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