···4455## Project Overview
6677-ATCR (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.
77+ATCR (ATProto Container Registry) is an OCI-compliant container registry that uses the AT Protocol for manifest storage and S3 for blob storage. Manifests are stored in users' Personal Data Servers (PDS) while layers are stored in S3.
8899## Go Workspace
1010···2626# Build scanner (separate module)
2727cd scanner && go build -o ../bin/atcr-scanner ./cmd/scanner && cd ..
28282929-# Build hold with billing support (optional, uses build tag)
2929+# Build hold with billing support (optional build tag)
3030go build -tags billing -o bin/atcr-hold ./cmd/hold
31313232-# Run tests
3333-go test ./...
3434-3535-# Run tests for specific package
3636-go test ./pkg/atproto/...
3737-go test ./pkg/appview/storage/...
3838-3939-# Run specific test
4040-go test -run TestManifestStore ./pkg/atproto/...
4141-4242-# Run with race detector
4343-go test -race ./...
4444-4545-# Run tests with verbose output
4646-go test -v ./...
4747-4848-# Update dependencies
4949-go mod tidy
3232+# Tests
3333+go test ./... # all tests
3434+go test ./pkg/atproto/... # specific package
3535+go test -run TestManifestStore ./pkg/atproto/... # specific test
3636+go test -race ./... # race detector
50375151-# Build Docker images
3838+# Docker
5239docker build -t atcr.io/appview:latest .
5340docker build -f Dockerfile.hold -t atcr.io/hold:latest .
5441docker build -f Dockerfile.scanner -t atcr.io/scanner:latest .
5555-5656-# Or use docker-compose
5742docker-compose up -d
58435959-# Generate default config files
4444+# Generate & run with config
6045./bin/atcr-appview config init config-appview.yaml
6146./bin/atcr-hold config init config-hold.yaml
6262-6363-# Run locally (AppView) - YAML config (preferred)
6447./bin/atcr-appview serve --config config-appview.yaml
6565-# Or env vars only (still works):
6666-ATCR_SERVER_DEFAULT_HOLD_DID=did:web:hold01.atcr.io ./bin/atcr-appview serve
6767-6868-# Run hold service - YAML config (preferred)
6969-# For local development, use Minio as S3-compatible storage:
7070-# docker run -p 9000:9000 minio/minio server /data
7148./bin/atcr-hold serve --config config-hold.yaml
7272-# Or env vars only:
7373-HOLD_SERVER_PUBLIC_URL=http://127.0.0.1:8080 S3_BUCKET=test ./bin/atcr-hold serve
74497575-# Run scanner service (env vars only, no YAML)
5050+# Scanner (env vars only, no YAML)
7651SCANNER_HOLD_URL=ws://localhost:8080 SCANNER_SHARED_SECRET=secret ./bin/atcr-scanner serve
77527878-# Usage report tool
5353+# Usage report
7954go run ./cmd/usage-report --hold https://hold01.atcr.io
8055go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests
8181-8282-# Request Bluesky relay crawl (makes your PDS discoverable)
8383-./deploy/request-crawl.sh hold01.atcr.io
8456```
85578658## Architecture Overview
87598888-### Core Design
6060+ATCR uses **distribution/distribution** as a library, extending it via middleware to route content to different backends:
89619090-ATCR uses **distribution/distribution** as a library and extends it through middleware to route different types of content to different storage backends:
9191-9292-- **Manifests** → ATProto PDS (small JSON metadata, stored as `io.atcr.manifest` records)
9393-- **Blobs/Layers** → S3 or user-deployed storage (large binary data)
6262+- **Manifests** → ATProto PDS (small JSON, stored as `io.atcr.manifest` records)
6363+- **Blobs/Layers** → S3 via hold service (presigned URLs for direct client-to-S3 transfers)
9464- **Authentication** → ATProto OAuth with DPoP + Docker credential helpers
95659696-### Four-Component Architecture
6666+### Four Components
97679898-1. **AppView** (`cmd/appview`) - OCI Distribution API server
9999- - Resolves identities (handle/DID → PDS endpoint)
100100- - Routes manifests to user's PDS
101101- - Routes blobs to storage endpoint (default or BYOS)
102102- - Validates OAuth tokens via PDS
103103- - Issues registry JWTs
6868+1. **AppView** (`cmd/appview`) — OCI Distribution API server. Resolves identities, routes manifests to PDS, routes blobs to hold service, validates OAuth, issues registry JWTs. Includes web UI for browsing.
6969+2. **Hold Service** (`cmd/hold`) — BYOS blob storage. Embedded PDS with captain/crew records, S3-compatible storage, presigned URLs. Optional subsystems: admin UI, quotas, billing (Stripe), GC, scan dispatch.
7070+3. **Scanner** (`scanner/cmd/scanner`) — Vulnerability scanning. Connects to hold via WebSocket, generates SBOMs (Syft), scans vulnerabilities (Grype). Priority queue with tier-based scheduling.
7171+4. **Credential Helper** (`cmd/credential-helper`) — Docker credential helper implementing ATProto OAuth flow, exchanges OAuth token for registry JWT.
10472105105-2. **Hold Service** (`cmd/hold`) - Optional BYOS component
106106- - Lightweight HTTP server for presigned URLs
107107- - Embedded PDS with captain + crew records
108108- - Supports S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.)
109109- - Authorization based on captain record (public, allowAllCrew)
110110- - Self-describing via DID resolution
111111- - Optional subsystems: admin UI, quota enforcement, billing (Stripe), garbage collection
112112- - Dispatches scan jobs to scanner instances via WebSocket
7373+### Request Flow Summary
11374114114-3. **Scanner** (`scanner/cmd/scanner`) - Vulnerability scanning service
115115- - Separate Go module (heavy Syft/Grype dependencies isolated)
116116- - Connects to hold service via WebSocket (`/xrpc/io.atcr.hold.subscribeScanJobs`)
117117- - Generates SBOMs with Syft, scans for vulnerabilities with Grype
118118- - Priority queue with tier-based scheduling (owner > quartermaster > bosun > deckhand)
119119- - Competing-consumer pattern: multiple scanners pull from same hold
7575+**Push:** Client pushes to `atcr.io/<identity>/<image>:<tag>`. Registry middleware resolves identity → DID → PDS, discovers hold DID (from sailor profile `defaultHold` → legacy `io.atcr.hold` records → AppView default). Blobs go to hold via XRPC multipart upload (presigned S3 URLs). Manifests stored in user's PDS as `io.atcr.manifest` records with `holdDid` reference.
12076121121-4. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth
122122- - Implements Docker credential helper protocol
123123- - ATProto OAuth flow with DPoP
124124- - Token caching and refresh
125125- - Exchanges OAuth token for registry JWT
7777+**Pull:** AppView fetches manifest from user's PDS. The manifest's `holdDid` field tells where blobs were stored. Blobs fetched from that hold via presigned download URLs. Pull always uses the historical hold from the manifest, even if the user changed their default since pushing.
12678127127-### Request Flow
128128-129129-#### Push with Default Storage
130130-```
131131-1. Client: docker push atcr.io/alice/myapp:latest
132132-2. HTTP Request → /v2/alice/myapp/manifests/latest
133133-3. Registry Middleware (pkg/appview/middleware/registry.go)
134134- → Resolves "alice" to DID and PDS endpoint
135135- → Queries alice's sailor profile for defaultHold (returns DID if set)
136136- → If not set, checks alice's io.atcr.hold records
137137- → Falls back to AppView's default_hold_did
138138- → Stores DID/PDS/hold DID in RegistryContext
139139-4. Routing Repository (pkg/appview/storage/routing_repository.go)
140140- → Creates RoutingRepository
141141- → Returns ATProto ManifestStore for manifests
142142- → Returns ProxyBlobStore for blobs (routes to hold DID)
143143-5. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints:
144144- a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID)
145145- b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part)
146146- c. PUT to S3 presigned URL (client uploads directly to S3)
147147- d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload)
148148-6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint)
149149- → Manifest also uploaded to PDS blob storage (ATProto CID format)
150150-```
151151-152152-#### Push with BYOS (Bring Your Own Storage)
153153-```
154154-1. Client: docker push atcr.io/alice/myapp:latest
155155-2. Registry Middleware resolves alice → did:plc:alice123
156156-3. Hold discovery via findHoldDID():
157157- a. Check alice's sailor profile for defaultHold (returns DID if set)
158158- b. If not set, check alice's io.atcr.hold records (legacy)
159159- c. Fall back to AppView's default_hold_did
160160-4. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev"
161161-5. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev)
162162-6. ProxyBlobStore:
163163- a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution)
164164- b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
165165- c. Calls hold XRPC endpoints with service token authentication:
166166- - POST /xrpc/io.atcr.hold.initiateUpload
167167- - POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL)
168168- - PUT to S3 presigned URL (direct upload to alice's S3/Storj)
169169- - POST /xrpc/io.atcr.hold.completeUpload
170170-7. Hold service validates service token, checks crew membership, generates presigned URLs
171171-8. Manifest stored in alice's PDS with:
172172- - holdDid = "did:web:alice-storage.fly.dev" (primary)
173173- - holdEndpoint = "https://alice-storage.fly.dev" (backward compat)
174174-```
175175-176176-#### Pull Flow
177177-```
178178-1. Client: docker pull atcr.io/alice/myapp:latest
179179-2. GET /v2/alice/myapp/manifests/latest
180180-3. AppView fetches manifest from alice's PDS
181181-4. Manifest contains:
182182- - holdDid = "did:web:alice-storage.fly.dev" (primary reference)
183183- - holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback)
184184-5. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev"
185185- TTL: 10 minutes (covers typical pull operations)
186186-6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123
187187-7. AppView checks cache, routes to hold DID from manifest (not re-discovered)
188188-8. ProxyBlobStore:
189189- a. Resolves hold DID → https://alice-storage.fly.dev
190190- b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
191191- c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET
192192- d. Hold returns presigned download URL in JSON response
193193-9. Client redirected to download blob directly from alice's S3 via presigned URL
194194-```
195195-196196-**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.
7979+**Hold discovery priority** (in `findHoldDID()`, `pkg/appview/middleware/registry.go`):
8080+1. Sailor profile's `defaultHold` (user preference)
8181+2. User's `io.atcr.hold` records (legacy)
8282+3. AppView's `default_hold_did` (fallback)
1978319884### Name Resolution
19985200200-Names follow the pattern: `atcr.io/<identity>/<image>:<tag>`
201201-202202-Where `<identity>` can be:
203203-- **Handle**: `alice.bsky.social` → resolved via .well-known/atproto-did
204204-- **DID**: `did:plc:xyz123` → resolved via PLC directory
205205-206206-Resolution happens in `pkg/atproto/resolver.go`:
207207-1. Handle → DID (via DNS/HTTPS)
208208-2. DID → PDS endpoint (via DID document)
209209-210210-### Middleware System
211211-212212-ATCR uses middleware and routing to handle requests:
213213-214214-#### 1. Registry Middleware (`pkg/appview/middleware/registry.go`)
215215-- Wraps `distribution.Namespace`
216216-- Intercepts `Repository(name)` calls
217217-- Performs name resolution (alice → did:plc:xyz → pds.example.com)
218218-- Queries PDS for `io.atcr.hold` records to find storage endpoint
219219-- Stores resolved identity and storage endpoint in context
220220-221221-#### 2. Auth Middleware (`pkg/appview/middleware/auth.go`)
222222-- Validates JWT tokens from Docker clients
223223-- Extracts DID from token claims
224224-- Injects authenticated identity into context
225225-226226-#### 3. Routing Repository (`pkg/appview/storage/routing_repository.go`)
227227-- Implements `distribution.Repository`
228228-- Returns custom `Manifests()` and `Blobs()` implementations
229229-- Routes manifests to ATProto, blobs to S3 or BYOS
230230-- **IMPORTANT**: RoutingRepository is created fresh on EVERY request (no caching)
231231- - Each Docker layer upload is a separate HTTP request (possibly different process)
232232- - OAuth sessions can be refreshed/invalidated between requests
233233- - The OAuth refresher already caches sessions efficiently (in-memory + DB)
234234- - Previous caching of repositories with stale ATProtoClient caused "invalid refresh token" errors
235235-236236-### Authentication Architecture
237237-238238-#### Token Types and Flows
239239-240240-ATCR uses three distinct token types in its authentication flow:
241241-242242-**1. OAuth Tokens (Access + Refresh)**
243243-- **Issued by:** User's PDS via OAuth flow
244244-- **Stored in:** AppView database (`oauth_sessions` table)
245245-- **Cached in:** Refresher's in-memory map (per-DID)
246246-- **Used for:** AppView → User's PDS communication (write manifests, read profiles)
247247-- **Managed by:** Indigo library with DPoP (automatic refresh)
248248-- **Lifetime:** Access ~2 hours, Refresh ~90 days (PDS controlled)
249249-250250-**2. Registry JWTs**
251251-- **Issued by:** AppView after OAuth login
252252-- **Stored in:** Docker credential helper (`~/.atcr/credential-helper-token.json`)
253253-- **Used for:** Docker client → AppView authentication
254254-- **Lifetime:** 5 minutes
255255-- **Format:** JWT with DID claim
256256-257257-**3. Service Tokens**
258258-- **Issued by:** User's PDS via `com.atproto.server.getServiceAuth`
259259-- **Stored in:** AppView memory (in-memory cache with ~50s TTL)
260260-- **Used for:** AppView → Hold service authentication (acting on behalf of user)
261261-- **Lifetime:** 60 seconds (PDS controlled), cached for 50s
262262-- **Required:** OAuth session to obtain (catch-22 solved by Refresher)
263263-264264-**Token Flow Diagram:**
265265-```
266266-┌─────────────┐ ┌──────────────┐
267267-│ Docker │ ─── Registry JWT ──────────────→ │ AppView │
268268-│ Client │ │ │
269269-└─────────────┘ └──────┬───────┘
270270- │
271271- │ OAuth tokens
272272- │ (access + refresh)
273273- ↓
274274- ┌──────────────┐
275275- │ User's PDS │
276276- └──────┬───────┘
277277- │
278278- │ Service token
279279- │ (via getServiceAuth)
280280- ↓
281281- ┌──────────────┐
282282- │ Hold Service │
283283- └──────────────┘
284284-```
285285-286286-#### ATProto OAuth with DPoP
287287-288288-ATCR implements the full ATProto OAuth specification with mandatory security features:
289289-290290-**Required Components:**
291291-- **DPoP** (RFC 9449) - Cryptographic proof-of-possession for every request
292292-- **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange
293293-- **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception
294294-295295-**Key Components** (`pkg/auth/oauth/`):
296296-297297-1. **Client** (`client.go`) - OAuth client configuration and session management
298298- - **ClientApp setup:**
299299- - `NewClientApp()` - Creates configured `*oauth.ClientApp` (uses indigo directly, no wrapper)
300300- - Uses `NewLocalhostConfig()` for localhost (public client)
301301- - Uses `NewPublicConfig()` for production (upgraded to confidential with P-256 key)
302302- - `GetDefaultScopes()` - Returns ATCR-specific OAuth scopes
303303- - `ScopesMatch()` - Compares scope lists (order-independent)
304304- - **Session management (Refresher):**
305305- - `NewRefresher()` - Creates session cache manager for AppView
306306- - **Purpose:** In-memory cache for `*oauth.ClientSession` objects (performance optimization)
307307- - **Why needed:** Saves 1-2 DB queries per request (~2ms) with minimal code complexity
308308- - Per-DID locking prevents concurrent database loads
309309- - Calls `ClientApp.ResumeSession()` on cache miss
310310- - Indigo handles token refresh automatically (transparent to ATCR)
311311- - **Performance:** Essential for high-traffic deployments, negligible for low-traffic
312312- - **Architecture:** Single file containing both ClientApp helpers and Refresher (combined from previous two-file structure)
313313-314314-2. **Keys** (`keys.go`) - P-256 key management for confidential clients
315315- - `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk
316316- - Follows hold service pattern: auto-generation, 0600 permissions, /var/lib/atcr/oauth/
317317- - `GenerateKeyID()` - derives key ID from public key hash
318318- - `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API
319319- - **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys)
320320-321321-3. **Storage** - Persists OAuth sessions
322322- - `db/oauth_store.go` - SQLite-backed storage for AppView (in UI database)
323323- - `store.go` - File-based storage for CLI tools (`~/.atcr/oauth-sessions.json`)
324324- - Implements indigo's `ClientAuthStore` interface
325325-326326-4. **Server** (`server.go`) - OAuth authorization endpoints for AppView
327327- - `GET /auth/oauth/authorize` - starts OAuth flow
328328- - `GET /auth/oauth/callback` - handles OAuth callback
329329- - Uses `ClientApp` methods directly (no wrapper)
330330-331331-5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
332332- - Used by credential helper and hold service registration
333333- - Two-phase callback setup ensures PAR metadata availability
334334-335335-**Client Configuration:**
336336-- **Localhost:** Always public client (no client authentication)
337337- - Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based)
338338- - No P-256 key generation
339339-- **Production:** Confidential client with P-256 private key (if key exists)
340340- - Client ID: `{baseURL}/client-metadata.json` (metadata endpoint)
341341- - Key path: `/var/lib/atcr/oauth/client.key` (auto-generated on first run)
342342- - Key algorithm: ES256 (P-256, not K-256)
343343- - Upgraded via `config.SetClientSecret(key, keyID)`
344344-345345-**Authentication Flow:**
346346-```
347347-1. User configures Docker to use the credential helper (adds to config.json)
348348-2. On first docker push/pull, Docker calls credential helper
349349-3. Credential helper opens browser → AppView OAuth page
350350-4. AppView handles OAuth flow:
351351- - Resolves handle → DID → PDS endpoint
352352- - Discovers OAuth server metadata from PDS
353353- - PAR request with DPoP header → get request_uri
354354- - User authorizes in browser
355355- - AppView exchanges code for OAuth token with DPoP proof
356356- - AppView stores: OAuth session (tokens managed by indigo library with DPoP), DID, handle
357357-5. AppView shows device approval page: "Can [device] push to your account?"
358358-6. User approves device
359359-7. AppView issues registry JWT with validated DID
360360-8. AppView returns JSON token to credential helper (via callback or browser display)
361361-9. Credential helper saves registry JWT locally
362362-10. Helper returns registry JWT to Docker
363363-364364-Later (subsequent docker push):
365365-11. Docker calls credential helper
366366-12. Helper returns cached registry JWT (or re-authenticates if expired)
367367-```
368368-369369-**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:
370370-- Writing manifests to user's PDS (with DPoP authentication)
371371-- Getting service tokens from user's PDS (with DPoP authentication)
372372-- Service tokens are then used to authenticate to hold services (Bearer tokens, not DPoP)
373373-374374-**Security:**
375375-- Tokens validated against authoritative source (user's PDS)
376376-- No trust in client-provided identity information
377377-- DPoP binds tokens to specific client key
378378-- 15-minute token expiry for registry JWTs
379379-- **Confidential clients** (production): Client authentication via P-256 private key JWT assertion
380380- - Prevents client impersonation attacks
381381- - Key stored in `/var/lib/atcr/oauth/client.key` with 0600 permissions
382382- - Automatically generated on first run
383383-- **Public clients** (localhost): No client authentication (development only)
384384-385385-### Key Components
386386-387387-#### ATProto Integration (`pkg/atproto/`)
388388-389389-**resolver.go**: DID and handle resolution
390390-- `ResolveIdentity()`: alice → did:plc:xyz → pds.example.com
391391-- `ResolveHandle()`: Uses .well-known/atproto-did
392392-- `ResolvePDS()`: Parses DID document for PDS endpoint
393393-394394-**client.go**: ATProto PDS client
395395-- `PutRecord()`: Store manifest as ATProto record
396396-- `GetRecord()`: Retrieve manifest from PDS
397397-- `DeleteRecord()`: Remove manifest
398398-- Uses XRPC protocol (com.atproto.repo.*)
399399-400400-**lexicon.go**: ATProto record schemas
401401-- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdDid` + `holdEndpoint` fields)
402402-- `TagRecord`: Tag pointing to manifest digest
403403-- `HoldRecord`: Storage hold definition (LEGACY - for old BYOS model)
404404-- `HoldCrewRecord`: Hold crew membership (LEGACY - stored in owner's PDS)
405405-- `CaptainRecord`: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self")
406406-- `CrewRecord`: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member)
407407-- `SailorProfileRecord`: User profile with `defaultHold` preference (can be DID or URL)
408408-- 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`
409409-410410-**profile.go**: Sailor profile management
411411-- `EnsureProfile()`: Creates profile with default hold on first authentication
412412-- `GetProfile()`: Retrieves user's profile from PDS
413413-- `UpdateProfile()`: Updates user's profile
414414-415415-**manifest_store.go**: Implements `distribution.ManifestService`
416416-- Stores OCI manifests as ATProto records
417417-- Digest-based addressing (sha256:abc123 → record key)
418418-- Converts between OCI and ATProto formats
419419-420420-#### Storage Layer (`pkg/appview/storage/`)
421421-422422-**routing_repository.go**: Routes content by type
423423-- `Manifests()` → returns ATProto ManifestStore (caches instance for hold DID extraction)
424424-- `Blobs()` → checks hold cache for pull, uses discovery for push
425425- - Pull: Uses cached `holdDid` from manifest (historical reference)
426426- - Push: Uses discovery-based DID from `findHoldDID()` in middleware
427427- - Always returns ProxyBlobStore (routes to hold service via DID)
428428-- Implements `distribution.Repository` interface
429429-- Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc.
430430-431431-**Database-based hold DID lookups**:
432432-- Queries SQLite `manifests` table for hold DID (indexed, fast)
433433-- No in-memory caching needed - database IS the cache
434434-- Persistent across restarts, multi-instance safe
435435-- Pull operations use hold DID from latest manifest (historical reference)
436436-- Push operations use fresh discovery from profile/default
437437-- Function: `db.GetLatestHoldDIDForRepo(did, repository)` in `pkg/appview/db/queries.go`
438438-439439-**proxy_blob_store.go**: External storage proxy (routes to hold via XRPC)
440440-- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution)
441441-- Gets service tokens from user's PDS (`com.atproto.server.getServiceAuth`)
442442-- Calls hold XRPC endpoints with service token authentication:
443443- - Multipart upload: initiateUpload, getPartUploadUrl, completeUpload, abortUpload
444444- - Blob read: com.atproto.sync.getBlob (returns presigned download URL)
445445-- Implements full `distribution.BlobStore` interface
446446-- Uses presigned URLs for direct client-to-S3 transfers
447447-448448-#### AppView Web UI (`pkg/appview/`)
449449-450450-The AppView includes a web interface for browsing the registry:
451451-452452-**Features:**
453453-- Repository browsing and search
454454-- Star/favorite repositories
455455-- Pull count tracking
456456-- User profiles and settings
457457-- OAuth-based authentication for web users
458458-459459-**Database Layer** (`pkg/appview/db/`):
460460-- SQLite database for metadata (stars, pulls, repository info)
461461-- Schema migrations via SQL files in `pkg/appview/db/schema.go`
462462-- Stores: OAuth sessions, device flows, repository metadata
463463-- **NOTE:** Simple SQLite for MVP. For production multi-instance: use PostgreSQL
464464-465465-**Jetstream Integration** (`pkg/appview/jetstream/`):
466466-- Consumes ATProto Jetstream for real-time updates
467467-- Backfills repository records from PDS
468468-- Indexes manifests, tags, and repository metadata
469469-- Worker processes incoming events
470470-471471-**Web Handlers** (`pkg/appview/handlers/`):
472472-- `home.go` - Landing page
473473-- `repository.go` - Repository detail pages
474474-- `search.go` - Search functionality
475475-- `auth.go` - OAuth login/logout for web
476476-- `settings.go` - User settings management
477477-- `api.go` - JSON API endpoints
478478-479479-**Public Assets & Templates** (`pkg/appview/public/`, `pkg/appview/templates/`):
480480-- `pkg/appview/public/` is served at the web root `/` (e.g., `public/favicon.svg` → `/favicon.svg`)
481481-- Templates use Go html/template
482482-- JavaScript in `public/js/app.js`
483483-- Minimal CSS for clean UI
484484-485485-#### Hold Service (`cmd/hold/`)
486486-487487-Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS:
488488-489489-**Architecture:**
490490-- **Embedded PDS**: Each hold has a full ATProto PDS for storing captain + crew records
491491-- **DID**: Hold identified by did:web (e.g., `did:web:hold01.atcr.io`)
492492-- **Storage**: Requires S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.)
493493-- **Authorization**: Based on captain + crew records in embedded PDS
494494-- **Blob operations**: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC
495495-496496-**Authorization Model:**
8686+Pattern: `atcr.io/<identity>/<image>:<tag>` where identity is a handle or DID.
49787498498-Read access:
499499-- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users
500500-- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read OR blob:write permission
501501-- **Note:** `blob:write` implicitly grants `blob:read` access (can't push without pulling)
8888+Resolution in `pkg/atproto/resolver.go`: Handle → DID (DNS/HTTPS) → PDS endpoint (DID document).
50289503503-Write access:
504504-- Hold owner OR crew members with blob:write permission
505505-- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
9090+### Nautical Terminology
50691507507-**Permission Matrix:**
9292+- **Sailors** = registry users, **Captains** = hold owners, **Crew** = hold members
9393+- **Holds** = storage endpoints (BYOS), **Quartermaster/Bosun/Deckhand** = crew tiers
50894509509-| User Type | Public Read | Private Read | Write | Crew Admin |
510510-|-----------|-------------|--------------|-------|------------|
511511-| Anonymous | Yes | No | No | No |
512512-| Owner (captain) | Yes | Yes | Yes | Yes (implied) |
513513-| Crew (blob:read only) | Yes | Yes | No | No |
514514-| Crew (blob:write only) | Yes | Yes* | Yes | No |
515515-| Crew (blob:read + blob:write) | Yes | Yes | Yes | No |
516516-| Crew (crew:admin) | Yes | Yes | Yes | Yes |
517517-| Authenticated non-crew | Yes | No | No | No |
9595+## Authentication
51896519519-*`blob:write` implicitly grants `blob:read` access
9797+Three token types flow through the system:
52098521521-**Authorization Error Format:**
9999+| Token | Issued By | Used For | Lifetime |
100100+|-------|-----------|----------|----------|
101101+| OAuth (access+refresh) | User's PDS | AppView → PDS communication | ~2h / ~90d |
102102+| Registry JWT | AppView | Docker client → AppView | 5 min |
103103+| Service Token | User's PDS | AppView → Hold service | 60s (cached 50s) |
522104523523-All authorization failures use consistent structured errors (`pkg/hold/pds/auth.go`):
524105```
525525-access denied for [action]: [reason] (required: [permission(s)])
106106+Docker Client ──Registry JWT──→ AppView ──OAuth──→ User's PDS ──Service Token──→ Hold
526107```
527108528528-Examples:
529529-- `access denied for blob:read: user is not a crew member (required: blob:read or blob:write)`
530530-- `access denied for blob:write: crew member lacks permission (required: blob:write)`
531531-- `access denied for crew:admin: user is not a crew member (required: crew:admin)`
109109+The credential helper never manages OAuth tokens directly — AppView owns the OAuth session and issues registry JWTs. See `docs/OAUTH.md` for full OAuth/DPoP implementation details.
532110533533-**Shared Error Constants** (`pkg/hold/pds/auth.go`):
534534-- `ErrMissingAuthHeader` - Missing Authorization header
535535-- `ErrInvalidAuthFormat` - Invalid Authorization header format
536536-- `ErrInvalidAuthScheme` - Invalid scheme (expected Bearer or DPoP)
537537-- `ErrInvalidJWTFormat` - Malformed JWT
538538-- `ErrMissingISSClaim` / `ErrMissingSubClaim` - Missing JWT claims
539539-- `ErrTokenExpired` - Token has expired
111111+## Hold Authorization
540112541541-**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`):
113113+- **Public hold**: Anonymous reads allowed. Writes require captain or crew with `blob:write`.
114114+- **Private hold**: Reads require crew with `blob:read` or `blob:write`. Writes require `blob:write`.
115115+- `blob:write` implicitly grants `blob:read`.
116116+- Captain has all permissions implicitly.
117117+- See `docs/BYOS.md` for full authorization model and permission matrix.
542118543543-Standard ATProto sync endpoints:
544544-- `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file
545545-- `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision
546546-- `GET /xrpc/com.atproto.sync.getRepoStatus?did={did}` - Get repository hosting status and current revision
547547-- `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events
548548-- `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS)
549549-- `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL
119119+## Key File Locations
550120551551-Repository management:
552552-- `GET /xrpc/com.atproto.repo.describeRepo?repo={did}` - Repository metadata
553553-- `GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}` - Get record
554554-- `GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}` - List records (supports pagination)
555555-- `POST /xrpc/com.atproto.repo.deleteRecord` - Delete record (owner/crew admin only)
556556-- `POST /xrpc/com.atproto.repo.uploadBlob` - Upload ATProto blob (owner/crew admin only)
121121+| Responsibility | Files |
122122+|---|---|
123123+| ATProto records & collections | `pkg/atproto/lexicon.go` |
124124+| DID/handle resolution | `pkg/atproto/resolver.go` |
125125+| PDS client (XRPC) | `pkg/atproto/client.go` |
126126+| Manifest ↔ ATProto storage | `pkg/atproto/manifest_store.go` |
127127+| Sailor profiles | `pkg/atproto/profile.go` |
128128+| Registry middleware (identity resolution, hold discovery) | `pkg/appview/middleware/registry.go` |
129129+| Auth middleware (JWT validation) | `pkg/appview/middleware/auth.go` |
130130+| Content routing (manifests vs blobs) | `pkg/appview/storage/routing_repository.go` |
131131+| Blob proxy to hold (presigned URLs) | `pkg/appview/storage/proxy_blob_store.go` |
132132+| Request context struct | `pkg/appview/storage/context.go` |
133133+| Database queries | `pkg/appview/db/queries.go` |
134134+| Database schema | `pkg/appview/db/schema.sql` |
135135+| OAuth client & session refresher | `pkg/auth/oauth/client.go` |
136136+| OAuth P-256 key management | `pkg/auth/oauth/keys.go` |
137137+| Hold PDS endpoints & auth | `pkg/hold/pds/xrpc.go`, `pkg/hold/pds/auth.go` |
138138+| Hold OCI upload endpoints | `pkg/hold/oci/xrpc.go` |
139139+| Hold config | `pkg/hold/config.go` |
140140+| AppView config | `pkg/appview/config.go` |
141141+| Config marshaling (commented YAML) | `pkg/config/marshal.go` |
142142+| Scanner config (env-only) | `scanner/internal/config/config.go` |
557143558558-DID resolution:
559559-- `GET /.well-known/did.json` - DID document (did:web resolution)
560560-- `GET /.well-known/atproto-did` - DID for handle resolution
561561-562562-Crew management:
563563-- `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership (authenticated users)
564564-565565-**OCI Multipart Upload Endpoints** (`pkg/hold/oci/xrpc.go`):
566566-567567-All require blob:write permission via service token authentication:
568568-- `POST /xrpc/io.atcr.hold.initiateUpload` - Start multipart upload session
569569-- `POST /xrpc/io.atcr.hold.getPartUploadUrl` - Get presigned URL for uploading a part
570570-- `POST /xrpc/io.atcr.hold.completeUpload` - Finalize multipart upload and move to final location
571571-- `POST /xrpc/io.atcr.hold.abortUpload` - Cancel multipart upload and cleanup temp data
572572-573573-**AppView-to-Hold Authentication:**
574574-- AppView uses service tokens from user's PDS (`com.atproto.server.getServiceAuth`)
575575-- Service tokens are scoped to specific hold DIDs and include the user's DID
576576-- Hold validates tokens and checks crew membership for authorization
577577-- Tokens cached for 50 seconds (valid for 60 seconds from PDS)
578578-579579-**Hold Subsystems** (`pkg/hold/`):
580580-- **Admin UI** (`admin/`) - Web-based admin panel for crew and storage management (enabled via `admin.enabled: true`)
581581-- **Quota** (`quota/`) - Per-user storage quota enforcement with tier-based limits (configured in YAML under `quota:`)
582582-- **Billing** (`billing/`) - Stripe integration for paid tiers (build tag `billing`, compile with `-tags billing`). Zero overhead when disabled.
583583-- **Garbage Collection** (`gc/`) - Blob garbage collection for orphaned data
584584-- **Scan Broadcaster** (`pds/scan_broadcaster.go`) - WebSocket server dispatching scan jobs to scanner instances via `/xrpc/io.atcr.hold.subscribeScanJobs`
144144+## Configuration
585145586586-**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc.
587587-588588-#### Scanner Service (`scanner/`)
589589-590590-Separate Go module for vulnerability scanning. Connects to hold services via WebSocket.
591591-592592-**Architecture:**
593593-- `scanner/internal/client/hold.go` - WebSocket client with auto-reconnect (exponential backoff)
594594-- `scanner/internal/queue/priority_queue.go` - Thread-safe priority queue (tier-based: owner > quartermaster > bosun > deckhand)
595595-- `scanner/internal/scan/worker.go` - Configurable worker pool
596596-- `scanner/internal/scan/syft.go` - SBOM generation via Syft
597597-- `scanner/internal/scan/grype.go` - Vulnerability scanning via Grype
598598-- `scanner/internal/scan/extractor.go` - Container layer extraction
599599-- `scanner/internal/config/config.go` - Environment-only config (no YAML, no Viper)
600600-601601-**Scanner env vars** (prefix `SCANNER_`):
602602-- `SCANNER_HOLD_URL` - WebSocket URL of hold service (required)
603603-- `SCANNER_SHARED_SECRET` - Authentication secret (required)
604604-- `SCANNER_WORKERS` - Number of concurrent scan workers
605605-- `SCANNER_VULN_ENABLED` - Enable vulnerability scanning (default: true)
606606-- `SCANNER_ADDR` - Health endpoint address (default: `:9090`)
607607-608608-#### Usage Report Tool (`cmd/usage-report/`)
609609-610610-CLI tool for analyzing hold storage usage:
611611-```bash
612612-go run ./cmd/usage-report --hold https://hold01.atcr.io # summary
613613-go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests # from manifests
614614-go run ./cmd/usage-report --hold https://hold01.atcr.io --list-blobs # individual blobs
615615-```
146146+ATCR uses **Viper** for config. YAML primary, env vars override. Generate defaults with `config init`.
616147617617-### ATProto Storage Model
148148+**Env var convention:** Prefix + YAML path with `_` separators:
149149+- AppView: `ATCR_` (e.g., `ATCR_SERVER_DEFAULT_HOLD_DID`)
150150+- Hold: `HOLD_` (e.g., `HOLD_SERVER_PUBLIC_URL`)
151151+- S3: standard AWS names (`AWS_ACCESS_KEY_ID`, `S3_BUCKET`, `S3_ENDPOINT`)
152152+- Scanner: `SCANNER_` prefix (env-only, no Viper)
618153619619-Manifests are stored as records with this structure:
620620-```json
621621-{
622622- "$type": "io.atcr.manifest",
623623- "repository": "myapp",
624624- "digest": "sha256:abc123...",
625625- "holdDid": "did:web:hold01.atcr.io",
626626- "holdEndpoint": "https://hold1.atcr.io",
627627- "schemaVersion": 2,
628628- "mediaType": "application/vnd.oci.image.manifest.v1+json",
629629- "config": { "digest": "sha256:...", "size": 1234 },
630630- "layers": [
631631- { "digest": "sha256:...", "size": 5678 }
632632- ],
633633- "manifestBlob": {
634634- "$type": "blob",
635635- "ref": { "$link": "bafyrei..." },
636636- "mimeType": "application/vnd.oci.image.manifest.v1+json",
637637- "size": 1234
638638- },
639639- "createdAt": "2025-09-30T..."
640640-}
641641-```
154154+See `config-appview.example.yaml` and `config-hold.example.yaml` for all options. Config structs use `comment` struct tags for auto-generating commented YAML via `MarshalCommentedYAML()` in `pkg/config/marshal.go`.
642155643643-**Key fields:**
644644-- `holdDid` - DID of the hold service where blobs are stored (PRIMARY reference, new)
645645-- `holdEndpoint` - HTTP URL of hold service (DEPRECATED, kept for backward compatibility)
646646-- `manifestBlob` - Reference to manifest blob in ATProto blob storage (CID format)
156156+## Development Gotchas
647157648648-Record key = manifest digest (without algorithm prefix)
649649-Collection = `io.atcr.manifest`
158158+- **Do NOT run `npm run css:build` or `npm run js:build` manually** — Air handles these on file change
159159+- **RoutingRepository is created fresh on EVERY request** (no caching). Previous caching caused stale OAuth sessions and "invalid refresh token" errors. The OAuth refresher caches efficiently already (in-memory + DB).
160160+- **Storage driver import**: `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` — blank import required
161161+- **Hold DID lookups use database** (`manifests` table), not in-memory cache — persistent across restarts
162162+- **Context keys** (`auth.method`, `puller.did`) exist because `Repository()` receives `context.Context` from the distribution library interface — context values are the only way to pass data from HTTP middleware into the distribution middleware layer. Both are copied into `RegistryContext` inside `Repository()`.
163163+- **OAuth key types**: AppView uses P-256 (ES256) for OAuth, not K-256 like PDS keys
164164+- **Confidential vs public clients**: Production uses P-256 key at `/var/lib/atcr/oauth/client.key` (auto-generated); localhost is always public client
650165651651-### Sailor Profile System
166166+## Common Tasks
652167653653-ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture:
654654-- **Sailors** = Registry users
655655-- **Captains** = Hold owners
656656-- **Crew** = Hold members with access
657657-- **Holds** = Storage endpoints (BYOS)
658658-659659-**Profile Record** (`io.atcr.sailor.profile`):
660660-```json
661661-{
662662- "$type": "io.atcr.sailor.profile",
663663- "defaultHold": "did:web:hold1.alice.com",
664664- "createdAt": "2025-10-02T...",
665665- "updatedAt": "2025-10-02T..."
666666-}
667667-```
668668-669669-**Profile Management:**
670670-- Created automatically on first authentication (OAuth or Basic Auth)
671671-- `defaultHold` can be a DID (preferred, e.g., `did:web:hold01.atcr.io`) or legacy URL
672672-- If AppView has `default_hold_did` configured, profile gets that as `defaultHold`
673673-- Users can update their profile to change default hold (future: via UI)
674674-- Setting `defaultHold` to null opts out of defaults (use own holds or AppView default)
675675-676676-**Hold Resolution Priority** (in `findHoldDID()` in middleware):
677677-1. **Profile's `defaultHold`** - User's explicit preference (DID or URL)
678678-2. **User's `io.atcr.hold` records** - User's own holds (legacy BYOS model)
679679-3. **AppView's `default_hold_did`** - Fallback default (configured in middleware)
680680-681681-This ensures:
682682-- Users can join shared holds by setting their profile's `defaultHold`
683683-- Users can opt out of defaults (set `defaultHold` to null)
684684-- URL structure remains `atcr.io/<owner>/<image>` (ownership-based, not hold-based)
685685-- Hold choice is transparent infrastructure (like choosing an S3 region)
686686-687687-### Key Design Decisions
688688-689689-1. **No fork of distribution**: Uses distribution as library, extends via middleware
690690-2. **Hybrid storage**: Manifests in ATProto (small), blobs in S3 or BYOS (cheap, scalable)
691691-3. **Content addressing**: Manifests stored by digest, blobs deduplicated globally
692692-4. **ATProto-native**: Manifests are first-class ATProto records, discoverable via AT Protocol
693693-5. **OCI compliant**: Fully compatible with Docker/containerd/podman
694694-6. **Account-agnostic AppView**: Server validates any user's token, queries their PDS for config
695695-7. **BYOS architecture**: Users can deploy their own storage service, AppView just routes
696696-8. **OAuth with DPoP**: Full ATProto OAuth implementation with mandatory DPoP proofs
697697-9. **Sailor profile system**: User preferences for hold selection, transparent to image ownership
698698-10. **Historical hold references**: Manifests store `holdEndpoint` for immutable blob location tracking
699699-700700-### Configuration
701701-702702-ATCR uses **Viper** for configuration. YAML files are the primary method; environment variables work as overrides.
703703-704704-**Loading priority** (highest wins):
705705-1. Environment variables (always override YAML)
706706-2. YAML config file (via `--config` / `-c` flag)
707707-3. Hardcoded defaults
708708-709709-**Generating config files:**
710710-```bash
711711-./bin/atcr-appview config init config-appview.yaml # fully-commented YAML with defaults
712712-./bin/atcr-hold config init config-hold.yaml
713713-```
714714-715715-**Env var naming convention:** Prefix + YAML path with `_` separators:
716716-- AppView prefix: `ATCR_` — e.g., `server.default_hold_did` → `ATCR_SERVER_DEFAULT_HOLD_DID`
717717-- Hold prefix: `HOLD_` — e.g., `server.public_url` → `HOLD_SERVER_PUBLIC_URL`
718718-- S3 uses standard AWS names: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET`, `S3_ENDPOINT`
719719-720720-**AppView config** (see `config-appview.example.yaml`):
721721-- `server.addr` - Listen address (default: `:5000`)
722722-- `server.base_url` - Public URL for OAuth/JWT realm (auto-detected in dev)
723723-- `server.default_hold_did` - Default hold DID for blob storage (REQUIRED)
724724-- `server.oauth_key_path` - P-256 key for OAuth client auth (auto-generated)
725725-- `server.registry_domain` - Separate domain for OCI API (e.g., `buoy.cr`)
726726-- `auth.key_path` - RSA key for signing registry JWTs
727727-- `ui.database_path` - SQLite database path
728728-- `jetstream.url` - ATProto firehose endpoint
729729-- `jetstream.backfill_enabled` - Sync existing records on startup
730730-- `log_shipper` - Remote log shipping (victoria, opensearch, loki)
731731-732732-**Hold config** (see `config-hold.example.yaml`):
733733-- `server.public_url` - Externally reachable URL for did:web identity (REQUIRED)
734734-- `server.public` - Allow unauthenticated reads (default: false)
735735-- `storage.bucket` - S3 bucket (REQUIRED)
736736-- `storage.endpoint` - Custom S3 endpoint for non-AWS providers
737737-- `registration.owner_did` - DID for captain record auto-creation
738738-- `registration.allow_all_crew` - Allow any authenticated user to join
739739-- `database.path` - Embedded PDS database directory
740740-- `admin.enabled` - Enable web admin panel
741741-- `quota.tiers` - Storage quota tiers (e.g., `deckhand: {quota: "5GB"}`)
742742-- `quota.defaults.new_crew_tier` - Default tier for new crew members
743743-744744-**Hold billing config** (requires `-tags billing` build):
745745-- `billing.enabled` - Enable Stripe billing
746746-- `billing.currency` - ISO currency code
747747-- `billing.tiers` - Map of tier names to Stripe price IDs
748748-- Env vars: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`
749749-750750-**Scanner config** (env vars only, no YAML/Viper):
751751-- `SCANNER_HOLD_URL`, `SCANNER_SHARED_SECRET`, `SCANNER_WORKERS`, `SCANNER_ADDR`
752752-753753-**Credential Helper**:
754754-- Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store)
755755-- Contains: Registry JWT issued by AppView (NOT OAuth tokens)
756756-- OAuth session managed entirely by AppView
757757-758758-### Development Notes
759759-760760-**General:**
761761-- Do NOT run `npm run css:build` or `npm run js:build` manually — Air handles these automatically on file change
762762-- Middleware is in `pkg/appview/middleware/` (auth.go, registry.go)
763763-- Storage routing is in `pkg/appview/storage/` (routing_repository.go, proxy_blob_store.go)
764764-- Hold DID lookups use database queries (no in-memory caching)
765765-- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"`
766766-- Hold service reuses distribution's driver factory for multi-backend support
767767-768768-**Configuration system:**
769769-- Config loading uses Viper (`pkg/config/viper.go`) — YAML primary, env vars override
770770-- Config structs use `comment` struct tags for auto-generating commented YAML via `MarshalCommentedYAML()` (`pkg/config/marshal.go`)
771771-- AppView config: `pkg/appview/config.go` (prefix `ATCR_`)
772772-- Hold config: `pkg/hold/config.go` (prefix `HOLD_`, plus standard `AWS_*`/`S3_*` bindings)
773773-- Quota/billing configs are subsections of the hold YAML file, loaded by passing the config path
774774-- Scanner config is env-only (no Viper): `scanner/internal/config/config.go`
775775-776776-**OAuth implementation:**
777777-- Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration
778778-- Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity
779779-- All ATCR components use standardized `/auth/oauth/callback` path
780780-- Client ID generation (localhost query-based vs production metadata URL) handled internally
781781-782782-### Testing Strategy
783783-784784-When writing tests:
785785-- Mock ATProto client for manifest operations
786786-- Mock S3 driver for blob operations
787787-- Test name resolution independently
788788-- Integration tests require real PDS + S3
789789-790790-### Common Tasks
791791-792792-**Adding a new ATProto record type**:
168168+**Adding a new ATProto record type:**
7931691. Define schema in `pkg/atproto/lexicon.go`
7941702. Add collection constant (e.g., `MyCollection = "io.atcr.my-type"`)
7951713. Add constructor function (e.g., `NewMyRecord()`)
7961724. Update client methods if needed
797173798798-**Modifying storage routing**:
174174+**Modifying storage routing:**
7991751. Edit `pkg/appview/storage/routing_repository.go`
800800-2. Update `Blobs()` method to change routing logic
801801-3. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.)
176176+2. Update `Blobs()` or `Manifests()` method
177177+3. Context passed via `RegistryContext` struct (`pkg/appview/storage/context.go`)
802178803803-**Changing name resolution**:
179179+**Changing name resolution:**
8041801. Modify `pkg/atproto/resolver.go` for DID/handle resolution
805805-2. Update `pkg/appview/middleware/registry.go` if changing routing logic
806806-3. Remember: `findHoldDID()` checks sailor profile, then `io.atcr.hold` records (legacy), then default hold DID
181181+2. Update `pkg/appview/middleware/registry.go` if changing routing
182182+3. `findHoldDID()` checks: sailor profile → `io.atcr.hold` records (legacy) → default hold DID
807183808808-**Working with OAuth client**:
809809-- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes
810810-- For AppView server/refresher: use `NewClient(baseURL)` or `NewClientWithKey(baseURL, storedKey)`
811811-- For custom scopes: call `client.SetScopes(customScopes)` after initialization
812812-- Standard callback path: `/auth/oauth/callback` (used by all ATCR components)
813813-- Client methods are consistent across authorization, token exchange, and refresh flows
184184+**Working with OAuth client:**
185185+- Self-contained: pass `baseURL`, handles client ID/redirect URI/scopes
186186+- Standard callback path: `/auth/oauth/callback` (all ATCR components)
187187+- See `pkg/auth/oauth/client.go` for `NewClientApp()`, refresher setup
814188815815-**Adding BYOS support for a user**:
189189+**Adding BYOS support for a user:**
8161901. User configures hold YAML (storage credentials, public URL, owner DID)
817817-2. User runs hold service - creates captain + crew records in embedded PDS
818818-3. Hold creates `io.atcr.hold.captain` + `io.atcr.hold.crew` records
819819-4. User sets sailor profile `defaultHold` to point to their hold
820820-5. AppView automatically queries hold's PDS and routes blobs to user's storage
821821-6. No AppView changes needed - fully decentralized
822822-823823-**Using S3-compatible storage**:
824824-ATCR requires S3-compatible storage. Configure in hold YAML under `storage:` or via env vars.
825825-Supported providers: AWS S3, Storj (`storage.endpoint: https://gateway.storjshare.io`),
826826-Minio (`storage.endpoint: http://localhost:9000`), UpCloud, Azure/GCS (S3-compatible endpoints).
191191+2. User runs hold service — creates captain + crew records in embedded PDS
192192+3. User sets sailor profile `defaultHold` to their hold's DID
193193+4. AppView automatically routes blobs to user's storage — no AppView changes needed
827194828828-**Working with the database**:
829829-- **Base schema** defined in `pkg/appview/db/schema.sql` - source of truth for fresh installations
830830-- **Migrations** in `pkg/appview/db/migrations/*.yaml` - only for ALTER/UPDATE/DELETE on existing databases
831831-- **Queries** in `pkg/appview/db/queries.go`
832832-- **Stores** for OAuth, devices, sessions in separate files
833833-- **Execution order**: schema.sql first, then migrations (automatically on startup)
834834-- **Database path** configurable via `ui.database_path` in YAML (or `ATCR_UI_DATABASE_PATH` env var)
195195+**Working with the database:**
196196+- **Base schema**: `pkg/appview/db/schema.sql` — source of truth for fresh installs
197197+- **Migrations**: `pkg/appview/db/migrations/*.yaml` — only for ALTER/UPDATE/DELETE on existing DBs
835198- **Adding new tables**: Add to `schema.sql` only (no migration needed)
836199- **Altering tables**: Create migration AND update `schema.sql` to keep them in sync
837200838838-**Adding web UI features**:
201201+**Adding web UI features:**
839202- Add handler in `pkg/appview/handlers/`
840203- Register route in `cmd/appview/serve.go`
841204- Create template in `pkg/appview/templates/pages/`
842842-- Use existing auth middleware for protected routes
843843-- API endpoints return JSON, pages return HTML
844205845845-## Important Context Values
206206+## Testing Strategy
846207847847-When working with the codebase, routing information is passed via the `RegistryContext` struct (`pkg/appview/storage/context.go`):
848848-849849-- `DID` - User's DID (e.g., `did:plc:alice123`)
850850-- `PDSEndpoint` - User's PDS endpoint (e.g., `https://bsky.social`)
851851-- `HoldDID` - Hold service DID (e.g., `did:web:hold01.atcr.io`)
852852-- `Repository` - Image repository name (e.g., `myapp`)
853853-- `ATProtoClient` - Client for calling user's PDS with OAuth/Basic Auth
854854-- `Refresher` - OAuth token refresher for service token requests
855855-- `Database` - Database for metrics tracking
856856-- `Authorizer` - Hold authorizer for access control
857857-858858-Legacy context keys (deprecated):
859859-- `hold.did` - Hold DID (now in RegistryContext)
860860-- `auth.did` - Authenticated DID from validated token (now in auth middleware)
208208+- Mock ATProto client for manifest operations
209209+- Mock S3 driver for blob operations
210210+- Test name resolution independently
211211+- Integration tests require real PDS + S3
861212862213## Documentation References
863214864864-- **BYOS Architecture**: See `docs/BYOS.md` for complete BYOS documentation
865865-- **OAuth Implementation**: See `docs/OAUTH.md` for OAuth/DPoP flow details
215215+- **BYOS Architecture**: `docs/BYOS.md`
216216+- **OAuth Implementation**: `docs/OAUTH.md`
217217+- **Hold Service**: `docs/hold.md`
218218+- **AppView**: `docs/appview.md`
219219+- **Hold XRPC Endpoints**: `docs/HOLD_XRPC_ENDPOINTS.md`
220220+- **Development Guide**: `docs/DEVELOPMENT.md`
221221+- **Billing/Quotas**: `docs/BILLING.md`, `docs/QUOTAS.md`
222222+- **Scanning**: `docs/SBOM_SCANNING.md`
866223- **ATProto Spec**: https://atproto.com/specs/oauth
867224- **OCI Distribution Spec**: https://github.com/opencontainers/distribution-spec
868868-- **DPoP RFC**: https://datatracker.ietf.org/doc/html/rfc9449
869869-- **PAR RFC**: https://datatracker.ietf.org/doc/html/rfc9126
870870-- **PKCE RFC**: https://datatracker.ietf.org/doc/html/rfc7636
-5
pkg/appview/middleware/registry.go
···2323 "atcr.io/pkg/auth/token"
2424)
25252626-// holdDIDKey is the context key for storing hold DID
2727-const holdDIDKey contextKey = "hold.did"
2828-2926// authMethodKey is the context key for storing auth method from JWT
3027const authMethodKey contextKey = "auth.method"
3128···296293 // This is a fatal configuration error - registry cannot function without a hold service
297294 return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config")
298295 }
299299- ctx = context.WithValue(ctx, holdDIDKey, holdDID)
300300-301296 // Auto-reconcile crew membership on first push/pull
302297 // This ensures users can push immediately after docker login without web sign-in
303298 // EnsureCrewMembership is best-effort and logs errors without failing the request
+2-2
pkg/atproto/lexicon.go
···599599 Member string `json:"member" cborgen:"member"`
600600 Role string `json:"role" cborgen:"role"`
601601 Permissions []string `json:"permissions" cborgen:"permissions"`
602602- Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
602602+ Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
603603 Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner"` // Early adopter flag - gets plankowner_crew_tier for free
604604- AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
604604+ AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
605605}
606606607607// LayerRecord represents metadata about a container layer stored in the hold