CLAUDE.md#
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview#
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.
Build Commands#
# Build all binaries
# create go builds in the bin/ directory
go build -o bin/atcr-appview ./cmd/appview
go build -o bin/atcr-hold ./cmd/hold
go build -o bin/docker-credential-atcr ./cmd/credential-helper
go build -o bin/oauth-helper ./cmd/oauth-helper
# Run tests
go test ./...
# Run tests for specific package
go test ./pkg/atproto/...
go test ./pkg/appview/storage/...
# Run specific test
go test -run TestManifestStore ./pkg/atproto/...
# Run with race detector
go test -race ./...
# Run tests with verbose output
go test -v ./...
# Update dependencies
go mod tidy
# Build Docker images
docker build -t atcr.io/appview:latest .
docker build -f Dockerfile.hold -t atcr.io/hold:latest .
# Or use docker-compose
docker-compose up -d
# Run locally (AppView) - configure via env vars (see .env.appview.example)
export ATCR_HTTP_ADDR=:5000
export ATCR_DEFAULT_HOLD=http://127.0.0.1:8080
./bin/atcr-appview serve
# Or use .env file:
cp .env.appview.example .env.appview
# Edit .env.appview with your settings
source .env.appview
./bin/atcr-appview serve
# Legacy mode (still supported):
# ./bin/atcr-appview serve config/config.yml
# Run hold service (configure via env vars - see .env.hold.example)
export HOLD_PUBLIC_URL=http://127.0.0.1:8080
export STORAGE_DRIVER=filesystem
export STORAGE_ROOT_DIR=/tmp/atcr-hold
export HOLD_OWNER=did:plc:your-did-here
./bin/atcr-hold
# Hold starts immediately with embedded PDS
# Request Bluesky relay crawl (makes your PDS discoverable)
./deploy/request-crawl.sh hold01.atcr.io
# Or specify a different relay:
./deploy/request-crawl.sh hold01.atcr.io https://custom-relay.example.com/xrpc/com.atproto.sync.requestCrawl
Architecture Overview#
Core Design#
ATCR uses distribution/distribution as a library and extends it through middleware to route different types of content to different storage backends:
- Manifests → ATProto PDS (small JSON metadata, stored as
io.atcr.manifestrecords) - Blobs/Layers → S3 or user-deployed storage (large binary data)
- Authentication → ATProto OAuth with DPoP + Docker credential helpers
Three-Component Architecture#
-
AppView (
cmd/appview) - OCI Distribution API server- Resolves identities (handle/DID → PDS endpoint)
- Routes manifests to user's PDS
- Routes blobs to storage endpoint (default or BYOS)
- Validates OAuth tokens via PDS
- Issues registry JWTs
-
Hold Service (
cmd/hold) - Optional BYOS component- Lightweight HTTP server for presigned URLs
- Embedded PDS with captain + crew records
- Supports S3, Storj, Minio, filesystem, etc.
- Authorization based on captain record (public, allowAllCrew)
- Self-describing via DID resolution
- Configured entirely via environment variables
-
Credential Helper (
cmd/credential-helper) - Client-side OAuth- Implements Docker credential helper protocol
- ATProto OAuth flow with DPoP
- Token caching and refresh
- Exchanges OAuth token for registry JWT
Request Flow#
Push with Default Storage#
1. Client: docker push atcr.io/alice/myapp:latest
2. HTTP Request → /v2/alice/myapp/manifests/latest
3. Registry Middleware (pkg/appview/middleware/registry.go)
→ Resolves "alice" to DID and PDS endpoint
→ Queries alice's sailor profile for defaultHold (returns DID if set)
→ If not set, checks alice's io.atcr.hold records
→ Falls back to AppView's default_hold_did
→ Stores DID/PDS/hold DID in RegistryContext
4. Routing Repository (pkg/appview/storage/routing_repository.go)
→ Creates RoutingRepository
→ Returns ATProto ManifestStore for manifests
→ Returns ProxyBlobStore for blobs (routes to hold DID)
5. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints:
a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID)
b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part)
c. PUT to S3 presigned URL (or PUT /xrpc/io.atcr.hold.uploadPart for buffered mode)
d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload)
6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint)
→ Manifest also uploaded to PDS blob storage (ATProto CID format)
Push with BYOS (Bring Your Own Storage)#
1. Client: docker push atcr.io/alice/myapp:latest
2. Registry Middleware resolves alice → did:plc:alice123
3. Hold discovery via findHoldDID():
a. Check alice's sailor profile for defaultHold (returns DID if set)
b. If not set, check alice's io.atcr.hold records (legacy)
c. Fall back to AppView's default_hold_did
4. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev"
5. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev)
6. ProxyBlobStore:
a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution)
b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
c. Calls hold XRPC endpoints with service token authentication:
- POST /xrpc/io.atcr.hold.initiateUpload
- POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL)
- PUT to S3 presigned URL (direct upload to alice's S3/Storj)
- POST /xrpc/io.atcr.hold.completeUpload
7. Hold service validates service token, checks crew membership, generates presigned URLs
8. Manifest stored in alice's PDS with:
- holdDid = "did:web:alice-storage.fly.dev" (primary)
- holdEndpoint = "https://alice-storage.fly.dev" (backward compat)
Pull Flow#
1. Client: docker pull atcr.io/alice/myapp:latest
2. GET /v2/alice/myapp/manifests/latest
3. AppView fetches manifest from alice's PDS
4. Manifest contains:
- holdDid = "did:web:alice-storage.fly.dev" (primary reference)
- holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback)
5. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev"
TTL: 10 minutes (covers typical pull operations)
6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123
7. AppView checks cache, routes to hold DID from manifest (not re-discovered)
8. ProxyBlobStore:
a. Resolves hold DID → https://alice-storage.fly.dev
b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET
d. Hold returns presigned download URL in JSON response
9. Client redirected to download blob directly from alice's S3 via presigned URL
Key insight: Pull uses the historical holdDid from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold. Hold cache (10min TTL) avoids re-querying PDS for each blob during the same pull operation.
Name Resolution#
Names follow the pattern: atcr.io/<identity>/<image>:<tag>
Where <identity> can be:
- Handle:
alice.bsky.social→ resolved via .well-known/atproto-did - DID:
did:plc:xyz123→ resolved via PLC directory
Resolution happens in pkg/atproto/resolver.go:
- Handle → DID (via DNS/HTTPS)
- DID → PDS endpoint (via DID document)
Middleware System#
ATCR uses middleware and routing to handle requests:
1. Registry Middleware (pkg/appview/middleware/registry.go)#
- Wraps
distribution.Namespace - Intercepts
Repository(name)calls - Performs name resolution (alice → did:plc:xyz → pds.example.com)
- Queries PDS for
io.atcr.holdrecords to find storage endpoint - Stores resolved identity and storage endpoint in context
2. Auth Middleware (pkg/appview/middleware/auth.go)#
- Validates JWT tokens from Docker clients
- Extracts DID from token claims
- Injects authenticated identity into context
3. Routing Repository (pkg/appview/storage/routing_repository.go)#
- Implements
distribution.Repository - Returns custom
Manifests()andBlobs()implementations - Routes manifests to ATProto, blobs to S3 or BYOS
- IMPORTANT: RoutingRepository is created fresh on EVERY request (no caching)
- Each Docker layer upload is a separate HTTP request (possibly different process)
- OAuth sessions can be refreshed/invalidated between requests
- The OAuth refresher already caches sessions efficiently (in-memory + DB)
- Previous caching of repositories with stale ATProtoClient caused "invalid refresh token" errors
Authentication Architecture#
Token Types and Flows#
ATCR uses three distinct token types in its authentication flow:
1. OAuth Tokens (Access + Refresh)
- Issued by: User's PDS via OAuth flow
- Stored in: AppView database (
oauth_sessionstable) - Cached in: Refresher's in-memory map (per-DID)
- Used for: AppView → User's PDS communication (write manifests, read profiles)
- Managed by: Indigo library with DPoP (automatic refresh)
- Lifetime: Access ~2 hours, Refresh ~90 days (PDS controlled)
2. Registry JWTs
- Issued by: AppView after OAuth login
- Stored in: Docker credential helper (
~/.atcr/credential-helper-token.json) - Used for: Docker client → AppView authentication
- Lifetime: 15 minutes (configurable via
ATCR_TOKEN_EXPIRATION) - Format: JWT with DID claim
3. Service Tokens
- Issued by: User's PDS via
com.atproto.server.getServiceAuth - Stored in: AppView memory (in-memory cache with ~50s TTL)
- Used for: AppView → Hold service authentication (acting on behalf of user)
- Lifetime: 60 seconds (PDS controlled), cached for 50s
- Required: OAuth session to obtain (catch-22 solved by Refresher)
Token Flow Diagram:
┌─────────────┐ ┌──────────────┐
│ Docker │ ─── Registry JWT ──────────────→ │ AppView │
│ Client │ │ │
└─────────────┘ └──────┬───────┘
│
│ OAuth tokens
│ (access + refresh)
↓
┌──────────────┐
│ User's PDS │
└──────┬───────┘
│
│ Service token
│ (via getServiceAuth)
↓
┌──────────────┐
│ Hold Service │
└──────────────┘
ATProto OAuth with DPoP#
ATCR implements the full ATProto OAuth specification with mandatory security features:
Required Components:
- DPoP (RFC 9449) - Cryptographic proof-of-possession for every request
- PAR (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange
- PKCE (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception
Key Components (pkg/auth/oauth/):
-
Client (
client.go) - OAuth client configuration and session management- ClientApp setup:
NewClientApp()- Creates configured*oauth.ClientApp(uses indigo directly, no wrapper)- Uses
NewLocalhostConfig()for localhost (public client) - Uses
NewPublicConfig()for production (upgraded to confidential with P-256 key) GetDefaultScopes()- Returns ATCR-specific OAuth scopesScopesMatch()- Compares scope lists (order-independent)
- Session management (Refresher):
NewRefresher()- Creates session cache manager for AppView- Purpose: In-memory cache for
*oauth.ClientSessionobjects (performance optimization) - Why needed: Saves 1-2 DB queries per request (~2ms) with minimal code complexity
- Per-DID locking prevents concurrent database loads
- Calls
ClientApp.ResumeSession()on cache miss - Indigo handles token refresh automatically (transparent to ATCR)
- Performance: Essential for high-traffic deployments, negligible for low-traffic
- Architecture: Single file containing both ClientApp helpers and Refresher (combined from previous two-file structure)
- ClientApp setup:
-
Keys (
keys.go) - P-256 key management for confidential clientsGenerateOrLoadClientKey()- generates or loads P-256 key from disk- Follows hold service pattern: auto-generation, 0600 permissions, /var/lib/atcr/oauth/
GenerateKeyID()- derives key ID from public key hashPrivateKeyToMultibase()- converts key forSetClientSecret()API- Key type: P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys)
-
Storage - Persists OAuth sessions
db/oauth_store.go- SQLite-backed storage for AppView (in UI database)store.go- File-based storage for CLI tools (~/.atcr/oauth-sessions.json)- Implements indigo's
ClientAuthStoreinterface
-
Server (
server.go) - OAuth authorization endpoints for AppViewGET /auth/oauth/authorize- starts OAuth flowGET /auth/oauth/callback- handles OAuth callback- Uses
ClientAppmethods directly (no wrapper)
-
Interactive Flow (
interactive.go) - Reusable OAuth flow for CLI tools- Used by credential helper and hold service registration
- Two-phase callback setup ensures PAR metadata availability
Client Configuration:
- Localhost: Always public client (no client authentication)
- Client ID:
http://localhost?redirect_uri=...&scope=...(query-based) - No P-256 key generation
- Client ID:
- Production: Confidential client with P-256 private key (if key exists)
- Client ID:
{baseURL}/client-metadata.json(metadata endpoint) - Key path:
/var/lib/atcr/oauth/client.key(auto-generated on first run) - Key algorithm: ES256 (P-256, not K-256)
- Upgraded via
config.SetClientSecret(key, keyID)
- Client ID:
Authentication Flow:
1. User configures Docker to use the credential helper (adds to config.json)
2. On first docker push/pull, Docker calls credential helper
3. Credential helper opens browser → AppView OAuth page
4. AppView handles OAuth flow:
- Resolves handle → DID → PDS endpoint
- Discovers OAuth server metadata from PDS
- PAR request with DPoP header → get request_uri
- User authorizes in browser
- AppView exchanges code for OAuth token with DPoP proof
- AppView stores: OAuth session (tokens managed by indigo library with DPoP), DID, handle
5. AppView shows device approval page: "Can [device] push to your account?"
6. User approves device
7. AppView issues registry JWT with validated DID
8. AppView returns JSON token to credential helper (via callback or browser display)
9. Credential helper saves registry JWT locally
10. Helper returns registry JWT to Docker
Later (subsequent docker push):
11. Docker calls credential helper
12. Helper returns cached registry JWT (or re-authenticates if expired)
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:
- Writing manifests to user's PDS (with DPoP authentication)
- Getting service tokens from user's PDS (with DPoP authentication)
- Service tokens are then used to authenticate to hold services (Bearer tokens, not DPoP)
Security:
- Tokens validated against authoritative source (user's PDS)
- No trust in client-provided identity information
- DPoP binds tokens to specific client key
- 15-minute token expiry for registry JWTs
- Confidential clients (production): Client authentication via P-256 private key JWT assertion
- Prevents client impersonation attacks
- Key stored in
/var/lib/atcr/oauth/client.keywith 0600 permissions - Automatically generated on first run
- Public clients (localhost): No client authentication (development only)
Key Components#
ATProto Integration (pkg/atproto/)#
resolver.go: DID and handle resolution
ResolveIdentity(): alice → did:plc:xyz → pds.example.comResolveHandle(): Uses .well-known/atproto-didResolvePDS(): Parses DID document for PDS endpoint
client.go: ATProto PDS client
PutRecord(): Store manifest as ATProto recordGetRecord(): Retrieve manifest from PDSDeleteRecord(): Remove manifest- Uses XRPC protocol (com.atproto.repo.*)
lexicon.go: ATProto record schemas
ManifestRecord: OCI manifest stored as ATProto record (includesholdDid+holdEndpointfields)TagRecord: Tag pointing to manifest digestHoldRecord: Storage hold definition (LEGACY - for old BYOS model)HoldCrewRecord: Hold crew membership (LEGACY - stored in owner's PDS)CaptainRecord: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self")CrewRecord: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member)SailorProfileRecord: User profile withdefaultHoldpreference (can be DID or URL)- Collections:
io.atcr.manifest,io.atcr.tag,io.atcr.hold(legacy),io.atcr.hold.crew(used by both legacy and new models),io.atcr.hold.captain(new),io.atcr.sailor.profile
profile.go: Sailor profile management
EnsureProfile(): Creates profile with default hold on first authenticationGetProfile(): Retrieves user's profile from PDSUpdateProfile(): Updates user's profile
manifest_store.go: Implements distribution.ManifestService
- Stores OCI manifests as ATProto records
- Digest-based addressing (sha256:abc123 → record key)
- Converts between OCI and ATProto formats
Storage Layer (pkg/appview/storage/)#
routing_repository.go: Routes content by type
Manifests()→ returns ATProto ManifestStore (caches instance for hold DID extraction)Blobs()→ checks hold cache for pull, uses discovery for push- Pull: Uses cached
holdDidfrom manifest (historical reference) - Push: Uses discovery-based DID from
findHoldDID()in middleware - Always returns ProxyBlobStore (routes to hold service via DID)
- Pull: Uses cached
- Implements
distribution.Repositoryinterface - Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc.
Database-based hold DID lookups:
- Queries SQLite
manifeststable for hold DID (indexed, fast) - No in-memory caching needed - database IS the cache
- Persistent across restarts, multi-instance safe
- Pull operations use hold DID from latest manifest (historical reference)
- Push operations use fresh discovery from profile/default
- Function:
db.GetLatestHoldDIDForRepo(did, repository)inpkg/appview/db/queries.go
proxy_blob_store.go: External storage proxy (routes to hold via XRPC)
- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution)
- Gets service tokens from user's PDS (
com.atproto.server.getServiceAuth) - Calls hold XRPC endpoints with service token authentication:
- Multipart upload: initiateUpload, getPartUploadUrl, uploadPart, completeUpload, abortUpload
- Blob read: com.atproto.sync.getBlob (returns presigned download URL)
- Implements full
distribution.BlobStoreinterface - Supports both presigned URL mode (S3 direct) and buffered mode (proxy via hold)
AppView Web UI (pkg/appview/)#
The AppView includes a web interface for browsing the registry:
Features:
- Repository browsing and search
- Star/favorite repositories
- Pull count tracking
- User profiles and settings
- OAuth-based authentication for web users
Database Layer (pkg/appview/db/):
- SQLite database for metadata (stars, pulls, repository info)
- Schema migrations via SQL files in
pkg/appview/db/schema.go - Stores: OAuth sessions, device flows, repository metadata
- NOTE: Simple SQLite for MVP. For production multi-instance: use PostgreSQL
Jetstream Integration (pkg/appview/jetstream/):
- Consumes ATProto Jetstream for real-time updates
- Backfills repository records from PDS
- Indexes manifests, tags, and repository metadata
- Worker processes incoming events
Web Handlers (pkg/appview/handlers/):
home.go- Landing pagerepository.go- Repository detail pagessearch.go- Search functionalityauth.go- OAuth login/logout for websettings.go- User settings managementapi.go- JSON API endpoints
Static Assets (pkg/appview/static/, pkg/appview/templates/):
- Templates use Go html/template
- JavaScript in
static/js/app.js - Minimal CSS for clean UI
Hold Service (cmd/hold/)#
Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS:
Architecture:
- Embedded PDS: Each hold has a full ATProto PDS for storing captain + crew records
- DID: Hold identified by did:web (e.g.,
did:web:hold01.atcr.io) - Storage: Reuses distribution's storage driver factory (S3, Storj, Minio, Azure, GCS, filesystem)
- Authorization: Based on captain + crew records in embedded PDS
- Blob operations: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC
Authorization Model:
Read access:
- Public hold (
HOLD_PUBLIC=true): Anonymous + all authenticated users - Private hold (
HOLD_PUBLIC=false): Requires authentication + crew membership with blob:read permission
Write access:
- Hold owner OR crew members with blob:write permission
- Verified via
io.atcr.hold.crewrecords in hold's embedded PDS
Embedded PDS Endpoints (pkg/hold/pds/xrpc.go):
Standard ATProto sync endpoints:
GET /xrpc/com.atproto.sync.getRepo?did={did}- Download full repository as CAR fileGET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}- Download repository diff since revisionGET /xrpc/com.atproto.sync.getRepoStatus?did={did}- Get repository hosting status and current revisionGET /xrpc/com.atproto.sync.subscribeRepos- WebSocket firehose for real-time eventsGET /xrpc/com.atproto.sync.listRepos- List all repositories (single-user PDS)GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}- Get blob or presigned download URL
Repository management:
GET /xrpc/com.atproto.repo.describeRepo?repo={did}- Repository metadataGET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}- Get recordGET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}- List records (supports pagination)POST /xrpc/com.atproto.repo.deleteRecord- Delete record (owner/crew admin only)POST /xrpc/com.atproto.repo.uploadBlob- Upload ATProto blob (owner/crew admin only)
DID resolution:
GET /.well-known/did.json- DID document (did:web resolution)GET /.well-known/atproto-did- DID for handle resolution
Crew management:
POST /xrpc/io.atcr.hold.requestCrew- Request crew membership (authenticated users)
OCI Multipart Upload Endpoints (pkg/hold/oci/xrpc.go):
All require blob:write permission via service token authentication:
POST /xrpc/io.atcr.hold.initiateUpload- Start multipart upload sessionPOST /xrpc/io.atcr.hold.getPartUploadUrl- Get presigned URL for uploading a partPUT /xrpc/io.atcr.hold.uploadPart- Direct buffered part upload (alternative to presigned URLs)POST /xrpc/io.atcr.hold.completeUpload- Finalize multipart upload and move to final locationPOST /xrpc/io.atcr.hold.abortUpload- Cancel multipart upload and cleanup temp data
AppView-to-Hold Authentication:
- AppView uses service tokens from user's PDS (
com.atproto.server.getServiceAuth) - Service tokens are scoped to specific hold DIDs and include the user's DID
- Hold validates tokens and checks crew membership for authorization
- Tokens cached for 50 seconds (valid for 60 seconds from PDS)
Configuration: Environment variables (see .env.hold.example)
HOLD_PUBLIC_URL- Public URL of hold service (required, used for did:web generation)STORAGE_DRIVER- Storage driver type (s3, filesystem)AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY- S3 credentialsS3_BUCKET,S3_ENDPOINT- S3 configurationHOLD_PUBLIC- Allow public reads (default: false)HOLD_OWNER- DID for captain record creation (optional)HOLD_ALLOW_ALL_CREW- Allow any authenticated user to register as crew (default: false)HOLD_DATABASE_PATH- Path for embedded PDS database (required)HOLD_DATABASE_KEY_PATH- Path for PDS signing keys (optional, generated if missing)
Deployment: Can run on Fly.io, Railway, Docker, Kubernetes, etc.
ATProto Storage Model#
Manifests are stored as records with this structure:
{
"$type": "io.atcr.manifest",
"repository": "myapp",
"digest": "sha256:abc123...",
"holdDid": "did:web:hold01.atcr.io",
"holdEndpoint": "https://hold1.atcr.io",
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": { "digest": "sha256:...", "size": 1234 },
"layers": [
{ "digest": "sha256:...", "size": 5678 }
],
"manifestBlob": {
"$type": "blob",
"ref": { "$link": "bafyrei..." },
"mimeType": "application/vnd.oci.image.manifest.v1+json",
"size": 1234
},
"createdAt": "2025-09-30T..."
}
Key fields:
holdDid- DID of the hold service where blobs are stored (PRIMARY reference, new)holdEndpoint- HTTP URL of hold service (DEPRECATED, kept for backward compatibility)manifestBlob- Reference to manifest blob in ATProto blob storage (CID format)
Record key = manifest digest (without algorithm prefix)
Collection = io.atcr.manifest
Sailor Profile System#
ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture:
- Sailors = Registry users
- Captains = Hold owners
- Crew = Hold members with access
- Holds = Storage endpoints (BYOS)
Profile Record (io.atcr.sailor.profile):
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:hold1.alice.com",
"createdAt": "2025-10-02T...",
"updatedAt": "2025-10-02T..."
}
Profile Management:
- Created automatically on first authentication (OAuth or Basic Auth)
defaultHoldcan be a DID (preferred, e.g.,did:web:hold01.atcr.io) or legacy URL- If AppView has
default_hold_didconfigured, profile gets that asdefaultHold - Users can update their profile to change default hold (future: via UI)
- Setting
defaultHoldto null opts out of defaults (use own holds or AppView default)
Hold Resolution Priority (in findHoldDID() in middleware):
- Profile's
defaultHold- User's explicit preference (DID or URL) - User's
io.atcr.holdrecords - User's own holds (legacy BYOS model) - AppView's
default_hold_did- Fallback default (configured in middleware)
This ensures:
- Users can join shared holds by setting their profile's
defaultHold - Users can opt out of defaults (set
defaultHoldto null) - URL structure remains
atcr.io/<owner>/<image>(ownership-based, not hold-based) - Hold choice is transparent infrastructure (like choosing an S3 region)
Key Design Decisions#
- No fork of distribution: Uses distribution as library, extends via middleware
- Hybrid storage: Manifests in ATProto (small), blobs in S3 or BYOS (cheap, scalable)
- Content addressing: Manifests stored by digest, blobs deduplicated globally
- ATProto-native: Manifests are first-class ATProto records, discoverable via AT Protocol
- OCI compliant: Fully compatible with Docker/containerd/podman
- Account-agnostic AppView: Server validates any user's token, queries their PDS for config
- BYOS architecture: Users can deploy their own storage service, AppView just routes
- OAuth with DPoP: Full ATProto OAuth implementation with mandatory DPoP proofs
- Sailor profile system: User preferences for hold selection, transparent to image ownership
- Historical hold references: Manifests store
holdEndpointfor immutable blob location tracking
Configuration#
AppView configuration (environment variables):
Both AppView and Hold service follow the same pattern: zero config files, all configuration via environment variables.
See .env.appview.example for all available options. Key environment variables:
Server:
ATCR_HTTP_ADDR- HTTP listen address (default::5000)ATCR_BASE_URL- Public URL for OAuth/JWT realm (auto-detected in dev)ATCR_DEFAULT_HOLD_DID- Default hold DID for blob storage (REQUIRED, e.g.,did:web:hold01.atcr.io)
Authentication:
ATCR_AUTH_KEY_PATH- JWT signing key path (default:/var/lib/atcr/auth/private-key.pem)ATCR_TOKEN_EXPIRATION- JWT expiration in seconds (default: 300)
UI:
ATCR_UI_ENABLED- Enable web interface (default: true)ATCR_UI_DATABASE_PATH- SQLite database path (default:/var/lib/atcr/ui.db)
Jetstream:
JETSTREAM_URL- ATProto event stream URLATCR_BACKFILL_ENABLED- Enable periodic sync (default: false)
Legacy: config/config.yml is still supported but deprecated. Use environment variables instead.
Hold Service configuration (environment variables):
See .env.hold.example for all available options. Key environment variables:
HOLD_PUBLIC_URL- Public URL of hold service (REQUIRED)STORAGE_DRIVER- Storage backend (s3, filesystem)AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY- S3 credentialsS3_BUCKET,S3_ENDPOINT- S3 configurationHOLD_PUBLIC- Allow public reads (default: false)HOLD_OWNER- DID for captain record creation (optional)HOLD_ALLOW_ALL_CREW- Allow any authenticated user to register as crew (default: false)
Credential Helper:
- Token storage:
~/.atcr/credential-helper-token.json(or Docker's credential store) - Contains: Registry JWT issued by AppView (NOT OAuth tokens)
- OAuth session managed entirely by AppView
Development Notes#
General:
- Middleware is in
pkg/appview/middleware/(auth.go, registry.go) - Storage routing is in
pkg/appview/storage/(routing_repository.go, proxy_blob_store.go) - Hold DID lookups use database queries (no in-memory caching)
- Storage drivers imported as
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" - Hold service reuses distribution's driver factory for multi-backend support
OAuth implementation:
- Client (
pkg/auth/oauth/client.go) encapsulates all OAuth configuration - Token validation via
com.atproto.server.getSessionensures no trust in client-provided identity - All ATCR components use standardized
/auth/oauth/callbackpath - Client ID generation (localhost query-based vs production metadata URL) handled internally
Testing Strategy#
When writing tests:
- Mock ATProto client for manifest operations
- Mock S3 driver for blob operations
- Test name resolution independently
- Integration tests require real PDS + S3
Common Tasks#
Adding a new ATProto record type:
- Define schema in
pkg/atproto/lexicon.go - Add collection constant (e.g.,
MyCollection = "io.atcr.my-type") - Add constructor function (e.g.,
NewMyRecord()) - Update client methods if needed
Modifying storage routing:
- Edit
pkg/appview/storage/routing_repository.go - Update
Blobs()method to change routing logic - Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.)
Changing name resolution:
- Modify
pkg/atproto/resolver.gofor DID/handle resolution - Update
pkg/appview/middleware/registry.goif changing routing logic - Remember:
findHoldDID()checks sailor profile, thenio.atcr.holdrecords (legacy), then default hold DID
Working with OAuth client:
- Client is self-contained: pass
baseURL, it handles client ID/redirect URI/scopes - For AppView server/refresher: use
NewClient(baseURL)orNewClientWithKey(baseURL, storedKey) - For custom scopes: call
client.SetScopes(customScopes)after initialization - Standard callback path:
/auth/oauth/callback(used by all ATCR components) - Client methods are consistent across authorization, token exchange, and refresh flows
Adding BYOS support for a user:
- User sets environment variables (storage credentials, public URL, HOLD_OWNER)
- User runs hold service - creates captain + crew records in embedded PDS
- Hold creates
io.atcr.hold.captain+io.atcr.hold.crewrecords - User sets sailor profile
defaultHoldto point to their hold - AppView automatically queries hold's PDS and routes blobs to user's storage
- No AppView changes needed - fully decentralized
Supporting a new storage backend:
- Ensure driver is registered in
cmd/hold/main.goimports - Distribution supports: S3, Azure, GCS, Swift, filesystem, OSS
- For custom drivers: implement
storagedriver.StorageDriverinterface - Add case to
buildStorageConfig()incmd/hold/main.go - Update
.env.examplewith new driver's env vars
Working with the database:
- Base schema defined in
pkg/appview/db/schema.sql- source of truth for fresh installations - Migrations in
pkg/appview/db/migrations/*.yaml- only for ALTER/UPDATE/DELETE on existing databases - Queries in
pkg/appview/db/queries.go - Stores for OAuth, devices, sessions in separate files
- Execution order: schema.sql first, then migrations (automatically on startup)
- Database path configurable via
ATCR_UI_DATABASE_PATHenv var - Adding new tables: Add to
schema.sqlonly (no migration needed) - Altering tables: Create migration AND update
schema.sqlto keep them in sync
Adding web UI features:
- Add handler in
pkg/appview/handlers/ - Register route in
cmd/appview/serve.go - Create template in
pkg/appview/templates/pages/ - Use existing auth middleware for protected routes
- API endpoints return JSON, pages return HTML
Important Context Values#
When working with the codebase, routing information is passed via the RegistryContext struct (pkg/appview/storage/context.go):
DID- User's DID (e.g.,did:plc:alice123)PDSEndpoint- User's PDS endpoint (e.g.,https://bsky.social)HoldDID- Hold service DID (e.g.,did:web:hold01.atcr.io)Repository- Image repository name (e.g.,myapp)ATProtoClient- Client for calling user's PDS with OAuth/Basic AuthRefresher- OAuth token refresher for service token requestsDatabase- Database for metrics trackingAuthorizer- Hold authorizer for access control
Legacy context keys (deprecated):
hold.did- Hold DID (now in RegistryContext)auth.did- Authenticated DID from validated token (now in auth middleware)
Documentation References#
- BYOS Architecture: See
docs/BYOS.mdfor complete BYOS documentation - OAuth Implementation: See
docs/OAUTH.mdfor OAuth/DPoP flow details - ATProto Spec: https://atproto.com/specs/oauth
- OCI Distribution Spec: https://github.com/opencontainers/distribution-spec
- DPoP RFC: https://datatracker.ietf.org/doc/html/rfc9449
- PAR RFC: https://datatracker.ietf.org/doc/html/rfc9126
- PKCE RFC: https://datatracker.ietf.org/doc/html/rfc7636