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

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.manifest records)
  • Blobs/Layers → S3 or user-deployed storage (large binary data)
  • Authentication → ATProto OAuth with DPoP + Docker credential helpers

Three-Component Architecture#

  1. 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
  2. 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
  3. 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:

  1. Handle → DID (via DNS/HTTPS)
  2. 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.hold records 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() and Blobs() 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_sessions table)
  • 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/):

  1. 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 scopes
      • ScopesMatch() - Compares scope lists (order-independent)
    • Session management (Refresher):
      • NewRefresher() - Creates session cache manager for AppView
      • Purpose: In-memory cache for *oauth.ClientSession objects (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)
  2. Keys (keys.go) - P-256 key management for confidential clients

    • GenerateOrLoadClientKey() - 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 hash
    • PrivateKeyToMultibase() - converts key for SetClientSecret() API
    • Key type: P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys)
  3. 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 ClientAuthStore interface
  4. Server (server.go) - OAuth authorization endpoints for AppView

    • GET /auth/oauth/authorize - starts OAuth flow
    • GET /auth/oauth/callback - handles OAuth callback
    • Uses ClientApp methods directly (no wrapper)
  5. 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
  • 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)

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.key with 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.com
  • ResolveHandle(): Uses .well-known/atproto-did
  • ResolvePDS(): Parses DID document for PDS endpoint

client.go: ATProto PDS client

  • PutRecord(): Store manifest as ATProto record
  • GetRecord(): Retrieve manifest from PDS
  • DeleteRecord(): Remove manifest
  • Uses XRPC protocol (com.atproto.repo.*)

lexicon.go: ATProto record schemas

  • ManifestRecord: OCI manifest stored as ATProto record (includes holdDid + holdEndpoint fields)
  • TagRecord: Tag pointing to manifest digest
  • HoldRecord: 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 with defaultHold preference (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 authentication
  • GetProfile(): Retrieves user's profile from PDS
  • UpdateProfile(): 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 holdDid from manifest (historical reference)
    • Push: Uses discovery-based DID from findHoldDID() in middleware
    • Always returns ProxyBlobStore (routes to hold service via DID)
  • Implements distribution.Repository interface
  • Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc.

Database-based hold DID lookups:

  • Queries SQLite manifests table 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) in pkg/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.BlobStore interface
  • 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 page
  • repository.go - Repository detail pages
  • search.go - Search functionality
  • auth.go - OAuth login/logout for web
  • settings.go - User settings management
  • api.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.crew records 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 file
  • GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev} - Download repository diff since revision
  • GET /xrpc/com.atproto.sync.getRepoStatus?did={did} - Get repository hosting status and current revision
  • GET /xrpc/com.atproto.sync.subscribeRepos - WebSocket firehose for real-time events
  • GET /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 metadata
  • GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key} - Get record
  • GET /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 session
  • POST /xrpc/io.atcr.hold.getPartUploadUrl - Get presigned URL for uploading a part
  • PUT /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 location
  • POST /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 credentials
  • S3_BUCKET, S3_ENDPOINT - S3 configuration
  • HOLD_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)
  • defaultHold can be a DID (preferred, e.g., did:web:hold01.atcr.io) or legacy URL
  • If AppView has default_hold_did configured, profile gets that as defaultHold
  • Users can update their profile to change default hold (future: via UI)
  • Setting defaultHold to null opts out of defaults (use own holds or AppView default)

Hold Resolution Priority (in findHoldDID() in middleware):

  1. Profile's defaultHold - User's explicit preference (DID or URL)
  2. User's io.atcr.hold records - User's own holds (legacy BYOS model)
  3. 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 defaultHold to 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#

  1. No fork of distribution: Uses distribution as library, extends via middleware
  2. Hybrid storage: Manifests in ATProto (small), blobs in S3 or BYOS (cheap, scalable)
  3. Content addressing: Manifests stored by digest, blobs deduplicated globally
  4. ATProto-native: Manifests are first-class ATProto records, discoverable via AT Protocol
  5. OCI compliant: Fully compatible with Docker/containerd/podman
  6. Account-agnostic AppView: Server validates any user's token, queries their PDS for config
  7. BYOS architecture: Users can deploy their own storage service, AppView just routes
  8. OAuth with DPoP: Full ATProto OAuth implementation with mandatory DPoP proofs
  9. Sailor profile system: User preferences for hold selection, transparent to image ownership
  10. Historical hold references: Manifests store holdEndpoint for 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 URL
  • ATCR_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 credentials
  • S3_BUCKET, S3_ENDPOINT - S3 configuration
  • HOLD_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.getSession ensures no trust in client-provided identity
  • All ATCR components use standardized /auth/oauth/callback path
  • 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:

  1. Define schema in pkg/atproto/lexicon.go
  2. Add collection constant (e.g., MyCollection = "io.atcr.my-type")
  3. Add constructor function (e.g., NewMyRecord())
  4. Update client methods if needed

Modifying storage routing:

  1. Edit pkg/appview/storage/routing_repository.go
  2. Update Blobs() method to change routing logic
  3. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.)

Changing name resolution:

  1. Modify pkg/atproto/resolver.go for DID/handle resolution
  2. Update pkg/appview/middleware/registry.go if changing routing logic
  3. Remember: findHoldDID() checks sailor profile, then io.atcr.hold records (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) or NewClientWithKey(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:

  1. User sets environment variables (storage credentials, public URL, HOLD_OWNER)
  2. User runs hold service - creates captain + crew records in embedded PDS
  3. Hold creates io.atcr.hold.captain + io.atcr.hold.crew records
  4. User sets sailor profile defaultHold to point to their hold
  5. AppView automatically queries hold's PDS and routes blobs to user's storage
  6. No AppView changes needed - fully decentralized

Supporting a new storage backend:

  1. Ensure driver is registered in cmd/hold/main.go imports
  2. Distribution supports: S3, Azure, GCS, Swift, filesystem, OSS
  3. For custom drivers: implement storagedriver.StorageDriver interface
  4. Add case to buildStorageConfig() in cmd/hold/main.go
  5. Update .env.example with 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_PATH env var
  • Adding new tables: Add to schema.sql only (no migration needed)
  • Altering tables: Create migration AND update schema.sql to 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 Auth
  • Refresher - OAuth token refresher for service token requests
  • Database - Database for metrics tracking
  • Authorizer - 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#