···11+# ATCR Hold Service Configuration
22+# Copy this file to .env and fill in your values
33+44+# ==============================================================================
55+# Required Configuration
66+# ==============================================================================
77+88+# Hold service public URL (REQUIRED)
99+# The hostname becomes the hold name/record key
1010+# Examples: https://hold1.atcr.io, http://127.0.0.1:8080
1111+HOLD_PUBLIC_URL=http://127.0.0.1:8080
1212+1313+# ==============================================================================
1414+# Storage Configuration
1515+# ==============================================================================
1616+1717+# Storage driver type (s3, filesystem)
1818+# Default: s3
1919+STORAGE_DRIVER=s3
2020+2121+# For S3/Storj/Minio:
2222+AWS_ACCESS_KEY_ID=your_access_key
2323+AWS_SECRET_ACCESS_KEY=your_secret_key
2424+AWS_REGION=us-east-1
2525+S3_BUCKET=atcr-blobs
2626+2727+# For Storj/Minio (optional - custom S3 endpoint):
2828+# S3_ENDPOINT=https://gateway.storjshare.io
2929+3030+# For filesystem driver:
3131+# STORAGE_DRIVER=filesystem
3232+# STORAGE_ROOT_DIR=/var/lib/atcr/hold
3333+3434+# ==============================================================================
3535+# Server Configuration
3636+# ==============================================================================
3737+3838+# Server listen address (default: :8080)
3939+# HOLD_SERVER_ADDR=:8080
4040+4141+# Allow public blob reads (pulls) without authentication
4242+# Writes (pushes) always require crew membership via PDS
4343+# Default: false
4444+HOLD_PUBLIC=false
4545+4646+# ==============================================================================
4747+# Registration (REQUIRED)
4848+# ==============================================================================
4949+5050+# Your ATProto DID (REQUIRED for registration)
5151+# Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social
5252+#
5353+# On first run with HOLD_CREW_OWNER set:
5454+# 1. Hold service will print an OAuth URL to the logs
5555+# 2. Visit the URL in your browser to authorize
5656+# 3. Hold service creates hold + crew records in your PDS
5757+# 4. Registration complete!
5858+#
5959+# On subsequent runs:
6060+# - Hold service checks if already registered
6161+# - Skips OAuth if records exist
6262+#
6363+HOLD_CREW_OWNER=did:plc:your-did-here
+21
.gitignore
···11+# Binaries
22+bin/
33+44+# Test artifacts
55+.atcr-pids
66+77+# OAuth tokens
88+.atcr/
99+1010+# Environment configuration
1111+.env
1212+1313+# IDE
1414+.vscode/
1515+.idea/
1616+*.swp
1717+*.swo
1818+1919+# OS
2020+.DS_Store
2121+Thumbs.db
+459
CLAUDE.md
···11+# CLAUDE.md
22+33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44+55+## Project Overview
66+77+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.
88+99+## Build Commands
1010+1111+```bash
1212+# Build all binaries
1313+go build -o atcr-registry ./cmd/registry
1414+go build -o atcr-hold ./cmd/hold
1515+go build -o docker-credential-atcr ./cmd/credential-helper
1616+1717+# Run tests
1818+go test ./...
1919+2020+# Run with race detector
2121+go test -race ./...
2222+2323+# Update dependencies
2424+go mod tidy
2525+2626+# Build Docker images
2727+docker build -t atcr.io/registry:latest .
2828+docker build -f Dockerfile.hold -t atcr.io/hold:latest .
2929+3030+# Or use docker-compose
3131+docker-compose up -d
3232+3333+# Run locally (AppView)
3434+export ATPROTO_DID=did:plc:your-did
3535+export ATPROTO_ACCESS_TOKEN=your-token
3636+./atcr-registry serve config/config.yml
3737+3838+# Run hold service (configure via env vars - see .env.example)
3939+export HOLD_PUBLIC_URL=http://127.0.0.1:8080
4040+export STORAGE_DRIVER=filesystem
4141+export STORAGE_ROOT_DIR=/tmp/atcr-hold
4242+export HOLD_CREW_OWNER=did:plc:your-did-here
4343+./atcr-hold
4444+# Check logs for OAuth URL, visit in browser to complete registration
4545+```
4646+4747+## Architecture Overview
4848+4949+### Core Design
5050+5151+ATCR uses **distribution/distribution** as a library and extends it through middleware to route different types of content to different storage backends:
5252+5353+- **Manifests** → ATProto PDS (small JSON metadata, stored as `io.atcr.manifest` records)
5454+- **Blobs/Layers** → S3 or user-deployed storage (large binary data)
5555+- **Authentication** → ATProto OAuth with DPoP + Docker credential helpers
5656+5757+### Three-Component Architecture
5858+5959+1. **AppView** (`cmd/registry`) - OCI Distribution API server
6060+ - Resolves identities (handle/DID → PDS endpoint)
6161+ - Routes manifests to user's PDS
6262+ - Routes blobs to storage endpoint (default or BYOS)
6363+ - Validates OAuth tokens via PDS
6464+ - Issues registry JWTs
6565+6666+2. **Hold Service** (`cmd/hold`) - Optional BYOS component
6767+ - Lightweight HTTP server for presigned URLs
6868+ - Supports S3, Storj, Minio, filesystem, etc.
6969+ - Authorization based on PDS records (hold.public, crew records)
7070+ - Auto-registration via OAuth
7171+ - Configured entirely via environment variables
7272+7373+3. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth
7474+ - Implements Docker credential helper protocol
7575+ - ATProto OAuth flow with DPoP
7676+ - Token caching and refresh
7777+ - Exchanges OAuth token for registry JWT
7878+7979+### Request Flow
8080+8181+#### Push with Default Storage
8282+```
8383+1. Client: docker push atcr.io/alice/myapp:latest
8484+2. HTTP Request → /v2/alice/myapp/manifests/latest
8585+3. Registry Middleware (pkg/middleware/registry.go)
8686+ → Resolves "alice" to DID and PDS endpoint
8787+ → Queries alice's sailor profile for defaultHold
8888+ → If not set, checks alice's io.atcr.hold records
8989+ → Falls back to AppView's default_storage_endpoint
9090+ → Stores DID/PDS/storage endpoint in context
9191+4. Repository Middleware (pkg/middleware/repository.go)
9292+ → Creates RoutingRepository
9393+ → Returns ATProto ManifestStore for manifests
9494+ → Returns ProxyBlobStore for blobs
9595+5. Blob PUT → Resolved hold service (redirects to S3/storage)
9696+6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdEndpoint)
9797+```
9898+9999+#### Push with BYOS (Bring Your Own Storage)
100100+```
101101+1. Client: docker push atcr.io/alice/myapp:latest
102102+2. Registry Middleware resolves alice → did:plc:alice123
103103+3. Hold discovery via findStorageEndpoint():
104104+ a. Check alice's sailor profile for defaultHold
105105+ b. If not set, check alice's io.atcr.hold records
106106+ c. Fall back to AppView's default_storage_endpoint
107107+4. Found: alice's profile has defaultHold = "https://alice-storage.fly.dev"
108108+5. Routing Repository returns ProxyBlobStore(alice-storage.fly.dev)
109109+6. ProxyBlobStore calls alice-storage.fly.dev for presigned URL
110110+7. Storage service validates alice's DID, generates S3 presigned URL
111111+8. Client redirected to upload blob directly to alice's S3/Storj
112112+9. Manifest stored in alice's PDS with holdEndpoint = "https://alice-storage.fly.dev"
113113+```
114114+115115+#### Pull Flow
116116+```
117117+1. Client: docker pull atcr.io/alice/myapp:latest
118118+2. GET /v2/alice/myapp/manifests/latest
119119+3. AppView fetches manifest from alice's PDS
120120+4. Manifest contains holdEndpoint = "https://alice-storage.fly.dev"
121121+5. Hold endpoint cached: (alice's DID, "myapp") → "https://alice-storage.fly.dev"
122122+6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123
123123+7. AppView checks cache, routes to hold from manifest (not re-discovered)
124124+8. ProxyBlobStore calls alice-storage.fly.dev for presigned download URL
125125+9. Client redirected to download blob directly from alice's S3
126126+```
127127+128128+**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold.
129129+130130+### Name Resolution
131131+132132+Names follow the pattern: `atcr.io/<identity>/<image>:<tag>`
133133+134134+Where `<identity>` can be:
135135+- **Handle**: `alice.bsky.social` → resolved via .well-known/atproto-did
136136+- **DID**: `did:plc:xyz123` → resolved via PLC directory
137137+138138+Resolution happens in `pkg/atproto/resolver.go`:
139139+1. Handle → DID (via DNS/HTTPS)
140140+2. DID → PDS endpoint (via DID document)
141141+142142+### Middleware System
143143+144144+ATCR uses two levels of middleware:
145145+146146+#### 1. Registry Middleware (`pkg/middleware/registry.go`)
147147+- Wraps `distribution.Namespace`
148148+- Intercepts `Repository(name)` calls
149149+- Performs name resolution (alice → did:plc:xyz → pds.example.com)
150150+- Queries PDS for `io.atcr.hold` records to find storage endpoint
151151+- Stores resolved identity and storage endpoint in context
152152+153153+#### 2. Repository Middleware (`pkg/middleware/repository.go`)
154154+- Wraps `distribution.Repository`
155155+- Returns custom `Manifests()` and `Blobs()` implementations
156156+- Routes manifests to ATProto, blobs to S3 or BYOS
157157+158158+### Authentication Architecture
159159+160160+#### ATProto OAuth with DPoP
161161+162162+ATCR implements the full ATProto OAuth specification with mandatory security features:
163163+164164+**Required Components:**
165165+- **DPoP** (RFC 9449) - Cryptographic proof-of-possession for every request
166166+- **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange
167167+- **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception
168168+169169+**Key Components** (`pkg/auth/`):
170170+171171+1. **OAuth Client** (`oauth/client.go`) - Handles authorization flow with DPoP
172172+2. **DPoP Transport** (`oauth/transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
173173+3. **Token Storage** (`oauth/storage.go`) - Persists tokens and DPoP key in `~/.atcr/oauth-token.json`
174174+4. **Token Validator** (`atproto/validator.go`) - Validates tokens via PDS `getSession` endpoint
175175+5. **Exchange Handler** (`exchange/handler.go`) - Exchanges OAuth tokens for registry JWTs
176176+177177+**Authentication Flow:**
178178+```
179179+1. User runs: docker-credential-atcr configure
180180+2. Helper generates ECDSA P-256 DPoP key
181181+3. Resolve handle → DID → PDS endpoint
182182+4. Discover OAuth server metadata from PDS
183183+5. PAR request with DPoP header → get request_uri
184184+6. Open browser for user authorization
185185+7. Exchange code for token with DPoP proof
186186+8. Save: access token, refresh token, DPoP key, DID, handle
187187+188188+Later (docker push):
189189+9. Docker calls credential helper
190190+10. Helper loads token, refreshes if needed
191191+11. Helper calls /auth/exchange with OAuth token + handle
192192+12. AppView validates token via PDS getSession
193193+13. AppView ensures sailor profile exists (creates with defaultHold if first login)
194194+14. AppView issues registry JWT with validated DID
195195+15. Helper returns JWT to Docker
196196+```
197197+198198+**Security:**
199199+- Tokens validated against authoritative source (user's PDS)
200200+- No trust in client-provided identity information
201201+- DPoP binds tokens to specific client key
202202+- 15-minute token expiry for registry JWTs
203203+204204+### Key Components
205205+206206+#### ATProto Integration (`pkg/atproto/`)
207207+208208+**resolver.go**: DID and handle resolution
209209+- `ResolveIdentity()`: alice → did:plc:xyz → pds.example.com
210210+- `ResolveHandle()`: Uses .well-known/atproto-did
211211+- `ResolvePDS()`: Parses DID document for PDS endpoint
212212+213213+**client.go**: ATProto PDS client
214214+- `PutRecord()`: Store manifest as ATProto record
215215+- `GetRecord()`: Retrieve manifest from PDS
216216+- `DeleteRecord()`: Remove manifest
217217+- Uses XRPC protocol (com.atproto.repo.*)
218218+219219+**lexicon.go**: ATProto record schemas
220220+- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdEndpoint` field)
221221+- `TagRecord`: Tag pointing to manifest digest
222222+- `HoldRecord`: Storage hold definition (for BYOS)
223223+- `HoldCrewRecord`: Hold crew membership/permissions
224224+- `SailorProfileRecord`: User profile with `defaultHold` preference
225225+- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold`, `io.atcr.hold.crew`, `io.atcr.sailor.profile`
226226+227227+**profile.go**: Sailor profile management
228228+- `EnsureProfile()`: Creates profile with default hold on first authentication
229229+- `GetProfile()`: Retrieves user's profile from PDS
230230+- `UpdateProfile()`: Updates user's profile
231231+232232+**manifest_store.go**: Implements `distribution.ManifestService`
233233+- Stores OCI manifests as ATProto records
234234+- Digest-based addressing (sha256:abc123 → record key)
235235+- Converts between OCI and ATProto formats
236236+237237+#### Storage Layer (`pkg/storage/`)
238238+239239+**routing_repository.go**: Routes content by type
240240+- `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction)
241241+- `Blobs()` → checks hold cache for pull, uses discovery for push
242242+ - Pull: Uses cached `holdEndpoint` from manifest (historical reference)
243243+ - Push: Uses discovery-based endpoint from `findStorageEndpoint()`
244244+ - Always returns ProxyBlobStore (routes to hold service)
245245+- Implements `distribution.Repository` interface
246246+247247+**hold_cache.go**: In-memory hold endpoint cache
248248+- Caches `(DID, repository) → holdEndpoint` for pull operations
249249+- TTL: 10 minutes (covers typical pull operations)
250250+- Cleanup: Background goroutine runs every 5 minutes
251251+- **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar
252252+- Prevents expensive ATProto lookups on every blob request
253253+254254+**s3_blob_store.go**: S3 blob storage wrapper
255255+- Wraps distribution's built-in S3 driver
256256+- Inherits full `distribution.BlobStore` interface
257257+- Used for default shared storage
258258+259259+**proxy_blob_store.go**: External storage proxy
260260+- Calls user's storage service for presigned URLs
261261+- Issues HTTP redirects for blob uploads/downloads
262262+- Implements full `distribution.BlobStore` interface
263263+- Used when user has `io.atcr.hold` record
264264+265265+#### Hold Service (`cmd/hold/`)
266266+267267+Lightweight standalone service for BYOS (Bring Your Own Storage):
268268+269269+**Architecture:**
270270+- Reuses distribution's storage driver factory
271271+- Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem
272272+- Authorization based on PDS records (hold.public field, crew records)
273273+- Generates presigned URLs (15min expiry) or proxies uploads/downloads
274274+275275+**Endpoints:**
276276+- `POST /get-presigned-url` - Get download URL for blob
277277+- `POST /put-presigned-url` - Get upload URL for blob
278278+- `GET /blobs/{digest}` - Proxy download (fallback if no presigned URL support)
279279+- `PUT /blobs/{digest}` - Proxy upload (fallback)
280280+- `POST /register` - Manual registration endpoint
281281+- `GET /health` - Health check
282282+283283+**Configuration:** Environment variables (see `.env.example`)
284284+- `HOLD_PUBLIC_URL` - Public URL of hold service (required)
285285+- `STORAGE_DRIVER` - Storage driver type (s3, filesystem)
286286+- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
287287+- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
288288+- `HOLD_PUBLIC` - Allow public reads (default: false)
289289+- `HOLD_CREW_OWNER` - DID for auto-registration (optional)
290290+291291+**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc.
292292+293293+### ATProto Storage Model
294294+295295+Manifests are stored as records with this structure:
296296+```json
297297+{
298298+ "$type": "io.atcr.manifest",
299299+ "repository": "myapp",
300300+ "digest": "sha256:abc123...",
301301+ "holdEndpoint": "https://hold1.alice.com",
302302+ "schemaVersion": 2,
303303+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
304304+ "config": { "digest": "sha256:...", "size": 1234 },
305305+ "layers": [
306306+ { "digest": "sha256:...", "size": 5678 }
307307+ ],
308308+ "createdAt": "2025-09-30T..."
309309+}
310310+```
311311+312312+Record key = manifest digest (without algorithm prefix)
313313+Collection = `io.atcr.manifest`
314314+315315+### Sailor Profile System
316316+317317+ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture:
318318+- **Sailors** = Registry users
319319+- **Captains** = Hold owners
320320+- **Crew** = Hold members with access
321321+- **Holds** = Storage endpoints (BYOS)
322322+323323+**Profile Record** (`io.atcr.sailor.profile`):
324324+```json
325325+{
326326+ "$type": "io.atcr.sailor.profile",
327327+ "defaultHold": "https://hold1.alice.com",
328328+ "createdAt": "2025-10-02T...",
329329+ "updatedAt": "2025-10-02T..."
330330+}
331331+```
332332+333333+**Profile Management:**
334334+- Created automatically on first authentication (OAuth or Basic Auth)
335335+- If AppView has `default_storage_endpoint` configured, profile gets that as `defaultHold`
336336+- Users can update their profile to change default hold (future: via UI)
337337+- Setting `defaultHold` to null opts out of defaults (use own holds or AppView default)
338338+339339+**Hold Resolution Priority** (in `findStorageEndpoint()`):
340340+1. **Profile's `defaultHold`** - User's explicit preference
341341+2. **User's `io.atcr.hold` records** - User's own holds
342342+3. **AppView's `default_storage_endpoint`** - Fallback default
343343+344344+This ensures:
345345+- Users can join shared holds by setting their profile's `defaultHold`
346346+- Users can opt out of defaults (set `defaultHold` to null)
347347+- URL structure remains `atcr.io/<owner>/<image>` (ownership-based, not hold-based)
348348+- Hold choice is transparent infrastructure (like choosing an S3 region)
349349+350350+### Key Design Decisions
351351+352352+1. **No fork of distribution**: Uses distribution as library, extends via middleware
353353+2. **Hybrid storage**: Manifests in ATProto (small, federated), blobs in S3 or BYOS (cheap, scalable)
354354+3. **Content addressing**: Manifests stored by digest, blobs deduplicated globally
355355+4. **ATProto-native**: Manifests are first-class ATProto records, discoverable via AT Protocol
356356+5. **OCI compliant**: Fully compatible with Docker/containerd/podman
357357+6. **Account-agnostic AppView**: Server validates any user's token, queries their PDS for config
358358+7. **BYOS architecture**: Users can deploy their own storage service, AppView just routes
359359+8. **OAuth with DPoP**: Full ATProto OAuth implementation with mandatory DPoP proofs
360360+9. **Sailor profile system**: User preferences for hold selection, transparent to image ownership
361361+10. **Historical hold references**: Manifests store `holdEndpoint` for immutable blob location tracking
362362+363363+### Configuration
364364+365365+**AppView configuration** (`config/config.yml`):
366366+- S3 bucket settings under `storage.s3`
367367+- ATProto middleware under `middleware.repository`
368368+- Name resolver under `middleware.registry`
369369+- Default storage endpoint: `middleware.registry.options.default_storage_endpoint`
370370+- Auth token signing keys and expiration
371371+372372+**Hold Service configuration** (environment variables):
373373+- Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*`
374374+- Authorization: Based on PDS records (`hold.public`, crew records)
375375+- Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC`
376376+- Auto-registration: `HOLD_CREW_OWNER` (optional)
377377+378378+**Credential Helper**:
379379+- Token storage: `~/.atcr/oauth-token.json`
380380+- Contains: access token, refresh token, DPoP key (PEM), DID, handle
381381+382382+Environment variables:
383383+- `ATPROTO_DID`: DID for authentication with PDS (AppView only)
384384+- `ATPROTO_ACCESS_TOKEN`: Access token for PDS operations (AppView only)
385385+386386+### Development Notes
387387+388388+- Middleware is registered via `init()` functions in `pkg/middleware/`
389389+- Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware
390390+- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"`
391391+- Storage service reuses distribution's driver factory for multi-backend support
392392+- OAuth client uses `authelia.com/client/oauth2` for PAR support
393393+- DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK)
394394+- Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity
395395+396396+### Testing Strategy
397397+398398+When writing tests:
399399+- Mock ATProto client for manifest operations
400400+- Mock S3 driver for blob operations
401401+- Test name resolution independently
402402+- Integration tests require real PDS + S3
403403+404404+### Common Tasks
405405+406406+**Adding a new ATProto record type**:
407407+1. Define schema in `pkg/atproto/lexicon.go`
408408+2. Add collection constant (e.g., `MyCollection = "io.atcr.my-type"`)
409409+3. Add constructor function (e.g., `NewMyRecord()`)
410410+4. Update client methods if needed
411411+412412+**Modifying storage routing**:
413413+1. Edit `pkg/storage/routing_repository.go`
414414+2. Update `Blobs()` method to change routing logic
415415+3. Consider context values: `storage.endpoint`, `atproto.did`
416416+417417+**Changing name resolution**:
418418+1. Modify `pkg/atproto/resolver.go` for DID/handle resolution
419419+2. Update `pkg/middleware/registry.go` if changing routing logic
420420+3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
421421+422422+**Implementing OAuth authentication**:
423423+- AppView: `pkg/auth/exchange/handler.go` - validates tokens via PDS getSession
424424+- Client: `pkg/auth/oauth/client.go` - OAuth + DPoP flow
425425+- Helper: `cmd/credential-helper/` - Docker credential protocol
426426+427427+**Adding BYOS support for a user**:
428428+1. User sets environment variables (storage credentials, public URL)
429429+2. User runs hold service with `HOLD_CREW_OWNER` set - auto-registration via OAuth
430430+3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS
431431+4. AppView automatically queries PDS and routes blobs to user's storage
432432+5. No AppView changes needed - fully decentralized
433433+434434+**Supporting a new storage backend**:
435435+1. Ensure driver is registered in `cmd/hold/main.go` imports
436436+2. Distribution supports: S3, Azure, GCS, Swift, filesystem, OSS
437437+3. For custom drivers: implement `storagedriver.StorageDriver` interface
438438+4. Add case to `buildStorageConfig()` in `cmd/hold/main.go`
439439+5. Update `.env.example` with new driver's env vars
440440+441441+## Important Context Values
442442+443443+When working with the codebase, these context values are used for routing:
444444+445445+- `atproto.did` - Resolved DID for the user (e.g., `did:plc:alice123`)
446446+- `atproto.pds` - User's PDS endpoint (e.g., `https://bsky.social`)
447447+- `atproto.identity` - Original identity string (handle or DID)
448448+- `storage.endpoint` - Storage service URL (if user has `io.atcr.registry` record)
449449+- `auth.did` - Authenticated DID from validated token
450450+451451+## Documentation References
452452+453453+- **BYOS Architecture**: See `docs/BYOS.md` for complete BYOS documentation
454454+- **OAuth Implementation**: See `docs/OAUTH.md` for OAuth/DPoP flow details
455455+- **ATProto Spec**: https://atproto.com/specs/oauth
456456+- **OCI Distribution Spec**: https://github.com/opencontainers/distribution-spec
457457+- **DPoP RFC**: https://datatracker.ietf.org/doc/html/rfc9449
458458+- **PAR RFC**: https://datatracker.ietf.org/doc/html/rfc9126
459459+- **PKCE RFC**: https://datatracker.ietf.org/doc/html/rfc7636
+48
Dockerfile
···11+# Build stage
22+FROM golang:1.24-alpine AS builder
33+44+# Install build dependencies
55+RUN apk add --no-cache git make
66+77+# Set working directory
88+WORKDIR /build
99+1010+# Copy go mod files
1111+COPY go.mod go.sum ./
1212+1313+# Download dependencies
1414+RUN go mod download
1515+1616+# Copy source code
1717+COPY . .
1818+1919+# Build the binary
2020+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o atcr-registry ./cmd/registry
2121+2222+# Runtime stage
2323+FROM alpine:latest
2424+2525+# Install CA certificates for HTTPS
2626+RUN apk --no-cache add ca-certificates
2727+2828+# Set working directory
2929+WORKDIR /app
3030+3131+# Copy binary from builder
3232+COPY --from=builder /build/atcr-registry .
3333+3434+# Copy default configuration
3535+COPY config/config.yml /etc/atcr/config.yml
3636+3737+# Create directories for storage
3838+RUN mkdir -p /var/lib/atcr/blobs /var/lib/atcr/auth
3939+4040+# Expose ports
4141+EXPOSE 5000 5001
4242+4343+# Set environment variables
4444+ENV ATCR_CONFIG=/etc/atcr/config.yml
4545+4646+# Run the registry
4747+ENTRYPOINT ["/app/atcr-registry"]
4848+CMD ["serve", "/etc/atcr/config.yml"]
+37
Dockerfile.hold
···11+# Build stage
22+FROM golang:1.24-alpine AS builder
33+44+WORKDIR /app
55+66+# Copy go mod files
77+COPY go.mod go.sum ./
88+RUN go mod download
99+1010+# Copy source code
1111+COPY . .
1212+1313+# Build the hold service
1414+RUN CGO_ENABLED=0 GOOS=linux go build -o atcr-hold ./cmd/hold
1515+1616+# Runtime stage
1717+FROM alpine:latest
1818+1919+RUN apk --no-cache add ca-certificates
2020+2121+WORKDIR /root/
2222+2323+# Copy binary from builder
2424+COPY --from=builder /app/atcr-hold .
2525+2626+# Copy default config
2727+COPY config/hold.yml /etc/atcr/hold.yml
2828+2929+# Create directories for storage
3030+RUN mkdir -p /var/lib/atcr/hold
3131+3232+# Expose default port
3333+EXPOSE 8080
3434+3535+# Run the hold service
3636+ENTRYPOINT ["./atcr-hold"]
3737+CMD ["/etc/atcr/hold.yml"]
+244
README.md
···11+# ATCR - ATProto Container Registry
22+33+A container registry that uses the AT Protocol (ATProto) for manifest storage and S3 for blob storage.
44+55+## Overview
66+77+ATCR is an OCI-compliant container registry that integrates with the AT Protocol ecosystem. It stores container image manifests as ATProto records in Personal Data Servers (PDS) while keeping the actual image layers in S3-compatible storage.
88+99+### Architecture
1010+1111+- **Manifests**: Stored as ATProto records in user PDSs (small JSON metadata)
1212+- **Blobs/Layers**: Stored in S3 (large binary data)
1313+- **Name Resolution**: Supports both ATProto handles and DIDs
1414+ - `atcr.io/alice.bsky.social/myimage:latest`
1515+ - `atcr.io/did:plc:xyz123/myimage:latest`
1616+1717+## Features
1818+1919+- OCI Distribution Spec compliant
2020+- ATProto-native manifest storage
2121+- S3 blob storage for container layers
2222+- DID/handle resolution
2323+- Decentralized manifest ownership
2424+2525+## Building
2626+2727+```bash
2828+# Build all binaries locally
2929+go build -o atcr-registry ./cmd/registry
3030+go build -o atcr-hold ./cmd/hold
3131+go build -o docker-credential-atcr ./cmd/credential-helper
3232+3333+# Build Docker images
3434+docker build -t atcr.io/registry:latest .
3535+docker build -f Dockerfile.hold -t atcr.io/hold:latest .
3636+```
3737+3838+## Quick Start (Local Testing)
3939+4040+**Automated setup:**
4141+```bash
4242+# Run the test script (handles everything)
4343+./test-local.sh
4444+```
4545+4646+The script will:
4747+1. Create necessary directories (`/var/lib/atcr/*`)
4848+2. Build all binaries
4949+3. Start registry and hold service
5050+4. Show you how to test
5151+5252+**Manual setup:**
5353+```bash
5454+# 1. Create directories
5555+sudo mkdir -p /var/lib/atcr/{blobs,hold,auth}
5656+sudo chown -R $USER:$USER /var/lib/atcr
5757+5858+# 2. Build binaries
5959+go build -o atcr-registry ./cmd/registry
6060+go build -o atcr-hold ./cmd/hold
6161+6262+# 3. Configure environment
6363+cp .env.example .env
6464+# Edit .env - set ATPROTO_HANDLE and HOLD_PUBLIC_URL
6565+export $(cat .env | xargs)
6666+6767+# 4. Start services
6868+# Terminal 1:
6969+./atcr-registry serve config/config.yml
7070+# Terminal 2 (will prompt for OAuth):
7171+./atcr-hold config/hold.yml
7272+# Follow OAuth URL in logs to authorize
7373+7474+# 5. Test with Docker
7575+docker tag alpine:latest localhost:5000/alice/alpine:test
7676+docker push localhost:5000/alice/alpine:test
7777+docker pull localhost:5000/alice/alpine:test
7878+```
7979+8080+## Running
8181+8282+### Local Development
8383+8484+**Configure environment:**
8585+```bash
8686+# Copy and edit .env file
8787+cp .env.example .env
8888+# Edit .env with:
8989+# - ATPROTO_HANDLE (your Bluesky handle)
9090+# - HOLD_PUBLIC_URL (e.g., http://127.0.0.1:8080 or https://hold1.atcr.io)
9191+# - HOLD_AUTO_REGISTER=true
9292+9393+# Load environment
9494+export $(cat .env | xargs)
9595+```
9696+9797+**AppView (Registry):**
9898+```bash
9999+./atcr-registry serve config/config.yml
100100+```
101101+102102+**Hold (Storage Service):**
103103+```bash
104104+# Starts OAuth flow to register in your PDS
105105+./atcr-hold config/hold.yml
106106+# Follow the OAuth URL in the logs to authorize
107107+```
108108+109109+### Docker
110110+111111+**Run with Docker Compose:**
112112+```bash
113113+docker-compose up -d
114114+```
115115+116116+**Or run containers separately:**
117117+118118+**AppView (Registry):**
119119+```bash
120120+docker run -d \
121121+ --name atcr-registry \
122122+ -p 5000:5000 \
123123+ -e ATPROTO_DID=did:plc:your-did \
124124+ -e ATPROTO_ACCESS_TOKEN=your-access-token \
125125+ -e AWS_ACCESS_KEY_ID=your-aws-key \
126126+ -e AWS_SECRET_ACCESS_KEY=your-aws-secret \
127127+ -v $(pwd)/config/config.yml:/etc/atcr/config.yml \
128128+ atcr.io/registry:latest
129129+```
130130+131131+**Hold (Storage Service):**
132132+```bash
133133+docker run -d \
134134+ --name atcr-hold \
135135+ -p 8080:8080 \
136136+ -e AWS_ACCESS_KEY_ID=your-aws-key \
137137+ -e AWS_SECRET_ACCESS_KEY=your-aws-secret \
138138+ -v $(pwd)/config/hold.yml:/etc/atcr/hold.yml \
139139+ atcr.io/hold:latest
140140+```
141141+142142+### Kubernetes
143143+144144+```yaml
145145+apiVersion: apps/v1
146146+kind: Deployment
147147+metadata:
148148+ name: atcr-registry
149149+spec:
150150+ replicas: 3
151151+ selector:
152152+ matchLabels:
153153+ app: atcr-registry
154154+ template:
155155+ metadata:
156156+ labels:
157157+ app: atcr-registry
158158+ spec:
159159+ containers:
160160+ - name: registry
161161+ image: atcr.io/registry:latest
162162+ ports:
163163+ - containerPort: 5000
164164+ env:
165165+ - name: ATPROTO_DID
166166+ valueFrom:
167167+ secretKeyRef:
168168+ name: atcr-secrets
169169+ key: did
170170+ - name: ATPROTO_ACCESS_TOKEN
171171+ valueFrom:
172172+ secretKeyRef:
173173+ name: atcr-secrets
174174+ key: access-token
175175+ volumeMounts:
176176+ - name: config
177177+ mountPath: /etc/atcr
178178+ volumes:
179179+ - name: config
180180+ configMap:
181181+ name: atcr-config
182182+```
183183+184184+## Configuration
185185+186186+See `config/config.yml` for full configuration options.
187187+188188+Key settings:
189189+- **storage.s3**: S3 bucket configuration for blob storage
190190+- **middleware.repository**: ATProto routing middleware
191191+- **middleware.registry**: Name resolution middleware
192192+193193+## Usage
194194+195195+### Pushing an Image
196196+197197+```bash
198198+# Tag your image
199199+docker tag myapp:latest atcr.io/alice/myapp:latest
200200+201201+# Push to ATCR
202202+docker push atcr.io/alice/myapp:latest
203203+```
204204+205205+### Pulling an Image
206206+207207+```bash
208208+# Pull from ATCR
209209+docker pull atcr.io/alice/myapp:latest
210210+```
211211+212212+## Development
213213+214214+### Project Structure
215215+216216+```
217217+atcr.io/
218218+├── cmd/registry/ # Main entrypoint
219219+├── pkg/
220220+│ ├── atproto/ # ATProto client and manifest store
221221+│ ├── storage/ # S3 blob store and routing
222222+│ ├── middleware/ # Registry and repository middleware
223223+│ └── server/ # HTTP handlers
224224+├── config/ # Configuration files
225225+└── Dockerfile
226226+```
227227+228228+### Testing
229229+230230+```bash
231231+# Run tests
232232+go test ./...
233233+234234+# Run with race detector
235235+go test -race ./...
236236+```
237237+238238+## License
239239+240240+MIT
241241+242242+## Contributing
243243+244244+Contributions welcome! Please open an issue or PR.
+101
SAILOR.md
···11+User approved Claude's plan:
22+ ⎿ Implement Sailor Profile & Hold Discovery System
33+44+ Summary
55+66+ Add io.atcr.sailor.profile record type to manage user's default hold preference, and update manifest to store historical hold endpoint reference. This enables transparent hold
77+ routing while preserving image ownership semantics.
88+99+ Changes Required
1010+1111+ 1. Create Sailor Profile Lexicon
1212+1313+ File: lexicons/io/atcr/sailor/profile.json
1414+ - New record type: io.atcr.sailor.profile
1515+ - Fields: defaultHold (string, nullable), createdAt, updatedAt
1616+1717+ 2. Update Manifest Lexicon
1818+1919+ File: lexicons/io/atcr/manifest.json
2020+ - Add holdEndpoint field (string, required)
2121+ - This is historical reference (immutable per manifest)
2222+2323+ 3. Update Go Types
2424+2525+ File: pkg/atproto/lexicon.go
2626+ - Add SailorProfileCollection = "io.atcr.sailor.profile"
2727+ - Add SailorProfileRecord struct
2828+ - Add NewSailorProfileRecord() constructor
2929+ - Update ManifestRecord struct to include HoldEndpoint field
3030+3131+ 4. Create Profile Management
3232+3333+ File: pkg/atproto/profile.go (new file)
3434+ - EnsureProfile(ctx, client, defaultHoldEndpoint) function
3535+ - Logic: check if profile exists, create with default if not
3636+3737+ 5. Update Auth Handlers
3838+3939+ Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go
4040+ - Call EnsureProfile() after token validation
4141+ - Use authenticated client (has write access to user's PDS)
4242+ - Pass AppView's default_hold_endpoint config
4343+4444+ 6. Update Hold Resolution
4545+4646+ File: pkg/middleware/registry.go
4747+ - Update findStorageEndpoint() priority:
4848+ a. Check io.atcr.sailor.profile.defaultHold
4949+ b. If null (opted out): check user's io.atcr.hold, then AppView default
5050+ c. If no profile: check user's io.atcr.hold, then AppView default
5151+5252+ 7. Store Hold in Manifest
5353+5454+ File: pkg/atproto/manifest_store.go
5555+ - When creating manifest, include resolved holdEndpoint
5656+ - Pass hold endpoint through context or parameter
5757+5858+ 8. Update Pull to Use Manifest Hold
5959+6060+ File: pkg/atproto/manifest_store.go and pkg/storage/routing_repository.go
6161+ - On pull, extract holdEndpoint from manifest
6262+ - Route blob requests to that hold (not via discovery)
6363+6464+ 9. Update Documentation
6565+6666+ Files: CLAUDE.md, docs/BYOS.md, .env.example
6767+ - Document sailor profile concept
6868+ - Explain hold resolution priority
6969+ - Update examples for shared holds
7070+ - Document how crew members configure profile
7171+7272+ Benefits
7373+7474+ - ✅ URL structure remains atcr.io/<owner>/<image> (ownership clear)
7575+ - ✅ Hold is transparent infrastructure (like S3 region)
7676+ - ✅ Supports personal, shared, and public holds
7777+ - ✅ Historical reference in manifest (pull works even if defaults change)
7878+ - ✅ User can opt-out (set defaultHold to null)
7979+ - ✅ Future: UI for self-service profile management
8080+8181+8282+Progress Summary
8383+8484+ ✅ Completed:
8585+ 1. Created io.atcr.sailor.profile lexicon
8686+ 2. Updated io.atcr.manifest lexicon with holdEndpoint field
8787+ 3. Updated Go types in pkg/atproto/lexicon.go
8888+ 4. Created profile management in pkg/atproto/profile.go
8989+ 5. Updated /auth/exchange handler to manage profile
9090+9191+ ⏳ In Progress:
9292+ - Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management)
9393+ - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not interface{}
9494+9595+ 🔜 Remaining:
9696+ - Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
9797+ - Update manifest_store.go to include holdEndpoint when creating manifests
9898+ - Update pull flow to use manifest holdEndpoint
9999+ - Update documentation
100100+101101+ The architecture is solid - just need to finish the token handler update and fix the type issue in the config extraction. Would you like me to continue?
+460
SPEC.md
···11+ATProto Container Registry (atcr.io) Implementation Plan
22+33+ Project Structure
44+55+ /home/data/atcr.io/
66+ ├── cmd/
77+ │ └── registry/
88+ │ └── main.go # Entrypoint that imports distribution
99+ ├── pkg/
1010+ │ ├── atproto/
1111+ │ │ ├── client.go # ATProto client wrapper (using indigo)
1212+ │ │ ├── manifest_store.go # Implements distribution.ManifestService
1313+ │ │ ├── resolver.go # DID/handle resolution (alice → did:plc:...)
1414+ │ │ └── lexicon.go # ATProto record schemas for manifests
1515+ │ ├── storage/
1616+ │ │ ├── s3_blob_store.go # Wraps distribution's S3 driver for blobs
1717+ │ │ └── routing_repository.go # Routes manifests→ATProto, blobs→S3
1818+ │ ├── middleware/
1919+ │ │ ├── repository.go # Repository middleware registration
2020+ │ │ └── registry.go # Registry middleware for name resolution
2121+ │ └── server/
2222+ │ └── handler.go # HTTP wrapper for custom name resolution
2323+ ├── config/
2424+ │ └── config.yml # Registry configuration
2525+ ├── go.mod
2626+ ├── go.sum
2727+ ├── Dockerfile
2828+ ├── README.md
2929+ └── CLAUDE.md # Updated with architecture docs
3030+3131+3232+ Implementation Steps
3333+3434+ Phase 1: Project Setup
3535+3636+ 1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo
3737+ 2. Create basic project structure
3838+ 3. Set up cmd/registry/main.go that imports distribution and registers middleware
3939+4040+ Phase 2: Core ATProto Integration
4141+4242+ 4. Implement DID/handle resolver (pkg/atproto/resolver.go)
4343+ - Resolve handles to DIDs (alice.bsky.social → did:plc:xyz)
4444+ - Discover PDS endpoints from DID documents
4545+ 5. Create ATProto client wrapper (pkg/atproto/client.go)
4646+ - Wrap indigo SDK for manifest storage
4747+ - Handle authentication with PDS
4848+ 6. Design ATProto lexicon for manifest records (pkg/atproto/lexicon.go)
4949+ - Define schema for storing OCI manifests as ATProto records
5050+5151+ Phase 3: Storage Layer
5252+5353+ 7. Implement ATProto manifest store (pkg/atproto/manifest_store.go)
5454+ - Implements distribution.ManifestService
5555+ - Stores/retrieves manifests from PDS
5656+ 8. Implement S3 blob store wrapper (pkg/storage/s3_blob_store.go)
5757+ - Wraps distribution's built-in S3 driver
5858+ 9. Create routing repository (pkg/storage/routing_repository.go)
5959+ - Returns ATProto store for Manifests()
6060+ - Returns S3 store for Blobs()
6161+6262+ Phase 4: Middleware Layer
6363+6464+ 10. Implement repository middleware (pkg/middleware/repository.go)
6565+ - Registers routing repository
6666+ - Configurable via YAML
6767+ 11. Implement registry/namespace middleware (pkg/middleware/registry.go)
6868+ - Intercepts Repository(name) calls
6969+ - Performs name resolution before repository creation
7070+7171+ Phase 5: HTTP Layer (if needed)
7272+7373+ 12. Create custom HTTP handler (pkg/server/handler.go)
7474+ - Wraps distribution's HTTP handlers
7575+ - Performs early name resolution: atcr.io/alice/myimage → resolve alice
7676+ - Delegates to distribution handlers
7777+7878+ Phase 6: Configuration & Deployment
7979+8080+ 13. Create registry configuration (config/config.yml)
8181+ 14. Create Dockerfile for building atcr-registry binary
8282+ 16. Write README.md with usage instructions
8383+8484+ Phase 7: Documentation
8585+8686+ 17. Update CLAUDE.md with:
8787+ - Architecture overview (ATProto for manifests, S3 for blobs)
8888+ - Build/run/test commands
8989+ - How name resolution works
9090+ - Middleware registration flow
9191+ - Key design decisions
9292+9393+ Key Technical Decisions
9494+9595+ ATProto Storage Design:
9696+ - Manifests stored as ATProto records in user's PDS
9797+ - Each image manifest is a record in a io.atcr.manifest collection
9898+ - Record key = image digest (sha256:...)
9999+100100+ Name Resolution:
101101+ - atcr.io/alice/myimage → resolve alice to DID → discover PDS
102102+ - Support both handles (alice.bsky.social) and DIDs (did:plc:xyz)
103103+104104+ Blob Storage:
105105+ - All layers/blobs in S3 (content-addressable by digest)
106106+ - Manifests reference S3-stored blobs by digest
107107+ - S3 provides cheap, durable blob storage
108108+109109+ Middleware Strategy:
110110+ - Repository middleware for storage routing
111111+ - Registry middleware (or HTTP wrapper) for name resolution
112112+ - No fork of distribution core needed
113113+114114+Perfect. To match Docker Hub/ghcr.io/gcr.io, here's what we need:
115115+116116+● Implementation Plan (Drop-in replacement for Docker Hub/ghcr.io)
117117+118118+ Flow 1: Standard Token Auth (Like Docker Hub) - PRIMARY
119119+120120+ # User experience
121121+ docker login atcr.io -u alice.bsky.social -p <atproto-app-password>
122122+ docker push atcr.io/alice/myapp:latest
123123+124124+ # Behind the scenes
125125+ 1. docker login stores credentials locally
126126+ 2. docker push → Registry returns 401 with WWW-Authenticate: Bearer realm="https://atcr.io/auth/token"...
127127+ 3. Docker auto-calls /auth/token with Basic auth (alice.bsky.social:app-password)
128128+ 4. Auth service validates against ATProto createSession
129129+ 5. Returns JWT token with scope for alice/myapp
130130+ 6. Docker uses JWT for manifest/blob uploads
131131+ 7. Registry validates JWT signature and scope
132132+133133+ Components:
134134+ - /auth/token endpoint (standalone service or embedded)
135135+ - ATProto session validator (username/password → validate via PDS)
136136+ - JWT issuer/signer
137137+ - JWT validator middleware for registry
138138+139139+ Flow 2: Credential Helper (Like gcr.io) - ADVANCED
140140+141141+ # User experience
142142+ docker-credential-atcr configure
143143+ # Opens browser for ATProto OAuth
144144+ docker push atcr.io/alice/myapp:latest
145145+ # No manual login needed
146146+147147+ # Behind the scenes
148148+ 1. Helper does OAuth flow → gets ATProto access token
149149+ 2. Caches token securely
150150+ 3. When Docker needs credentials, calls helper via stdin/stdout
151151+ 4. Helper exchanges ATProto token for registry JWT at /auth/exchange
152152+ 5. Returns JWT to Docker
153153+ 6. Docker uses JWT for requests
154154+155155+ Components:
156156+ - cmd/credential-helper/main.go - Standalone binary
157157+ - ATProto OAuth client
158158+ - Token exchange endpoint (/auth/exchange)
159159+ - Secure token cache
160160+161161+ Architecture:
162162+163163+ pkg/auth/
164164+ ├── token/
165165+ │ ├── service.go # HTTP handler for /auth/token
166166+ │ ├── claims.go # JWT claims structure
167167+ │ ├── issuer.go # Signs JWTs
168168+ │ └── validator.go # Validates JWTs (middleware for registry)
169169+ ├── atproto/
170170+ │ ├── session.go # Validates username/password via ATProto
171171+ │ └── oauth.go # OAuth flow implementation
172172+ ├── exchange/
173173+ │ └── handler.go # /auth/exchange endpoint (OAuth → JWT)
174174+ └── scope.go # Parses/validates Docker scopes
175175+176176+ cmd/
177177+ ├── registry/main.go # Registry server (existing)
178178+ ├── auth/main.go # Standalone auth service (optional)
179179+ └── credential-helper/
180180+ └── main.go # docker-credential-atcr binary
181181+182182+ Config:
183183+184184+ auth:
185185+ token:
186186+ realm: https://atcr.io/auth/token # Where Docker gets tokens
187187+ service: atcr.io
188188+ issuer: atcr.io
189189+ rootcertbundle: /etc/atcr/token-signing.crt
190190+ privatekey: /etc/atcr/token-signing.pem
191191+ expiration: 300
192192+193193+ atproto:
194194+ # Used by auth service to validate credentials
195195+ pds_endpoint: https://bsky.social
196196+ client_id: atcr-registry
197197+ oauth_redirect: http://localhost:8888/callback
198198+199199+ATProto OAuth Implementation Plan
200200+201201+ Architecture
202202+203203+ Dependencies:
204204+ - authelia.com/client/oauth2 - OAuth + PAR support
205205+ - github.com/AxisCommunications/go-dpop - DPoP proof generation (handles JWK automatically)
206206+ - github.com/golang-jwt/jwt/v5 - JWT library (transitive via go-dpop)
207207+ - Our existing pkg/atproto/resolver.go - ATProto identity resolution
208208+209209+ Implementation Components
210210+211211+ 1. OAuth Client (pkg/auth/oauth/client.go) - ~100 lines
212212+213213+ type Client struct {
214214+ config *oauth2.Config
215215+ dpopKey *ecdsa.PrivateKey
216216+ resolver *atproto.Resolver
217217+ clientID string // URL to our metadata document
218218+ redirectURI string
219219+ dpopNonce string // Server-provided nonce
220220+ }
221221+222222+ func NewClient(clientID, redirectURI string) (*Client, error)
223223+ func (c *Client) AuthorizeURL(handle string, scopes []string) (string, error)
224224+ func (c *Client) Exchange(code string) (*Token, error)
225225+ func (c *Client) addDPoPHeader(req *http.Request, method, url string) error
226226+227227+ Flow:
228228+ 1. Generate ECDSA P-256 key for DPoP
229229+ 2. Discover authorization server from handle/DID
230230+ 3. Use authelia's PushedAuth() for PAR with DPoP header
231231+ 4. Exchange code for token with DPoP proof
232232+233233+ 2. Authorization Server Discovery (pkg/auth/oauth/discovery.go) - ~30 lines
234234+235235+ type AuthServerMetadata struct {
236236+ Issuer string `json:"issuer"`
237237+ AuthorizationEndpoint string `json:"authorization_endpoint"`
238238+ TokenEndpoint string `json:"token_endpoint"`
239239+ PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
240240+ DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
241241+ }
242242+243243+ func DiscoverAuthServer(pdsEndpoint string) (*AuthServerMetadata, error)
244244+245245+ Implementation:
246246+ - GET {pds}/.well-known/oauth-authorization-server
247247+ - Parse JSON metadata
248248+ - Validate required endpoints exist
249249+250250+ 3. Client Metadata Server (pkg/auth/oauth/metadata.go) - ~40 lines
251251+252252+ type ClientMetadata struct {
253253+ ClientID string `json:"client_id"`
254254+ RedirectURIs []string `json:"redirect_uris"`
255255+ GrantTypes []string `json:"grant_types"`
256256+ ResponseTypes []string `json:"response_types"`
257257+ Scope string `json:"scope"`
258258+ DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
259259+ }
260260+261261+ func ServeMetadata(clientID string, redirectURIs []string) http.Handler
262262+263263+ Serves: https://atcr.io/oauth/client-metadata.json
264264+265265+ 4. Token Storage (pkg/auth/oauth/storage.go) - ~50 lines
266266+267267+ type TokenStore struct {
268268+ AccessToken string
269269+ RefreshToken string
270270+ DPoPKey *ecdsa.PrivateKey // Persist for refresh
271271+ ExpiresAt time.Time
272272+ }
273273+274274+ func (s *TokenStore) Save(path string) error
275275+ func LoadTokenStore(path string) (*TokenStore, error)
276276+277277+ Storage location: ~/.atcr/oauth-tokens.json
278278+279279+ 5. Credential Helper (cmd/credential-helper/main.go) - ~80 lines
280280+281281+ // Docker credential helper protocol
282282+ // Reads JSON from stdin, writes to stdout
283283+284284+ func main() {
285285+ if len(os.Args) < 2 {
286286+ os.Exit(1)
287287+ }
288288+289289+ switch os.Args[1] {
290290+ case "get":
291291+ handleGet() // Return credentials for registry
292292+ case "store":
293293+ handleStore() // Store credentials
294294+ case "erase":
295295+ handleErase() // Remove credentials
296296+ }
297297+ }
298298+299299+ func handleGet() {
300300+ var request struct {
301301+ ServerURL string `json:"ServerURL"`
302302+ }
303303+ json.NewDecoder(os.Stdin).Decode(&request)
304304+305305+ // Load token from storage
306306+ // Exchange for registry JWT if needed
307307+ // Output: {"Username": "oauth2", "Secret": "<jwt>"}
308308+ }
309309+310310+ 6. OAuth Flow (cmd/credential-helper/oauth.go) - ~60 lines
311311+312312+ func RunOAuthFlow(handle string) (*TokenStore, error) {
313313+ // 1. Start local HTTP server on :8888
314314+ // 2. Open browser to authorization URL
315315+ // 3. Wait for callback with code
316316+ // 4. Exchange code for token
317317+ // 5. Save token store
318318+ // 6. Return token
319319+ }
320320+321321+ func startCallbackServer() (chan string, *http.Server)
322322+323323+ Complete Flow Example
324324+325325+ User runs:
326326+ docker-credential-atcr configure
327327+328328+ What happens:
329329+330330+ 1. Generate DPoP key (client.go)
331331+ dpopKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
332332+333333+ 2. Resolve handle → DID → PDS (using our resolver)
334334+ did, pds, _ := resolver.ResolveIdentity(ctx, "alice.bsky.social")
335335+336336+ 3. Discover auth server (discovery.go)
337337+ metadata, _ := DiscoverAuthServer(pds)
338338+ // Returns: PAR endpoint, token endpoint, etc.
339339+340340+ 4. Create PAR request with DPoP (client.go + go-dpop)
341341+ // Generate DPoP proof for PAR endpoint
342342+ claims := &dpop.ProofTokenClaims{
343343+ Method: dpop.POST,
344344+ URL: metadata.PushedAuthorizationRequestEndpoint,
345345+ RegisteredClaims: &jwt.RegisteredClaims{
346346+ IssuedAt: jwt.NewNumericDate(time.Now()),
347347+ },
348348+ }
349349+ dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
350350+351351+ // Use authelia for PAR
352352+ config := &oauth2.Config{
353353+ ClientID: "https://atcr.io/oauth/client-metadata.json",
354354+ Endpoint: oauth2.Endpoint{
355355+ AuthURL: metadata.AuthorizationEndpoint,
356356+ TokenURL: metadata.TokenEndpoint,
357357+ },
358358+ }
359359+360360+ // Create custom HTTP client that adds DPoP header
361361+ client := &http.Client{
362362+ Transport: &dpopTransport{
363363+ base: http.DefaultTransport,
364364+ dpopKey: dpopKey,
365365+ },
366366+ }
367367+ ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
368368+369369+ // PAR request (authelia handles this)
370370+ authURL, parResp, _ := config.PushedAuth(ctx, state,
371371+ oauth2.SetAuthURLParam("code_challenge", pkceChallenge),
372372+ oauth2.SetAuthURLParam("code_challenge_method", "S256"),
373373+ )
374374+375375+ 5. Open browser, get code (oauth.go)
376376+ exec.Command("open", authURL).Run()
377377+ // User authorizes
378378+ // Callback: http://localhost:8888?code=xyz&state=abc
379379+380380+ 6. Exchange code for token with DPoP (client.go + go-dpop)
381381+ // Generate DPoP proof for token endpoint
382382+ claims := &dpop.ProofTokenClaims{
383383+ Method: dpop.POST,
384384+ URL: metadata.TokenEndpoint,
385385+ RegisteredClaims: &jwt.RegisteredClaims{
386386+ IssuedAt: jwt.NewNumericDate(time.Now()),
387387+ },
388388+ }
389389+ dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
390390+391391+ // Exchange (with DPoP header added by our transport)
392392+ token, _ := config.Exchange(ctx, code,
393393+ oauth2.SetAuthURLParam("code_verifier", pkceVerifier),
394394+ )
395395+396396+ 7. Save token + DPoP key (storage.go)
397397+ store := &TokenStore{
398398+ AccessToken: token.AccessToken,
399399+ RefreshToken: token.RefreshToken,
400400+ DPoPKey: dpopKey,
401401+ ExpiresAt: token.Expiry,
402402+ }
403403+ store.Save("~/.atcr/oauth-tokens.json")
404404+405405+ Later, when docker push happens:
406406+ docker push atcr.io/alice/myapp:latest
407407+408408+ 1. Docker calls credential helper: docker-credential-atcr get
409409+ 2. Helper loads stored token
410410+ 3. Helper calls /auth/exchange with OAuth token → gets registry JWT
411411+ 4. Returns JWT to Docker
412412+ 5. Docker uses JWT for push
413413+414414+ Directory Structure
415415+416416+ pkg/auth/oauth/
417417+ ├── client.go # OAuth client with DPoP integration
418418+ ├── discovery.go # Authorization server discovery
419419+ ├── metadata.go # Client metadata server
420420+ ├── storage.go # Token persistence
421421+ └── transport.go # HTTP transport that adds DPoP headers
422422+423423+ cmd/credential-helper/
424424+ ├── main.go # Docker credential helper protocol
425425+ ├── oauth.go # OAuth flow (browser, callback)
426426+ └── config.go # Configuration
427427+428428+ go.mod additions:
429429+ authelia.com/client/oauth2 v0.25.0
430430+ github.com/AxisCommunications/go-dpop v1.1.2
431431+432432+Unified Model
433433+434434+ Every hold service requires HOLD_CREW_OWNER:
435435+ - Owner's PDS has the io.atcr.hold record
436436+ - Owner's PDS has all io.atcr.hold.crew records
437437+ - Authorization is always governed by PDS records
438438+439439+ For "public" hold (like Tangled's public knot):
440440+ - Owner creates hold with public: true
441441+ - Anyone can push/pull without being crew
442442+ - Owner can add crew records for special privileges/tracking if desired
443443+444444+ Config has emergency override:
445445+ auth:
446446+ # Emergency freeze: ignore public setting, restrict to crew only
447447+ # Use this to stop abuse without changing PDS records
448448+ freeze: false
449449+450450+ Authorization logic:
451451+ 1. Check freeze in config → if true, skip to crew check
452452+ 2. Query owner's PDS for io.atcr.hold record
453453+ 3. If public: true → allow all operations (unless frozen)
454454+ 4. If public: false OR frozen → query io.atcr.hold.crew records, check membership
455455+456456+ Remove from config:
457457+ - allow_all (replaced by public: true in PDS)
458458+ - allowed_dids (replaced by crew records in PDS)
459459+460460+ This way the hold owner at atcr.io can run a public hold at hold1.atcr.io that anyone can use, but can freeze it instantly if needed without touching PDS records.
+334
TESTING.md
···11+# Local Testing Guide
22+33+## Quick Start
44+55+```bash
66+./test-local.sh
77+```
88+99+This automated script will:
1010+1. Create storage directories
1111+2. Build all binaries
1212+3. Start both services
1313+4. Show test commands
1414+1515+## Manual Testing Steps
1616+1717+### 1. Setup Directories
1818+1919+```bash
2020+sudo mkdir -p /var/lib/atcr/{blobs,hold,auth}
2121+sudo chown -R $USER:$USER /var/lib/atcr
2222+```
2323+2424+### 2. Build Binaries
2525+2626+```bash
2727+go build -o atcr-registry ./cmd/registry
2828+go build -o atcr-hold ./cmd/hold
2929+go build -o docker-credential-atcr ./cmd/credential-helper
3030+```
3131+3232+### 3. Configure Environment
3333+3434+Create a `.env` file in the project root:
3535+3636+```bash
3737+cp .env.example .env
3838+```
3939+4040+Edit `.env` with your credentials:
4141+4242+```env
4343+# Your ATProto handle
4444+ATPROTO_HANDLE=your-handle.bsky.social
4545+4646+# Hold service public URL (hostname becomes the hold name)
4747+HOLD_PUBLIC_URL=http://127.0.0.1:8080
4848+4949+# Enable OAuth registration on startup
5050+HOLD_AUTO_REGISTER=true
5151+```
5252+5353+**Notes:**
5454+- Use your Bluesky handle (e.g., `alice.bsky.social`)
5555+- For localhost, use `127.0.0.1` instead of `localhost` for OAuth
5656+- The hostname from the URL becomes the hold name (e.g., `127.0.0.1` or `hold1.atcr.io`)
5757+5858+**Load environment:**
5959+```bash
6060+export $(cat .env | xargs)
6161+```
6262+6363+### 4. Start Services
6464+6565+**Terminal 1 - Registry:**
6666+```bash
6767+./atcr-registry serve config/config.yml
6868+```
6969+7070+**Terminal 2 - Hold:**
7171+```bash
7272+./atcr-hold config/hold.yml
7373+```
7474+7575+### 5. Start Services and OAuth Registration
7676+7777+**Terminal 1 - Registry:**
7878+```bash
7979+./atcr-registry serve config/config.yml
8080+```
8181+8282+**Terminal 2 - Hold (OAuth registration):**
8383+```bash
8484+./atcr-hold config/hold.yml
8585+```
8686+8787+The hold service will start an OAuth flow. You'll see output like:
8888+8989+```
9090+================================================================================
9191+OAUTH AUTHORIZATION REQUIRED
9292+================================================================================
9393+9494+Please visit this URL to authorize the hold service:
9595+9696+ https://bsky.social/oauth/authorize?...
9797+9898+Waiting for authorization...
9999+================================================================================
100100+```
101101+102102+**Steps:**
103103+1. Copy the OAuth URL from the logs
104104+2. Open it in your browser
105105+3. Sign in to Bluesky and authorize
106106+4. The callback will complete automatically
107107+5. Hold service registers in your PDS
108108+109109+After successful OAuth, you'll see:
110110+```
111111+✓ Created hold record: at://did:plc:.../io.atcr.hold/127.0.0.1
112112+✓ Created crew record: at://did:plc:.../io.atcr.hold.crew/127.0.0.1-did:plc:...
113113+================================================================================
114114+REGISTRATION COMPLETE
115115+================================================================================
116116+Hold service is now registered and ready to use!
117117+```
118118+119119+This creates two records in your PDS:
120120+- `io.atcr.hold` - Defines the storage endpoint URL
121121+- `io.atcr.hold.crew` - Grants you admin access
122122+123123+### 6. Test Docker Push/Pull
124124+125125+**Test 1: Basic Push**
126126+```bash
127127+# Tag an image
128128+docker tag alpine:latest localhost:5000/alice/alpine:test
129129+130130+# Push to local registry
131131+docker push localhost:5000/alice/alpine:test
132132+```
133133+134134+**Test 2: Pull**
135135+```bash
136136+# Remove local image
137137+docker rmi localhost:5000/alice/alpine:test
138138+139139+# Pull from registry
140140+docker pull localhost:5000/alice/alpine:test
141141+```
142142+143143+**Test 3: Verify Storage**
144144+```bash
145145+# Check manifests were stored in ATProto
146146+# (Check your PDS for io.atcr.manifest records)
147147+148148+# Check blobs were stored locally
149149+ls -lh /var/lib/atcr/blobs/docker/registry/v2/
150150+```
151151+152152+## OAuth Testing (Optional)
153153+154154+### Setup Credential Helper
155155+156156+```bash
157157+# Configure OAuth
158158+./docker-credential-atcr configure
159159+160160+# Follow the browser flow to authorize
161161+162162+# Verify token was saved
163163+ls -la ~/.atcr/oauth-token.json
164164+```
165165+166166+### Configure Docker to Use Helper
167167+168168+Edit `~/.docker/config.json`:
169169+```json
170170+{
171171+ "credHelpers": {
172172+ "localhost:5000": "atcr"
173173+ }
174174+}
175175+```
176176+177177+### Test with OAuth
178178+179179+```bash
180180+# Push should now use OAuth automatically
181181+docker push localhost:5000/alice/myapp:latest
182182+```
183183+184184+## Troubleshooting
185185+186186+### Registry won't start
187187+188188+**Error:** `failed to create storage driver`
189189+```bash
190190+# Check directory permissions
191191+ls -ld /var/lib/atcr/blobs
192192+# Should be owned by your user
193193+194194+# Fix permissions
195195+sudo chown -R $USER:$USER /var/lib/atcr
196196+```
197197+198198+**Error:** `address already in use`
199199+```bash
200200+# Check what's using port 5000
201201+lsof -i :5000
202202+203203+# Kill existing process
204204+kill $(lsof -t -i :5000)
205205+```
206206+207207+### Hold service won't start
208208+209209+**Error:** `failed to create storage driver`
210210+```bash
211211+# Check hold directory
212212+ls -ld /var/lib/atcr/hold
213213+sudo chown -R $USER:$USER /var/lib/atcr/hold
214214+```
215215+216216+**Error:** `address already in use`
217217+```bash
218218+# Check port 8080
219219+lsof -i :8080
220220+kill $(lsof -t -i :8080)
221221+```
222222+223223+### Docker push fails
224224+225225+**Error:** `unauthorized: authentication required`
226226+- Check `ATPROTO_DID` and `ATPROTO_ACCESS_TOKEN` are set
227227+- Verify token is valid (not expired)
228228+- Check registry logs for auth errors
229229+230230+**Error:** `denied: requested access to the resource is denied`
231231+- Check the identity in the image name matches your DID
232232+- Example: If your handle is `alice.bsky.social`, use:
233233+ ```bash
234234+ docker push localhost:5000/alice/myapp:test
235235+ # NOT localhost:5000/bob/myapp:test
236236+ ```
237237+238238+**Error:** `failed to resolve identity`
239239+- Check internet connection (needs to resolve DIDs)
240240+- Verify handle is correct
241241+- Try using DID directly instead of handle
242242+243243+### OAuth issues
244244+245245+**Error:** `Failed to exchange token`
246246+- Ensure registry is running and accessible
247247+- Check `/auth/exchange` endpoint is responding
248248+- Verify OAuth token hasn't expired
249249+250250+**Error:** `Token validation failed`
251251+- Token might be expired
252252+- Run `./docker-credential-atcr configure` again
253253+- Check PDS is accessible
254254+255255+## Verifying the Flow
256256+257257+### Check Registry is Running
258258+```bash
259259+curl http://localhost:5000/v2/
260260+# Should return: {}
261261+```
262262+263263+### Check Hold is Running
264264+```bash
265265+curl http://localhost:8080/health
266266+# Should return: {"status":"ok"}
267267+```
268268+269269+### Check Auth Endpoint
270270+```bash
271271+curl -v http://localhost:5000/v2/
272272+# Should return 401 with WWW-Authenticate header
273273+```
274274+275275+### Inspect Stored Data
276276+277277+**Manifests (in ATProto):**
278278+- Check your PDS web interface
279279+- Look for `io.atcr.manifest` collection records
280280+281281+**Blobs (local filesystem):**
282282+```bash
283283+# List blobs
284284+find /var/lib/atcr/blobs -type f
285285+286286+# Check blob content (should be binary)
287287+ls -lh /var/lib/atcr/blobs/docker/registry/v2/blobs/sha256/
288288+```
289289+290290+## Clean Up
291291+292292+### Stop Services
293293+```bash
294294+# If using test script
295295+kill $(cat .atcr-pids)
296296+297297+# Or manually
298298+pkill atcr-registry
299299+pkill atcr-hold
300300+```
301301+302302+### Remove Test Data
303303+```bash
304304+# Remove all stored data
305305+sudo rm -rf /var/lib/atcr/*
306306+307307+# Remove OAuth tokens
308308+rm -rf ~/.atcr/
309309+```
310310+311311+### Reset Docker Config
312312+```bash
313313+# Remove credential helper config
314314+# Edit ~/.docker/config.json and remove "credHelpers" section
315315+```
316316+317317+## Next Steps
318318+319319+Once local testing works:
320320+321321+1. **Deploy to production:**
322322+ - Use S3/Storj for blob storage
323323+ - Deploy registry and hold to separate hosts
324324+ - Configure DNS for `atcr.io`
325325+326326+2. **Enable BYOS:**
327327+ - Users create `io.atcr.hold` records
328328+ - Deploy their own hold service
329329+ - AppView automatically routes to their storage
330330+331331+3. **Add monitoring:**
332332+ - Registry metrics
333333+ - Hold service metrics
334334+ - Storage usage tracking
atcr-storage
This is a binary file and will not be displayed.
+158
cmd/credential-helper/main.go
···11+package main
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "os"
77+ "path/filepath"
88+)
99+1010+// Docker credential helper protocol
1111+// https://github.com/docker/docker-credential-helpers
1212+1313+// Credentials represents docker credentials
1414+type Credentials struct {
1515+ ServerURL string `json:"ServerURL,omitempty"`
1616+ Username string `json:"Username,omitempty"`
1717+ Secret string `json:"Secret,omitempty"`
1818+}
1919+2020+func main() {
2121+ if len(os.Args) < 2 {
2222+ fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure>\n")
2323+ os.Exit(1)
2424+ }
2525+2626+ command := os.Args[1]
2727+2828+ switch command {
2929+ case "get":
3030+ handleGet()
3131+ case "store":
3232+ handleStore()
3333+ case "erase":
3434+ handleErase()
3535+ case "configure":
3636+ handleConfigure()
3737+ default:
3838+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
3939+ os.Exit(1)
4040+ }
4141+}
4242+4343+// handleGet retrieves credentials for the given server
4444+func handleGet() {
4545+ var request Credentials
4646+ if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
4747+ fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
4848+ os.Exit(1)
4949+ }
5050+5151+ // Load token from storage
5252+ tokenPath := getTokenPath()
5353+ token, err := loadToken(tokenPath)
5454+ if err != nil {
5555+ fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err)
5656+ os.Exit(1)
5757+ }
5858+5959+ // Check if token is expired and refresh if needed
6060+ if token.IsExpired && token.RefreshToken != "" {
6161+ if err := refreshToken(token); err != nil {
6262+ fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err)
6363+ os.Exit(1)
6464+ }
6565+ }
6666+6767+ // Exchange ATProto token for registry JWT
6868+ registryJWT, err := exchangeForRegistryToken(token.AccessToken, request.ServerURL)
6969+ if err != nil {
7070+ fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err)
7171+ os.Exit(1)
7272+ }
7373+7474+ // Return credentials
7575+ creds := Credentials{
7676+ ServerURL: request.ServerURL,
7777+ Username: "oauth2",
7878+ Secret: registryJWT,
7979+ }
8080+8181+ if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
8282+ fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err)
8383+ os.Exit(1)
8484+ }
8585+}
8686+8787+// handleStore stores credentials (Docker calls this after login)
8888+func handleStore() {
8989+ var creds Credentials
9090+ if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
9191+ fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err)
9292+ os.Exit(1)
9393+ }
9494+9595+ // For OAuth flow, we don't actually store credentials from docker login
9696+ // The credentials are managed through the OAuth flow
9797+ // This is a no-op for us
9898+}
9999+100100+// handleErase removes stored credentials
101101+func handleErase() {
102102+ var request Credentials
103103+ if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
104104+ fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
105105+ os.Exit(1)
106106+ }
107107+108108+ // Remove token file
109109+ tokenPath := getTokenPath()
110110+ if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) {
111111+ fmt.Fprintf(os.Stderr, "Error removing token: %v\n", err)
112112+ os.Exit(1)
113113+ }
114114+}
115115+116116+// handleConfigure runs the OAuth flow to get initial credentials
117117+func handleConfigure() {
118118+ fmt.Println("ATCR Credential Helper Configuration")
119119+ fmt.Println("=====================================")
120120+ fmt.Println()
121121+122122+ // Ask for handle
123123+ fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
124124+ var handle string
125125+ if _, err := fmt.Scanln(&handle); err != nil {
126126+ fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err)
127127+ os.Exit(1)
128128+ }
129129+130130+ // Run OAuth flow
131131+ fmt.Println("\nStarting OAuth flow...")
132132+ token, err := runOAuthFlow(handle)
133133+ if err != nil {
134134+ fmt.Fprintf(os.Stderr, "Error during OAuth flow: %v\n", err)
135135+ os.Exit(1)
136136+ }
137137+138138+ // Save token
139139+ tokenPath := getTokenPath()
140140+ if err := token.Save(tokenPath); err != nil {
141141+ fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
142142+ os.Exit(1)
143143+ }
144144+145145+ fmt.Println("\nConfiguration complete!")
146146+ fmt.Println("You can now use docker push/pull with atcr.io")
147147+}
148148+149149+// getTokenPath returns the path to the token file
150150+func getTokenPath() string {
151151+ homeDir, err := os.UserHomeDir()
152152+ if err != nil {
153153+ fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
154154+ os.Exit(1)
155155+ }
156156+157157+ return filepath.Join(homeDir, ".atcr", "oauth-token.json")
158158+}
+176
cmd/credential-helper/oauth.go
···11+package main
22+33+import (
44+ "context"
55+ "fmt"
66+ "net/http"
77+ "os/exec"
88+ "runtime"
99+ "time"
1010+1111+ "atcr.io/pkg/atproto"
1212+ "atcr.io/pkg/auth/oauth"
1313+)
1414+1515+const (
1616+ clientID = "http://localhost:8888/client-metadata.json"
1717+ redirectURI = "http://localhost:8888/callback"
1818+)
1919+2020+// runOAuthFlow executes the OAuth flow with browser
2121+func runOAuthFlow(handle string) (*oauth.TokenStore, error) {
2222+ // Create OAuth client
2323+ client, err := oauth.NewClient(clientID, redirectURI)
2424+ if err != nil {
2525+ return nil, fmt.Errorf("failed to create OAuth client: %w", err)
2626+ }
2727+2828+ // Initialize for the given handle
2929+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
3030+ defer cancel()
3131+3232+ if err := client.InitializeForHandle(ctx, handle); err != nil {
3333+ return nil, fmt.Errorf("failed to initialize client: %w", err)
3434+ }
3535+3636+ // Start local callback server
3737+ codeChan := make(chan string, 1)
3838+ errChan := make(chan error, 1)
3939+ server := startCallbackServer(codeChan, errChan)
4040+ defer server.Shutdown(context.Background())
4141+4242+ // Also serve client metadata
4343+ http.HandleFunc("/client-metadata.json", oauth.ServeMetadata(
4444+ oauth.NewClientMetadata(clientID, []string{redirectURI}),
4545+ ))
4646+4747+ // Generate authorization URL with PKCE
4848+ state := generateState()
4949+ authURL, codeVerifier, err := client.AuthorizeURL(state)
5050+ if err != nil {
5151+ return nil, fmt.Errorf("failed to generate auth URL: %w", err)
5252+ }
5353+5454+ // Open browser
5555+ fmt.Printf("Opening browser to: %s\n", authURL)
5656+ if err := openBrowser(authURL); err != nil {
5757+ fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL)
5858+ }
5959+6060+ // Wait for callback
6161+ var code string
6262+ select {
6363+ case code = <-codeChan:
6464+ fmt.Println("Authorization successful!")
6565+ case err := <-errChan:
6666+ return nil, fmt.Errorf("authorization failed: %w", err)
6767+ case <-time.After(5 * time.Minute):
6868+ return nil, fmt.Errorf("authorization timed out")
6969+ }
7070+7171+ // Exchange code for token
7272+ ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
7373+ defer cancel()
7474+7575+ token, err := client.Exchange(ctx, code, codeVerifier)
7676+ if err != nil {
7777+ return nil, fmt.Errorf("failed to exchange code: %w", err)
7878+ }
7979+8080+ // Resolve handle to get DID
8181+ resolver := atproto.NewResolver()
8282+ did, _, err := resolver.ResolveIdentity(context.Background(), handle)
8383+ if err != nil {
8484+ return nil, fmt.Errorf("failed to resolve DID: %w", err)
8585+ }
8686+8787+ // Create token store
8888+ store := &oauth.TokenStore{
8989+ AccessToken: token.AccessToken,
9090+ RefreshToken: token.RefreshToken,
9191+ TokenType: token.TokenType,
9292+ ExpiresAt: token.Expiry,
9393+ Handle: handle,
9494+ DID: did,
9595+ }
9696+9797+ // Save DPoP key
9898+ if err := store.SetDPoPKey(client.DPoPKey()); err != nil {
9999+ return nil, fmt.Errorf("failed to save DPoP key: %w", err)
100100+ }
101101+102102+ return store, nil
103103+}
104104+105105+// startCallbackServer starts a local HTTP server to receive the OAuth callback
106106+func startCallbackServer(codeChan chan string, errChan chan error) *http.Server {
107107+ mux := http.NewServeMux()
108108+109109+ mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
110110+ code := r.URL.Query().Get("code")
111111+ errorParam := r.URL.Query().Get("error")
112112+113113+ if errorParam != "" {
114114+ errChan <- fmt.Errorf("OAuth error: %s (%s)",
115115+ errorParam,
116116+ r.URL.Query().Get("error_description"))
117117+ http.Error(w, "Authorization failed", http.StatusBadRequest)
118118+ return
119119+ }
120120+121121+ if code == "" {
122122+ errChan <- fmt.Errorf("no code in callback")
123123+ http.Error(w, "No code provided", http.StatusBadRequest)
124124+ return
125125+ }
126126+127127+ codeChan <- code
128128+ w.Header().Set("Content-Type", "text/html")
129129+ fmt.Fprintf(w, `
130130+ <html>
131131+ <head><title>ATCR Authorization</title></head>
132132+ <body>
133133+ <h1>Authorization Successful!</h1>
134134+ <p>You can close this window and return to the terminal.</p>
135135+ </body>
136136+ </html>
137137+ `)
138138+ })
139139+140140+ server := &http.Server{
141141+ Addr: ":8888",
142142+ Handler: mux,
143143+ }
144144+145145+ go func() {
146146+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
147147+ errChan <- fmt.Errorf("callback server error: %w", err)
148148+ }
149149+ }()
150150+151151+ return server
152152+}
153153+154154+// openBrowser opens the default browser to the given URL
155155+func openBrowser(url string) error {
156156+ var cmd *exec.Cmd
157157+158158+ switch runtime.GOOS {
159159+ case "darwin":
160160+ cmd = exec.Command("open", url)
161161+ case "linux":
162162+ cmd = exec.Command("xdg-open", url)
163163+ case "windows":
164164+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
165165+ default:
166166+ return fmt.Errorf("unsupported platform")
167167+ }
168168+169169+ return cmd.Start()
170170+}
171171+172172+// generateState generates a random state parameter
173173+func generateState() string {
174174+ // Use the same UUID generation as we do elsewhere
175175+ return fmt.Sprintf("state_%d", time.Now().UnixNano())
176176+}
+119
cmd/credential-helper/token.go
···11+package main
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "fmt"
88+ "net/http"
99+ "time"
1010+1111+ "atcr.io/pkg/auth/oauth"
1212+)
1313+1414+// tokenData holds the token information
1515+type tokenData struct {
1616+ *oauth.TokenStore
1717+ IsExpired bool
1818+}
1919+2020+// loadToken loads the token from disk
2121+func loadToken(path string) (*tokenData, error) {
2222+ store, err := oauth.LoadTokenStore(path)
2323+ if err != nil {
2424+ return nil, err
2525+ }
2626+2727+ return &tokenData{
2828+ TokenStore: store,
2929+ IsExpired: store.IsExpired(),
3030+ }, nil
3131+}
3232+3333+// refreshToken refreshes an expired token
3434+func refreshToken(token *tokenData) error {
3535+ // Create OAuth client
3636+ client, err := oauth.NewClient("http://localhost:8888/client-metadata.json", "http://localhost:8888/callback")
3737+ if err != nil {
3838+ return fmt.Errorf("failed to create OAuth client: %w", err)
3939+ }
4040+4141+ // Load DPoP key
4242+ dpopKey, err := token.GetDPoPKey()
4343+ if err != nil {
4444+ return fmt.Errorf("failed to load DPoP key: %w", err)
4545+ }
4646+ client.SetDPoPKey(dpopKey)
4747+4848+ // Initialize for the handle
4949+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
5050+ defer cancel()
5151+5252+ if err := client.InitializeForHandle(ctx, token.Handle); err != nil {
5353+ return fmt.Errorf("failed to initialize client: %w", err)
5454+ }
5555+5656+ // Refresh the token
5757+ newToken, err := client.RefreshToken(ctx, token.RefreshToken)
5858+ if err != nil {
5959+ return fmt.Errorf("failed to refresh token: %w", err)
6060+ }
6161+6262+ // Update token store
6363+ token.AccessToken = newToken.AccessToken
6464+ token.RefreshToken = newToken.RefreshToken
6565+ token.ExpiresAt = newToken.Expiry
6666+ token.IsExpired = false
6767+6868+ // Save updated token
6969+ return token.Save(getTokenPath())
7070+}
7171+7272+// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT
7373+func exchangeForRegistryToken(atprotoToken, registryURL string) (string, error) {
7474+ // Call the registry's /auth/exchange endpoint
7575+ // This endpoint validates the ATProto token and returns a registry JWT
7676+7777+ exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL)
7878+7979+ // Load token store to get DID/handle
8080+ store, err := loadToken(getTokenPath())
8181+ if err != nil {
8282+ return "", fmt.Errorf("failed to load token store: %w", err)
8383+ }
8484+8585+ reqBody := map[string]interface{}{
8686+ "access_token": atprotoToken,
8787+ "handle": store.Handle, // Required for PDS resolution and token validation
8888+ "scope": []string{"repository:*:pull,push"},
8989+ }
9090+9191+ body, err := json.Marshal(reqBody)
9292+ if err != nil {
9393+ return "", fmt.Errorf("failed to marshal request: %w", err)
9494+ }
9595+9696+ resp, err := http.Post(exchangeURL, "application/json", bytes.NewReader(body))
9797+ if err != nil {
9898+ return "", fmt.Errorf("failed to call exchange endpoint: %w", err)
9999+ }
100100+ defer resp.Body.Close()
101101+102102+ if resp.StatusCode != http.StatusOK {
103103+ return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode)
104104+ }
105105+106106+ var result struct {
107107+ Token string `json:"token"`
108108+ AccessToken string `json:"access_token"`
109109+ }
110110+111111+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
112112+ return "", fmt.Errorf("failed to decode response: %w", err)
113113+ }
114114+115115+ if result.Token != "" {
116116+ return result.Token, nil
117117+ }
118118+ return result.AccessToken, nil
119119+}
+770
cmd/hold/main.go
···11+package main
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "log"
99+ "net/http"
1010+ "net/url"
1111+ "os"
1212+ "strings"
1313+ "time"
1414+1515+ "github.com/distribution/distribution/v3/configuration"
1616+ storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
1717+ "github.com/distribution/distribution/v3/registry/storage/driver/factory"
1818+1919+ "atcr.io/pkg/atproto"
2020+ "atcr.io/pkg/auth/oauth"
2121+2222+ // Import storage drivers
2323+ _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
2424+ _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
2525+)
2626+2727+// Config represents the hold service configuration
2828+type Config struct {
2929+ Version string `yaml:"version"`
3030+ Storage StorageConfig `yaml:"storage"`
3131+ Server ServerConfig `yaml:"server"`
3232+ Registration RegistrationConfig `yaml:"registration"`
3333+}
3434+3535+// RegistrationConfig defines auto-registration settings
3636+type RegistrationConfig struct {
3737+ // OwnerDID is the owner's ATProto DID (from env: HOLD_CREW_OWNER)
3838+ // If set, auto-registration is enabled
3939+ OwnerDID string `yaml:"owner_did"`
4040+}
4141+4242+// StorageConfig wraps distribution's storage configuration
4343+type StorageConfig struct {
4444+ configuration.Storage `yaml:",inline"`
4545+}
4646+4747+// ServerConfig defines server settings
4848+type ServerConfig struct {
4949+ // Addr is the address to listen on (e.g., ":8080")
5050+ Addr string `yaml:"addr"`
5151+5252+ // PublicURL is the public URL of this hold service (e.g., "https://hold.example.com")
5353+ PublicURL string `yaml:"public_url"`
5454+5555+ // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC)
5656+ Public bool `yaml:"public"`
5757+5858+ // ReadTimeout for HTTP requests
5959+ ReadTimeout time.Duration `yaml:"read_timeout"`
6060+6161+ // WriteTimeout for HTTP requests
6262+ WriteTimeout time.Duration `yaml:"write_timeout"`
6363+}
6464+6565+// HoldService provides presigned URLs for blob storage in a hold
6666+type HoldService struct {
6767+ driver storagedriver.StorageDriver
6868+ config *Config
6969+}
7070+7171+// NewHoldService creates a new hold service
7272+func NewHoldService(cfg *Config) (*HoldService, error) {
7373+ // Create storage driver from config
7474+ ctx := context.Background()
7575+ driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
7676+ if err != nil {
7777+ return nil, fmt.Errorf("failed to create storage driver: %w", err)
7878+ }
7979+8080+ return &HoldService{
8181+ driver: driver,
8282+ config: cfg,
8383+ }, nil
8484+}
8585+8686+// GetPresignedURLRequest represents a request for a presigned download URL
8787+type GetPresignedURLRequest struct {
8888+ DID string `json:"did"`
8989+ Digest string `json:"digest"`
9090+}
9191+9292+// GetPresignedURLResponse contains the presigned URL
9393+type GetPresignedURLResponse struct {
9494+ URL string `json:"url"`
9595+ ExpiresAt time.Time `json:"expires_at"`
9696+}
9797+9898+// PutPresignedURLRequest represents a request for a presigned upload URL
9999+type PutPresignedURLRequest struct {
100100+ DID string `json:"did"`
101101+ Digest string `json:"digest"`
102102+ Size int64 `json:"size"`
103103+}
104104+105105+// PutPresignedURLResponse contains the presigned upload URL
106106+type PutPresignedURLResponse struct {
107107+ URL string `json:"url"`
108108+ ExpiresAt time.Time `json:"expires_at"`
109109+}
110110+111111+// HandleGetPresignedURL handles requests for download URLs
112112+func (s *HoldService) HandleGetPresignedURL(w http.ResponseWriter, r *http.Request) {
113113+ if r.Method != http.MethodPost {
114114+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
115115+ return
116116+ }
117117+118118+ var req GetPresignedURLRequest
119119+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
120120+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
121121+ return
122122+ }
123123+124124+ // Validate DID authorization
125125+ if !s.isAuthorized(req.DID) {
126126+ http.Error(w, "forbidden: DID not authorized", http.StatusForbidden)
127127+ return
128128+ }
129129+130130+ // Generate presigned URL (15 minute expiry)
131131+ ctx := context.Background()
132132+ expiry := time.Now().Add(15 * time.Minute)
133133+134134+ // For now, construct direct URL to blob
135135+ // In production, this would use driver-specific presigned URLs
136136+ url, err := s.getDownloadURL(ctx, req.Digest)
137137+ if err != nil {
138138+ http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
139139+ return
140140+ }
141141+142142+ resp := GetPresignedURLResponse{
143143+ URL: url,
144144+ ExpiresAt: expiry,
145145+ }
146146+147147+ w.Header().Set("Content-Type", "application/json")
148148+ json.NewEncoder(w).Encode(resp)
149149+}
150150+151151+// HandlePutPresignedURL handles requests for upload URLs
152152+func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Request) {
153153+ if r.Method != http.MethodPost {
154154+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
155155+ return
156156+ }
157157+158158+ var req PutPresignedURLRequest
159159+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
160160+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
161161+ return
162162+ }
163163+164164+ // Validate DID authorization
165165+ if !s.isAuthorized(req.DID) {
166166+ http.Error(w, "forbidden: DID not authorized", http.StatusForbidden)
167167+ return
168168+ }
169169+170170+ // Generate presigned upload URL (15 minute expiry)
171171+ ctx := context.Background()
172172+ expiry := time.Now().Add(15 * time.Minute)
173173+174174+ url, err := s.getUploadURL(ctx, req.Digest, req.Size)
175175+ if err != nil {
176176+ http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
177177+ return
178178+ }
179179+180180+ resp := PutPresignedURLResponse{
181181+ URL: url,
182182+ ExpiresAt: expiry,
183183+ }
184184+185185+ w.Header().Set("Content-Type", "application/json")
186186+ json.NewEncoder(w).Encode(resp)
187187+}
188188+189189+// HandleProxyGet proxies a blob download through the service
190190+func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) {
191191+ if r.Method != http.MethodGet {
192192+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
193193+ return
194194+ }
195195+196196+ // Extract digest from path (e.g., /blobs/sha256:abc123)
197197+ digest := r.URL.Path[len("/blobs/"):]
198198+ if digest == "" {
199199+ http.Error(w, "missing digest", http.StatusBadRequest)
200200+ return
201201+ }
202202+203203+ // Get DID from query param or header
204204+ did := r.URL.Query().Get("did")
205205+ if did == "" {
206206+ did = r.Header.Get("X-ATCR-DID")
207207+ }
208208+209209+ if !s.isAuthorized(did) {
210210+ http.Error(w, "forbidden", http.StatusForbidden)
211211+ return
212212+ }
213213+214214+ // Read blob from storage
215215+ ctx := r.Context()
216216+ path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
217217+218218+ content, err := s.driver.GetContent(ctx, path)
219219+ if err != nil {
220220+ http.Error(w, "blob not found", http.StatusNotFound)
221221+ return
222222+ }
223223+224224+ w.Header().Set("Content-Type", "application/octet-stream")
225225+ w.Write(content)
226226+}
227227+228228+// HandleProxyPut proxies a blob upload through the service
229229+func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) {
230230+ if r.Method != http.MethodPut {
231231+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
232232+ return
233233+ }
234234+235235+ digest := r.URL.Path[len("/blobs/"):]
236236+ if digest == "" {
237237+ http.Error(w, "missing digest", http.StatusBadRequest)
238238+ return
239239+ }
240240+241241+ did := r.URL.Query().Get("did")
242242+ if did == "" {
243243+ did = r.Header.Get("X-ATCR-DID")
244244+ }
245245+246246+ if !s.isAuthorized(did) {
247247+ http.Error(w, "forbidden", http.StatusForbidden)
248248+ return
249249+ }
250250+251251+ // Write blob to storage
252252+ ctx := r.Context()
253253+ path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
254254+255255+ content, err := io.ReadAll(r.Body)
256256+ if err != nil {
257257+ http.Error(w, "failed to read body", http.StatusBadRequest)
258258+ return
259259+ }
260260+261261+ if err := s.driver.PutContent(ctx, path, content); err != nil {
262262+ http.Error(w, "failed to store blob", http.StatusInternalServerError)
263263+ return
264264+ }
265265+266266+ w.WriteHeader(http.StatusCreated)
267267+}
268268+269269+// isAuthorized checks if a DID is authorized to use this hold
270270+// Authorization is now based on:
271271+// - Hold record's "public" field (for reads)
272272+// - Crew records in PDS (for writes)
273273+// TODO: Query PDS to check hold.public and crew membership
274274+func (s *HoldService) isAuthorized(did string) bool {
275275+ // For now, allow all requests
276276+ // Real implementation should query PDS for hold record and crew records
277277+ return true
278278+}
279279+280280+// getDownloadURL generates a download URL for a blob
281281+func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) {
282282+ // Check if blob exists
283283+ path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
284284+ _, err := s.driver.Stat(ctx, path)
285285+ if err != nil {
286286+ return "", fmt.Errorf("blob not found: %w", err)
287287+ }
288288+289289+ // For drivers that support presigned URLs (S3), use those
290290+ // For now, return a proxy URL through this service
291291+ return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil
292292+}
293293+294294+// getUploadURL generates an upload URL for a blob
295295+func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64) (string, error) {
296296+ // For drivers that support presigned URLs (S3), use those
297297+ // For now, return a proxy URL through this service
298298+ return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil
299299+}
300300+301301+// RegisterRequest represents a request to register this hold in a user's PDS
302302+type RegisterRequest struct {
303303+ DID string `json:"did"`
304304+ AccessToken string `json:"access_token"`
305305+ PDSEndpoint string `json:"pds_endpoint"`
306306+}
307307+308308+// RegisterResponse contains the registration result
309309+type RegisterResponse struct {
310310+ HoldURI string `json:"hold_uri"`
311311+ CrewURI string `json:"crew_uri"`
312312+ Message string `json:"message"`
313313+}
314314+315315+// HandleRegister registers this hold service in a user's PDS (manual endpoint)
316316+func (s *HoldService) HandleRegister(w http.ResponseWriter, r *http.Request) {
317317+ if r.Method != http.MethodPost {
318318+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
319319+ return
320320+ }
321321+322322+ var req RegisterRequest
323323+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
324324+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
325325+ return
326326+ }
327327+328328+ // Validate required fields
329329+ if req.DID == "" || req.AccessToken == "" || req.PDSEndpoint == "" {
330330+ http.Error(w, "missing required fields: did, access_token, pds_endpoint", http.StatusBadRequest)
331331+ return
332332+ }
333333+334334+ // Get public URL from config
335335+ publicURL := s.config.Server.PublicURL
336336+ if publicURL == "" {
337337+ // Fallback to constructing URL from request
338338+ scheme := "http"
339339+ if r.TLS != nil {
340340+ scheme = "https"
341341+ }
342342+ publicURL = fmt.Sprintf("%s://%s", scheme, r.Host)
343343+ }
344344+345345+ // Derive hold name from URL
346346+ holdName, err := extractHostname(publicURL)
347347+ if err != nil {
348348+ http.Error(w, fmt.Sprintf("failed to extract hostname: %v", err), http.StatusBadRequest)
349349+ return
350350+ }
351351+352352+ ctx := r.Context()
353353+354354+ // Create ATProto client with user's credentials
355355+ client := atproto.NewClient(req.PDSEndpoint, req.DID, req.AccessToken)
356356+357357+ // Create HoldRecord
358358+ holdRecord := atproto.NewHoldRecord(publicURL, req.DID, s.config.Server.Public)
359359+360360+ holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
361361+ if err != nil {
362362+ http.Error(w, fmt.Sprintf("failed to create hold record: %v", err), http.StatusInternalServerError)
363363+ return
364364+ }
365365+366366+ log.Printf("Created hold record: %s", holdResult.URI)
367367+368368+ // Create HoldCrewRecord for the owner
369369+ crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, req.DID, "owner")
370370+371371+ crewRKey := fmt.Sprintf("%s-%s", holdName, req.DID)
372372+ crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
373373+ if err != nil {
374374+ http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
375375+ return
376376+ }
377377+378378+ log.Printf("Created crew record: %s", crewResult.URI)
379379+380380+ resp := RegisterResponse{
381381+ HoldURI: holdResult.URI,
382382+ CrewURI: crewResult.URI,
383383+ Message: fmt.Sprintf("Successfully registered hold service. Storage endpoint: %s", publicURL),
384384+ }
385385+386386+ w.Header().Set("Content-Type", "application/json")
387387+ json.NewEncoder(w).Encode(resp)
388388+}
389389+390390+// HealthHandler handles health check requests
391391+func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
392392+ w.Header().Set("Content-Type", "application/json")
393393+ json.NewEncoder(w).Encode(map[string]string{
394394+ "status": "ok",
395395+ })
396396+}
397397+398398+func main() {
399399+ // Load configuration from environment variables
400400+ cfg, err := loadConfigFromEnv()
401401+ if err != nil {
402402+ log.Fatalf("Failed to load config: %v", err)
403403+ }
404404+405405+ // Create hold service
406406+ service, err := NewHoldService(cfg)
407407+ if err != nil {
408408+ log.Fatalf("Failed to create hold service: %v", err)
409409+ }
410410+411411+ // Auto-register if owner DID is set
412412+ if cfg.Registration.OwnerDID != "" {
413413+ if err := service.AutoRegister(); err != nil {
414414+ log.Printf("WARNING: Auto-registration failed: %v", err)
415415+ log.Printf("You can register manually later using the /register endpoint")
416416+ } else {
417417+ log.Printf("Successfully registered hold service in PDS")
418418+ }
419419+ }
420420+421421+ // Setup HTTP routes
422422+ mux := http.NewServeMux()
423423+ mux.HandleFunc("/health", service.HealthHandler)
424424+ mux.HandleFunc("/register", service.HandleRegister)
425425+ mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL)
426426+ mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL)
427427+ mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
428428+ if r.Method == http.MethodGet {
429429+ service.HandleProxyGet(w, r)
430430+ } else if r.Method == http.MethodPut {
431431+ service.HandleProxyPut(w, r)
432432+ } else {
433433+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
434434+ }
435435+ })
436436+437437+ // Create server
438438+ server := &http.Server{
439439+ Addr: cfg.Server.Addr,
440440+ Handler: mux,
441441+ ReadTimeout: cfg.Server.ReadTimeout,
442442+ WriteTimeout: cfg.Server.WriteTimeout,
443443+ }
444444+445445+ log.Printf("Starting hold service on %s", cfg.Server.Addr)
446446+ if err := server.ListenAndServe(); err != nil {
447447+ log.Fatalf("Server failed: %v", err)
448448+ }
449449+}
450450+451451+// loadConfigFromEnv loads all configuration from environment variables
452452+func loadConfigFromEnv() (*Config, error) {
453453+ cfg := &Config{
454454+ Version: "0.1",
455455+ }
456456+457457+ // Server configuration
458458+ cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080")
459459+ cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL")
460460+ if cfg.Server.PublicURL == "" {
461461+ return nil, fmt.Errorf("HOLD_PUBLIC_URL is required")
462462+ }
463463+ cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true"
464464+ cfg.Server.ReadTimeout = 30 * time.Second
465465+ cfg.Server.WriteTimeout = 30 * time.Second
466466+467467+ // Registration configuration (optional)
468468+ cfg.Registration.OwnerDID = os.Getenv("HOLD_CREW_OWNER")
469469+470470+ // Storage configuration - build from env vars based on storage type
471471+ storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
472472+ var err error
473473+ cfg.Storage, err = buildStorageConfig(storageType)
474474+ if err != nil {
475475+ return nil, fmt.Errorf("failed to build storage config: %w", err)
476476+ }
477477+478478+ return cfg, nil
479479+}
480480+481481+// buildStorageConfig creates storage configuration based on driver type
482482+func buildStorageConfig(driver string) (StorageConfig, error) {
483483+ params := make(map[string]interface{})
484484+485485+ switch driver {
486486+ case "s3":
487487+ // S3/Storj/Minio configuration from standard AWS env vars
488488+ accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
489489+ secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
490490+ region := getEnvOrDefault("AWS_REGION", "us-east-1")
491491+ bucket := os.Getenv("S3_BUCKET")
492492+ endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio
493493+494494+ if bucket == "" {
495495+ return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage")
496496+ }
497497+498498+ params["accesskey"] = accessKey
499499+ params["secretkey"] = secretKey
500500+ params["region"] = region
501501+ params["bucket"] = bucket
502502+ if endpoint != "" {
503503+ params["regionendpoint"] = endpoint
504504+ }
505505+506506+ case "filesystem":
507507+ // Filesystem configuration
508508+ rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold")
509509+ params["rootdirectory"] = rootDir
510510+511511+ default:
512512+ return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver)
513513+ }
514514+515515+ // Build distribution Storage config
516516+ storageCfg := configuration.Storage{}
517517+ storageCfg[driver] = configuration.Parameters(params)
518518+519519+ return StorageConfig{Storage: storageCfg}, nil
520520+}
521521+522522+// getEnvOrDefault gets an environment variable or returns a default value
523523+func getEnvOrDefault(key, defaultValue string) string {
524524+ if val := os.Getenv(key); val != "" {
525525+ return val
526526+ }
527527+ return defaultValue
528528+}
529529+530530+// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
531531+func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
532532+ // We need to query the PDS without authentication to check public records
533533+ // ATProto records are publicly readable, so we can use an unauthenticated client
534534+ client := atproto.NewClient(pdsEndpoint, did, "")
535535+536536+ // List all hold records for this DID
537537+ records, err := client.ListRecords(ctx, atproto.HoldCollection, 100)
538538+ if err != nil {
539539+ return false, fmt.Errorf("failed to list hold records: %w", err)
540540+ }
541541+542542+ // Check if any hold record matches our public URL
543543+ for _, record := range records {
544544+ var holdRecord atproto.HoldRecord
545545+ if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
546546+ continue
547547+ }
548548+549549+ if holdRecord.Endpoint == publicURL {
550550+ return true, nil
551551+ }
552552+ }
553553+554554+ return false, nil
555555+}
556556+557557+// AutoRegister registers this hold service in the owner's PDS
558558+// Checks if already registered first, then does OAuth if needed
559559+func (s *HoldService) AutoRegister() error {
560560+ reg := &s.config.Registration
561561+ publicURL := s.config.Server.PublicURL
562562+563563+ if publicURL == "" {
564564+ return fmt.Errorf("HOLD_PUBLIC_URL not set")
565565+ }
566566+567567+ if reg.OwnerDID == "" {
568568+ return fmt.Errorf("HOLD_CREW_OWNER not set - required for registration")
569569+ }
570570+571571+ ctx := context.Background()
572572+573573+ log.Printf("Checking registration status for DID: %s", reg.OwnerDID)
574574+575575+ // Resolve DID to PDS endpoint
576576+ resolver := atproto.NewResolver()
577577+ pdsEndpoint, err := resolver.ResolvePDS(ctx, reg.OwnerDID)
578578+ if err != nil {
579579+ return fmt.Errorf("failed to resolve PDS for DID: %w", err)
580580+ }
581581+582582+ log.Printf("PDS endpoint: %s", pdsEndpoint)
583583+584584+ // Check if hold is already registered
585585+ isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL)
586586+ if err != nil {
587587+ log.Printf("Warning: failed to check registration status: %v", err)
588588+ log.Printf("Proceeding with OAuth registration...")
589589+ } else if isRegistered {
590590+ log.Printf("✓ Hold service already registered in PDS")
591591+ log.Printf("Public URL: %s", publicURL)
592592+ return nil
593593+ }
594594+595595+ // Not registered, need to do OAuth
596596+ log.Printf("Hold not registered, starting OAuth flow...")
597597+598598+ // Get handle from DID document
599599+ handle, err := resolver.ResolveHandleFromDID(ctx, reg.OwnerDID)
600600+ if err != nil {
601601+ return fmt.Errorf("failed to get handle from DID: %w", err)
602602+ }
603603+604604+ log.Printf("Resolved handle: %s", handle)
605605+ log.Printf("Starting OAuth registration for hold service")
606606+ log.Printf("Public URL: %s", publicURL)
607607+608608+ return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint)
609609+}
610610+611611+// registerWithOAuth performs OAuth flow and registers the hold
612612+func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error {
613613+ // Use 127.0.0.1 for localhost callback (works better than "localhost")
614614+ callbackAddr := "127.0.0.1:8888"
615615+ redirectURI := fmt.Sprintf("http://%s/callback", callbackAddr)
616616+617617+ // Create OAuth client
618618+ oauthClient, err := oauth.NewClient("http://hold-service", redirectURI)
619619+ if err != nil {
620620+ return fmt.Errorf("failed to create OAuth client: %w", err)
621621+ }
622622+623623+ // Initialize for the user's handle
624624+ ctx := context.Background()
625625+ if err := oauthClient.InitializeForHandle(ctx, handle); err != nil {
626626+ return fmt.Errorf("failed to initialize OAuth: %w", err)
627627+ }
628628+629629+ // Generate authorization URL
630630+ state := "hold-registration"
631631+ authURL, codeVerifier, err := oauthClient.AuthorizeURL(state)
632632+ if err != nil {
633633+ return fmt.Errorf("failed to generate auth URL: %w", err)
634634+ }
635635+636636+ // Print the OAuth URL for user to visit
637637+ log.Printf("\n" + strings.Repeat("=", 80))
638638+ log.Printf("OAUTH AUTHORIZATION REQUIRED")
639639+ log.Printf(strings.Repeat("=", 80))
640640+ log.Printf("\nPlease visit this URL to authorize the hold service:\n")
641641+ log.Printf(" %s\n", authURL)
642642+ log.Printf("Waiting for authorization...")
643643+ log.Printf(strings.Repeat("=", 80) + "\n")
644644+645645+ // Start temporary HTTP server for callback
646646+ codeChan := make(chan string, 1)
647647+ errChan := make(chan error, 1)
648648+649649+ mux := http.NewServeMux()
650650+ mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
651651+ code := r.URL.Query().Get("code")
652652+ receivedState := r.URL.Query().Get("state")
653653+654654+ if receivedState != state {
655655+ errChan <- fmt.Errorf("invalid state parameter")
656656+ http.Error(w, "Invalid state", http.StatusBadRequest)
657657+ return
658658+ }
659659+660660+ if code == "" {
661661+ errChan <- fmt.Errorf("no authorization code received")
662662+ http.Error(w, "No code", http.StatusBadRequest)
663663+ return
664664+ }
665665+666666+ w.Header().Set("Content-Type", "text/html")
667667+ fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
668668+ codeChan <- code
669669+ })
670670+671671+ server := &http.Server{
672672+ Addr: callbackAddr,
673673+ Handler: mux,
674674+ }
675675+676676+ go func() {
677677+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
678678+ errChan <- err
679679+ }
680680+ }()
681681+682682+ // Wait for callback or error
683683+ var code string
684684+ select {
685685+ case code = <-codeChan:
686686+ // Got the code, shutdown callback server
687687+ server.Shutdown(context.Background())
688688+ case err := <-errChan:
689689+ server.Shutdown(context.Background())
690690+ return err
691691+ case <-time.After(5 * time.Minute):
692692+ server.Shutdown(context.Background())
693693+ return fmt.Errorf("OAuth timeout - no response after 5 minutes")
694694+ }
695695+696696+ log.Printf("Authorization received, exchanging code for token...")
697697+698698+ // Exchange code for token
699699+ token, err := oauthClient.Exchange(ctx, code, codeVerifier)
700700+ if err != nil {
701701+ return fmt.Errorf("failed to exchange code: %w", err)
702702+ }
703703+704704+ log.Printf("OAuth token obtained successfully")
705705+ log.Printf("DID: %s", did)
706706+ log.Printf("PDS: %s", pdsEndpoint)
707707+708708+ // Now register with the token
709709+ return s.registerWithToken(publicURL, did, pdsEndpoint, token.AccessToken)
710710+}
711711+712712+// registerWithToken registers the hold using an access token
713713+func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken string) error {
714714+ // Derive hold name from URL (hostname)
715715+ holdName, err := extractHostname(publicURL)
716716+ if err != nil {
717717+ return fmt.Errorf("failed to extract hostname from URL: %w", err)
718718+ }
719719+720720+ log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
721721+722722+ ctx := context.Background()
723723+724724+ // Create ATProto client with owner's credentials
725725+ client := atproto.NewClient(pdsEndpoint, did, accessToken)
726726+727727+ // Create HoldRecord
728728+ holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
729729+730730+ // Use hostname as record key
731731+ holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
732732+ if err != nil {
733733+ return fmt.Errorf("failed to create hold record: %w", err)
734734+ }
735735+736736+ log.Printf("✓ Created hold record: %s", holdResult.URI)
737737+738738+ // Create HoldCrewRecord for the owner
739739+ crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner")
740740+741741+ crewRKey := fmt.Sprintf("%s-%s", holdName, did)
742742+ crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
743743+ if err != nil {
744744+ return fmt.Errorf("failed to create crew record: %w", err)
745745+ }
746746+747747+ log.Printf("✓ Created crew record: %s", crewResult.URI)
748748+749749+ log.Printf("\n" + strings.Repeat("=", 80))
750750+ log.Printf("REGISTRATION COMPLETE")
751751+ log.Printf(strings.Repeat("=", 80))
752752+ log.Printf("Hold service is now registered and ready to use!")
753753+ log.Printf(strings.Repeat("=", 80) + "\n")
754754+755755+ return nil
756756+}
757757+758758+// extractHostname extracts the hostname from a URL to use as the hold name
759759+func extractHostname(urlStr string) (string, error) {
760760+ u, err := url.Parse(urlStr)
761761+ if err != nil {
762762+ return "", err
763763+ }
764764+ // Remove port if present
765765+ hostname := u.Hostname()
766766+ if hostname == "" {
767767+ return "", fmt.Errorf("no hostname in URL")
768768+ }
769769+ return hostname, nil
770770+}
···11+package main
22+33+import (
44+ "context"
55+ "fmt"
66+ "net/http"
77+ "os"
88+ "os/signal"
99+ "syscall"
1010+ "time"
1111+1212+ "github.com/distribution/distribution/v3/configuration"
1313+ "github.com/distribution/distribution/v3/registry"
1414+ "github.com/distribution/distribution/v3/registry/handlers"
1515+ "github.com/spf13/cobra"
1616+1717+ "atcr.io/pkg/auth/exchange"
1818+ "atcr.io/pkg/auth/token"
1919+)
2020+2121+var serveCmd = &cobra.Command{
2222+ Use: "serve <config>",
2323+ Short: "Start the ATCR registry server",
2424+ Long: "Start the ATCR registry server with authentication endpoints",
2525+ Args: cobra.ExactArgs(1),
2626+ RunE: serveRegistry,
2727+}
2828+2929+func init() {
3030+ // Replace the default serve command with our custom one
3131+ for i, cmd := range registry.RootCmd.Commands() {
3232+ if cmd.Name() == "serve" {
3333+ registry.RootCmd.Commands()[i] = serveCmd
3434+ break
3535+ }
3636+ }
3737+}
3838+3939+func serveRegistry(cmd *cobra.Command, args []string) error {
4040+ configPath := args[0]
4141+4242+ // Parse configuration
4343+ fp, err := os.Open(configPath)
4444+ if err != nil {
4545+ return fmt.Errorf("failed to open config file: %w", err)
4646+ }
4747+ defer fp.Close()
4848+4949+ config, err := configuration.Parse(fp)
5050+ if err != nil {
5151+ return fmt.Errorf("failed to parse configuration: %w", err)
5252+ }
5353+5454+ // Initialize auth keys if needed
5555+ var issuer *token.Issuer
5656+ if config.Auth["token"] != nil {
5757+ if err := initializeAuthKeys(config); err != nil {
5858+ return fmt.Errorf("failed to initialize auth keys: %w", err)
5959+ }
6060+6161+ // Create token issuer for auth handlers
6262+ issuer, err = createTokenIssuer(config)
6363+ if err != nil {
6464+ return fmt.Errorf("failed to create token issuer: %w", err)
6565+ }
6666+ }
6767+6868+ // Create registry app (returns http.Handler)
6969+ ctx := context.Background()
7070+ app := handlers.NewApp(ctx, config)
7171+7272+ // Create main HTTP mux
7373+ mux := http.NewServeMux()
7474+7575+ // Mount registry at /v2/
7676+ mux.Handle("/v2/", app)
7777+7878+ // Mount auth endpoints if enabled
7979+ if issuer != nil {
8080+ // Extract default hold endpoint from middleware config
8181+ defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
8282+8383+ tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint)
8484+ tokenHandler.RegisterRoutes(mux)
8585+8686+ exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint)
8787+ exchangeHandler.RegisterRoutes(mux)
8888+ fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange")
8989+ }
9090+9191+ // Create HTTP server
9292+ server := &http.Server{
9393+ Addr: config.HTTP.Addr,
9494+ Handler: mux,
9595+ }
9696+9797+ // Handle graceful shutdown
9898+ stop := make(chan os.Signal, 1)
9999+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
100100+101101+ // Start server in goroutine
102102+ errChan := make(chan error, 1)
103103+ go func() {
104104+ fmt.Printf("Starting registry server on %s\n", config.HTTP.Addr)
105105+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
106106+ errChan <- err
107107+ }
108108+ }()
109109+110110+ // Wait for shutdown signal or error
111111+ select {
112112+ case <-stop:
113113+ fmt.Println("Shutting down registry server...")
114114+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
115115+ defer cancel()
116116+117117+ if err := server.Shutdown(shutdownCtx); err != nil {
118118+ return fmt.Errorf("server shutdown error: %w", err)
119119+ }
120120+ case err := <-errChan:
121121+ return fmt.Errorf("server error: %w", err)
122122+ }
123123+124124+ return nil
125125+}
126126+127127+// initializeAuthKeys creates the auth keys if they don't exist
128128+func initializeAuthKeys(config *configuration.Configuration) error {
129129+ tokenParams, ok := config.Auth["token"]
130130+ if !ok {
131131+ return nil
132132+ }
133133+134134+ privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
135135+ issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
136136+ service := getStringParam(tokenParams, "service", "atcr.io")
137137+ expirationSecs := getIntParam(tokenParams, "expiration", 300)
138138+139139+ // Create issuer (this will generate the key if it doesn't exist)
140140+ _, err := token.NewIssuer(
141141+ privateKeyPath,
142142+ issuerName,
143143+ service,
144144+ time.Duration(expirationSecs)*time.Second,
145145+ )
146146+ if err != nil {
147147+ return fmt.Errorf("failed to initialize token issuer: %w", err)
148148+ }
149149+150150+ fmt.Printf("Auth keys initialized at %s\n", privateKeyPath)
151151+ return nil
152152+}
153153+154154+// createTokenIssuer creates a token issuer for auth handlers
155155+func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, error) {
156156+ tokenParams, ok := config.Auth["token"]
157157+ if !ok {
158158+ return nil, fmt.Errorf("token auth not configured")
159159+ }
160160+161161+ privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
162162+ issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
163163+ service := getStringParam(tokenParams, "service", "atcr.io")
164164+ expirationSecs := getIntParam(tokenParams, "expiration", 300)
165165+166166+ return token.NewIssuer(
167167+ privateKeyPath,
168168+ issuerName,
169169+ service,
170170+ time.Duration(expirationSecs)*time.Second,
171171+ )
172172+}
173173+174174+// Helper functions to extract values from config parameters
175175+func getStringParam(params configuration.Parameters, key, defaultValue string) string {
176176+ if v, ok := params[key]; ok {
177177+ if s, ok := v.(string); ok {
178178+ return s
179179+ }
180180+ }
181181+ return defaultValue
182182+}
183183+184184+func getIntParam(params configuration.Parameters, key string, defaultValue int) int {
185185+ if v, ok := params[key]; ok {
186186+ if i, ok := v.(int); ok {
187187+ return i
188188+ }
189189+ }
190190+ return defaultValue
191191+}
192192+193193+// extractDefaultHoldEndpoint extracts the default hold endpoint from middleware config
194194+func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
195195+ // Navigate through: middleware.registry[].options.default_storage_endpoint
196196+ registryMiddleware, ok := config.Middleware["registry"]
197197+ if !ok {
198198+ return ""
199199+ }
200200+201201+ // Find atproto-resolver middleware
202202+ for _, mw := range registryMiddleware {
203203+ // Check if this is the atproto-resolver
204204+ if mw.Name != "atproto-resolver" {
205205+ continue
206206+ }
207207+208208+ // Extract options - options is configuration.Parameters which is map[string]interface{}
209209+ if mw.Options != nil {
210210+ if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok {
211211+ return endpoint
212212+ }
213213+ }
214214+ }
215215+216216+ return ""
217217+}
+56
config/config.yml
···11+version: 0.1
22+log:
33+ level: info
44+ formatter: text
55+ fields:
66+ service: atcr-registry
77+88+# Storage is handled by external services:
99+# - Manifests/Tags -> ATProto PDS (user's personal data server)
1010+# - Blobs/Layers -> Hold service (default or BYOS)
1111+# The AppView (registry) should be stateless with no local storage
1212+#
1313+# NOTE: The storage section below is required for distribution config validation
1414+# but is NOT actually used - all blob operations are routed through hold service
1515+storage:
1616+ inmemory: {}
1717+1818+http:
1919+ addr: :5000
2020+ headers:
2121+ X-Content-Type-Options: [nosniff]
2222+ debug:
2323+ addr: :5001
2424+2525+middleware:
2626+ registry:
2727+ # Name resolution middleware
2828+ - name: atproto-resolver
2929+ options:
3030+ # Default hold service for blob storage
3131+ # Users without their own hold will use this endpoint
3232+ default_storage_endpoint: http://atcr-hold:8080
3333+3434+# Authentication - all endpoints on port 5000
3535+auth:
3636+ token:
3737+ # Token service realm (where Docker gets tokens)
3838+ realm: http://127.0.0.1:5000/auth/token
3939+ service: atcr.io
4040+ issuer: atcr.io
4141+4242+ # Certificate bundle for validating JWTs
4343+ rootcertbundle: /var/lib/atcr/auth/private-key.crt
4444+4545+ # Private key for signing JWTs (used by custom auth handlers)
4646+ privatekey: /var/lib/atcr/auth/private-key.pem
4747+4848+ # Token expiration in seconds (5 minutes)
4949+ expiration: 300
5050+5151+# Health check
5252+health:
5353+ storagedriver:
5454+ enabled: true
5555+ interval: 10s
5656+ threshold: 3
+16
config/hold.yml
···11+# DEPRECATED: This config file is no longer used
22+#
33+# The hold service now loads all configuration from environment variables.
44+# Please use .env.example as a reference and set environment variables instead.
55+#
66+# See .env.example for all available configuration options.
77+#
88+# Key changes:
99+# - Storage config now uses standard env vars (AWS_ACCESS_KEY_ID, etc.)
1010+# - Authorization is now based on PDS records (hold.public, crew records)
1111+# - No more allow_all or allowed_dids config
1212+#
1313+# To run the hold service:
1414+# 1. Copy .env.example to .env
1515+# 2. Fill in your values
1616+# 3. Run: source .env && ./atcr-hold
credential-helper
This is a binary file and will not be displayed.
+34
docker-compose.yml
···11+services:
22+ registry:
33+ build:
44+ context: .
55+ dockerfile: Dockerfile
66+ image: atcr-registry:latest
77+ container_name: atcr-registry
88+ ports:
99+ - "5000:5000"
1010+ volumes:
1111+ # Only auth keys (could be moved to secrets in production)
1212+ - atcr-auth:/var/lib/atcr/auth
1313+ restart: unless-stopped
1414+ # The registry should be stateless - all storage is external:
1515+ # - Manifests/Tags -> ATProto PDS
1616+ # - Blobs/Layers -> Hold service
1717+ # Future: Add read_only: true for production deployments
1818+1919+ hold:
2020+ build:
2121+ context: .
2222+ dockerfile: Dockerfile.hold
2323+ image: atcr-hold:latest
2424+ container_name: atcr-hold
2525+ ports:
2626+ - "8080:8080"
2727+ volumes:
2828+ - atcr-hold:/var/lib/atcr/hold
2929+ restart: unless-stopped
3030+3131+volumes:
3232+ atcr-blobs:
3333+ atcr-hold:
3434+ atcr-auth:
+464
docs/BYOS.md
···11+# Bring Your Own Storage (BYOS)
22+33+## Overview
44+55+ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. This allows users to:
66+- Deploy their own storage service backed by S3/Storj/Minio/filesystem
77+- Control who can use their storage (public or private)
88+- Keep blob data in their own infrastructure while manifests remain in their ATProto PDS
99+1010+## Architecture
1111+1212+```
1313+┌─────────────────────────────────────────────┐
1414+│ ATCR AppView (API) │
1515+│ - Manifests → ATProto PDS │
1616+│ - Auth & token validation │
1717+│ - Blob routing (issues redirects) │
1818+│ - Profile management │
1919+└─────────────────┬───────────────────────────┘
2020+ │
2121+ │ Hold discovery priority:
2222+ │ 1. io.atcr.sailor.profile.defaultHold
2323+ │ 2. io.atcr.hold records
2424+ │ 3. AppView default_storage_endpoint
2525+ ▼
2626+┌─────────────────────────────────────────────┐
2727+│ User's PDS │
2828+│ - io.atcr.sailor.profile (hold preference) │
2929+│ - io.atcr.hold records (own holds) │
3030+│ - io.atcr.manifest records (with holdEP) │
3131+└─────────────────┬───────────────────────────┘
3232+ │
3333+ │ Redirects to hold
3434+ ▼
3535+┌─────────────────────────────────────────────┐
3636+│ Storage Service (Hold) │
3737+│ - Blob storage (S3/Storj/Minio/filesystem) │
3838+│ - Presigned URL generation │
3939+│ - Authorization (DID-based) │
4040+└─────────────────────────────────────────────┘
4141+```
4242+4343+## ATProto Records
4444+4545+### io.atcr.sailor.profile
4646+4747+**NEW:** User profile for hold selection preferences. Created automatically on first authentication.
4848+4949+```json
5050+{
5151+ "$type": "io.atcr.sailor.profile",
5252+ "defaultHold": "https://team-hold.example.com",
5353+ "createdAt": "2025-10-02T12:00:00Z",
5454+ "updatedAt": "2025-10-02T12:00:00Z"
5555+}
5656+```
5757+5858+**Record key:** Always `"self"` (only one profile per user)
5959+6060+**Behavior:**
6161+- Created automatically when user first authenticates (OAuth or Basic Auth)
6262+- If AppView has `default_storage_endpoint`, profile gets that as initial `defaultHold`
6363+- User can update to join shared holds or use their own hold
6464+- Set `defaultHold` to `null` to opt out of defaults (use own hold or AppView default)
6565+6666+**This solves the multi-hold problem:** Users who are crew members of multiple holds can explicitly choose which one to use via their profile.
6767+6868+### io.atcr.hold
6969+7070+Users create a hold record in their PDS to configure their own storage:
7171+7272+```json
7373+{
7474+ "$type": "io.atcr.hold",
7575+ "endpoint": "https://alice-storage.example.com",
7676+ "owner": "did:plc:alice123",
7777+ "public": false,
7878+ "createdAt": "2025-10-01T12:00:00Z"
7979+}
8080+```
8181+8282+### io.atcr.hold.crew
8383+8484+Hold owners can add crew members (for shared storage):
8585+8686+```json
8787+{
8888+ "$type": "io.atcr.hold.crew",
8989+ "hold": "at://did:plc:alice/io.atcr.hold/my-storage",
9090+ "member": "did:plc:bob456",
9191+ "role": "write",
9292+ "addedAt": "2025-10-01T12:00:00Z"
9393+}
9494+```
9595+9696+**Note:** Crew records are stored in the **hold owner's PDS**, not the crew member's PDS. This ensures the hold owner maintains full control over access.
9797+9898+## Storage Service
9999+100100+### Deployment
101101+102102+The storage service is a lightweight HTTP server that:
103103+1. Accepts presigned URL requests
104104+2. Verifies DID authorization
105105+3. Generates presigned URLs for S3/Storj/etc
106106+4. Returns URLs to AppView for client redirect
107107+108108+### Configuration
109109+110110+The hold service is configured entirely via environment variables. See `.env.example` for all options.
111111+112112+**Required environment variables:**
113113+114114+```bash
115115+# Hold service public URL (REQUIRED)
116116+HOLD_PUBLIC_URL=https://storage.example.com
117117+118118+# Storage driver type
119119+STORAGE_DRIVER=s3
120120+121121+# For S3/Minio
122122+AWS_ACCESS_KEY_ID=your_access_key
123123+AWS_SECRET_ACCESS_KEY=your_secret_key
124124+AWS_REGION=us-east-1
125125+S3_BUCKET=my-blobs
126126+127127+# For Storj (optional - custom S3 endpoint)
128128+# S3_ENDPOINT=https://gateway.storjshare.io
129129+130130+# For filesystem storage
131131+# STORAGE_DRIVER=filesystem
132132+# STORAGE_ROOT_DIR=/var/lib/atcr-storage
133133+```
134134+135135+**Authorization:**
136136+- Authorization is now based on PDS records, not local config
137137+- Public reads: controlled by `HOLD_PUBLIC` env var (stored in hold record)
138138+- Writes: controlled by `io.atcr.hold.crew` records in PDS
139139+140140+### Running
141141+142142+```bash
143143+# Build
144144+go build -o atcr-hold ./cmd/hold
145145+146146+# Set environment variables (or use .env file)
147147+export HOLD_PUBLIC_URL=https://storage.example.com
148148+export STORAGE_DRIVER=s3
149149+export AWS_ACCESS_KEY_ID=...
150150+export AWS_SECRET_ACCESS_KEY=...
151151+export AWS_REGION=us-east-1
152152+export S3_BUCKET=my-blobs
153153+154154+# Run
155155+./atcr-hold
156156+```
157157+158158+**Registration (required):**
159159+160160+The hold service must be registered in a PDS to be discoverable by the AppView.
161161+162162+**Standard registration workflow:**
163163+164164+1. Set `HOLD_CREW_OWNER` to your DID:
165165+ ```bash
166166+ export HOLD_CREW_OWNER=did:plc:your-did-here
167167+ ```
168168+169169+2. Start the hold service:
170170+ ```bash
171171+ ./atcr-hold
172172+ ```
173173+174174+3. **Check the logs** for the OAuth authorization URL:
175175+ ```
176176+ ================================================================================
177177+ OAUTH AUTHORIZATION REQUIRED
178178+ ================================================================================
179179+180180+ Please visit this URL to authorize the hold service:
181181+182182+ https://bsky.app/authorize?client_id=...
183183+184184+ Waiting for authorization...
185185+ ================================================================================
186186+ ```
187187+188188+4. Visit the URL in your browser and authorize
189189+190190+5. The hold service will:
191191+ - Exchange the authorization code for a token
192192+ - Create `io.atcr.hold` record in your PDS
193193+ - Create `io.atcr.hold.crew` record (making you the owner)
194194+ - Save registration state
195195+196196+6. On subsequent runs, the service checks if already registered and skips OAuth
197197+198198+**Alternative methods:**
199199+200200+- **Manual API registration**: Call `POST /register` with your own OAuth token
201201+- **Completely manual**: Create PDS records yourself using any ATProto client
202202+203203+### Deploy to Fly.io
204204+205205+```bash
206206+# Create fly.toml
207207+cat > fly.toml <<EOF
208208+app = "my-atcr-hold"
209209+primary_region = "ord"
210210+211211+[env]
212212+ HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev"
213213+ HOLD_SERVER_ADDR = ":8080"
214214+ STORAGE_DRIVER = "s3"
215215+ AWS_REGION = "us-east-1"
216216+ S3_BUCKET = "my-blobs"
217217+ HOLD_PUBLIC = "false"
218218+219219+[http_service]
220220+ internal_port = 8080
221221+ force_https = true
222222+ auto_stop_machines = true
223223+ auto_start_machines = true
224224+ min_machines_running = 0
225225+226226+[[vm]]
227227+ cpu_kind = "shared"
228228+ cpus = 1
229229+ memory_mb = 256
230230+EOF
231231+232232+# Deploy
233233+fly launch
234234+fly deploy
235235+236236+# Set secrets
237237+fly secrets set AWS_ACCESS_KEY_ID=...
238238+fly secrets set AWS_SECRET_ACCESS_KEY=...
239239+fly secrets set HOLD_CREW_OWNER=did:plc:your-did-here
240240+241241+# Check logs for OAuth URL on first run
242242+fly logs
243243+244244+# Visit the OAuth URL shown in logs to authorize
245245+# The hold service will register itself in your PDS
246246+```
247247+248248+## Request Flow
249249+250250+### Push with BYOS
251251+252252+1. **Docker push** `atcr.io/alice/myapp:latest`
253253+2. **AppView** resolves `alice` → `did:plc:alice123`
254254+3. **AppView** discovers hold via priority logic:
255255+ - Check alice's `io.atcr.sailor.profile` for `defaultHold`
256256+ - If not set, check alice's `io.atcr.hold` records
257257+ - Fall back to AppView's `default_storage_endpoint`
258258+4. **Found:** `alice.profile.defaultHold = "https://team-hold.example.com"`
259259+5. **AppView** → team-hold: POST `/put-presigned-url`
260260+ ```json
261261+ {
262262+ "did": "did:plc:alice123",
263263+ "digest": "sha256:abc123...",
264264+ "size": 1048576
265265+ }
266266+ ```
267267+6. **Hold service**:
268268+ - Verifies alice is authorized (checks crew records)
269269+ - Generates S3 presigned upload URL (15min expiry)
270270+ - Returns: `{"url": "https://s3.../blob?signature=..."}`
271271+7. **AppView** → Docker: `307 Redirect` to presigned URL
272272+8. **Docker** → S3: PUT blob directly (no proxy)
273273+9. **Manifest** stored in alice's PDS with `holdEndpoint: "https://team-hold.example.com"`
274274+275275+### Pull with BYOS
276276+277277+1. **Docker pull** `atcr.io/alice/myapp:latest`
278278+2. **AppView** fetches manifest from alice's PDS
279279+3. **Manifest** contains `holdEndpoint: "https://team-hold.example.com"`
280280+4. **AppView** caches: `(alice's DID, "myapp") → "https://team-hold.example.com"` (10min TTL)
281281+5. **Docker** requests blobs: GET `/v2/alice/myapp/blobs/sha256:abc123`
282282+6. **AppView** uses **cached hold from manifest** (not re-discovered)
283283+7. **AppView** → team-hold: POST `/get-presigned-url`
284284+8. **Hold service** returns presigned download URL
285285+9. **AppView** → Docker: `307 Redirect`
286286+10. **Docker** → S3: GET blob directly
287287+288288+**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from where they were originally pushed, even if alice later changes her profile's `defaultHold`.
289289+290290+## Default Registry
291291+292292+The AppView can run its own storage service as the default:
293293+294294+### AppView config
295295+296296+```yaml
297297+middleware:
298298+ - name: registry
299299+ options:
300300+ atproto-resolver:
301301+ default_storage_endpoint: https://storage.atcr.io
302302+```
303303+304304+### Default hold service config
305305+306306+```bash
307307+# Accept any authenticated DID
308308+HOLD_PUBLIC=false # Requires authentication
309309+310310+# Or allow public reads
311311+HOLD_PUBLIC=true # Public reads, auth required for writes
312312+```
313313+314314+This provides free-tier shared storage for users who don't want to deploy their own.
315315+316316+## Storage Drivers Supported
317317+318318+The storage service uses distribution's storage drivers:
319319+320320+- **S3** - AWS S3, Minio, Storj (via S3 gateway)
321321+- **Filesystem** - Local disk (for testing)
322322+- **Azure** - Azure Blob Storage
323323+- **GCS** - Google Cloud Storage
324324+- **Swift** - OpenStack Swift
325325+- **OSS** - Alibaba Cloud OSS
326326+327327+## Quotas
328328+329329+Quotas are NOT implemented in the storage service. Instead, use:
330330+331331+- **S3**: Bucket policies, lifecycle rules
332332+- **Storj**: Project limits in Storj dashboard
333333+- **Minio**: Quota enforcement features
334334+- **Filesystem**: Disk quotas at OS level
335335+336336+## Security
337337+338338+### Authorization
339339+340340+Authorization is now based on ATProto PDS records:
341341+342342+- **Public reads**: Controlled by `hold.public` field in hold record (set via `HOLD_PUBLIC` env var)
343343+- **Writes**: Controlled by `io.atcr.hold.crew` records in PDS
344344+- **Owner**: User who created the hold record automatically gets crew owner role
345345+- **No local config**: Authorization state lives in PDS, not hold service config
346346+347347+The hold service queries the PDS to check:
348348+1. Hold record's `public` field for read authorization
349349+2. Crew records for write authorization
350350+351351+### Presigned URLs
352352+353353+- 15 minute expiry
354354+- Client uploads/downloads directly to storage
355355+- No data flows through AppView or hold service
356356+357357+### Private Holds
358358+359359+Users can restrict access by:
360360+1. Setting `HOLD_PUBLIC=false` (requires authentication for all operations)
361361+2. Adding crew members via `io.atcr.hold.crew` records in PDS
362362+363363+Only users with crew records can write to the hold.
364364+365365+## Example: Personal Storage
366366+367367+Alice wants to use her own Storj account:
368368+369369+1. **Set environment variables**:
370370+ ```bash
371371+ export HOLD_PUBLIC_URL=https://alice-storage.fly.dev
372372+ export HOLD_CREW_OWNER=did:plc:alice123
373373+ export STORAGE_DRIVER=s3
374374+ export AWS_ACCESS_KEY_ID=your_storj_access_key
375375+ export AWS_SECRET_ACCESS_KEY=your_storj_secret_key
376376+ export S3_ENDPOINT=https://gateway.storjshare.io
377377+ export S3_BUCKET=alice-blobs
378378+ ```
379379+380380+2. **Deploy hold service** to Fly.io - auto-registration creates hold + crew record
381381+382382+3. **Push images** - AppView automatically routes to her storage
383383+384384+## Example: Team Hold
385385+386386+A company wants shared storage for their team:
387387+388388+1. **Deploy hold service** with S3 credentials and auto-registration:
389389+ ```bash
390390+ export HOLD_PUBLIC_URL=https://company-hold.fly.dev
391391+ export HOLD_CREW_OWNER=did:plc:admin
392392+ export HOLD_PUBLIC=false
393393+ export STORAGE_DRIVER=s3
394394+ export AWS_ACCESS_KEY_ID=...
395395+ export AWS_SECRET_ACCESS_KEY=...
396396+ export S3_BUCKET=company-blobs
397397+ ```
398398+399399+2. **Hold service auto-registers** on first run, creating:
400400+ - Hold record in admin's PDS
401401+ - Crew record making admin the owner
402402+403403+3. **Admin adds crew members** via ATProto client or manually:
404404+ ```bash
405405+ # Using atproto client
406406+ atproto put-record \
407407+ --collection io.atcr.hold.crew \
408408+ --rkey "company-did:plc:engineer1" \
409409+ --value '{
410410+ "$type": "io.atcr.hold.crew",
411411+ "hold": "at://did:plc:admin/io.atcr.hold/company",
412412+ "member": "did:plc:engineer1",
413413+ "role": "write"
414414+ }'
415415+ ```
416416+417417+4. **Team members set their profile** to use the shared hold:
418418+ ```bash
419419+ # Engineer updates their sailor profile
420420+ atproto put-record \
421421+ --collection io.atcr.sailor.profile \
422422+ --rkey "self" \
423423+ --value '{
424424+ "$type": "io.atcr.sailor.profile",
425425+ "defaultHold": "https://company-hold.fly.dev"
426426+ }'
427427+ ```
428428+429429+5. **Hold service queries PDS** for crew records to authorize writes
430430+6. **Engineers push/pull** using `atcr.io/engineer1/myapp` - blobs go to company hold
431431+432432+## Limitations
433433+434434+1. **No resume/partial uploads** - Storage service doesn't track upload state
435435+2. **No advanced features** - Just basic put/get, no deduplication logic
436436+3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis)
437437+4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client)
438438+439439+## Future Improvements
440440+441441+1. **Automatic failover** - Multiple storage endpoints, fallback to default
442442+2. **Storage analytics** - Track usage per DID
443443+3. **Quota integration** - Optional quota tracking in storage service
444444+4. **Direct presigned URL support** - S3 native presigned URLs (bypass proxy)
445445+5. **Profile management UI** - Web interface for users to manage their sailor profile
446446+6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments
447447+448448+## Comparison to Default Storage
449449+450450+| Feature | Default (Shared S3) | BYOS |
451451+|---------|---------------------|------|
452452+| Setup | None required | Deploy storage service |
453453+| Cost | Free (with quota) | User pays for S3/Storj |
454454+| Control | Limited | Full control |
455455+| Performance | Shared | Dedicated |
456456+| Quotas | Enforced by AppView | User managed |
457457+| Privacy | Blobs in shared bucket | Blobs in user's bucket |
458458+459459+## References
460460+461461+- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
462462+- [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/)
463463+- [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)
464464+- [Storj Documentation](https://docs.storj.io/)
+281
docs/OAUTH.md
···11+# ATCR OAuth Implementation
22+33+## Overview
44+55+ATCR now supports ATProto OAuth authentication via Docker credential helpers. This allows users to authenticate with their ATProto identity (Bluesky account) and use Docker push/pull commands seamlessly.
66+77+## Architecture
88+99+### Components
1010+1111+1. **OAuth Client** (`pkg/auth/oauth/`)
1212+ - Full ATProto OAuth implementation with DPoP support
1313+ - Uses `authelia.com/client/oauth2` for OAuth + PAR
1414+ - Uses `github.com/AxisCommunications/go-dpop` for DPoP proof generation
1515+ - Automatic authorization server discovery
1616+ - PKCE support for security
1717+1818+2. **Credential Helper** (`cmd/credential-helper/`)
1919+ - Standalone binary: `docker-credential-atcr`
2020+ - Implements Docker credential helper protocol
2121+ - Manages OAuth flow with browser
2222+ - Stores tokens securely in `~/.atcr/oauth-token.json`
2323+2424+3. **Registry Integration**
2525+ - `/auth/exchange` endpoint exchanges OAuth tokens for registry JWTs
2626+ - Existing `/auth/token` endpoint for standard Docker auth
2727+2828+## Dependencies
2929+3030+- `authelia.com/client/oauth2` - OAuth client with PAR support (2⭐, Authelia-backed)
3131+- `github.com/AxisCommunications/go-dpop` - DPoP implementation (10⭐, RFC 9449 compliant)
3232+- `github.com/golang-jwt/jwt/v5` - JWT library (transitive, 11k+⭐)
3333+3434+## Usage
3535+3636+### Setup
3737+3838+1. Build the credential helper:
3939+```bash
4040+go build -o docker-credential-atcr ./cmd/credential-helper
4141+```
4242+4343+2. Install it in your PATH:
4444+```bash
4545+sudo mv docker-credential-atcr /usr/local/bin/
4646+```
4747+4848+3. Configure Docker to use it by editing `~/.docker/config.json`:
4949+```json
5050+{
5151+ "credsStore": "atcr"
5252+}
5353+```
5454+5555+### Configuration
5656+5757+Run the OAuth flow:
5858+```bash
5959+docker-credential-atcr configure
6060+```
6161+6262+This will:
6363+1. Prompt for your ATProto handle (e.g., `alice.bsky.social`)
6464+2. Open your browser for OAuth authorization
6565+3. Store the OAuth token and DPoP key in `~/.atcr/oauth-token.json`
6666+6767+### Using with Docker
6868+6969+Once configured, use Docker normally:
7070+7171+```bash
7272+# Push an image
7373+docker push atcr.io/alice/myapp:latest
7474+7575+# Pull an image
7676+docker pull atcr.io/alice/myapp:latest
7777+```
7878+7979+The credential helper automatically:
8080+1. Loads your stored OAuth token
8181+2. Refreshes it if expired
8282+3. Exchanges it for a registry JWT
8383+4. Provides the JWT to Docker
8484+8585+## How It Works
8686+8787+### OAuth Flow
8888+8989+1. **User runs** `docker-credential-atcr configure`
9090+2. **Resolve identity**: alice.bsky.social → DID → PDS endpoint
9191+3. **Discover auth server**: GET `{pds}/.well-known/oauth-authorization-server`
9292+4. **Generate DPoP key**: ECDSA P-256 key pair
9393+5. **PAR request**: POST to PAR endpoint with DPoP header + PKCE challenge
9494+6. **Open browser**: User authorizes on their PDS
9595+7. **Receive code**: Callback to `localhost:8888/callback`
9696+8. **Exchange code**: POST to token endpoint with DPoP header + PKCE verifier
9797+9. **Save tokens**: Store OAuth token + DPoP key + DID/handle
9898+9999+### Docker Push/Pull Flow
100100+101101+1. **Docker needs credentials** for `atcr.io`
102102+2. **Calls credential helper**: `docker-credential-atcr get`
103103+3. **Helper loads token** from `~/.atcr/oauth-token.json`
104104+4. **Refresh if needed**: Uses refresh token + DPoP if expired
105105+5. **Exchange for registry JWT**: POST to `/auth/exchange` with OAuth token + handle
106106+6. **Registry validates token**: Calls `getSession` on PDS to validate token
107107+7. **Registry issues JWT**: Creates registry JWT with validated DID/handle
108108+8. **Return to Docker**: `{"Username": "oauth2", "Secret": "<jwt>"}`
109109+9. **Docker uses JWT**: For authentication to registry API
110110+111111+## Security
112112+113113+### DPoP (Demonstrating Proof-of-Possession)
114114+115115+Every OAuth request includes a DPoP proof:
116116+- Unique JWT signed with ECDSA private key
117117+- Contains HTTP method, URL, timestamp, nonce
118118+- Public key (JWK) included in JWT header
119119+- Binds the token to the specific client
120120+121121+### PKCE (Proof Key for Code Exchange)
122122+123123+- Code verifier generated locally
124124+- Code challenge sent in authorization request
125125+- Verifier sent in token exchange
126126+- Prevents authorization code interception
127127+128128+### Token Storage
129129+130130+- Tokens stored in `~/.atcr/oauth-token.json`
131131+- File permissions: 0600 (owner read/write only)
132132+- DPoP key stored in PEM format
133133+- Refresh tokens for long-term access
134134+135135+## Implementation Details
136136+137137+### Code Structure
138138+139139+```
140140+pkg/auth/oauth/
141141+├── client.go # OAuth client with DPoP
142142+├── discovery.go # Authorization server discovery
143143+├── metadata.go # Client metadata document
144144+├── storage.go # Token persistence
145145+└── transport.go # DPoP HTTP transport
146146+147147+pkg/auth/atproto/
148148+├── session.go # ATProto session validation (Basic auth)
149149+└── validator.go # OAuth token validation via getSession
150150+151151+cmd/credential-helper/
152152+├── main.go # Docker credential helper protocol
153153+├── oauth.go # OAuth flow orchestration
154154+└── token.go # Token management
155155+156156+pkg/auth/exchange/
157157+└── handler.go # OAuth → Registry JWT exchange
158158+```
159159+160160+### Key Classes
161161+162162+**OAuth Client** (`pkg/auth/oauth/client.go`)
163163+- `NewClient()` - Create client with DPoP key
164164+- `InitializeForHandle()` - Discover auth server
165165+- `AuthorizeURL()` - Generate authorization URL with PAR + PKCE
166166+- `Exchange()` - Exchange code for token with DPoP
167167+- `RefreshToken()` - Refresh expired token with DPoP
168168+169169+**DPoP Transport** (`pkg/auth/oauth/transport.go`)
170170+- Implements `http.RoundTripper`
171171+- Automatically adds DPoP header to all requests
172172+- Handles nonce management and retries
173173+- Used by OAuth client for all HTTP requests
174174+175175+**Token Store** (`pkg/auth/oauth/storage.go`)
176176+- Persists OAuth tokens and DPoP key
177177+- PEM encoding for private key
178178+- Expiration checking
179179+- Secure file permissions
180180+181181+**Token Validator** (`pkg/auth/atproto/validator.go`)
182182+- `ValidateToken()` - Validate token via PDS getSession
183183+- `ValidateTokenWithResolver()` - Auto-resolve PDS from handle
184184+- Returns validated DID and handle
185185+- Used by registry to verify OAuth tokens
186186+187187+## Testing
188188+189189+### Manual Testing
190190+191191+1. Configure the helper:
192192+```bash
193193+./docker-credential-atcr configure
194194+# Enter handle: alice.bsky.social
195195+# Browser opens for authorization
196196+# Token saved to ~/.atcr/oauth-token.json
197197+```
198198+199199+2. Test credential retrieval:
200200+```bash
201201+echo '{"ServerURL": "atcr.io"}' | ./docker-credential-atcr get
202202+# Should return: {"Username":"oauth2","Secret":"<jwt>"}
203203+```
204204+205205+3. Test with Docker:
206206+```bash
207207+docker push atcr.io/alice/test:latest
208208+```
209209+210210+### Integration Testing
211211+212212+TODO: Add automated tests for:
213213+- OAuth flow with mock PDS
214214+- DPoP proof generation
215215+- Token exchange
216216+- Credential helper protocol
217217+218218+## Security Features
219219+220220+### OAuth Token Validation
221221+222222+The registry validates ATProto OAuth tokens by calling `com.atproto.server.getSession` on the user's PDS. This ensures:
223223+- Token is valid and not expired
224224+- Token belongs to the claimed user
225225+- User's DID and handle are extracted from the PDS response
226226+- No trust in client-provided identity information
227227+228228+**Flow:**
229229+1. Client sends OAuth token + handle to `/auth/exchange`
230230+2. Registry resolves handle → PDS endpoint
231231+3. Registry calls `{pds}/xrpc/com.atproto.server.getSession` with token
232232+4. PDS validates token and returns session info (DID, handle)
233233+5. Registry uses validated DID/handle to issue registry JWT
234234+235235+## Future Improvements
236236+237237+1. **Token refresh in background**
238238+ - Proactively refresh before expiry
239239+ - Reduce latency on Docker commands
240240+241241+3. **Multiple account support**
242242+ - Store tokens for multiple handles
243243+ - Allow selecting which account to use
244244+245245+4. **Revocation support**
246246+ - Implement token revocation
247247+ - Clean up on logout
248248+249249+5. **Better error messages**
250250+ - User-friendly OAuth error handling
251251+ - Guide users through common issues
252252+253253+## Troubleshooting
254254+255255+### "Failed to resolve identity"
256256+- Check internet connection
257257+- Verify handle is correct (e.g., `alice.bsky.social`)
258258+- Ensure PDS is accessible
259259+260260+### "Authorization timed out"
261261+- Complete authorization within 5 minutes
262262+- Check if browser opened correctly
263263+- Try running `configure` again
264264+265265+### "Token expired"
266266+- Credential helper should auto-refresh
267267+- If persistent, run `configure` again
268268+- Check `~/.atcr/oauth-token.json` permissions
269269+270270+### "Failed to exchange token"
271271+- Ensure registry is running
272272+- Check `/auth/exchange` endpoint is accessible
273273+- Verify token hasn't been revoked
274274+275275+## References
276276+277277+- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
278278+- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
279279+- [RFC 9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126)
280280+- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
281281+- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.sailor.profile",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "User profile for ATCR registry. Stores preferences like default hold for blob storage.",
88+ "key": "literal:self",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["createdAt"],
1212+ "properties": {
1313+ "defaultHold": {
1414+ "type": "string",
1515+ "format": "uri",
1616+ "description": "Default hold endpoint for blob storage. If null, user has opted out of defaults."
1717+ },
1818+ "createdAt": {
1919+ "type": "string",
2020+ "format": "datetime",
2121+ "description": "Profile creation timestamp"
2222+ },
2323+ "updatedAt": {
2424+ "type": "string",
2525+ "format": "datetime",
2626+ "description": "Profile last updated timestamp"
2727+ }
2828+ }
2929+ }
3030+ }
3131+ }
3232+}
+36
lexicons/io/atcr/tag.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.tag",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A named tag pointing to a specific manifest digest",
88+ "key": "any",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["repository", "tag", "manifestDigest", "createdAt"],
1212+ "properties": {
1313+ "repository": {
1414+ "type": "string",
1515+ "description": "Repository name (e.g., 'myapp'). Scoped to user's DID.",
1616+ "maxLength": 255
1717+ },
1818+ "tag": {
1919+ "type": "string",
2020+ "description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')",
2121+ "maxLength": 128
2222+ },
2323+ "manifestDigest": {
2424+ "type": "string",
2525+ "description": "Digest of the manifest this tag points to (e.g., 'sha256:...')"
2626+ },
2727+ "createdAt": {
2828+ "type": "string",
2929+ "format": "datetime",
3030+ "description": "Tag creation timestamp"
3131+ }
3232+ }
3333+ }
3434+ }
3535+ }
3636+}
+184
pkg/atproto/client.go
···11+package atproto
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "fmt"
88+ "io"
99+ "net/http"
1010+)
1111+1212+// Client wraps ATProto operations for the registry
1313+type Client struct {
1414+ pdsEndpoint string
1515+ did string
1616+ accessToken string
1717+ httpClient *http.Client
1818+}
1919+2020+// NewClient creates a new ATProto client
2121+func NewClient(pdsEndpoint, did, accessToken string) *Client {
2222+ return &Client{
2323+ pdsEndpoint: pdsEndpoint,
2424+ did: did,
2525+ accessToken: accessToken,
2626+ httpClient: &http.Client{},
2727+ }
2828+}
2929+3030+// Record represents a generic ATProto record
3131+type Record struct {
3232+ URI string `json:"uri"`
3333+ CID string `json:"cid"`
3434+ Value json.RawMessage `json:"value"`
3535+}
3636+3737+// PutRecord stores a record in the ATProto repository
3838+func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record interface{}) (*Record, error) {
3939+ // Construct the record URI
4040+ // Format: at://<did>/<collection>/<rkey>
4141+4242+ payload := map[string]interface{}{
4343+ "repo": c.did,
4444+ "collection": collection,
4545+ "rkey": rkey,
4646+ "record": record,
4747+ }
4848+4949+ body, err := json.Marshal(payload)
5050+ if err != nil {
5151+ return nil, fmt.Errorf("failed to marshal record: %w", err)
5252+ }
5353+5454+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", c.pdsEndpoint)
5555+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
5656+ if err != nil {
5757+ return nil, err
5858+ }
5959+6060+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
6161+ req.Header.Set("Content-Type", "application/json")
6262+6363+ resp, err := c.httpClient.Do(req)
6464+ if err != nil {
6565+ return nil, fmt.Errorf("failed to put record: %w", err)
6666+ }
6767+ defer resp.Body.Close()
6868+6969+ if resp.StatusCode != http.StatusOK {
7070+ bodyBytes, _ := io.ReadAll(resp.Body)
7171+ return nil, fmt.Errorf("put record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
7272+ }
7373+7474+ var result Record
7575+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
7676+ return nil, fmt.Errorf("failed to decode response: %w", err)
7777+ }
7878+7979+ return &result, nil
8080+}
8181+8282+// GetRecord retrieves a record from the ATProto repository
8383+func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) {
8484+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
8585+ c.pdsEndpoint, c.did, collection, rkey)
8686+8787+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
8888+ if err != nil {
8989+ return nil, err
9090+ }
9191+9292+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
9393+9494+ resp, err := c.httpClient.Do(req)
9595+ if err != nil {
9696+ return nil, fmt.Errorf("failed to get record: %w", err)
9797+ }
9898+ defer resp.Body.Close()
9999+100100+ if resp.StatusCode == http.StatusNotFound {
101101+ return nil, fmt.Errorf("record not found")
102102+ }
103103+104104+ if resp.StatusCode != http.StatusOK {
105105+ bodyBytes, _ := io.ReadAll(resp.Body)
106106+ return nil, fmt.Errorf("get record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
107107+ }
108108+109109+ var result Record
110110+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
111111+ return nil, fmt.Errorf("failed to decode response: %w", err)
112112+ }
113113+114114+ return &result, nil
115115+}
116116+117117+// DeleteRecord deletes a record from the ATProto repository
118118+func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error {
119119+ payload := map[string]interface{}{
120120+ "repo": c.did,
121121+ "collection": collection,
122122+ "rkey": rkey,
123123+ }
124124+125125+ body, err := json.Marshal(payload)
126126+ if err != nil {
127127+ return fmt.Errorf("failed to marshal delete request: %w", err)
128128+ }
129129+130130+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", c.pdsEndpoint)
131131+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
132132+ if err != nil {
133133+ return err
134134+ }
135135+136136+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
137137+ req.Header.Set("Content-Type", "application/json")
138138+139139+ resp, err := c.httpClient.Do(req)
140140+ if err != nil {
141141+ return fmt.Errorf("failed to delete record: %w", err)
142142+ }
143143+ defer resp.Body.Close()
144144+145145+ if resp.StatusCode != http.StatusOK {
146146+ bodyBytes, _ := io.ReadAll(resp.Body)
147147+ return fmt.Errorf("delete record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
148148+ }
149149+150150+ return nil
151151+}
152152+153153+// ListRecords lists records in a collection
154154+func (c *Client) ListRecords(ctx context.Context, collection string, limit int) ([]Record, error) {
155155+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d",
156156+ c.pdsEndpoint, c.did, collection, limit)
157157+158158+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
159159+ if err != nil {
160160+ return nil, err
161161+ }
162162+163163+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
164164+165165+ resp, err := c.httpClient.Do(req)
166166+ if err != nil {
167167+ return nil, fmt.Errorf("failed to list records: %w", err)
168168+ }
169169+ defer resp.Body.Close()
170170+171171+ if resp.StatusCode != http.StatusOK {
172172+ bodyBytes, _ := io.ReadAll(resp.Body)
173173+ return nil, fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
174174+ }
175175+176176+ var result struct {
177177+ Records []Record `json:"records"`
178178+ }
179179+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
180180+ return nil, fmt.Errorf("failed to decode response: %w", err)
181181+ }
182182+183183+ return result.Records, nil
184184+}
+273
pkg/atproto/lexicon.go
···11+package atproto
22+33+import (
44+ "encoding/json"
55+ "time"
66+)
77+88+// Collection names for ATProto records
99+const (
1010+ // ManifestCollection is the collection name for container manifests
1111+ ManifestCollection = "io.atcr.manifest"
1212+1313+ // TagCollection is the collection name for image tags
1414+ TagCollection = "io.atcr.tag"
1515+1616+ // HoldCollection is the collection name for storage holds (BYOS)
1717+ HoldCollection = "io.atcr.hold"
1818+1919+ // HoldCrewCollection is the collection name for hold crew (membership)
2020+ HoldCrewCollection = "io.atcr.hold.crew"
2121+2222+ // SailorProfileCollection is the collection name for user profiles
2323+ SailorProfileCollection = "io.atcr.sailor.profile"
2424+)
2525+2626+// ManifestRecord represents a container image manifest stored in ATProto
2727+// This follows the OCI image manifest specification but stored as an ATProto record
2828+type ManifestRecord struct {
2929+ // Type should be "io.atcr.manifest"
3030+ Type string `json:"$type"`
3131+3232+ // Repository is the name of the repository (e.g., "myapp")
3333+ Repository string `json:"repository"`
3434+3535+ // Digest is the content digest (e.g., "sha256:abc123...")
3636+ Digest string `json:"digest"`
3737+3838+ // HoldEndpoint is the hold service endpoint where blobs are stored
3939+ // This is a historical reference that doesn't change even if user's default hold changes
4040+ HoldEndpoint string `json:"holdEndpoint"`
4141+4242+ // MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json")
4343+ MediaType string `json:"mediaType"`
4444+4545+ // SchemaVersion is the OCI schema version (typically 2)
4646+ SchemaVersion int `json:"schemaVersion"`
4747+4848+ // Config references the image configuration blob
4949+ Config BlobReference `json:"config"`
5050+5151+ // Layers references the filesystem layers
5252+ Layers []BlobReference `json:"layers"`
5353+5454+ // Annotations contains arbitrary metadata
5555+ Annotations map[string]string `json:"annotations,omitempty"`
5656+5757+ // Subject references another manifest (for attestations, signatures, etc.)
5858+ Subject *BlobReference `json:"subject,omitempty"`
5959+6060+ // CreatedAt timestamp
6161+ CreatedAt time.Time `json:"createdAt"`
6262+}
6363+6464+// BlobReference represents a reference to a blob (layer or config)
6565+// Blobs are stored in S3 and referenced by digest
6666+type BlobReference struct {
6767+ // MediaType of the blob
6868+ MediaType string `json:"mediaType"`
6969+7070+ // Digest is the content digest (e.g., "sha256:abc123...")
7171+ Digest string `json:"digest"`
7272+7373+ // Size in bytes
7474+ Size int64 `json:"size"`
7575+7676+ // URLs where the blob can be retrieved (S3 URLs)
7777+ URLs []string `json:"urls,omitempty"`
7878+7979+ // Annotations for the blob
8080+ Annotations map[string]string `json:"annotations,omitempty"`
8181+}
8282+8383+// NewManifestRecord creates a new manifest record from OCI manifest JSON
8484+func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) {
8585+ // Parse the OCI manifest
8686+ var ociData struct {
8787+ SchemaVersion int `json:"schemaVersion"`
8888+ MediaType string `json:"mediaType"`
8989+ Config json.RawMessage `json:"config"`
9090+ Layers []json.RawMessage `json:"layers"`
9191+ Subject json.RawMessage `json:"subject,omitempty"`
9292+ Annotations map[string]string `json:"annotations,omitempty"`
9393+ }
9494+9595+ if err := json.Unmarshal(ociManifest, &ociData); err != nil {
9696+ return nil, err
9797+ }
9898+9999+ record := &ManifestRecord{
100100+ Type: ManifestCollection,
101101+ Repository: repository,
102102+ Digest: digest,
103103+ MediaType: ociData.MediaType,
104104+ SchemaVersion: ociData.SchemaVersion,
105105+ Annotations: ociData.Annotations,
106106+ CreatedAt: time.Now(),
107107+ }
108108+109109+ // Parse config
110110+ if err := json.Unmarshal(ociData.Config, &record.Config); err != nil {
111111+ return nil, err
112112+ }
113113+114114+ // Parse layers
115115+ record.Layers = make([]BlobReference, len(ociData.Layers))
116116+ for i, layer := range ociData.Layers {
117117+ if err := json.Unmarshal(layer, &record.Layers[i]); err != nil {
118118+ return nil, err
119119+ }
120120+ }
121121+122122+ // Parse subject if present
123123+ if len(ociData.Subject) > 0 {
124124+ var subject BlobReference
125125+ if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
126126+ return nil, err
127127+ }
128128+ record.Subject = &subject
129129+ }
130130+131131+ return record, nil
132132+}
133133+134134+// ToOCIManifest converts the manifest record back to OCI manifest JSON
135135+func (m *ManifestRecord) ToOCIManifest() ([]byte, error) {
136136+ ociManifest := map[string]interface{}{
137137+ "schemaVersion": m.SchemaVersion,
138138+ "mediaType": m.MediaType,
139139+ "config": m.Config,
140140+ "layers": m.Layers,
141141+ }
142142+143143+ if m.Subject != nil {
144144+ ociManifest["subject"] = m.Subject
145145+ }
146146+147147+ if len(m.Annotations) > 0 {
148148+ ociManifest["annotations"] = m.Annotations
149149+ }
150150+151151+ return json.Marshal(ociManifest)
152152+}
153153+154154+// TagRecord represents a tag pointing to a manifest
155155+type TagRecord struct {
156156+ // Type should be "io.atcr.tag"
157157+ Type string `json:"$type"`
158158+159159+ // Repository is the name of the repository
160160+ Repository string `json:"repository"`
161161+162162+ // Tag is the tag name (e.g., "latest", "v1.0.0")
163163+ Tag string `json:"tag"`
164164+165165+ // ManifestDigest is the digest of the manifest this tag points to
166166+ ManifestDigest string `json:"manifestDigest"`
167167+168168+ // UpdatedAt timestamp
169169+ UpdatedAt time.Time `json:"updatedAt"`
170170+}
171171+172172+// NewTagRecord creates a new tag record
173173+func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
174174+ return &TagRecord{
175175+ Type: TagCollection,
176176+ Repository: repository,
177177+ Tag: tag,
178178+ ManifestDigest: manifestDigest,
179179+ UpdatedAt: time.Now(),
180180+ }
181181+}
182182+183183+// HoldRecord represents a storage hold definition (BYOS)
184184+// Users create these records to define where their blobs should be stored
185185+type HoldRecord struct {
186186+ // Type should be "io.atcr.hold"
187187+ Type string `json:"$type"`
188188+189189+ // Endpoint is the URL of the hold service
190190+ // e.g., "https://hold1.example.com"
191191+ Endpoint string `json:"endpoint"`
192192+193193+ // Owner is the DID of the hold owner
194194+ Owner string `json:"owner"`
195195+196196+ // Public controls whether this hold allows public blob reads (pulls) without auth
197197+ // Writes always require crew membership
198198+ Public bool `json:"public"`
199199+200200+ // CreatedAt timestamp
201201+ CreatedAt time.Time `json:"createdAt"`
202202+}
203203+204204+// NewHoldRecord creates a new hold record
205205+func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
206206+ return &HoldRecord{
207207+ Type: HoldCollection,
208208+ Endpoint: endpoint,
209209+ Owner: owner,
210210+ Public: public,
211211+ CreatedAt: time.Now(),
212212+ }
213213+}
214214+215215+// HoldCrewRecord represents membership in a storage hold
216216+// Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control
217217+// Owner can add/remove crew members by creating/deleting these records in their own PDS
218218+type HoldCrewRecord struct {
219219+ // Type should be "io.atcr.hold.crew"
220220+ Type string `json:"$type"`
221221+222222+ // Hold is the AT URI of the hold record
223223+ // e.g., "at://did:plc:owner/io.atcr.hold/hold1"
224224+ Hold string `json:"hold"`
225225+226226+ // Member is the DID of the crew member
227227+ Member string `json:"member"`
228228+229229+ // Role defines permissions: "owner", "write", "read"
230230+ Role string `json:"role"`
231231+232232+ // AddedAt timestamp
233233+ AddedAt time.Time `json:"createdAt"`
234234+}
235235+236236+// NewHoldCrewRecord creates a new hold crew record
237237+func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
238238+ return &HoldCrewRecord{
239239+ Type: HoldCrewCollection,
240240+ Hold: hold,
241241+ Member: member,
242242+ Role: role,
243243+ AddedAt: time.Now(),
244244+ }
245245+}
246246+247247+// SailorProfileRecord represents a user's profile with registry preferences
248248+// Stored in the user's PDS to configure default hold and other settings
249249+type SailorProfileRecord struct {
250250+ // Type should be "io.atcr.sailor.profile"
251251+ Type string `json:"$type"`
252252+253253+ // DefaultHold is the default hold endpoint for blob storage
254254+ // If null/empty, user has opted out of defaults
255255+ DefaultHold string `json:"defaultHold,omitempty"`
256256+257257+ // CreatedAt timestamp
258258+ CreatedAt time.Time `json:"createdAt"`
259259+260260+ // UpdatedAt timestamp
261261+ UpdatedAt time.Time `json:"updatedAt"`
262262+}
263263+264264+// NewSailorProfileRecord creates a new sailor profile record
265265+func NewSailorProfileRecord(defaultHold string) *SailorProfileRecord {
266266+ now := time.Now()
267267+ return &SailorProfileRecord{
268268+ Type: SailorProfileCollection,
269269+ DefaultHold: defaultHold,
270270+ CreatedAt: now,
271271+ UpdatedAt: now,
272272+ }
273273+}
+170
pkg/atproto/manifest_store.go
···11+package atproto
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "strings"
88+99+ "github.com/distribution/distribution/v3"
1010+ "github.com/opencontainers/go-digest"
1111+)
1212+1313+// ManifestStore implements distribution.ManifestService
1414+// It stores manifests in ATProto as records
1515+type ManifestStore struct {
1616+ client *Client
1717+ repository string
1818+ holdEndpoint string // Hold service endpoint where blobs are stored (for push)
1919+ did string // User's DID for cache key
2020+ lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull)
2121+}
2222+2323+// NewManifestStore creates a new ATProto-backed manifest store
2424+func NewManifestStore(client *Client, repository string, holdEndpoint string, did string) *ManifestStore {
2525+ return &ManifestStore{
2626+ client: client,
2727+ repository: repository,
2828+ holdEndpoint: holdEndpoint,
2929+ did: did,
3030+ }
3131+}
3232+3333+// Exists checks if a manifest exists by digest
3434+func (s *ManifestStore) Exists(ctx context.Context, dgst digest.Digest) (bool, error) {
3535+ rkey := digestToRKey(dgst)
3636+ _, err := s.client.GetRecord(ctx, ManifestCollection, rkey)
3737+ if err != nil {
3838+ // If not found, return false without error
3939+ if err.Error() == "record not found" {
4040+ return false, nil
4141+ }
4242+ return false, err
4343+ }
4444+ return true, nil
4545+}
4646+4747+// Get retrieves a manifest by digest
4848+func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) {
4949+ rkey := digestToRKey(dgst)
5050+ record, err := s.client.GetRecord(ctx, ManifestCollection, rkey)
5151+ if err != nil {
5252+ return nil, distribution.ErrManifestUnknownRevision{
5353+ Name: s.repository,
5454+ Revision: dgst,
5555+ }
5656+ }
5757+5858+ var manifestRecord ManifestRecord
5959+ if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
6060+ return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
6161+ }
6262+6363+ // Store the hold endpoint for subsequent blob requests during pull
6464+ // The routing repository will cache this for concurrent blob fetches
6565+ s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
6666+6767+ // Convert back to OCI manifest
6868+ ociManifest, err := manifestRecord.ToOCIManifest()
6969+ if err != nil {
7070+ return nil, fmt.Errorf("failed to convert to OCI manifest: %w", err)
7171+ }
7272+7373+ // Parse the manifest based on media type
7474+ // For now, we'll return the raw bytes wrapped in a manifest object
7575+ // In a full implementation, you'd use distribution's manifest parsing
7676+ return &rawManifest{
7777+ mediaType: manifestRecord.MediaType,
7878+ payload: ociManifest,
7979+ }, nil
8080+}
8181+8282+// Put stores a manifest
8383+func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest, options ...distribution.ManifestServiceOption) (digest.Digest, error) {
8484+ // Get the manifest payload
8585+ _, payload, err := manifest.Payload()
8686+ if err != nil {
8787+ return "", err
8888+ }
8989+9090+ // Calculate digest
9191+ dgst := digest.FromBytes(payload)
9292+9393+ // Create manifest record
9494+ manifestRecord, err := NewManifestRecord(s.repository, dgst.String(), payload)
9595+ if err != nil {
9696+ return "", fmt.Errorf("failed to create manifest record: %w", err)
9797+ }
9898+9999+ // Set the hold endpoint where blobs are stored
100100+ manifestRecord.HoldEndpoint = s.holdEndpoint
101101+102102+ // Store in ATProto
103103+ rkey := digestToRKey(dgst)
104104+ _, err = s.client.PutRecord(ctx, ManifestCollection, rkey, manifestRecord)
105105+ if err != nil {
106106+ return "", fmt.Errorf("failed to store manifest in ATProto: %w", err)
107107+ }
108108+109109+ // Also handle tag if specified
110110+ for _, option := range options {
111111+ if tagOpt, ok := option.(distribution.WithTagOption); ok {
112112+ tag := tagOpt.Tag
113113+ tagRecord := NewTagRecord(s.repository, tag, dgst.String())
114114+ tagRKey := repositoryTagToRKey(s.repository, tag)
115115+ _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
116116+ if err != nil {
117117+ return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
118118+ }
119119+ }
120120+ }
121121+122122+ return dgst, nil
123123+}
124124+125125+// Delete removes a manifest
126126+func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
127127+ rkey := digestToRKey(dgst)
128128+ return s.client.DeleteRecord(ctx, ManifestCollection, rkey)
129129+}
130130+131131+// digestToRKey converts a digest to an ATProto record key
132132+// ATProto rkeys must be valid strings, so we use the digest string without the algorithm prefix
133133+func digestToRKey(dgst digest.Digest) string {
134134+ // Remove the algorithm prefix (e.g., "sha256:")
135135+ return dgst.Encoded()
136136+}
137137+138138+// repositoryTagToRKey converts a repository and tag to an ATProto record key
139139+// ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$
140140+func repositoryTagToRKey(repository, tag string) string {
141141+ // Combine repository and tag to create a unique key
142142+ // Replace invalid characters: slashes become dashes
143143+ key := fmt.Sprintf("%s_%s", repository, tag)
144144+145145+ // Replace / with - (slash not allowed in rkeys)
146146+ key = strings.ReplaceAll(key, "/", "-")
147147+148148+ return key
149149+}
150150+151151+// GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest
152152+// This is used by the routing repository to cache the hold for blob requests
153153+func (s *ManifestStore) GetLastFetchedHoldEndpoint() string {
154154+ return s.lastFetchedHoldEndpoint
155155+}
156156+157157+// rawManifest is a simple implementation of distribution.Manifest
158158+type rawManifest struct {
159159+ mediaType string
160160+ payload []byte
161161+}
162162+163163+func (m *rawManifest) References() []distribution.Descriptor {
164164+ // TODO: Parse the manifest and return actual references
165165+ return nil
166166+}
167167+168168+func (m *rawManifest) Payload() (string, []byte, error) {
169169+ return m.mediaType, m.payload, nil
170170+}
+95
pkg/atproto/profile.go
···11+package atproto
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+)
88+99+// Profile record key is always "self" per lexicon
1010+const ProfileRKey = "self"
1111+1212+// EnsureProfile checks if a user's profile exists and creates it if needed
1313+// This should be called during authentication (OAuth exchange or token service)
1414+// If defaultHoldEndpoint is provided and profile doesn't exist, creates profile with that default
1515+func EnsureProfile(ctx context.Context, client *Client, defaultHoldEndpoint string) error {
1616+ // Check if profile already exists
1717+ profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
1818+ if err == nil && profile != nil {
1919+ // Profile exists, nothing to do
2020+ return nil
2121+ }
2222+2323+ // Profile doesn't exist
2424+ // Only create if we have a default hold endpoint to set
2525+ if defaultHoldEndpoint == "" {
2626+ // No default configured, don't create empty profile
2727+ return nil
2828+ }
2929+3030+ // Create new profile with default hold
3131+ newProfile := NewSailorProfileRecord(defaultHoldEndpoint)
3232+3333+ _, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile)
3434+ if err != nil {
3535+ return fmt.Errorf("failed to create sailor profile: %w", err)
3636+ }
3737+3838+ return nil
3939+}
4040+4141+// GetProfile retrieves the user's profile from their PDS
4242+// Returns nil if profile doesn't exist
4343+func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) {
4444+ record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
4545+ if err != nil {
4646+ // Check if it's a 404 (profile doesn't exist)
4747+ if isNotFoundError(err) {
4848+ return nil, nil
4949+ }
5050+ return nil, fmt.Errorf("failed to get profile: %w", err)
5151+ }
5252+5353+ // Parse the profile record
5454+ var profile SailorProfileRecord
5555+ if err := json.Unmarshal(record.Value, &profile); err != nil {
5656+ return nil, fmt.Errorf("failed to parse profile: %w", err)
5757+ }
5858+5959+ return &profile, nil
6060+}
6161+6262+// UpdateProfile updates the user's profile
6363+func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error {
6464+ _, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile)
6565+ if err != nil {
6666+ return fmt.Errorf("failed to update profile: %w", err)
6767+ }
6868+ return nil
6969+}
7070+7171+// isNotFoundError checks if an error is a 404 not found error
7272+func isNotFoundError(err error) bool {
7373+ // This is a simple check - in practice, you might need to parse the error more carefully
7474+ if err == nil {
7575+ return false
7676+ }
7777+ errStr := err.Error()
7878+ return contains(errStr, "404") || contains(errStr, "not found") || contains(errStr, "RecordNotFound")
7979+}
8080+8181+// contains checks if a string contains a substring (case-insensitive helper)
8282+func contains(s, substr string) bool {
8383+ return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
8484+ (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
8585+ findSubstring(s, substr)))
8686+}
8787+8888+func findSubstring(s, substr string) bool {
8989+ for i := 0; i <= len(s)-len(substr); i++ {
9090+ if s[i:i+len(substr)] == substr {
9191+ return true
9292+ }
9393+ }
9494+ return false
9595+}
+243
pkg/atproto/resolver.go
···11+package atproto
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "net"
99+ "net/http"
1010+ "strings"
1111+)
1212+1313+// Resolver handles DID/handle resolution for ATProto
1414+type Resolver struct {
1515+ httpClient *http.Client
1616+}
1717+1818+// NewResolver creates a new DID/handle resolver
1919+func NewResolver() *Resolver {
2020+ return &Resolver{
2121+ httpClient: &http.Client{},
2222+ }
2323+}
2424+2525+// ResolveIdentity resolves a handle or DID to a DID and PDS endpoint
2626+// Input can be:
2727+// - Handle: "alice.bsky.social" or "alice"
2828+// - DID: "did:plc:xyz123abc"
2929+func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) {
3030+ // Check if it's already a DID
3131+ if strings.HasPrefix(identity, "did:") {
3232+ did = identity
3333+ pdsEndpoint, err = r.ResolvePDS(ctx, did)
3434+ return did, pdsEndpoint, err
3535+ }
3636+3737+ // Otherwise, resolve handle to DID
3838+ did, err = r.ResolveHandle(ctx, identity)
3939+ if err != nil {
4040+ return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err)
4141+ }
4242+4343+ // Then resolve DID to PDS
4444+ pdsEndpoint, err = r.ResolvePDS(ctx, did)
4545+ if err != nil {
4646+ return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err)
4747+ }
4848+4949+ return did, pdsEndpoint, nil
5050+}
5151+5252+// ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known
5353+func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
5454+ // Normalize handle
5555+ if !strings.Contains(handle, ".") {
5656+ // Default to .bsky.social if no domain provided
5757+ handle = handle + ".bsky.social"
5858+ }
5959+6060+ // Try DNS TXT record first (faster)
6161+ if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" {
6262+ return did, nil
6363+ }
6464+6565+ // Fall back to HTTPS .well-known method
6666+ url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle)
6767+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
6868+ if err != nil {
6969+ return "", err
7070+ }
7171+7272+ resp, err := r.httpClient.Do(req)
7373+ if err != nil {
7474+ return "", fmt.Errorf("failed to fetch .well-known: %w", err)
7575+ }
7676+ defer resp.Body.Close()
7777+7878+ if resp.StatusCode == http.StatusOK {
7979+ body, err := io.ReadAll(resp.Body)
8080+ if err != nil {
8181+ return "", err
8282+ }
8383+ did := strings.TrimSpace(string(body))
8484+ if strings.HasPrefix(did, "did:") {
8585+ return did, nil
8686+ }
8787+ }
8888+8989+ return "", fmt.Errorf("could not resolve handle %s to DID", handle)
9090+}
9191+9292+// resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto.<handle>
9393+func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) {
9494+ txtRecords, err := net.LookupTXT("_atproto." + handle)
9595+ if err != nil {
9696+ return "", err
9797+ }
9898+9999+ // Look for a TXT record that starts with "did="
100100+ for _, record := range txtRecords {
101101+ if strings.HasPrefix(record, "did=") {
102102+ did := strings.TrimPrefix(record, "did=")
103103+ if strings.HasPrefix(did, "did:") {
104104+ return did, nil
105105+ }
106106+ }
107107+ }
108108+109109+ return "", fmt.Errorf("no valid DID found in DNS TXT records")
110110+}
111111+112112+// DIDDocument represents a simplified ATProto DID document
113113+type DIDDocument struct {
114114+ ID string `json:"id"`
115115+ AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
116116+ Service []struct {
117117+ ID string `json:"id"`
118118+ Type string `json:"type"`
119119+ ServiceEndpoint string `json:"serviceEndpoint"`
120120+ } `json:"service"`
121121+}
122122+123123+// ResolvePDS resolves a DID to its PDS endpoint
124124+func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) {
125125+ if !strings.HasPrefix(did, "did:") {
126126+ return "", fmt.Errorf("invalid DID format: %s", did)
127127+ }
128128+129129+ // Parse DID method
130130+ parts := strings.Split(did, ":")
131131+ if len(parts) < 3 {
132132+ return "", fmt.Errorf("invalid DID format: %s", did)
133133+ }
134134+135135+ method := parts[1]
136136+137137+ var resolverURL string
138138+ switch method {
139139+ case "plc":
140140+ // Use PLC directory
141141+ resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
142142+ case "web":
143143+ // For did:web, convert to HTTPS URL
144144+ domain := parts[2]
145145+ resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
146146+ default:
147147+ return "", fmt.Errorf("unsupported DID method: %s", method)
148148+ }
149149+150150+ req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
151151+ if err != nil {
152152+ return "", err
153153+ }
154154+155155+ resp, err := r.httpClient.Do(req)
156156+ if err != nil {
157157+ return "", fmt.Errorf("failed to fetch DID document: %w", err)
158158+ }
159159+ defer resp.Body.Close()
160160+161161+ if resp.StatusCode != http.StatusOK {
162162+ return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
163163+ }
164164+165165+ var didDoc DIDDocument
166166+ if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
167167+ return "", fmt.Errorf("failed to parse DID document: %w", err)
168168+ }
169169+170170+ // Find PDS service endpoint
171171+ for _, service := range didDoc.Service {
172172+ if service.Type == "AtprotoPersonalDataServer" {
173173+ return service.ServiceEndpoint, nil
174174+ }
175175+ }
176176+177177+ return "", fmt.Errorf("no PDS endpoint found in DID document")
178178+}
179179+180180+// ResolveDIDDocument fetches the full DID document for a DID
181181+func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) {
182182+ if !strings.HasPrefix(did, "did:") {
183183+ return nil, fmt.Errorf("invalid DID format: %s", did)
184184+ }
185185+186186+ parts := strings.Split(did, ":")
187187+ if len(parts) < 3 {
188188+ return nil, fmt.Errorf("invalid DID format: %s", did)
189189+ }
190190+191191+ method := parts[1]
192192+193193+ var resolverURL string
194194+ switch method {
195195+ case "plc":
196196+ resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
197197+ case "web":
198198+ domain := parts[2]
199199+ resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
200200+ default:
201201+ return nil, fmt.Errorf("unsupported DID method: %s", method)
202202+ }
203203+204204+ req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
205205+ if err != nil {
206206+ return nil, err
207207+ }
208208+209209+ resp, err := r.httpClient.Do(req)
210210+ if err != nil {
211211+ return nil, fmt.Errorf("failed to fetch DID document: %w", err)
212212+ }
213213+ defer resp.Body.Close()
214214+215215+ if resp.StatusCode != http.StatusOK {
216216+ return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
217217+ }
218218+219219+ var didDoc DIDDocument
220220+ if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
221221+ return nil, fmt.Errorf("failed to parse DID document: %w", err)
222222+ }
223223+224224+ return &didDoc, nil
225225+}
226226+227227+// ResolveHandle extracts the handle from a DID's alsoKnownAs field
228228+func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) {
229229+ didDoc, err := r.ResolveDIDDocument(ctx, did)
230230+ if err != nil {
231231+ return "", err
232232+ }
233233+234234+ // Look for handle in alsoKnownAs (format: "at://handle.bsky.social")
235235+ for _, aka := range didDoc.AlsoKnownAs {
236236+ if strings.HasPrefix(aka, "at://") {
237237+ handle := strings.TrimPrefix(aka, "at://")
238238+ return handle, nil
239239+ }
240240+ }
241241+242242+ return "", fmt.Errorf("no handle found in DID document")
243243+}
···11+package auth
22+33+import (
44+ "sync"
55+ "time"
66+)
77+88+// TokenCacheEntry represents a cached access token
99+type TokenCacheEntry struct {
1010+ AccessToken string
1111+ ExpiresAt time.Time
1212+}
1313+1414+// TokenCache is a simple in-memory cache for ATProto access tokens
1515+type TokenCache struct {
1616+ mu sync.RWMutex
1717+ tokens map[string]*TokenCacheEntry
1818+}
1919+2020+var globalTokenCache = &TokenCache{
2121+ tokens: make(map[string]*TokenCacheEntry),
2222+}
2323+2424+// GetGlobalTokenCache returns the global token cache instance
2525+func GetGlobalTokenCache() *TokenCache {
2626+ return globalTokenCache
2727+}
2828+2929+// Set stores an access token for a DID
3030+func (tc *TokenCache) Set(did, accessToken string, ttl time.Duration) {
3131+ tc.mu.Lock()
3232+ defer tc.mu.Unlock()
3333+3434+ tc.tokens[did] = &TokenCacheEntry{
3535+ AccessToken: accessToken,
3636+ ExpiresAt: time.Now().Add(ttl),
3737+ }
3838+}
3939+4040+// Get retrieves an access token for a DID
4141+func (tc *TokenCache) Get(did string) (string, bool) {
4242+ tc.mu.RLock()
4343+ defer tc.mu.RUnlock()
4444+4545+ entry, ok := tc.tokens[did]
4646+ if !ok {
4747+ return "", false
4848+ }
4949+5050+ // Check if expired
5151+ if time.Now().After(entry.ExpiresAt) {
5252+ return "", false
5353+ }
5454+5555+ return entry.AccessToken, true
5656+}
5757+5858+// Delete removes a cached token
5959+func (tc *TokenCache) Delete(did string) {
6060+ tc.mu.Lock()
6161+ defer tc.mu.Unlock()
6262+6363+ delete(tc.tokens, did)
6464+}
+8
pkg/auth/types.go
···11+package auth
22+33+// AccessEntry represents access permissions for a resource
44+type AccessEntry struct {
55+ Type string `json:"type"` // "repository"
66+ Name string `json:"name,omitempty"` // e.g., "alice/myapp"
77+ Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"]
88+}
+190
pkg/middleware/registry.go
···11+package middleware
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "strings"
88+99+ "github.com/distribution/distribution/v3"
1010+ registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
1111+ "github.com/distribution/distribution/v3/registry/storage/driver"
1212+ "github.com/distribution/reference"
1313+1414+ "atcr.io/pkg/atproto"
1515+ "atcr.io/pkg/auth"
1616+ "atcr.io/pkg/storage"
1717+)
1818+1919+func init() {
2020+ // Register the name resolution middleware
2121+ registrymw.Register("atproto-resolver", initATProtoResolver)
2222+}
2323+2424+// NamespaceResolver wraps a namespace and resolves names
2525+type NamespaceResolver struct {
2626+ distribution.Namespace
2727+ resolver *atproto.Resolver
2828+ defaultStorageEndpoint string
2929+}
3030+3131+// initATProtoResolver initializes the name resolution middleware
3232+func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]interface{}) (distribution.Namespace, error) {
3333+ resolver := atproto.NewResolver()
3434+3535+ // Get default storage endpoint from config (optional)
3636+ defaultStorageEndpoint := ""
3737+ if endpoint, ok := options["default_storage_endpoint"].(string); ok {
3838+ defaultStorageEndpoint = endpoint
3939+ }
4040+4141+ return &NamespaceResolver{
4242+ Namespace: ns,
4343+ resolver: resolver,
4444+ defaultStorageEndpoint: defaultStorageEndpoint,
4545+ }, nil
4646+}
4747+4848+// Repository resolves the repository name and delegates to underlying namespace
4949+// Handles names like:
5050+// - atcr.io/alice/myimage → resolve alice to DID
5151+// - atcr.io/did:plc:xyz123/myimage → use DID directly
5252+func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) {
5353+ // Extract the first part of the name (username or DID)
5454+ repoPath := name.Name()
5555+ parts := strings.SplitN(repoPath, "/", 2)
5656+5757+ if len(parts) < 2 {
5858+ // No user specified, use default or return error
5959+ return nil, fmt.Errorf("repository name must include user: %s", repoPath)
6060+ }
6161+6262+ identity := parts[0]
6363+ imageName := parts[1]
6464+6565+ // Resolve identity to DID and PDS
6666+ did, pdsEndpoint, err := nr.resolver.ResolveIdentity(ctx, identity)
6767+ if err != nil {
6868+ return nil, fmt.Errorf("failed to resolve identity %s: %w", identity, err)
6969+ }
7070+7171+ // Store resolved DID and PDS in context for downstream use
7272+ ctx = context.WithValue(ctx, "atproto.did", did)
7373+ ctx = context.WithValue(ctx, "atproto.pds", pdsEndpoint)
7474+ ctx = context.WithValue(ctx, "atproto.identity", identity)
7575+7676+ fmt.Printf("DEBUG [registry/middleware]: Set context values: did=%s, pds=%s, identity=%s\n", did, pdsEndpoint, identity)
7777+7878+ // Query for storage endpoint - either user's hold or default hold service
7979+ storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint)
8080+ if storageEndpoint == "" {
8181+ // This is a fatal configuration error - registry cannot function without a hold service
8282+ return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config")
8383+ }
8484+ ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint)
8585+ fmt.Printf("DEBUG [registry/middleware]: Using storage endpoint: %s\n", storageEndpoint)
8686+8787+ // Create a new reference with identity/image format
8888+ // Use the identity (or DID) as the namespace to ensure canonical format
8989+ // This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path)
9090+ canonicalName := fmt.Sprintf("%s/%s", identity, imageName)
9191+ ref, err := reference.ParseNamed(canonicalName)
9292+ if err != nil {
9393+ return nil, fmt.Errorf("invalid image name %s: %w", imageName, err)
9494+ }
9595+9696+ // Delegate to underlying namespace with modified name
9797+ repo, err := nr.Namespace.Repository(ctx, ref)
9898+ if err != nil {
9999+ return nil, err
100100+ }
101101+102102+ // Wrap the repository with our routing repository
103103+ // Get the cached access token for this DID
104104+ accessToken, ok := auth.GetGlobalTokenCache().Get(did)
105105+ if !ok {
106106+ fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did)
107107+ accessToken = "" // Will fail on manifest push, but let it try
108108+ } else {
109109+ fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken))
110110+ }
111111+112112+ // This is where we inject ATProto + storage routing
113113+ atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
114114+115115+ // IMPORTANT: Use only the image name (not identity/image) for ATProto storage
116116+ // ATProto records are scoped to the user's DID, so we don't need the identity prefix
117117+ // Example: "evan.jarrett.net/debian" -> store as "debian"
118118+ repositoryName := imageName
119119+120120+ fmt.Printf("DEBUG [registry/middleware]: Creating RoutingRepository for image=%s (ATProto repo name)\n", repositoryName)
121121+122122+ // Create routing repository - routes manifests to ATProto, blobs to hold service
123123+ // The registry is stateless - no local storage is used
124124+ // Pass storage endpoint and DID as parameters (can't use context as it gets lost)
125125+ routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did)
126126+127127+ return routingRepo, nil
128128+}
129129+130130+// Repositories delegates to underlying namespace
131131+func (nr *NamespaceResolver) Repositories(ctx context.Context, repos []string, last string) (int, error) {
132132+ return nr.Namespace.Repositories(ctx, repos, last)
133133+}
134134+135135+// Blobs delegates to underlying namespace
136136+func (nr *NamespaceResolver) Blobs() distribution.BlobEnumerator {
137137+ return nr.Namespace.Blobs()
138138+}
139139+140140+// BlobStatter delegates to underlying namespace
141141+func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
142142+ return nr.Namespace.BlobStatter()
143143+}
144144+145145+// findStorageEndpoint determines which hold endpoint to use for blob storage
146146+// Priority order:
147147+// 1. User's sailor profile defaultHold (if set)
148148+// 2. User's own hold record (io.atcr.hold)
149149+// 3. AppView's default hold endpoint
150150+// Returns the storage endpoint URL, or empty string if none configured
151151+func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string {
152152+ // Create ATProto client (without auth - reading public records)
153153+ client := atproto.NewClient(pdsEndpoint, did, "")
154154+155155+ // 1. Check for sailor profile
156156+ profile, err := atproto.GetProfile(ctx, client)
157157+ if err != nil {
158158+ // Error reading profile (not a 404) - log and continue
159159+ fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)
160160+ }
161161+162162+ if profile != nil && profile.DefaultHold != "" {
163163+ // Profile exists with defaultHold set - use it
164164+ return profile.DefaultHold
165165+ }
166166+167167+ // 2. Profile doesn't exist or defaultHold is null/empty
168168+ // Check for user's own hold records
169169+ records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
170170+ if err != nil {
171171+ // Failed to query holds, use default
172172+ return nr.defaultStorageEndpoint
173173+ }
174174+175175+ // Find the first hold record
176176+ for _, record := range records {
177177+ var holdRecord atproto.HoldRecord
178178+ if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
179179+ continue
180180+ }
181181+182182+ // Return the endpoint from the first hold
183183+ if holdRecord.Endpoint != "" {
184184+ return holdRecord.Endpoint
185185+ }
186186+ }
187187+188188+ // 3. No profile defaultHold and no own hold records - use AppView default
189189+ return nr.defaultStorageEndpoint
190190+}
+57
pkg/middleware/repository.go
···11+package middleware
22+33+import (
44+ "context"
55+ "fmt"
66+77+ "github.com/distribution/distribution/v3"
88+ repositorymw "github.com/distribution/distribution/v3/registry/middleware/repository"
99+1010+ "atcr.io/pkg/atproto"
1111+ "atcr.io/pkg/storage"
1212+)
1313+1414+func init() {
1515+ // Register the ATProto routing middleware
1616+ repositorymw.Register("atproto-router", initATProtoRouter)
1717+}
1818+1919+// initATProtoRouter initializes the ATProto routing middleware
2020+func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]interface{}) (distribution.Repository, error) {
2121+ fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name())
2222+ fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n",
2323+ ctx.Value("atproto.did"), ctx.Value("atproto.pds"))
2424+2525+ // Extract DID and PDS from context (set by registry middleware)
2626+ did, ok := ctx.Value("atproto.did").(string)
2727+ if !ok || did == "" {
2828+ fmt.Printf("DEBUG [repository/middleware]: DID not found in context, ok=%v, did=%q\n", ok, did)
2929+ return nil, fmt.Errorf("did is required for atproto-router middleware")
3030+ }
3131+3232+ pdsEndpoint, ok := ctx.Value("atproto.pds").(string)
3333+ if !ok || pdsEndpoint == "" {
3434+ return nil, fmt.Errorf("pds is required for atproto-router middleware")
3535+ }
3636+3737+ // For now, use empty access token (we'll add auth later)
3838+ accessToken := ""
3939+4040+ // Create ATProto client
4141+ atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
4242+4343+ // Get repository name
4444+ repoName := repo.Named().Name()
4545+4646+ // Get storage endpoint from context
4747+ storageEndpoint, ok := ctx.Value("storage.endpoint").(string)
4848+ if !ok || storageEndpoint == "" {
4949+ return nil, fmt.Errorf("storage.endpoint not found in context")
5050+ }
5151+5252+ // Create routing repository - no longer uses storage driver
5353+ // All blobs are routed through hold service
5454+ routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repoName, storageEndpoint, did)
5555+5656+ return routingRepo, nil
5757+}
+57
pkg/server/handler.go
···11+package server
22+33+import (
44+ "net/http"
55+ "strings"
66+77+ "atcr.io/pkg/atproto"
88+)
99+1010+// ATProtoHandler wraps an HTTP handler to provide name resolution
1111+// This is an optional layer if middleware doesn't provide enough control
1212+type ATProtoHandler struct {
1313+ handler http.Handler
1414+ resolver *atproto.Resolver
1515+}
1616+1717+// NewATProtoHandler creates a new HTTP handler wrapper
1818+func NewATProtoHandler(handler http.Handler) *ATProtoHandler {
1919+ return &ATProtoHandler{
2020+ handler: handler,
2121+ resolver: atproto.NewResolver(),
2222+ }
2323+}
2424+2525+// ServeHTTP handles HTTP requests with name resolution
2626+func (h *ATProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2727+ // Parse the request path to extract user/image
2828+ // OCI Distribution API paths look like:
2929+ // /v2/<name>/manifests/<reference>
3030+ // /v2/<name>/blobs/<digest>
3131+3232+ path := r.URL.Path
3333+3434+ // Check if this is a v2 API request
3535+ if strings.HasPrefix(path, "/v2/") {
3636+ // Extract the repository name
3737+ parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
3838+ if len(parts) >= 2 {
3939+ // parts[0] might be username/DID
4040+ // We could do early resolution here if needed
4141+ // For now, we'll let the middleware handle it
4242+ }
4343+ }
4444+4545+ // Delegate to the underlying handler
4646+ // The registry middleware will handle the actual resolution
4747+ h.handler.ServeHTTP(w, r)
4848+}
4949+5050+// Note: In the current architecture, most of the name resolution
5151+// is handled by the registry middleware. This HTTP handler wrapper
5252+// is here for cases where you need to intercept requests before
5353+// they reach the distribution handlers, such as for:
5454+// - Custom authentication based on DIDs
5555+// - Request rewriting
5656+// - Early validation
5757+// - Custom API endpoints beyond OCI spec
+98
pkg/storage/hold_cache.go
···11+package storage
22+33+import (
44+ "sync"
55+ "time"
66+)
77+88+// HoldCache caches hold endpoints for (DID, repository) pairs
99+// This avoids expensive ATProto lookups on every blob request during pulls
1010+//
1111+// NOTE: This is a simple in-memory cache for MVP. For production deployments:
1212+// - Use Redis or similar for distributed caching
1313+// - Consider implementing cache size limits
1414+// - Monitor memory usage under high load
1515+type HoldCache struct {
1616+ mu sync.RWMutex
1717+ cache map[string]*holdCacheEntry
1818+}
1919+2020+type holdCacheEntry struct {
2121+ holdEndpoint string
2222+ expiresAt time.Time
2323+}
2424+2525+var globalHoldCache = &HoldCache{
2626+ cache: make(map[string]*holdCacheEntry),
2727+}
2828+2929+func init() {
3030+ // Start background cleanup goroutine
3131+ go func() {
3232+ ticker := time.NewTicker(5 * time.Minute)
3333+ defer ticker.Stop()
3434+ for range ticker.C {
3535+ globalHoldCache.Cleanup()
3636+ }
3737+ }()
3838+}
3939+4040+// GetGlobalHoldCache returns the global hold cache instance
4141+func GetGlobalHoldCache() *HoldCache {
4242+ return globalHoldCache
4343+}
4444+4545+// Set stores a hold endpoint for a (DID, repository) pair with a TTL
4646+func (c *HoldCache) Set(did, repository, holdEndpoint string, ttl time.Duration) {
4747+ c.mu.Lock()
4848+ defer c.mu.Unlock()
4949+5050+ key := did + ":" + repository
5151+ c.cache[key] = &holdCacheEntry{
5252+ holdEndpoint: holdEndpoint,
5353+ expiresAt: time.Now().Add(ttl),
5454+ }
5555+}
5656+5757+// Get retrieves a hold endpoint for a (DID, repository) pair
5858+// Returns empty string and false if not found or expired
5959+func (c *HoldCache) Get(did, repository string) (string, bool) {
6060+ c.mu.RLock()
6161+ defer c.mu.RUnlock()
6262+6363+ key := did + ":" + repository
6464+ entry, ok := c.cache[key]
6565+ if !ok {
6666+ return "", false
6767+ }
6868+6969+ // Check if expired
7070+ if time.Now().After(entry.expiresAt) {
7171+ // Don't delete here (would need write lock), let cleanup handle it
7272+ return "", false
7373+ }
7474+7575+ return entry.holdEndpoint, true
7676+}
7777+7878+// Cleanup removes expired entries (called automatically every 5 minutes)
7979+func (c *HoldCache) Cleanup() {
8080+ c.mu.Lock()
8181+ defer c.mu.Unlock()
8282+8383+ now := time.Now()
8484+ removed := 0
8585+ for key, entry := range c.cache {
8686+ if now.After(entry.expiresAt) {
8787+ delete(c.cache, key)
8888+ removed++
8989+ }
9090+ }
9191+9292+ // Log cleanup stats for monitoring
9393+ if removed > 0 || len(c.cache) > 100 {
9494+ // Log if we removed entries OR if cache is growing large
9595+ // This helps identify if cache size is becoming a concern
9696+ println("Hold cache cleanup: removed", removed, "entries, remaining", len(c.cache))
9797+ }
9898+}
+402
pkg/storage/proxy_blob_store.go
···11+package storage
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "fmt"
88+ "io"
99+ "net/http"
1010+ "sync"
1111+ "time"
1212+1313+ "github.com/distribution/distribution/v3"
1414+ "github.com/opencontainers/go-digest"
1515+)
1616+1717+// Global upload tracking (shared across all ProxyBlobStore instances)
1818+// This is necessary because distribution creates new repository/blob store instances per request
1919+var (
2020+ globalUploads = make(map[string]*ProxyBlobWriter)
2121+ globalUploadsMu sync.RWMutex
2222+)
2323+2424+// ProxyBlobStore proxies blob requests to an external storage service
2525+type ProxyBlobStore struct {
2626+ storageEndpoint string
2727+ httpClient *http.Client
2828+ did string
2929+}
3030+3131+// NewProxyBlobStore creates a new proxy blob store
3232+func NewProxyBlobStore(storageEndpoint, did string) *ProxyBlobStore {
3333+ return &ProxyBlobStore{
3434+ storageEndpoint: storageEndpoint,
3535+ httpClient: &http.Client{},
3636+ did: did,
3737+ }
3838+}
3939+4040+// Stat returns the descriptor for a blob
4141+func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
4242+ // For simplicity, we'll just check if we can get a download URL
4343+ // In production, you'd want a dedicated stat endpoint
4444+ url, err := p.getDownloadURL(ctx, dgst)
4545+ if err != nil {
4646+ return distribution.Descriptor{}, distribution.ErrBlobUnknown
4747+ }
4848+4949+ // We don't have size info from the storage service
5050+ // Return a minimal descriptor
5151+ return distribution.Descriptor{
5252+ Digest: dgst,
5353+ MediaType: "application/octet-stream",
5454+ URLs: []string{url},
5555+ }, nil
5656+}
5757+5858+// Get retrieves a blob
5959+func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) {
6060+ url, err := p.getDownloadURL(ctx, dgst)
6161+ if err != nil {
6262+ return nil, err
6363+ }
6464+6565+ // Download the blob
6666+ resp, err := http.Get(url)
6767+ if err != nil {
6868+ return nil, err
6969+ }
7070+ defer resp.Body.Close()
7171+7272+ if resp.StatusCode != http.StatusOK {
7373+ return nil, distribution.ErrBlobUnknown
7474+ }
7575+7676+ return io.ReadAll(resp.Body)
7777+}
7878+7979+// Open returns a reader for a blob
8080+func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) {
8181+ url, err := p.getDownloadURL(ctx, dgst)
8282+ if err != nil {
8383+ return nil, err
8484+ }
8585+8686+ // Download the blob
8787+ resp, err := http.Get(url)
8888+ if err != nil {
8989+ return nil, err
9090+ }
9191+9292+ if resp.StatusCode != http.StatusOK {
9393+ resp.Body.Close()
9494+ return nil, distribution.ErrBlobUnknown
9595+ }
9696+9797+ // Wrap in a ReadSeekCloser
9898+ return &readSeekCloser{
9999+ ReadCloser: resp.Body,
100100+ }, nil
101101+}
102102+103103+// Put stores a blob
104104+func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
105105+ // Calculate digest
106106+ dgst := digest.FromBytes(content)
107107+108108+ // Get upload URL
109109+ url, err := p.getUploadURL(ctx, dgst, int64(len(content)))
110110+ if err != nil {
111111+ return distribution.Descriptor{}, err
112112+ }
113113+114114+ // Upload the blob
115115+ req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
116116+ if err != nil {
117117+ return distribution.Descriptor{}, err
118118+ }
119119+ req.Header.Set("Content-Type", "application/octet-stream")
120120+121121+ resp, err := p.httpClient.Do(req)
122122+ if err != nil {
123123+ return distribution.Descriptor{}, err
124124+ }
125125+ defer resp.Body.Close()
126126+127127+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
128128+ return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d", resp.StatusCode)
129129+ }
130130+131131+ return distribution.Descriptor{
132132+ Digest: dgst,
133133+ Size: int64(len(content)),
134134+ MediaType: mediaType,
135135+ }, nil
136136+}
137137+138138+// Delete removes a blob
139139+func (p *ProxyBlobStore) Delete(ctx context.Context, dgst digest.Digest) error {
140140+ // Not implemented - storage service would need a delete endpoint
141141+ return fmt.Errorf("delete not supported for proxy blob store")
142142+}
143143+144144+// ServeBlob serves a blob via HTTP redirect
145145+func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
146146+ // Get presigned download URL
147147+ url, err := p.getDownloadURL(ctx, dgst)
148148+ if err != nil {
149149+ return err
150150+ }
151151+152152+ // Redirect to presigned URL
153153+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
154154+ return nil
155155+}
156156+157157+// Create returns a blob writer for uploading
158158+func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
159159+ // Parse options
160160+ var opts distribution.CreateOptions
161161+ for _, option := range options {
162162+ if err := option.Apply(&opts); err != nil {
163163+ return nil, err
164164+ }
165165+ }
166166+167167+ // Create proxy blob writer
168168+ writer := &ProxyBlobWriter{
169169+ store: p,
170170+ ctx: ctx,
171171+ options: opts,
172172+ id: fmt.Sprintf("upload-%d", time.Now().UnixNano()),
173173+ startedAt: time.Now(),
174174+ }
175175+176176+ // Store in global uploads map for resume support
177177+ globalUploadsMu.Lock()
178178+ globalUploads[writer.id] = writer
179179+ globalUploadsMu.Unlock()
180180+181181+ return writer, nil
182182+}
183183+184184+// Resume returns a blob writer for resuming an upload
185185+func (p *ProxyBlobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) {
186186+ // Retrieve upload from global map
187187+ globalUploadsMu.RLock()
188188+ writer, ok := globalUploads[id]
189189+ globalUploadsMu.RUnlock()
190190+191191+ if !ok {
192192+ return nil, distribution.ErrBlobUploadUnknown
193193+ }
194194+195195+ return writer, nil
196196+}
197197+198198+// getDownloadURL requests a presigned download URL from the storage service
199199+func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
200200+ reqBody := map[string]interface{}{
201201+ "did": p.did,
202202+ "digest": dgst.String(),
203203+ }
204204+205205+ body, err := json.Marshal(reqBody)
206206+ if err != nil {
207207+ return "", err
208208+ }
209209+210210+ url := fmt.Sprintf("%s/get-presigned-url", p.storageEndpoint)
211211+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
212212+ if err != nil {
213213+ return "", err
214214+ }
215215+ req.Header.Set("Content-Type", "application/json")
216216+217217+ resp, err := p.httpClient.Do(req)
218218+ if err != nil {
219219+ return "", err
220220+ }
221221+ defer resp.Body.Close()
222222+223223+ if resp.StatusCode != http.StatusOK {
224224+ return "", fmt.Errorf("failed to get download URL: status %d", resp.StatusCode)
225225+ }
226226+227227+ var result struct {
228228+ URL string `json:"url"`
229229+ }
230230+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
231231+ return "", err
232232+ }
233233+234234+ return result.URL, nil
235235+}
236236+237237+// getUploadURL requests a presigned upload URL from the storage service
238238+func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) {
239239+ reqBody := map[string]interface{}{
240240+ "did": p.did,
241241+ "digest": dgst.String(),
242242+ "size": size,
243243+ }
244244+245245+ body, err := json.Marshal(reqBody)
246246+ if err != nil {
247247+ return "", err
248248+ }
249249+250250+ url := fmt.Sprintf("%s/put-presigned-url", p.storageEndpoint)
251251+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
252252+ if err != nil {
253253+ return "", err
254254+ }
255255+ req.Header.Set("Content-Type", "application/json")
256256+257257+ resp, err := p.httpClient.Do(req)
258258+ if err != nil {
259259+ return "", err
260260+ }
261261+ defer resp.Body.Close()
262262+263263+ if resp.StatusCode != http.StatusOK {
264264+ return "", fmt.Errorf("failed to get upload URL: status %d", resp.StatusCode)
265265+ }
266266+267267+ var result struct {
268268+ URL string `json:"url"`
269269+ }
270270+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
271271+ return "", err
272272+ }
273273+274274+ return result.URL, nil
275275+}
276276+277277+// ProxyBlobWriter implements distribution.BlobWriter for proxy uploads
278278+type ProxyBlobWriter struct {
279279+ store *ProxyBlobStore
280280+ ctx context.Context
281281+ options distribution.CreateOptions
282282+ buffer bytes.Buffer
283283+ size int64
284284+ closed bool
285285+ id string
286286+ startedAt time.Time
287287+}
288288+289289+// ID returns the upload ID
290290+func (w *ProxyBlobWriter) ID() string {
291291+ return w.id
292292+}
293293+294294+// StartedAt returns when the upload started
295295+func (w *ProxyBlobWriter) StartedAt() time.Time {
296296+ return w.startedAt
297297+}
298298+299299+// Write writes data to the upload
300300+func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
301301+ if w.closed {
302302+ return 0, fmt.Errorf("writer closed")
303303+ }
304304+ n, err := w.buffer.Write(p)
305305+ w.size += int64(n)
306306+ return n, err
307307+}
308308+309309+// ReadFrom reads from a reader
310310+func (w *ProxyBlobWriter) ReadFrom(r io.Reader) (int64, error) {
311311+ if w.closed {
312312+ return 0, fmt.Errorf("writer closed")
313313+ }
314314+ return w.buffer.ReadFrom(r)
315315+}
316316+317317+// Size returns the current size
318318+func (w *ProxyBlobWriter) Size() int64 {
319319+ return w.size
320320+}
321321+322322+// Commit finalizes the upload
323323+func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
324324+ if w.closed {
325325+ return distribution.Descriptor{}, fmt.Errorf("writer closed")
326326+ }
327327+ w.closed = true
328328+329329+ // Remove from global uploads map
330330+ globalUploadsMu.Lock()
331331+ delete(globalUploads, w.id)
332332+ globalUploadsMu.Unlock()
333333+334334+ // Upload the buffered content
335335+ content := w.buffer.Bytes()
336336+ dgst := digest.FromBytes(content)
337337+338338+ // Verify digest matches
339339+ if desc.Digest != "" && dgst != desc.Digest {
340340+ return distribution.Descriptor{}, fmt.Errorf("digest mismatch")
341341+ }
342342+343343+ // Get upload URL
344344+ url, err := w.store.getUploadURL(ctx, dgst, int64(len(content)))
345345+ if err != nil {
346346+ return distribution.Descriptor{}, err
347347+ }
348348+349349+ // Upload
350350+ req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
351351+ if err != nil {
352352+ return distribution.Descriptor{}, err
353353+ }
354354+ req.Header.Set("Content-Type", "application/octet-stream")
355355+356356+ resp, err := w.store.httpClient.Do(req)
357357+ if err != nil {
358358+ return distribution.Descriptor{}, err
359359+ }
360360+ defer resp.Body.Close()
361361+362362+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
363363+ return distribution.Descriptor{}, fmt.Errorf("upload failed: status %d", resp.StatusCode)
364364+ }
365365+366366+ return distribution.Descriptor{
367367+ Digest: dgst,
368368+ Size: int64(len(content)),
369369+ MediaType: desc.MediaType,
370370+ }, nil
371371+}
372372+373373+// Cancel cancels the upload
374374+func (w *ProxyBlobWriter) Cancel(ctx context.Context) error {
375375+ w.closed = true
376376+377377+ // Remove from global uploads map
378378+ globalUploadsMu.Lock()
379379+ delete(globalUploads, w.id)
380380+ globalUploadsMu.Unlock()
381381+382382+ return nil
383383+}
384384+385385+// Close closes the writer
386386+// NOTE: For resumable uploads, we don't mark as closed here
387387+// Distribution calls Close() after each PATCH, but the upload may continue
388388+// Only Commit() and Cancel() actually finalize the upload
389389+func (w *ProxyBlobWriter) Close() error {
390390+ // Don't set w.closed = true here - allow resuming
391391+ return nil
392392+}
393393+394394+// readSeekCloser wraps an io.ReadCloser to implement ReadSeekCloser
395395+type readSeekCloser struct {
396396+ io.ReadCloser
397397+}
398398+399399+func (r *readSeekCloser) Seek(offset int64, whence int) (int64, error) {
400400+ // Not implemented - would need buffering or re-downloading
401401+ return 0, fmt.Errorf("seek not supported")
402402+}
+95
pkg/storage/routing_repository.go
···11+package storage
22+33+import (
44+ "context"
55+ "fmt"
66+ "time"
77+88+ "atcr.io/pkg/atproto"
99+ "github.com/distribution/distribution/v3"
1010+)
1111+1212+// RoutingRepository routes manifests to ATProto and blobs to external hold service
1313+// The registry (AppView) is stateless and NEVER stores blobs locally
1414+type RoutingRepository struct {
1515+ distribution.Repository
1616+ atprotoClient *atproto.Client
1717+ repositoryName string
1818+ storageEndpoint string // Hold service endpoint for blobs (from discovery for push)
1919+ did string // User's DID for authorization
2020+ manifestStore *atproto.ManifestStore // Cached manifest store instance
2121+}
2222+2323+// NewRoutingRepository creates a new routing repository
2424+func NewRoutingRepository(
2525+ baseRepo distribution.Repository,
2626+ atprotoClient *atproto.Client,
2727+ repoName string,
2828+ storageEndpoint string,
2929+ did string,
3030+) *RoutingRepository {
3131+ return &RoutingRepository{
3232+ Repository: baseRepo,
3333+ atprotoClient: atprotoClient,
3434+ repositoryName: repoName,
3535+ storageEndpoint: storageEndpoint,
3636+ did: did,
3737+ }
3838+}
3939+4040+// Manifests returns the ATProto-backed manifest service
4141+func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
4242+ // Create or return cached manifest store
4343+ if r.manifestStore == nil {
4444+ r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did)
4545+ }
4646+4747+ // After any manifest operation, cache the hold endpoint for blob fetches
4848+ // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
4949+ go func() {
5050+ time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
5151+ if holdEndpoint := r.manifestStore.GetLastFetchedHoldEndpoint(); holdEndpoint != "" {
5252+ // Cache for 10 minutes - should cover typical pull operations
5353+ GetGlobalHoldCache().Set(r.did, r.repositoryName, holdEndpoint, 10*time.Minute)
5454+ fmt.Printf("DEBUG [storage/routing]: Cached hold endpoint: did=%s, repo=%s, hold=%s\n",
5555+ r.did, r.repositoryName, holdEndpoint)
5656+ }
5757+ }()
5858+5959+ return r.manifestStore, nil
6060+}
6161+6262+// Blobs returns a proxy blob store that routes to external hold service
6363+// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
6464+func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
6565+ // For pull operations, check if we have a cached hold endpoint from a recent manifest fetch
6666+ // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
6767+ holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint
6868+6969+ if cachedHold, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok {
7070+ // Use cached hold from manifest
7171+ holdEndpoint = cachedHold
7272+ fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n",
7373+ r.did, r.repositoryName, cachedHold)
7474+ } else {
7575+ // No cached hold, use discovery-based endpoint (for push or first pull)
7676+ fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n",
7777+ r.did, r.repositoryName, holdEndpoint)
7878+ }
7979+8080+ if holdEndpoint == "" {
8181+ // This should never happen if middleware is configured correctly
8282+ panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware")
8383+ }
8484+8585+ // Always use proxy blob store - routes to external hold service
8686+ return NewProxyBlobStore(holdEndpoint, r.did)
8787+}
8888+8989+// Tags returns the tag service
9090+// Tags will be handled by ATProto as well
9191+func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService {
9292+ // For now, delegate to the base repository
9393+ // In a full implementation, this would also use ATProto
9494+ return r.Repository.Tags(ctx)
9595+}
+54
pkg/storage/s3_blob_store.go
···11+package storage
22+33+import (
44+ "context"
55+66+ "github.com/distribution/distribution/v3"
77+ "github.com/distribution/distribution/v3/registry/storage"
88+ "github.com/distribution/distribution/v3/registry/storage/driver"
99+ "github.com/distribution/reference"
1010+)
1111+1212+// S3BlobStore wraps distribution's blob store with S3 backend
1313+type S3BlobStore struct {
1414+ distribution.BlobStore
1515+}
1616+1717+// NewS3BlobStore creates a new S3-backed blob store
1818+func NewS3BlobStore(ctx context.Context, storageDriver driver.StorageDriver, repoName string) (*S3BlobStore, error) {
1919+ // Create a registry instance with the S3 driver
2020+ reg, err := storage.NewRegistry(ctx, storageDriver)
2121+ if err != nil {
2222+ return nil, err
2323+ }
2424+2525+ // Parse the repository name into a Named reference
2626+ named, err := reference.ParseNamed(repoName)
2727+ if err != nil {
2828+ return nil, err
2929+ }
3030+3131+ // Get the repository
3232+ repo, err := reg.Repository(ctx, named)
3333+ if err != nil {
3434+ return nil, err
3535+ }
3636+3737+ // Get the blob store
3838+ blobStore := repo.Blobs(ctx)
3939+4040+ return &S3BlobStore{
4141+ BlobStore: blobStore,
4242+ }, nil
4343+}
4444+4545+// Note: S3BlobStore inherits all methods from distribution.BlobStore
4646+// including:
4747+// - Stat(ctx, dgst) - Check if blob exists
4848+// - Get(ctx, dgst) - Retrieve blob
4949+// - Open(ctx, dgst) - Open blob for reading
5050+// - Put(ctx, mediaType, payload) - Store blob
5151+// - Create(ctx, options...) - Create blob writer
5252+// - Resume(ctx, id) - Resume blob upload
5353+// - ServeBlob(ctx, w, r, dgst) - Serve blob over HTTP
5454+// - Delete(ctx, dgst) - Delete blob