A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

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

minor fixes

+139 -788
+118 -764
CLAUDE.md
··· 4 4 5 5 ## Project Overview 6 6 7 - 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. 7 + 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. 8 8 9 9 ## Go Workspace 10 10 ··· 26 26 # Build scanner (separate module) 27 27 cd scanner && go build -o ../bin/atcr-scanner ./cmd/scanner && cd .. 28 28 29 - # Build hold with billing support (optional, uses build tag) 29 + # Build hold with billing support (optional build tag) 30 30 go build -tags billing -o bin/atcr-hold ./cmd/hold 31 31 32 - # Run tests 33 - go test ./... 34 - 35 - # Run tests for specific package 36 - go test ./pkg/atproto/... 37 - go test ./pkg/appview/storage/... 38 - 39 - # Run specific test 40 - go test -run TestManifestStore ./pkg/atproto/... 41 - 42 - # Run with race detector 43 - go test -race ./... 44 - 45 - # Run tests with verbose output 46 - go test -v ./... 47 - 48 - # Update dependencies 49 - go mod tidy 32 + # Tests 33 + go test ./... # all tests 34 + go test ./pkg/atproto/... # specific package 35 + go test -run TestManifestStore ./pkg/atproto/... # specific test 36 + go test -race ./... # race detector 50 37 51 - # Build Docker images 38 + # Docker 52 39 docker build -t atcr.io/appview:latest . 53 40 docker build -f Dockerfile.hold -t atcr.io/hold:latest . 54 41 docker build -f Dockerfile.scanner -t atcr.io/scanner:latest . 55 - 56 - # Or use docker-compose 57 42 docker-compose up -d 58 43 59 - # Generate default config files 44 + # Generate & run with config 60 45 ./bin/atcr-appview config init config-appview.yaml 61 46 ./bin/atcr-hold config init config-hold.yaml 62 - 63 - # Run locally (AppView) - YAML config (preferred) 64 47 ./bin/atcr-appview serve --config config-appview.yaml 65 - # Or env vars only (still works): 66 - ATCR_SERVER_DEFAULT_HOLD_DID=did:web:hold01.atcr.io ./bin/atcr-appview serve 67 - 68 - # Run hold service - YAML config (preferred) 69 - # For local development, use Minio as S3-compatible storage: 70 - # docker run -p 9000:9000 minio/minio server /data 71 48 ./bin/atcr-hold serve --config config-hold.yaml 72 - # Or env vars only: 73 - HOLD_SERVER_PUBLIC_URL=http://127.0.0.1:8080 S3_BUCKET=test ./bin/atcr-hold serve 74 49 75 - # Run scanner service (env vars only, no YAML) 50 + # Scanner (env vars only, no YAML) 76 51 SCANNER_HOLD_URL=ws://localhost:8080 SCANNER_SHARED_SECRET=secret ./bin/atcr-scanner serve 77 52 78 - # Usage report tool 53 + # Usage report 79 54 go run ./cmd/usage-report --hold https://hold01.atcr.io 80 55 go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests 81 - 82 - # Request Bluesky relay crawl (makes your PDS discoverable) 83 - ./deploy/request-crawl.sh hold01.atcr.io 84 56 ``` 85 57 86 58 ## Architecture Overview 87 59 88 - ### Core Design 60 + ATCR uses **distribution/distribution** as a library, extending it via middleware to route content to different backends: 89 61 90 - ATCR uses **distribution/distribution** as a library and extends it through middleware to route different types of content to different storage backends: 91 - 92 - - **Manifests** → ATProto PDS (small JSON metadata, stored as `io.atcr.manifest` records) 93 - - **Blobs/Layers** → S3 or user-deployed storage (large binary data) 62 + - **Manifests** → ATProto PDS (small JSON, stored as `io.atcr.manifest` records) 63 + - **Blobs/Layers** → S3 via hold service (presigned URLs for direct client-to-S3 transfers) 94 64 - **Authentication** → ATProto OAuth with DPoP + Docker credential helpers 95 65 96 - ### Four-Component Architecture 66 + ### Four Components 97 67 98 - 1. **AppView** (`cmd/appview`) - OCI Distribution API server 99 - - Resolves identities (handle/DID → PDS endpoint) 100 - - Routes manifests to user's PDS 101 - - Routes blobs to storage endpoint (default or BYOS) 102 - - Validates OAuth tokens via PDS 103 - - Issues registry JWTs 68 + 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. 69 + 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. 70 + 3. **Scanner** (`scanner/cmd/scanner`) — Vulnerability scanning. Connects to hold via WebSocket, generates SBOMs (Syft), scans vulnerabilities (Grype). Priority queue with tier-based scheduling. 71 + 4. **Credential Helper** (`cmd/credential-helper`) — Docker credential helper implementing ATProto OAuth flow, exchanges OAuth token for registry JWT. 104 72 105 - 2. **Hold Service** (`cmd/hold`) - Optional BYOS component 106 - - Lightweight HTTP server for presigned URLs 107 - - Embedded PDS with captain + crew records 108 - - Supports S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.) 109 - - Authorization based on captain record (public, allowAllCrew) 110 - - Self-describing via DID resolution 111 - - Optional subsystems: admin UI, quota enforcement, billing (Stripe), garbage collection 112 - - Dispatches scan jobs to scanner instances via WebSocket 73 + ### Request Flow Summary 113 74 114 - 3. **Scanner** (`scanner/cmd/scanner`) - Vulnerability scanning service 115 - - Separate Go module (heavy Syft/Grype dependencies isolated) 116 - - Connects to hold service via WebSocket (`/xrpc/io.atcr.hold.subscribeScanJobs`) 117 - - Generates SBOMs with Syft, scans for vulnerabilities with Grype 118 - - Priority queue with tier-based scheduling (owner > quartermaster > bosun > deckhand) 119 - - Competing-consumer pattern: multiple scanners pull from same hold 75 + **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. 120 76 121 - 4. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth 122 - - Implements Docker credential helper protocol 123 - - ATProto OAuth flow with DPoP 124 - - Token caching and refresh 125 - - Exchanges OAuth token for registry JWT 77 + **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. 126 78 127 - ### Request Flow 128 - 129 - #### Push with Default Storage 130 - ``` 131 - 1. Client: docker push atcr.io/alice/myapp:latest 132 - 2. HTTP Request → /v2/alice/myapp/manifests/latest 133 - 3. Registry Middleware (pkg/appview/middleware/registry.go) 134 - → Resolves "alice" to DID and PDS endpoint 135 - → Queries alice's sailor profile for defaultHold (returns DID if set) 136 - → If not set, checks alice's io.atcr.hold records 137 - → Falls back to AppView's default_hold_did 138 - → Stores DID/PDS/hold DID in RegistryContext 139 - 4. Routing Repository (pkg/appview/storage/routing_repository.go) 140 - → Creates RoutingRepository 141 - → Returns ATProto ManifestStore for manifests 142 - → Returns ProxyBlobStore for blobs (routes to hold DID) 143 - 5. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints: 144 - a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID) 145 - b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part) 146 - c. PUT to S3 presigned URL (client uploads directly to S3) 147 - d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload) 148 - 6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint) 149 - → Manifest also uploaded to PDS blob storage (ATProto CID format) 150 - ``` 151 - 152 - #### Push with BYOS (Bring Your Own Storage) 153 - ``` 154 - 1. Client: docker push atcr.io/alice/myapp:latest 155 - 2. Registry Middleware resolves alice → did:plc:alice123 156 - 3. Hold discovery via findHoldDID(): 157 - a. Check alice's sailor profile for defaultHold (returns DID if set) 158 - b. If not set, check alice's io.atcr.hold records (legacy) 159 - c. Fall back to AppView's default_hold_did 160 - 4. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev" 161 - 5. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev) 162 - 6. ProxyBlobStore: 163 - a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution) 164 - b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth 165 - c. Calls hold XRPC endpoints with service token authentication: 166 - - POST /xrpc/io.atcr.hold.initiateUpload 167 - - POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL) 168 - - PUT to S3 presigned URL (direct upload to alice's S3/Storj) 169 - - POST /xrpc/io.atcr.hold.completeUpload 170 - 7. Hold service validates service token, checks crew membership, generates presigned URLs 171 - 8. Manifest stored in alice's PDS with: 172 - - holdDid = "did:web:alice-storage.fly.dev" (primary) 173 - - holdEndpoint = "https://alice-storage.fly.dev" (backward compat) 174 - ``` 175 - 176 - #### Pull Flow 177 - ``` 178 - 1. Client: docker pull atcr.io/alice/myapp:latest 179 - 2. GET /v2/alice/myapp/manifests/latest 180 - 3. AppView fetches manifest from alice's PDS 181 - 4. Manifest contains: 182 - - holdDid = "did:web:alice-storage.fly.dev" (primary reference) 183 - - holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback) 184 - 5. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev" 185 - TTL: 10 minutes (covers typical pull operations) 186 - 6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123 187 - 7. AppView checks cache, routes to hold DID from manifest (not re-discovered) 188 - 8. ProxyBlobStore: 189 - a. Resolves hold DID → https://alice-storage.fly.dev 190 - b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth 191 - c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET 192 - d. Hold returns presigned download URL in JSON response 193 - 9. Client redirected to download blob directly from alice's S3 via presigned URL 194 - ``` 195 - 196 - **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. 79 + **Hold discovery priority** (in `findHoldDID()`, `pkg/appview/middleware/registry.go`): 80 + 1. Sailor profile's `defaultHold` (user preference) 81 + 2. User's `io.atcr.hold` records (legacy) 82 + 3. AppView's `default_hold_did` (fallback) 197 83 198 84 ### Name Resolution 199 85 200 - Names follow the pattern: `atcr.io/<identity>/<image>:<tag>` 201 - 202 - Where `<identity>` can be: 203 - - **Handle**: `alice.bsky.social` → resolved via .well-known/atproto-did 204 - - **DID**: `did:plc:xyz123` → resolved via PLC directory 205 - 206 - Resolution happens in `pkg/atproto/resolver.go`: 207 - 1. Handle → DID (via DNS/HTTPS) 208 - 2. DID → PDS endpoint (via DID document) 209 - 210 - ### Middleware System 211 - 212 - ATCR uses middleware and routing to handle requests: 213 - 214 - #### 1. Registry Middleware (`pkg/appview/middleware/registry.go`) 215 - - Wraps `distribution.Namespace` 216 - - Intercepts `Repository(name)` calls 217 - - Performs name resolution (alice → did:plc:xyz → pds.example.com) 218 - - Queries PDS for `io.atcr.hold` records to find storage endpoint 219 - - Stores resolved identity and storage endpoint in context 220 - 221 - #### 2. Auth Middleware (`pkg/appview/middleware/auth.go`) 222 - - Validates JWT tokens from Docker clients 223 - - Extracts DID from token claims 224 - - Injects authenticated identity into context 225 - 226 - #### 3. Routing Repository (`pkg/appview/storage/routing_repository.go`) 227 - - Implements `distribution.Repository` 228 - - Returns custom `Manifests()` and `Blobs()` implementations 229 - - Routes manifests to ATProto, blobs to S3 or BYOS 230 - - **IMPORTANT**: RoutingRepository is created fresh on EVERY request (no caching) 231 - - Each Docker layer upload is a separate HTTP request (possibly different process) 232 - - OAuth sessions can be refreshed/invalidated between requests 233 - - The OAuth refresher already caches sessions efficiently (in-memory + DB) 234 - - Previous caching of repositories with stale ATProtoClient caused "invalid refresh token" errors 235 - 236 - ### Authentication Architecture 237 - 238 - #### Token Types and Flows 239 - 240 - ATCR uses three distinct token types in its authentication flow: 241 - 242 - **1. OAuth Tokens (Access + Refresh)** 243 - - **Issued by:** User's PDS via OAuth flow 244 - - **Stored in:** AppView database (`oauth_sessions` table) 245 - - **Cached in:** Refresher's in-memory map (per-DID) 246 - - **Used for:** AppView → User's PDS communication (write manifests, read profiles) 247 - - **Managed by:** Indigo library with DPoP (automatic refresh) 248 - - **Lifetime:** Access ~2 hours, Refresh ~90 days (PDS controlled) 249 - 250 - **2. Registry JWTs** 251 - - **Issued by:** AppView after OAuth login 252 - - **Stored in:** Docker credential helper (`~/.atcr/credential-helper-token.json`) 253 - - **Used for:** Docker client → AppView authentication 254 - - **Lifetime:** 5 minutes 255 - - **Format:** JWT with DID claim 256 - 257 - **3. Service Tokens** 258 - - **Issued by:** User's PDS via `com.atproto.server.getServiceAuth` 259 - - **Stored in:** AppView memory (in-memory cache with ~50s TTL) 260 - - **Used for:** AppView → Hold service authentication (acting on behalf of user) 261 - - **Lifetime:** 60 seconds (PDS controlled), cached for 50s 262 - - **Required:** OAuth session to obtain (catch-22 solved by Refresher) 263 - 264 - **Token Flow Diagram:** 265 - ``` 266 - ┌─────────────┐ ┌──────────────┐ 267 - │ Docker │ ─── Registry JWT ──────────────→ │ AppView │ 268 - │ Client │ │ │ 269 - └─────────────┘ └──────┬───────┘ 270 - 271 - │ OAuth tokens 272 - │ (access + refresh) 273 - 274 - ┌──────────────┐ 275 - │ User's PDS │ 276 - └──────┬───────┘ 277 - 278 - │ Service token 279 - │ (via getServiceAuth) 280 - 281 - ┌──────────────┐ 282 - │ Hold Service │ 283 - └──────────────┘ 284 - ``` 285 - 286 - #### ATProto OAuth with DPoP 287 - 288 - ATCR implements the full ATProto OAuth specification with mandatory security features: 289 - 290 - **Required Components:** 291 - - **DPoP** (RFC 9449) - Cryptographic proof-of-possession for every request 292 - - **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange 293 - - **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception 294 - 295 - **Key Components** (`pkg/auth/oauth/`): 296 - 297 - 1. **Client** (`client.go`) - OAuth client configuration and session management 298 - - **ClientApp setup:** 299 - - `NewClientApp()` - Creates configured `*oauth.ClientApp` (uses indigo directly, no wrapper) 300 - - Uses `NewLocalhostConfig()` for localhost (public client) 301 - - Uses `NewPublicConfig()` for production (upgraded to confidential with P-256 key) 302 - - `GetDefaultScopes()` - Returns ATCR-specific OAuth scopes 303 - - `ScopesMatch()` - Compares scope lists (order-independent) 304 - - **Session management (Refresher):** 305 - - `NewRefresher()` - Creates session cache manager for AppView 306 - - **Purpose:** In-memory cache for `*oauth.ClientSession` objects (performance optimization) 307 - - **Why needed:** Saves 1-2 DB queries per request (~2ms) with minimal code complexity 308 - - Per-DID locking prevents concurrent database loads 309 - - Calls `ClientApp.ResumeSession()` on cache miss 310 - - Indigo handles token refresh automatically (transparent to ATCR) 311 - - **Performance:** Essential for high-traffic deployments, negligible for low-traffic 312 - - **Architecture:** Single file containing both ClientApp helpers and Refresher (combined from previous two-file structure) 313 - 314 - 2. **Keys** (`keys.go`) - P-256 key management for confidential clients 315 - - `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk 316 - - Follows hold service pattern: auto-generation, 0600 permissions, /var/lib/atcr/oauth/ 317 - - `GenerateKeyID()` - derives key ID from public key hash 318 - - `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API 319 - - **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys) 320 - 321 - 3. **Storage** - Persists OAuth sessions 322 - - `db/oauth_store.go` - SQLite-backed storage for AppView (in UI database) 323 - - `store.go` - File-based storage for CLI tools (`~/.atcr/oauth-sessions.json`) 324 - - Implements indigo's `ClientAuthStore` interface 325 - 326 - 4. **Server** (`server.go`) - OAuth authorization endpoints for AppView 327 - - `GET /auth/oauth/authorize` - starts OAuth flow 328 - - `GET /auth/oauth/callback` - handles OAuth callback 329 - - Uses `ClientApp` methods directly (no wrapper) 330 - 331 - 5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools 332 - - Used by credential helper and hold service registration 333 - - Two-phase callback setup ensures PAR metadata availability 334 - 335 - **Client Configuration:** 336 - - **Localhost:** Always public client (no client authentication) 337 - - Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based) 338 - - No P-256 key generation 339 - - **Production:** Confidential client with P-256 private key (if key exists) 340 - - Client ID: `{baseURL}/client-metadata.json` (metadata endpoint) 341 - - Key path: `/var/lib/atcr/oauth/client.key` (auto-generated on first run) 342 - - Key algorithm: ES256 (P-256, not K-256) 343 - - Upgraded via `config.SetClientSecret(key, keyID)` 344 - 345 - **Authentication Flow:** 346 - ``` 347 - 1. User configures Docker to use the credential helper (adds to config.json) 348 - 2. On first docker push/pull, Docker calls credential helper 349 - 3. Credential helper opens browser → AppView OAuth page 350 - 4. AppView handles OAuth flow: 351 - - Resolves handle → DID → PDS endpoint 352 - - Discovers OAuth server metadata from PDS 353 - - PAR request with DPoP header → get request_uri 354 - - User authorizes in browser 355 - - AppView exchanges code for OAuth token with DPoP proof 356 - - AppView stores: OAuth session (tokens managed by indigo library with DPoP), DID, handle 357 - 5. AppView shows device approval page: "Can [device] push to your account?" 358 - 6. User approves device 359 - 7. AppView issues registry JWT with validated DID 360 - 8. AppView returns JSON token to credential helper (via callback or browser display) 361 - 9. Credential helper saves registry JWT locally 362 - 10. Helper returns registry JWT to Docker 363 - 364 - Later (subsequent docker push): 365 - 11. Docker calls credential helper 366 - 12. Helper returns cached registry JWT (or re-authenticates if expired) 367 - ``` 368 - 369 - **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: 370 - - Writing manifests to user's PDS (with DPoP authentication) 371 - - Getting service tokens from user's PDS (with DPoP authentication) 372 - - Service tokens are then used to authenticate to hold services (Bearer tokens, not DPoP) 373 - 374 - **Security:** 375 - - Tokens validated against authoritative source (user's PDS) 376 - - No trust in client-provided identity information 377 - - DPoP binds tokens to specific client key 378 - - 15-minute token expiry for registry JWTs 379 - - **Confidential clients** (production): Client authentication via P-256 private key JWT assertion 380 - - Prevents client impersonation attacks 381 - - Key stored in `/var/lib/atcr/oauth/client.key` with 0600 permissions 382 - - Automatically generated on first run 383 - - **Public clients** (localhost): No client authentication (development only) 384 - 385 - ### Key Components 386 - 387 - #### ATProto Integration (`pkg/atproto/`) 388 - 389 - **resolver.go**: DID and handle resolution 390 - - `ResolveIdentity()`: alice → did:plc:xyz → pds.example.com 391 - - `ResolveHandle()`: Uses .well-known/atproto-did 392 - - `ResolvePDS()`: Parses DID document for PDS endpoint 393 - 394 - **client.go**: ATProto PDS client 395 - - `PutRecord()`: Store manifest as ATProto record 396 - - `GetRecord()`: Retrieve manifest from PDS 397 - - `DeleteRecord()`: Remove manifest 398 - - Uses XRPC protocol (com.atproto.repo.*) 399 - 400 - **lexicon.go**: ATProto record schemas 401 - - `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdDid` + `holdEndpoint` fields) 402 - - `TagRecord`: Tag pointing to manifest digest 403 - - `HoldRecord`: Storage hold definition (LEGACY - for old BYOS model) 404 - - `HoldCrewRecord`: Hold crew membership (LEGACY - stored in owner's PDS) 405 - - `CaptainRecord`: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self") 406 - - `CrewRecord`: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member) 407 - - `SailorProfileRecord`: User profile with `defaultHold` preference (can be DID or URL) 408 - - 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` 409 - 410 - **profile.go**: Sailor profile management 411 - - `EnsureProfile()`: Creates profile with default hold on first authentication 412 - - `GetProfile()`: Retrieves user's profile from PDS 413 - - `UpdateProfile()`: Updates user's profile 414 - 415 - **manifest_store.go**: Implements `distribution.ManifestService` 416 - - Stores OCI manifests as ATProto records 417 - - Digest-based addressing (sha256:abc123 → record key) 418 - - Converts between OCI and ATProto formats 419 - 420 - #### Storage Layer (`pkg/appview/storage/`) 421 - 422 - **routing_repository.go**: Routes content by type 423 - - `Manifests()` → returns ATProto ManifestStore (caches instance for hold DID extraction) 424 - - `Blobs()` → checks hold cache for pull, uses discovery for push 425 - - Pull: Uses cached `holdDid` from manifest (historical reference) 426 - - Push: Uses discovery-based DID from `findHoldDID()` in middleware 427 - - Always returns ProxyBlobStore (routes to hold service via DID) 428 - - Implements `distribution.Repository` interface 429 - - Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc. 430 - 431 - **Database-based hold DID lookups**: 432 - - Queries SQLite `manifests` table for hold DID (indexed, fast) 433 - - No in-memory caching needed - database IS the cache 434 - - Persistent across restarts, multi-instance safe 435 - - Pull operations use hold DID from latest manifest (historical reference) 436 - - Push operations use fresh discovery from profile/default 437 - - Function: `db.GetLatestHoldDIDForRepo(did, repository)` in `pkg/appview/db/queries.go` 438 - 439 - **proxy_blob_store.go**: External storage proxy (routes to hold via XRPC) 440 - - Resolves hold DID → HTTP URL for XRPC requests (did:web resolution) 441 - - Gets service tokens from user's PDS (`com.atproto.server.getServiceAuth`) 442 - - Calls hold XRPC endpoints with service token authentication: 443 - - Multipart upload: initiateUpload, getPartUploadUrl, completeUpload, abortUpload 444 - - Blob read: com.atproto.sync.getBlob (returns presigned download URL) 445 - - Implements full `distribution.BlobStore` interface 446 - - Uses presigned URLs for direct client-to-S3 transfers 447 - 448 - #### AppView Web UI (`pkg/appview/`) 449 - 450 - The AppView includes a web interface for browsing the registry: 451 - 452 - **Features:** 453 - - Repository browsing and search 454 - - Star/favorite repositories 455 - - Pull count tracking 456 - - User profiles and settings 457 - - OAuth-based authentication for web users 458 - 459 - **Database Layer** (`pkg/appview/db/`): 460 - - SQLite database for metadata (stars, pulls, repository info) 461 - - Schema migrations via SQL files in `pkg/appview/db/schema.go` 462 - - Stores: OAuth sessions, device flows, repository metadata 463 - - **NOTE:** Simple SQLite for MVP. For production multi-instance: use PostgreSQL 464 - 465 - **Jetstream Integration** (`pkg/appview/jetstream/`): 466 - - Consumes ATProto Jetstream for real-time updates 467 - - Backfills repository records from PDS 468 - - Indexes manifests, tags, and repository metadata 469 - - Worker processes incoming events 470 - 471 - **Web Handlers** (`pkg/appview/handlers/`): 472 - - `home.go` - Landing page 473 - - `repository.go` - Repository detail pages 474 - - `search.go` - Search functionality 475 - - `auth.go` - OAuth login/logout for web 476 - - `settings.go` - User settings management 477 - - `api.go` - JSON API endpoints 478 - 479 - **Public Assets & Templates** (`pkg/appview/public/`, `pkg/appview/templates/`): 480 - - `pkg/appview/public/` is served at the web root `/` (e.g., `public/favicon.svg` → `/favicon.svg`) 481 - - Templates use Go html/template 482 - - JavaScript in `public/js/app.js` 483 - - Minimal CSS for clean UI 484 - 485 - #### Hold Service (`cmd/hold/`) 486 - 487 - Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS: 488 - 489 - **Architecture:** 490 - - **Embedded PDS**: Each hold has a full ATProto PDS for storing captain + crew records 491 - - **DID**: Hold identified by did:web (e.g., `did:web:hold01.atcr.io`) 492 - - **Storage**: Requires S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.) 493 - - **Authorization**: Based on captain + crew records in embedded PDS 494 - - **Blob operations**: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC 495 - 496 - **Authorization Model:** 86 + Pattern: `atcr.io/<identity>/<image>:<tag>` where identity is a handle or DID. 497 87 498 - Read access: 499 - - **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users 500 - - **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read OR blob:write permission 501 - - **Note:** `blob:write` implicitly grants `blob:read` access (can't push without pulling) 88 + Resolution in `pkg/atproto/resolver.go`: Handle → DID (DNS/HTTPS) → PDS endpoint (DID document). 502 89 503 - Write access: 504 - - Hold owner OR crew members with blob:write permission 505 - - Verified via `io.atcr.hold.crew` records in hold's embedded PDS 90 + ### Nautical Terminology 506 91 507 - **Permission Matrix:** 92 + - **Sailors** = registry users, **Captains** = hold owners, **Crew** = hold members 93 + - **Holds** = storage endpoints (BYOS), **Quartermaster/Bosun/Deckhand** = crew tiers 508 94 509 - | User Type | Public Read | Private Read | Write | Crew Admin | 510 - |-----------|-------------|--------------|-------|------------| 511 - | Anonymous | Yes | No | No | No | 512 - | Owner (captain) | Yes | Yes | Yes | Yes (implied) | 513 - | Crew (blob:read only) | Yes | Yes | No | No | 514 - | Crew (blob:write only) | Yes | Yes* | Yes | No | 515 - | Crew (blob:read + blob:write) | Yes | Yes | Yes | No | 516 - | Crew (crew:admin) | Yes | Yes | Yes | Yes | 517 - | Authenticated non-crew | Yes | No | No | No | 95 + ## Authentication 518 96 519 - *`blob:write` implicitly grants `blob:read` access 97 + Three token types flow through the system: 520 98 521 - **Authorization Error Format:** 99 + | Token | Issued By | Used For | Lifetime | 100 + |-------|-----------|----------|----------| 101 + | OAuth (access+refresh) | User's PDS | AppView → PDS communication | ~2h / ~90d | 102 + | Registry JWT | AppView | Docker client → AppView | 5 min | 103 + | Service Token | User's PDS | AppView → Hold service | 60s (cached 50s) | 522 104 523 - All authorization failures use consistent structured errors (`pkg/hold/pds/auth.go`): 524 105 ``` 525 - access denied for [action]: [reason] (required: [permission(s)]) 106 + Docker Client ──Registry JWT──→ AppView ──OAuth──→ User's PDS ──Service Token──→ Hold 526 107 ``` 527 108 528 - Examples: 529 - - `access denied for blob:read: user is not a crew member (required: blob:read or blob:write)` 530 - - `access denied for blob:write: crew member lacks permission (required: blob:write)` 531 - - `access denied for crew:admin: user is not a crew member (required: crew:admin)` 109 + 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. 532 110 533 - **Shared Error Constants** (`pkg/hold/pds/auth.go`): 534 - - `ErrMissingAuthHeader` - Missing Authorization header 535 - - `ErrInvalidAuthFormat` - Invalid Authorization header format 536 - - `ErrInvalidAuthScheme` - Invalid scheme (expected Bearer or DPoP) 537 - - `ErrInvalidJWTFormat` - Malformed JWT 538 - - `ErrMissingISSClaim` / `ErrMissingSubClaim` - Missing JWT claims 539 - - `ErrTokenExpired` - Token has expired 111 + ## Hold Authorization 540 112 541 - **Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`): 113 + - **Public hold**: Anonymous reads allowed. Writes require captain or crew with `blob:write`. 114 + - **Private hold**: Reads require crew with `blob:read` or `blob:write`. Writes require `blob:write`. 115 + - `blob:write` implicitly grants `blob:read`. 116 + - Captain has all permissions implicitly. 117 + - See `docs/BYOS.md` for full authorization model and permission matrix. 542 118 543 - Standard ATProto sync endpoints: 544 - - `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file 545 - - `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision 546 - - `GET /xrpc/com.atproto.sync.getRepoStatus?did={did}` - Get repository hosting status and current revision 547 - - `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events 548 - - `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS) 549 - - `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL 119 + ## Key File Locations 550 120 551 - Repository management: 552 - - `GET /xrpc/com.atproto.repo.describeRepo?repo={did}` - Repository metadata 553 - - `GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}` - Get record 554 - - `GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}` - List records (supports pagination) 555 - - `POST /xrpc/com.atproto.repo.deleteRecord` - Delete record (owner/crew admin only) 556 - - `POST /xrpc/com.atproto.repo.uploadBlob` - Upload ATProto blob (owner/crew admin only) 121 + | Responsibility | Files | 122 + |---|---| 123 + | ATProto records & collections | `pkg/atproto/lexicon.go` | 124 + | DID/handle resolution | `pkg/atproto/resolver.go` | 125 + | PDS client (XRPC) | `pkg/atproto/client.go` | 126 + | Manifest ↔ ATProto storage | `pkg/atproto/manifest_store.go` | 127 + | Sailor profiles | `pkg/atproto/profile.go` | 128 + | Registry middleware (identity resolution, hold discovery) | `pkg/appview/middleware/registry.go` | 129 + | Auth middleware (JWT validation) | `pkg/appview/middleware/auth.go` | 130 + | Content routing (manifests vs blobs) | `pkg/appview/storage/routing_repository.go` | 131 + | Blob proxy to hold (presigned URLs) | `pkg/appview/storage/proxy_blob_store.go` | 132 + | Request context struct | `pkg/appview/storage/context.go` | 133 + | Database queries | `pkg/appview/db/queries.go` | 134 + | Database schema | `pkg/appview/db/schema.sql` | 135 + | OAuth client & session refresher | `pkg/auth/oauth/client.go` | 136 + | OAuth P-256 key management | `pkg/auth/oauth/keys.go` | 137 + | Hold PDS endpoints & auth | `pkg/hold/pds/xrpc.go`, `pkg/hold/pds/auth.go` | 138 + | Hold OCI upload endpoints | `pkg/hold/oci/xrpc.go` | 139 + | Hold config | `pkg/hold/config.go` | 140 + | AppView config | `pkg/appview/config.go` | 141 + | Config marshaling (commented YAML) | `pkg/config/marshal.go` | 142 + | Scanner config (env-only) | `scanner/internal/config/config.go` | 557 143 558 - DID resolution: 559 - - `GET /.well-known/did.json` - DID document (did:web resolution) 560 - - `GET /.well-known/atproto-did` - DID for handle resolution 561 - 562 - Crew management: 563 - - `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership (authenticated users) 564 - 565 - **OCI Multipart Upload Endpoints** (`pkg/hold/oci/xrpc.go`): 566 - 567 - All require blob:write permission via service token authentication: 568 - - `POST /xrpc/io.atcr.hold.initiateUpload` - Start multipart upload session 569 - - `POST /xrpc/io.atcr.hold.getPartUploadUrl` - Get presigned URL for uploading a part 570 - - `POST /xrpc/io.atcr.hold.completeUpload` - Finalize multipart upload and move to final location 571 - - `POST /xrpc/io.atcr.hold.abortUpload` - Cancel multipart upload and cleanup temp data 572 - 573 - **AppView-to-Hold Authentication:** 574 - - AppView uses service tokens from user's PDS (`com.atproto.server.getServiceAuth`) 575 - - Service tokens are scoped to specific hold DIDs and include the user's DID 576 - - Hold validates tokens and checks crew membership for authorization 577 - - Tokens cached for 50 seconds (valid for 60 seconds from PDS) 578 - 579 - **Hold Subsystems** (`pkg/hold/`): 580 - - **Admin UI** (`admin/`) - Web-based admin panel for crew and storage management (enabled via `admin.enabled: true`) 581 - - **Quota** (`quota/`) - Per-user storage quota enforcement with tier-based limits (configured in YAML under `quota:`) 582 - - **Billing** (`billing/`) - Stripe integration for paid tiers (build tag `billing`, compile with `-tags billing`). Zero overhead when disabled. 583 - - **Garbage Collection** (`gc/`) - Blob garbage collection for orphaned data 584 - - **Scan Broadcaster** (`pds/scan_broadcaster.go`) - WebSocket server dispatching scan jobs to scanner instances via `/xrpc/io.atcr.hold.subscribeScanJobs` 144 + ## Configuration 585 145 586 - **Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. 587 - 588 - #### Scanner Service (`scanner/`) 589 - 590 - Separate Go module for vulnerability scanning. Connects to hold services via WebSocket. 591 - 592 - **Architecture:** 593 - - `scanner/internal/client/hold.go` - WebSocket client with auto-reconnect (exponential backoff) 594 - - `scanner/internal/queue/priority_queue.go` - Thread-safe priority queue (tier-based: owner > quartermaster > bosun > deckhand) 595 - - `scanner/internal/scan/worker.go` - Configurable worker pool 596 - - `scanner/internal/scan/syft.go` - SBOM generation via Syft 597 - - `scanner/internal/scan/grype.go` - Vulnerability scanning via Grype 598 - - `scanner/internal/scan/extractor.go` - Container layer extraction 599 - - `scanner/internal/config/config.go` - Environment-only config (no YAML, no Viper) 600 - 601 - **Scanner env vars** (prefix `SCANNER_`): 602 - - `SCANNER_HOLD_URL` - WebSocket URL of hold service (required) 603 - - `SCANNER_SHARED_SECRET` - Authentication secret (required) 604 - - `SCANNER_WORKERS` - Number of concurrent scan workers 605 - - `SCANNER_VULN_ENABLED` - Enable vulnerability scanning (default: true) 606 - - `SCANNER_ADDR` - Health endpoint address (default: `:9090`) 607 - 608 - #### Usage Report Tool (`cmd/usage-report/`) 609 - 610 - CLI tool for analyzing hold storage usage: 611 - ```bash 612 - go run ./cmd/usage-report --hold https://hold01.atcr.io # summary 613 - go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests # from manifests 614 - go run ./cmd/usage-report --hold https://hold01.atcr.io --list-blobs # individual blobs 615 - ``` 146 + ATCR uses **Viper** for config. YAML primary, env vars override. Generate defaults with `config init`. 616 147 617 - ### ATProto Storage Model 148 + **Env var convention:** Prefix + YAML path with `_` separators: 149 + - AppView: `ATCR_` (e.g., `ATCR_SERVER_DEFAULT_HOLD_DID`) 150 + - Hold: `HOLD_` (e.g., `HOLD_SERVER_PUBLIC_URL`) 151 + - S3: standard AWS names (`AWS_ACCESS_KEY_ID`, `S3_BUCKET`, `S3_ENDPOINT`) 152 + - Scanner: `SCANNER_` prefix (env-only, no Viper) 618 153 619 - Manifests are stored as records with this structure: 620 - ```json 621 - { 622 - "$type": "io.atcr.manifest", 623 - "repository": "myapp", 624 - "digest": "sha256:abc123...", 625 - "holdDid": "did:web:hold01.atcr.io", 626 - "holdEndpoint": "https://hold1.atcr.io", 627 - "schemaVersion": 2, 628 - "mediaType": "application/vnd.oci.image.manifest.v1+json", 629 - "config": { "digest": "sha256:...", "size": 1234 }, 630 - "layers": [ 631 - { "digest": "sha256:...", "size": 5678 } 632 - ], 633 - "manifestBlob": { 634 - "$type": "blob", 635 - "ref": { "$link": "bafyrei..." }, 636 - "mimeType": "application/vnd.oci.image.manifest.v1+json", 637 - "size": 1234 638 - }, 639 - "createdAt": "2025-09-30T..." 640 - } 641 - ``` 154 + 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`. 642 155 643 - **Key fields:** 644 - - `holdDid` - DID of the hold service where blobs are stored (PRIMARY reference, new) 645 - - `holdEndpoint` - HTTP URL of hold service (DEPRECATED, kept for backward compatibility) 646 - - `manifestBlob` - Reference to manifest blob in ATProto blob storage (CID format) 156 + ## Development Gotchas 647 157 648 - Record key = manifest digest (without algorithm prefix) 649 - Collection = `io.atcr.manifest` 158 + - **Do NOT run `npm run css:build` or `npm run js:build` manually** — Air handles these on file change 159 + - **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). 160 + - **Storage driver import**: `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` — blank import required 161 + - **Hold DID lookups use database** (`manifests` table), not in-memory cache — persistent across restarts 162 + - **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()`. 163 + - **OAuth key types**: AppView uses P-256 (ES256) for OAuth, not K-256 like PDS keys 164 + - **Confidential vs public clients**: Production uses P-256 key at `/var/lib/atcr/oauth/client.key` (auto-generated); localhost is always public client 650 165 651 - ### Sailor Profile System 166 + ## Common Tasks 652 167 653 - ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture: 654 - - **Sailors** = Registry users 655 - - **Captains** = Hold owners 656 - - **Crew** = Hold members with access 657 - - **Holds** = Storage endpoints (BYOS) 658 - 659 - **Profile Record** (`io.atcr.sailor.profile`): 660 - ```json 661 - { 662 - "$type": "io.atcr.sailor.profile", 663 - "defaultHold": "did:web:hold1.alice.com", 664 - "createdAt": "2025-10-02T...", 665 - "updatedAt": "2025-10-02T..." 666 - } 667 - ``` 668 - 669 - **Profile Management:** 670 - - Created automatically on first authentication (OAuth or Basic Auth) 671 - - `defaultHold` can be a DID (preferred, e.g., `did:web:hold01.atcr.io`) or legacy URL 672 - - If AppView has `default_hold_did` configured, profile gets that as `defaultHold` 673 - - Users can update their profile to change default hold (future: via UI) 674 - - Setting `defaultHold` to null opts out of defaults (use own holds or AppView default) 675 - 676 - **Hold Resolution Priority** (in `findHoldDID()` in middleware): 677 - 1. **Profile's `defaultHold`** - User's explicit preference (DID or URL) 678 - 2. **User's `io.atcr.hold` records** - User's own holds (legacy BYOS model) 679 - 3. **AppView's `default_hold_did`** - Fallback default (configured in middleware) 680 - 681 - This ensures: 682 - - Users can join shared holds by setting their profile's `defaultHold` 683 - - Users can opt out of defaults (set `defaultHold` to null) 684 - - URL structure remains `atcr.io/<owner>/<image>` (ownership-based, not hold-based) 685 - - Hold choice is transparent infrastructure (like choosing an S3 region) 686 - 687 - ### Key Design Decisions 688 - 689 - 1. **No fork of distribution**: Uses distribution as library, extends via middleware 690 - 2. **Hybrid storage**: Manifests in ATProto (small), blobs in S3 or BYOS (cheap, scalable) 691 - 3. **Content addressing**: Manifests stored by digest, blobs deduplicated globally 692 - 4. **ATProto-native**: Manifests are first-class ATProto records, discoverable via AT Protocol 693 - 5. **OCI compliant**: Fully compatible with Docker/containerd/podman 694 - 6. **Account-agnostic AppView**: Server validates any user's token, queries their PDS for config 695 - 7. **BYOS architecture**: Users can deploy their own storage service, AppView just routes 696 - 8. **OAuth with DPoP**: Full ATProto OAuth implementation with mandatory DPoP proofs 697 - 9. **Sailor profile system**: User preferences for hold selection, transparent to image ownership 698 - 10. **Historical hold references**: Manifests store `holdEndpoint` for immutable blob location tracking 699 - 700 - ### Configuration 701 - 702 - ATCR uses **Viper** for configuration. YAML files are the primary method; environment variables work as overrides. 703 - 704 - **Loading priority** (highest wins): 705 - 1. Environment variables (always override YAML) 706 - 2. YAML config file (via `--config` / `-c` flag) 707 - 3. Hardcoded defaults 708 - 709 - **Generating config files:** 710 - ```bash 711 - ./bin/atcr-appview config init config-appview.yaml # fully-commented YAML with defaults 712 - ./bin/atcr-hold config init config-hold.yaml 713 - ``` 714 - 715 - **Env var naming convention:** Prefix + YAML path with `_` separators: 716 - - AppView prefix: `ATCR_` — e.g., `server.default_hold_did` → `ATCR_SERVER_DEFAULT_HOLD_DID` 717 - - Hold prefix: `HOLD_` — e.g., `server.public_url` → `HOLD_SERVER_PUBLIC_URL` 718 - - S3 uses standard AWS names: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET`, `S3_ENDPOINT` 719 - 720 - **AppView config** (see `config-appview.example.yaml`): 721 - - `server.addr` - Listen address (default: `:5000`) 722 - - `server.base_url` - Public URL for OAuth/JWT realm (auto-detected in dev) 723 - - `server.default_hold_did` - Default hold DID for blob storage (REQUIRED) 724 - - `server.oauth_key_path` - P-256 key for OAuth client auth (auto-generated) 725 - - `server.registry_domain` - Separate domain for OCI API (e.g., `buoy.cr`) 726 - - `auth.key_path` - RSA key for signing registry JWTs 727 - - `ui.database_path` - SQLite database path 728 - - `jetstream.url` - ATProto firehose endpoint 729 - - `jetstream.backfill_enabled` - Sync existing records on startup 730 - - `log_shipper` - Remote log shipping (victoria, opensearch, loki) 731 - 732 - **Hold config** (see `config-hold.example.yaml`): 733 - - `server.public_url` - Externally reachable URL for did:web identity (REQUIRED) 734 - - `server.public` - Allow unauthenticated reads (default: false) 735 - - `storage.bucket` - S3 bucket (REQUIRED) 736 - - `storage.endpoint` - Custom S3 endpoint for non-AWS providers 737 - - `registration.owner_did` - DID for captain record auto-creation 738 - - `registration.allow_all_crew` - Allow any authenticated user to join 739 - - `database.path` - Embedded PDS database directory 740 - - `admin.enabled` - Enable web admin panel 741 - - `quota.tiers` - Storage quota tiers (e.g., `deckhand: {quota: "5GB"}`) 742 - - `quota.defaults.new_crew_tier` - Default tier for new crew members 743 - 744 - **Hold billing config** (requires `-tags billing` build): 745 - - `billing.enabled` - Enable Stripe billing 746 - - `billing.currency` - ISO currency code 747 - - `billing.tiers` - Map of tier names to Stripe price IDs 748 - - Env vars: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` 749 - 750 - **Scanner config** (env vars only, no YAML/Viper): 751 - - `SCANNER_HOLD_URL`, `SCANNER_SHARED_SECRET`, `SCANNER_WORKERS`, `SCANNER_ADDR` 752 - 753 - **Credential Helper**: 754 - - Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store) 755 - - Contains: Registry JWT issued by AppView (NOT OAuth tokens) 756 - - OAuth session managed entirely by AppView 757 - 758 - ### Development Notes 759 - 760 - **General:** 761 - - Do NOT run `npm run css:build` or `npm run js:build` manually — Air handles these automatically on file change 762 - - Middleware is in `pkg/appview/middleware/` (auth.go, registry.go) 763 - - Storage routing is in `pkg/appview/storage/` (routing_repository.go, proxy_blob_store.go) 764 - - Hold DID lookups use database queries (no in-memory caching) 765 - - Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` 766 - - Hold service reuses distribution's driver factory for multi-backend support 767 - 768 - **Configuration system:** 769 - - Config loading uses Viper (`pkg/config/viper.go`) — YAML primary, env vars override 770 - - Config structs use `comment` struct tags for auto-generating commented YAML via `MarshalCommentedYAML()` (`pkg/config/marshal.go`) 771 - - AppView config: `pkg/appview/config.go` (prefix `ATCR_`) 772 - - Hold config: `pkg/hold/config.go` (prefix `HOLD_`, plus standard `AWS_*`/`S3_*` bindings) 773 - - Quota/billing configs are subsections of the hold YAML file, loaded by passing the config path 774 - - Scanner config is env-only (no Viper): `scanner/internal/config/config.go` 775 - 776 - **OAuth implementation:** 777 - - Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration 778 - - Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity 779 - - All ATCR components use standardized `/auth/oauth/callback` path 780 - - Client ID generation (localhost query-based vs production metadata URL) handled internally 781 - 782 - ### Testing Strategy 783 - 784 - When writing tests: 785 - - Mock ATProto client for manifest operations 786 - - Mock S3 driver for blob operations 787 - - Test name resolution independently 788 - - Integration tests require real PDS + S3 789 - 790 - ### Common Tasks 791 - 792 - **Adding a new ATProto record type**: 168 + **Adding a new ATProto record type:** 793 169 1. Define schema in `pkg/atproto/lexicon.go` 794 170 2. Add collection constant (e.g., `MyCollection = "io.atcr.my-type"`) 795 171 3. Add constructor function (e.g., `NewMyRecord()`) 796 172 4. Update client methods if needed 797 173 798 - **Modifying storage routing**: 174 + **Modifying storage routing:** 799 175 1. Edit `pkg/appview/storage/routing_repository.go` 800 - 2. Update `Blobs()` method to change routing logic 801 - 3. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.) 176 + 2. Update `Blobs()` or `Manifests()` method 177 + 3. Context passed via `RegistryContext` struct (`pkg/appview/storage/context.go`) 802 178 803 - **Changing name resolution**: 179 + **Changing name resolution:** 804 180 1. Modify `pkg/atproto/resolver.go` for DID/handle resolution 805 - 2. Update `pkg/appview/middleware/registry.go` if changing routing logic 806 - 3. Remember: `findHoldDID()` checks sailor profile, then `io.atcr.hold` records (legacy), then default hold DID 181 + 2. Update `pkg/appview/middleware/registry.go` if changing routing 182 + 3. `findHoldDID()` checks: sailor profile → `io.atcr.hold` records (legacy) → default hold DID 807 183 808 - **Working with OAuth client**: 809 - - Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes 810 - - For AppView server/refresher: use `NewClient(baseURL)` or `NewClientWithKey(baseURL, storedKey)` 811 - - For custom scopes: call `client.SetScopes(customScopes)` after initialization 812 - - Standard callback path: `/auth/oauth/callback` (used by all ATCR components) 813 - - Client methods are consistent across authorization, token exchange, and refresh flows 184 + **Working with OAuth client:** 185 + - Self-contained: pass `baseURL`, handles client ID/redirect URI/scopes 186 + - Standard callback path: `/auth/oauth/callback` (all ATCR components) 187 + - See `pkg/auth/oauth/client.go` for `NewClientApp()`, refresher setup 814 188 815 - **Adding BYOS support for a user**: 189 + **Adding BYOS support for a user:** 816 190 1. User configures hold YAML (storage credentials, public URL, owner DID) 817 - 2. User runs hold service - creates captain + crew records in embedded PDS 818 - 3. Hold creates `io.atcr.hold.captain` + `io.atcr.hold.crew` records 819 - 4. User sets sailor profile `defaultHold` to point to their hold 820 - 5. AppView automatically queries hold's PDS and routes blobs to user's storage 821 - 6. No AppView changes needed - fully decentralized 822 - 823 - **Using S3-compatible storage**: 824 - ATCR requires S3-compatible storage. Configure in hold YAML under `storage:` or via env vars. 825 - Supported providers: AWS S3, Storj (`storage.endpoint: https://gateway.storjshare.io`), 826 - Minio (`storage.endpoint: http://localhost:9000`), UpCloud, Azure/GCS (S3-compatible endpoints). 191 + 2. User runs hold service — creates captain + crew records in embedded PDS 192 + 3. User sets sailor profile `defaultHold` to their hold's DID 193 + 4. AppView automatically routes blobs to user's storage — no AppView changes needed 827 194 828 - **Working with the database**: 829 - - **Base schema** defined in `pkg/appview/db/schema.sql` - source of truth for fresh installations 830 - - **Migrations** in `pkg/appview/db/migrations/*.yaml` - only for ALTER/UPDATE/DELETE on existing databases 831 - - **Queries** in `pkg/appview/db/queries.go` 832 - - **Stores** for OAuth, devices, sessions in separate files 833 - - **Execution order**: schema.sql first, then migrations (automatically on startup) 834 - - **Database path** configurable via `ui.database_path` in YAML (or `ATCR_UI_DATABASE_PATH` env var) 195 + **Working with the database:** 196 + - **Base schema**: `pkg/appview/db/schema.sql` — source of truth for fresh installs 197 + - **Migrations**: `pkg/appview/db/migrations/*.yaml` — only for ALTER/UPDATE/DELETE on existing DBs 835 198 - **Adding new tables**: Add to `schema.sql` only (no migration needed) 836 199 - **Altering tables**: Create migration AND update `schema.sql` to keep them in sync 837 200 838 - **Adding web UI features**: 201 + **Adding web UI features:** 839 202 - Add handler in `pkg/appview/handlers/` 840 203 - Register route in `cmd/appview/serve.go` 841 204 - Create template in `pkg/appview/templates/pages/` 842 - - Use existing auth middleware for protected routes 843 - - API endpoints return JSON, pages return HTML 844 205 845 - ## Important Context Values 206 + ## Testing Strategy 846 207 847 - When working with the codebase, routing information is passed via the `RegistryContext` struct (`pkg/appview/storage/context.go`): 848 - 849 - - `DID` - User's DID (e.g., `did:plc:alice123`) 850 - - `PDSEndpoint` - User's PDS endpoint (e.g., `https://bsky.social`) 851 - - `HoldDID` - Hold service DID (e.g., `did:web:hold01.atcr.io`) 852 - - `Repository` - Image repository name (e.g., `myapp`) 853 - - `ATProtoClient` - Client for calling user's PDS with OAuth/Basic Auth 854 - - `Refresher` - OAuth token refresher for service token requests 855 - - `Database` - Database for metrics tracking 856 - - `Authorizer` - Hold authorizer for access control 857 - 858 - Legacy context keys (deprecated): 859 - - `hold.did` - Hold DID (now in RegistryContext) 860 - - `auth.did` - Authenticated DID from validated token (now in auth middleware) 208 + - Mock ATProto client for manifest operations 209 + - Mock S3 driver for blob operations 210 + - Test name resolution independently 211 + - Integration tests require real PDS + S3 861 212 862 213 ## Documentation References 863 214 864 - - **BYOS Architecture**: See `docs/BYOS.md` for complete BYOS documentation 865 - - **OAuth Implementation**: See `docs/OAUTH.md` for OAuth/DPoP flow details 215 + - **BYOS Architecture**: `docs/BYOS.md` 216 + - **OAuth Implementation**: `docs/OAUTH.md` 217 + - **Hold Service**: `docs/hold.md` 218 + - **AppView**: `docs/appview.md` 219 + - **Hold XRPC Endpoints**: `docs/HOLD_XRPC_ENDPOINTS.md` 220 + - **Development Guide**: `docs/DEVELOPMENT.md` 221 + - **Billing/Quotas**: `docs/BILLING.md`, `docs/QUOTAS.md` 222 + - **Scanning**: `docs/SBOM_SCANNING.md` 866 223 - **ATProto Spec**: https://atproto.com/specs/oauth 867 224 - **OCI Distribution Spec**: https://github.com/opencontainers/distribution-spec 868 - - **DPoP RFC**: https://datatracker.ietf.org/doc/html/rfc9449 869 - - **PAR RFC**: https://datatracker.ietf.org/doc/html/rfc9126 870 - - **PKCE RFC**: https://datatracker.ietf.org/doc/html/rfc7636
-5
pkg/appview/middleware/registry.go
··· 23 23 "atcr.io/pkg/auth/token" 24 24 ) 25 25 26 - // holdDIDKey is the context key for storing hold DID 27 - const holdDIDKey contextKey = "hold.did" 28 - 29 26 // authMethodKey is the context key for storing auth method from JWT 30 27 const authMethodKey contextKey = "auth.method" 31 28 ··· 296 293 // This is a fatal configuration error - registry cannot function without a hold service 297 294 return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config") 298 295 } 299 - ctx = context.WithValue(ctx, holdDIDKey, holdDID) 300 - 301 296 // Auto-reconcile crew membership on first push/pull 302 297 // This ensures users can push immediately after docker login without web sign-in 303 298 // EnsureCrewMembership is best-effort and logs errors without failing the request
+2 -2
pkg/atproto/lexicon.go
··· 599 599 Member string `json:"member" cborgen:"member"` 600 600 Role string `json:"role" cborgen:"role"` 601 601 Permissions []string `json:"permissions" cborgen:"permissions"` 602 - Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') 602 + Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') 603 603 Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner"` // Early adopter flag - gets plankowner_crew_tier for free 604 - AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 604 + AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 605 605 } 606 606 607 607 // LayerRecord represents metadata about a container layer stored in the hold
+2 -2
pkg/hold/oci/xrpc.go
··· 25 25 pds *pds.HoldPDS 26 26 httpClient pds.HTTPClient 27 27 enableBlueskyPosts bool 28 - quotaMgr *quota.Manager // Quota manager for tier-based limits 29 - scanBroadcaster *pds.ScanBroadcaster // Scan job dispatcher (nil = scanning disabled) 28 + quotaMgr *quota.Manager // Quota manager for tier-based limits 29 + scanBroadcaster *pds.ScanBroadcaster // Scan job dispatcher (nil = scanning disabled) 30 30 } 31 31 32 32 // NewXRPCHandler creates a new OCI XRPC handler
+10 -8
pkg/hold/pds/scan_broadcaster.go
··· 59 59 60 60 // ScannerMessage is a message received from scanner over WebSocket 61 61 type ScannerMessage struct { 62 - Type string `json:"type"` // "ack", "result", "error" 63 - Seq int64 `json:"seq"` // Job sequence number 64 - SBOM string `json:"sbom,omitempty"` 65 - VulnReport string `json:"vulnReport,omitempty"` 66 - Summary *VulnerabilitySummary `json:"summary,omitempty"` 67 - Error string `json:"error,omitempty"` 62 + Type string `json:"type"` // "ack", "result", "error" 63 + Seq int64 `json:"seq"` // Job sequence number 64 + SBOM string `json:"sbom,omitempty"` 65 + VulnReport string `json:"vulnReport,omitempty"` 66 + Summary *VulnerabilitySummary `json:"summary,omitempty"` 67 + Error string `json:"error,omitempty"` 68 68 } 69 69 70 70 // VulnerabilitySummary contains counts of vulnerabilities by severity ··· 268 268 slog.Warn("Scanner buffer full, re-marking job as pending", 269 269 "seq", job.Seq, 270 270 "subscriberId", sub.id) 271 - sb.db.Exec(`UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL WHERE seq = ?`, job.Seq) 271 + if _, err := sb.db.Exec(`UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL WHERE seq = ?`, job.Seq); err != nil { 272 + slog.Error("Failed to re-mark scan job as pending", "seq", job.Seq, "error", err) 273 + } 272 274 } 273 275 } 274 276 ··· 591 593 592 594 func generateSubscriberID() string { 593 595 b := make([]byte, 8) 594 - rand.Read(b) 596 + _, _ = rand.Read(b) 595 597 return hex.EncodeToString(b) 596 598 }
+7 -7
pkg/hold/pds/xrpc.go
··· 44 44 45 45 // XRPCHandler handles XRPC requests for the embedded PDS 46 46 type XRPCHandler struct { 47 - pds *HoldPDS 48 - s3Service s3.S3Service 49 - storageDriver driver.StorageDriver 50 - broadcaster *EventBroadcaster 51 - scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners 52 - httpClient HTTPClient // For testing - allows injecting mock HTTP client 53 - quotaMgr *quota.Manager // Quota manager for tier-based limits 47 + pds *HoldPDS 48 + s3Service s3.S3Service 49 + storageDriver driver.StorageDriver 50 + broadcaster *EventBroadcaster 51 + scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners 52 + httpClient HTTPClient // For testing - allows injecting mock HTTP client 53 + quotaMgr *quota.Manager // Quota manager for tier-based limits 54 54 } 55 55 56 56 // PartInfo represents a completed part in a multipart upload