ui fixes, add ability to warn/hide unreachable manifests from the ui. clean up docs

This commit is contained in:
Evan Jarrett
2025-10-20 11:47:26 -05:00
parent 965e73881b
commit b155534d1b
27 changed files with 3572 additions and 3399 deletions
+154 -72
View File
@@ -19,9 +19,19 @@ go build -o bin/oauth-helper ./cmd/oauth-helper
# Run tests
go test ./...
# Run tests for specific package
go test ./pkg/atproto/...
go test ./pkg/appview/storage/...
# Run specific test
go test -run TestManifestStore ./pkg/atproto/...
# Run with race detector
go test -race ./...
# Run tests with verbose output
go test -v ./...
# Update dependencies
go mod tidy
@@ -101,32 +111,45 @@ ATCR uses **distribution/distribution** as a library and extends it through midd
2. HTTP Request → /v2/alice/myapp/manifests/latest
3. Registry Middleware (pkg/appview/middleware/registry.go)
→ Resolves "alice" to DID and PDS endpoint
→ Queries alice's sailor profile for defaultHold
→ Queries alice's sailor profile for defaultHold (returns DID if set)
→ If not set, checks alice's io.atcr.hold records
→ Falls back to AppView's default_storage_endpoint
→ Stores DID/PDS/storage endpoint in context
→ Falls back to AppView's default_hold_did
→ Stores DID/PDS/hold DID in RegistryContext
4. Routing Repository (pkg/appview/storage/routing_repository.go)
→ Creates RoutingRepository
→ Returns ATProto ManifestStore for manifests
→ Returns ProxyBlobStore for blobs
5. Blob PUT → Resolved hold service (redirects to S3/storage)
6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdEndpoint)
→ Returns ProxyBlobStore for blobs (routes to hold DID)
5. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints:
a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID)
b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part)
c. PUT to S3 presigned URL (or PUT /xrpc/io.atcr.hold.uploadPart for buffered mode)
d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload)
6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint)
→ Manifest also uploaded to PDS blob storage (ATProto CID format)
```
#### Push with BYOS (Bring Your Own Storage)
```
1. Client: docker push atcr.io/alice/myapp:latest
2. Registry Middleware resolves alice → did:plc:alice123
3. Hold discovery via findStorageEndpoint():
a. Check alice's sailor profile for defaultHold
b. If not set, check alice's io.atcr.hold records
c. Fall back to AppView's default_storage_endpoint
4. Found: alice's profile has defaultHold = "https://alice-storage.fly.dev"
5. Routing Repository returns ProxyBlobStore(alice-storage.fly.dev)
6. ProxyBlobStore calls alice-storage.fly.dev for presigned URL
7. Storage service validates alice's DID, generates S3 presigned URL
8. Client redirected to upload blob directly to alice's S3/Storj
9. Manifest stored in alice's PDS with holdEndpoint = "https://alice-storage.fly.dev"
3. Hold discovery via findHoldDID():
a. Check alice's sailor profile for defaultHold (returns DID if set)
b. If not set, check alice's io.atcr.hold records (legacy)
c. Fall back to AppView's default_hold_did
4. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev"
5. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev)
6. ProxyBlobStore:
a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution)
b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
c. Calls hold XRPC endpoints with service token authentication:
- POST /xrpc/io.atcr.hold.initiateUpload
- POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL)
- PUT to S3 presigned URL (direct upload to alice's S3/Storj)
- POST /xrpc/io.atcr.hold.completeUpload
7. Hold service validates service token, checks crew membership, generates presigned URLs
8. Manifest stored in alice's PDS with:
- holdDid = "did:web:alice-storage.fly.dev" (primary)
- holdEndpoint = "https://alice-storage.fly.dev" (backward compat)
```
#### Pull Flow
@@ -134,15 +157,22 @@ ATCR uses **distribution/distribution** as a library and extends it through midd
1. Client: docker pull atcr.io/alice/myapp:latest
2. GET /v2/alice/myapp/manifests/latest
3. AppView fetches manifest from alice's PDS
4. Manifest contains holdEndpoint = "https://alice-storage.fly.dev"
5. Hold endpoint cached: (alice's DID, "myapp") → "https://alice-storage.fly.dev"
4. Manifest contains:
- holdDid = "did:web:alice-storage.fly.dev" (primary reference)
- holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback)
5. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev"
TTL: 10 minutes (covers typical pull operations)
6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123
7. AppView checks cache, routes to hold from manifest (not re-discovered)
8. ProxyBlobStore calls alice-storage.fly.dev for presigned download URL
9. Client redirected to download blob directly from alice's S3
7. AppView checks cache, routes to hold DID from manifest (not re-discovered)
8. ProxyBlobStore:
a. Resolves hold DID → https://alice-storage.fly.dev
b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET
d. Hold returns presigned download URL in JSON response
9. Client redirected to download blob directly from alice's S3 via presigned URL
```
**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold.
**Key insight:** Pull uses the historical `holdDid` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold. Hold cache (10min TTL) avoids re-querying PDS for each blob during the same pull operation.
### Name Resolution
@@ -269,12 +299,14 @@ Later (subsequent docker push):
- Uses XRPC protocol (com.atproto.repo.*)
**lexicon.go**: ATProto record schemas
- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdEndpoint` field)
- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdDid` + `holdEndpoint` fields)
- `TagRecord`: Tag pointing to manifest digest
- `HoldRecord`: Storage hold definition (for BYOS)
- `HoldCrewRecord`: Hold crew membership/permissions
- `SailorProfileRecord`: User profile with `defaultHold` preference
- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold`, `io.atcr.hold.crew`, `io.atcr.sailor.profile`
- `HoldRecord`: Storage hold definition (LEGACY - for old BYOS model)
- `HoldCrewRecord`: Hold crew membership (LEGACY - stored in owner's PDS)
- `CaptainRecord`: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self")
- `CrewRecord`: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member)
- `SailorProfileRecord`: User profile with `defaultHold` preference (can be DID or URL)
- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold` (legacy), `io.atcr.hold.crew` (used by both legacy and new models), `io.atcr.hold.captain` (new), `io.atcr.sailor.profile`
**profile.go**: Sailor profile management
- `EnsureProfile()`: Creates profile with default hold on first authentication
@@ -289,26 +321,29 @@ Later (subsequent docker push):
#### Storage Layer (`pkg/appview/storage/`)
**routing_repository.go**: Routes content by type
- `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction)
- `Manifests()` → returns ATProto ManifestStore (caches instance for hold DID extraction)
- `Blobs()` → checks hold cache for pull, uses discovery for push
- Pull: Uses cached `holdEndpoint` from manifest (historical reference)
- Push: Uses discovery-based endpoint from `findStorageEndpoint()`
- Always returns ProxyBlobStore (routes to hold service)
- Pull: Uses cached `holdDid` from manifest (historical reference)
- Push: Uses discovery-based DID from `findHoldDID()` in middleware
- Always returns ProxyBlobStore (routes to hold service via DID)
- Implements `distribution.Repository` interface
- Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc.
**hold_cache.go**: In-memory hold endpoint cache
- Caches `(DID, repository) → holdEndpoint` for pull operations
**hold_cache.go**: In-memory hold DID cache
- Caches `(DID, repository) → holdDid` for pull operations
- TTL: 10 minutes (covers typical pull operations)
- Cleanup: Background goroutine runs every 5 minutes
- **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar
- Prevents expensive ATProto lookups on every blob request
- Prevents expensive PDS manifest lookups on every blob request during pull
**proxy_blob_store.go**: External storage proxy
- Calls user's storage service for presigned URLs
- Issues HTTP redirects for blob uploads/downloads
**proxy_blob_store.go**: External storage proxy (routes to hold via XRPC)
- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution)
- Gets service tokens from user's PDS (`com.atproto.server.getServiceAuth`)
- Calls hold XRPC endpoints with service token authentication:
- Multipart upload: initiateUpload, getPartUploadUrl, uploadPart, completeUpload, abortUpload
- Blob read: com.atproto.sync.getBlob (returns presigned download URL)
- Implements full `distribution.BlobStore` interface
- Supports multipart uploads for large blobs
- Used when user has `io.atcr.hold` record
- Supports both presigned URL mode (S3 direct) and buffered mode (proxy via hold)
#### AppView Web UI (`pkg/appview/`)
@@ -348,46 +383,73 @@ The AppView includes a web interface for browsing the registry:
#### Hold Service (`cmd/hold/`)
Lightweight standalone service for BYOS (Bring Your Own Storage):
Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS:
**Architecture:**
- Reuses distribution's storage driver factory
- Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem
- Authorization follows ATProto's public-by-default model
- Generates presigned URLs (15min expiry) or proxies uploads/downloads
- **Embedded PDS**: Each hold has a full ATProto PDS for storing captain + crew records
- **DID**: Hold identified by did:web (e.g., `did:web:hold01.atcr.io`)
- **Storage**: Reuses distribution's storage driver factory (S3, Storj, Minio, Azure, GCS, filesystem)
- **Authorization**: Based on captain + crew records in embedded PDS
- **Blob operations**: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC
**Authorization Model:**
Read access:
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users
- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users only (any ATCR user)
- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read permission
Write access:
- Hold owner OR crew members only
- Hold owner OR crew members with blob:write permission
- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet).
**Embedded PDS Endpoints:**
Each hold service includes an embedded PDS (Personal Data Server) that stores captain + crew records:
**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`):
Standard ATProto sync endpoints:
- `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file
- `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision
- `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events
- `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS)
- `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL
Repository management:
- `GET /xrpc/com.atproto.repo.describeRepo?repo={did}` - Repository metadata
- `GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}` - Get record
- `GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}` - List records (supports pagination)
- `POST /xrpc/com.atproto.repo.deleteRecord` - Delete record (owner/crew admin only)
- `POST /xrpc/com.atproto.repo.uploadBlob` - Upload ATProto blob (owner/crew admin only)
DID resolution:
- `GET /.well-known/did.json` - DID document (did:web resolution)
- Standard ATProto repo endpoints (getRecord, listRecords, etc.)
- `GET /.well-known/atproto-did` - DID for handle resolution
The `subscribeRepos` endpoint broadcasts #commit events whenever crew membership changes, allowing AppViews to monitor hold access control in real-time.
Crew management:
- `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership (authenticated users)
**Configuration:** Environment variables (see `.env.example`)
- `HOLD_PUBLIC_URL` - Public URL of hold service (required)
**OCI Multipart Upload Endpoints** (`pkg/hold/oci/xrpc.go`):
All require blob:write permission via service token authentication:
- `POST /xrpc/io.atcr.hold.initiateUpload` - Start multipart upload session
- `POST /xrpc/io.atcr.hold.getPartUploadUrl` - Get presigned URL for uploading a part
- `PUT /xrpc/io.atcr.hold.uploadPart` - Direct buffered part upload (alternative to presigned URLs)
- `POST /xrpc/io.atcr.hold.completeUpload` - Finalize multipart upload and move to final location
- `POST /xrpc/io.atcr.hold.abortUpload` - Cancel multipart upload and cleanup temp data
**AppView-to-Hold Authentication:**
- AppView uses service tokens from user's PDS (`com.atproto.server.getServiceAuth`)
- Service tokens are scoped to specific hold DIDs and include the user's DID
- Hold validates tokens and checks crew membership for authorization
- Tokens cached for 50 seconds (valid for 60 seconds from PDS)
**Configuration:** Environment variables (see `.env.hold.example`)
- `HOLD_PUBLIC_URL` - Public URL of hold service (required, used for did:web generation)
- `STORAGE_DRIVER` - Storage driver type (s3, filesystem)
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
- `HOLD_PUBLIC` - Allow public reads (default: false)
- `HOLD_OWNER` - DID for auto-registration (optional)
- `HOLD_OWNER` - DID for captain record creation (optional)
- `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false)
- `HOLD_DATABASE_PATH` - Path for embedded PDS database (required)
- `HOLD_DATABASE_KEY_PATH` - Path for PDS signing keys (optional, generated if missing)
**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc.
@@ -399,17 +461,29 @@ Manifests are stored as records with this structure:
"$type": "io.atcr.manifest",
"repository": "myapp",
"digest": "sha256:abc123...",
"holdEndpoint": "https://hold1.alice.com",
"holdDid": "did:web:hold01.atcr.io",
"holdEndpoint": "https://hold1.atcr.io",
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": { "digest": "sha256:...", "size": 1234 },
"layers": [
{ "digest": "sha256:...", "size": 5678 }
],
"manifestBlob": {
"$type": "blob",
"ref": { "$link": "bafyrei..." },
"mimeType": "application/vnd.oci.image.manifest.v1+json",
"size": 1234
},
"createdAt": "2025-09-30T..."
}
```
**Key fields:**
- `holdDid` - DID of the hold service where blobs are stored (PRIMARY reference, new)
- `holdEndpoint` - HTTP URL of hold service (DEPRECATED, kept for backward compatibility)
- `manifestBlob` - Reference to manifest blob in ATProto blob storage (CID format)
Record key = manifest digest (without algorithm prefix)
Collection = `io.atcr.manifest`
@@ -425,7 +499,7 @@ ATCR uses a "sailor profile" to manage user preferences for hold (storage) selec
```json
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "https://hold1.alice.com",
"defaultHold": "did:web:hold1.alice.com",
"createdAt": "2025-10-02T...",
"updatedAt": "2025-10-02T..."
}
@@ -433,14 +507,15 @@ ATCR uses a "sailor profile" to manage user preferences for hold (storage) selec
**Profile Management:**
- Created automatically on first authentication (OAuth or Basic Auth)
- If AppView has `default_storage_endpoint` configured, profile gets that as `defaultHold`
- `defaultHold` can be a DID (preferred, e.g., `did:web:hold01.atcr.io`) or legacy URL
- If AppView has `default_hold_did` configured, profile gets that as `defaultHold`
- Users can update their profile to change default hold (future: via UI)
- Setting `defaultHold` to null opts out of defaults (use own holds or AppView default)
**Hold Resolution Priority** (in `findStorageEndpoint()`):
1. **Profile's `defaultHold`** - User's explicit preference
2. **User's `io.atcr.hold` records** - User's own holds
3. **AppView's `default_storage_endpoint`** - Fallback default
**Hold Resolution Priority** (in `findHoldDID()` in middleware):
1. **Profile's `defaultHold`** - User's explicit preference (DID or URL)
2. **User's `io.atcr.hold` records** - User's own holds (legacy BYOS model)
3. **AppView's `default_hold_did`** - Fallback default (configured in middleware)
This ensures:
- Users can join shared holds by setting their profile's `defaultHold`
@@ -472,7 +547,7 @@ See `.env.appview.example` for all available options. Key environment variables:
**Server:**
- `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`)
- `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev)
- `ATCR_DEFAULT_HOLD` - Default hold endpoint for blob storage (REQUIRED)
- `ATCR_DEFAULT_HOLD_DID` - Default hold DID for blob storage (REQUIRED, e.g., `did:web:hold01.atcr.io`)
**Authentication:**
- `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`)
@@ -537,12 +612,12 @@ When writing tests:
**Modifying storage routing**:
1. Edit `pkg/appview/storage/routing_repository.go`
2. Update `Blobs()` method to change routing logic
3. Consider context values: `storage.endpoint`, `atproto.did`
3. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.)
**Changing name resolution**:
1. Modify `pkg/atproto/resolver.go` for DID/handle resolution
2. Update `pkg/appview/middleware/registry.go` if changing routing logic
3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
3. Remember: `findHoldDID()` checks sailor profile, then `io.atcr.hold` records (legacy), then default hold DID
**Working with OAuth client**:
- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes
@@ -582,13 +657,20 @@ When writing tests:
## Important Context Values
When working with the codebase, these context values are used for routing:
When working with the codebase, routing information is passed via the `RegistryContext` struct (`pkg/appview/storage/context.go`):
- `atproto.did` - Resolved DID for the user (e.g., `did:plc:alice123`)
- `atproto.pds` - User's PDS endpoint (e.g., `https://bsky.social`)
- `atproto.identity` - Original identity string (handle or DID)
- `storage.endpoint` - Storage service URL (if user has `io.atcr.registry` record)
- `auth.did` - Authenticated DID from validated token
- `DID` - User's DID (e.g., `did:plc:alice123`)
- `PDSEndpoint` - User's PDS endpoint (e.g., `https://bsky.social`)
- `HoldDID` - Hold service DID (e.g., `did:web:hold01.atcr.io`)
- `Repository` - Image repository name (e.g., `myapp`)
- `ATProtoClient` - Client for calling user's PDS with OAuth/Basic Auth
- `Refresher` - OAuth token refresher for service token requests
- `Database` - Database for metrics tracking
- `Authorizer` - Hold authorizer for access control
Legacy context keys (deprecated):
- `hold.did` - Hold DID (now in RegistryContext)
- `auth.did` - Authenticated DID from validated token (now in auth middleware)
## Documentation References
+8 -5
View File
@@ -21,20 +21,23 @@ atcr.io/did:plc:xyz123/myapp:latest
1. **AppView** - Registry API + web UI
- Serves OCI Distribution API (Docker push/pull)
- Resolves handles/DIDs to PDS endpoints
- Routes manifests to PDS, blobs to storage
- Routes manifests to user's PDS, blobs to hold services
- Web interface for browsing/search
2. **Hold Service** - Storage service (optional BYOS)
2. **Hold Service** - Storage service with embedded PDS (optional BYOS)
- Each hold has a full ATProto PDS for access control (captain + crew records)
- Identified by did:web (e.g., `did:web:hold01.atcr.io`)
- Generates presigned URLs for S3/Storj/Minio/etc.
- Users can deploy their own storage
- Users can deploy their own storage and control access via crew membership
3. **Credential Helper** - Client authentication
- ATProto OAuth with DPoP
- Automatic authentication on first push/pull
**Storage model:**
- Manifests → ATProto records (small JSON)
- Blobs → S3 or BYOS (large binaries)
- Manifests → ATProto records in user's PDS (small JSON, includes `holdDid` reference)
- Blobs → Hold services via XRPC multipart upload (large binaries, stored in S3/etc.)
- AppView uses service tokens to communicate with holds on behalf of users
## Features
+43 -11
View File
@@ -26,6 +26,7 @@ import (
"atcr.io/pkg/appview"
"atcr.io/pkg/appview/db"
uihandlers "atcr.io/pkg/appview/handlers"
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/jetstream"
"github.com/gorilla/mux"
)
@@ -72,6 +73,22 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to initialize UI database - required for session storage")
}
// Initialize hold health checker
fmt.Println("Initializing hold health checker...")
cacheTTL := 15 * time.Minute // Cache TTL from user requirements
healthChecker := holdhealth.NewChecker(cacheTTL)
// Start background health check worker
refreshInterval := 5 * time.Minute // Refresh every 5 minutes
dbAdapter := holdhealth.NewDBAdapter(uiDatabase)
healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval)
// Create context for worker lifecycle management
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() // Ensure context is cancelled on all exit paths
healthWorker.Start(workerCtx)
fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)")
// Initialize OAuth components
fmt.Println("Initializing OAuth components...")
@@ -132,8 +149,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
middleware.SetGlobalAuthorizer(holdAuthorizer)
fmt.Println("Hold authorizer initialized with database caching")
// Initialize UI routes with OAuth app, refresher, and device store
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
// Initialize UI routes with OAuth app, refresher, device store, and health checker
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker)
// Create OAuth server
oauthServer := oauth.NewServer(oauthApp)
@@ -256,6 +273,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
select {
case <-stop:
fmt.Println("Shutting down registry server...")
// Stop health worker first
fmt.Println("Stopping hold health worker...")
healthWorker.Stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -263,6 +285,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
return fmt.Errorf("server shutdown error: %w", err)
}
case err := <-errChan:
// Stop health worker on error (workerCancel called by defer)
healthWorker.Stop()
return fmt.Errorf("server error: %w", err)
}
@@ -320,7 +344,8 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
// database: read-write connection for auth and writes
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
// defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io")
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string) (*template.Template, *mux.Router) {
// healthChecker: hold endpoint health checker
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker) (*template.Template, *mux.Router) {
// Check if UI is enabled
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
if uiEnabled == "false" {
@@ -356,9 +381,10 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
router.Handle("/api/recent-pushes", middleware.OptionalAuth(sessionStore, database)(
&uihandlers.RecentPushesHandler{
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
HealthChecker: healthChecker,
},
)).Methods("GET")
@@ -428,6 +454,11 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
},
)).Methods("GET")
// Manifest health check API endpoint (HTMX polling)
router.Handle("/api/manifest-health", &uihandlers.ManifestHealthHandler{
HealthChecker: healthChecker,
}).Methods("GET")
router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)(
&uihandlers.UserPageHandler{
DB: readOnlyDB,
@@ -438,11 +469,12 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
router.Handle("/r/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)(
&uihandlers.RepositoryPageHandler{
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
Directory: oauthApp.Directory(),
Refresher: refresher,
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
Directory: oauthApp.Directory(),
Refresher: refresher,
HealthChecker: healthChecker,
},
)).Methods("GET")
+237 -387
View File
@@ -2,216 +2,138 @@
## Overview
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. This allows users to:
- Deploy their own storage service backed by S3/Storj/Minio/filesystem
- Control who can use their storage (public or private)
- Keep blob data in their own infrastructure while manifests remain in their ATProto PDS
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. Users can:
- Deploy their own hold service with embedded PDS
- Control access via crew membership in the hold's PDS
- Keep blob data in their own S3/Storj/Minio while manifests stay in their user PDS
## Architecture
```
┌─────────────────────────────────────────────
│ ATCR AppView (API)
│ - Manifests → ATProto PDS
│ - Auth & token validation
│ - Blob routing (issues redirects)
│ - Profile management
└─────────────────┬───────────────────────────┘
│ Hold discovery priority:
│ 1. io.atcr.sailor.profile.defaultHold
│ 2. io.atcr.hold records
│ 3. AppView default_storage_endpoint
┌─────────────────────────────────────────────
│ User's PDS
│ - io.atcr.sailor.profile (hold preference)
│ - io.atcr.hold records (own holds)
│ - io.atcr.manifest records (with holdEP) │
└─────────────────┬───────────────────────────┘
│ Redirects to hold
┌─────────────────────────────────────────────┐
Storage Service (Hold)
- Blob storage (S3/Storj/Minio/filesystem)
- Presigned URL generation
- Authorization (DID-based)
└─────────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ ATCR AppView (API) │
│ - Manifests → User's PDS │
│ - Auth & service token management
│ - Blob routing via XRPC
│ - Profile management │
└─────────────────────────────────────────┘
│ Hold discovery priority:
│ 1. io.atcr.sailor.profile.defaultHold (DID)
│ 2. io.atcr.hold records (legacy)
│ 3. AppView default_hold_did
┌──────────────────────────────────────────┐
│ User's PDS │
│ - io.atcr.sailor.profile (hold DID)
│ - io.atcr.manifest (with holdDid)
└────────────┬─────────────────────────────┘
Service token from user's PDS
┌──────────────────────────────────────────┐
│ Hold Service (did:web:hold.example.com) │
├── Embedded PDS
│ ├── Captain record (ownership)
│ └── Crew records (access control)
├── XRPC multipart upload endpoints
└── Storage driver (S3/Storj/etc.) │
└──────────────────────────────────────────┘
```
## ATProto Records
## Hold Service Components
### io.atcr.sailor.profile
Each hold is a full ATProto actor with:
- **DID**: `did:web:hold.example.com` (hold's identity)
- **Embedded PDS**: Stores captain + crew records (shared data)
- **Storage backend**: S3, Storj, Minio, filesystem, etc.
- **XRPC endpoints**: Standard ATProto + custom OCI multipart upload
**NEW:** User profile for hold selection preferences. Created automatically on first authentication.
### Records in Hold's PDS
**Captain record** (`io.atcr.hold.captain/self`):
```json
{
"$type": "io.atcr.hold.captain",
"owner": "did:plc:alice123",
"public": false,
"deployedAt": "2025-10-14T...",
"region": "iad",
"provider": "fly.io"
}
```
**Crew records** (`io.atcr.hold.crew/{rkey}`):
```json
{
"$type": "io.atcr.hold.crew",
"member": "did:plc:bob456",
"role": "admin",
"permissions": ["blob:read", "blob:write"],
"addedAt": "2025-10-14T..."
}
```
### Sailor Profile (User's PDS)
Users set their preferred hold in their sailor profile:
```json
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "https://team-hold.example.com",
"createdAt": "2025-10-02T12:00:00Z",
"updatedAt": "2025-10-02T12:00:00Z"
"defaultHold": "did:web:hold.example.com",
"createdAt": "2025-10-02T...",
"updatedAt": "2025-10-02T..."
}
```
**Record key:** Always `"self"` (only one profile per user)
**Behavior:**
- Created automatically when user first authenticates (OAuth or Basic Auth)
- If AppView has `default_storage_endpoint`, profile gets that as initial `defaultHold`
- User can update to join shared holds or use their own hold
- Set `defaultHold` to `null` to opt out of defaults (use own hold or AppView default)
**This solves the multi-hold problem:** Users who are crew members of multiple holds can explicitly choose which one to use via their profile.
### io.atcr.hold
Users create a hold record in their PDS to configure their own storage:
```json
{
"$type": "io.atcr.hold",
"endpoint": "https://alice-storage.example.com",
"owner": "did:plc:alice123",
"public": false,
"createdAt": "2025-10-01T12:00:00Z"
}
```
### io.atcr.hold.crew
Hold owners can add crew members (for shared storage):
```json
{
"$type": "io.atcr.hold.crew",
"hold": "at://did:plc:alice/io.atcr.hold/my-storage",
"member": "did:plc:bob456",
"role": "write",
"addedAt": "2025-10-01T12:00:00Z"
}
```
**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.
## Storage Service
### Deployment
The storage service is a lightweight HTTP server that:
1. Accepts presigned URL requests
2. Verifies DID authorization
3. Generates presigned URLs for S3/Storj/etc
4. Returns URLs to AppView for client redirect
## Deployment
### Configuration
The hold service is configured entirely via environment variables. See `.env.example` for all options.
**Required environment variables:**
Hold service is configured entirely via environment variables:
```bash
# Hold service public URL (REQUIRED)
HOLD_PUBLIC_URL=https://storage.example.com
# Hold identity (REQUIRED)
HOLD_PUBLIC_URL=https://hold.example.com
HOLD_OWNER=did:plc:your-did-here
# Storage driver type
# Storage backend
STORAGE_DRIVER=s3
# For S3/Minio
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET=my-blobs
# For Storj (optional - custom S3 endpoint)
# S3_ENDPOINT=https://gateway.storjshare.io
# Access control
HOLD_PUBLIC=false # Require authentication for reads
HOLD_ALLOW_ALL_CREW=false # Only explicit crew members can write
# For filesystem storage
# STORAGE_DRIVER=filesystem
# STORAGE_ROOT_DIR=/var/lib/atcr-storage
# Embedded PDS
HOLD_DATABASE_PATH=/var/lib/atcr-hold/hold.db
HOLD_DATABASE_KEY_PATH=/var/lib/atcr-hold/keys
```
**Authorization:**
ATCR follows ATProto's public-by-default model with gated anonymous access:
**Read Access:**
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous reads allowed (no authentication)
- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication (any ATCR user with sailor.profile)
**Write Access:**
- Always requires authentication
- Must be hold owner OR crew member (verified via `io.atcr.hold.crew` records in owner's PDS)
**Key Points:**
- "Private" just means "no anonymous access" - not "limited user access"
- Any authenticated ATCR user can read from private holds
- Crew membership only controls WRITE access, not READ access
- This aligns with ATProto's public records model (no private PDS records yet)
### Running
### Running Locally
```bash
# Build
go build -o atcr-hold ./cmd/hold
go build -o bin/atcr-hold ./cmd/hold
# Set environment variables (or use .env file)
export HOLD_PUBLIC_URL=https://storage.example.com
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=us-east-1
export S3_BUCKET=my-blobs
# Run (with env vars or .env file)
export HOLD_PUBLIC_URL=http://localhost:8080
export HOLD_OWNER=did:plc:your-did-here
export STORAGE_DRIVER=filesystem
export STORAGE_ROOT_DIR=/tmp/atcr-hold
export HOLD_DATABASE_PATH=/tmp/atcr-hold/hold.db
# Run
./atcr-hold
./bin/atcr-hold
```
**Registration (required):**
The hold service must be registered in a PDS to be discoverable by the AppView.
**Standard registration workflow:**
1. Set `HOLD_OWNER` to your DID:
```bash
export HOLD_OWNER=did:plc:your-did-here
```
2. Start the hold service:
```bash
./atcr-hold
```
3. **Check the logs** for the OAuth authorization URL:
```
================================================================================
OAUTH AUTHORIZATION REQUIRED
================================================================================
Please visit this URL to authorize the hold service:
https://bsky.app/authorize?client_id=...
Waiting for authorization...
================================================================================
```
4. Visit the URL in your browser and authorize
5. The hold service will:
- Exchange the authorization code for a token
- Create `io.atcr.hold` record in your PDS
- Create `io.atcr.hold.crew` record (making you the owner)
- Save registration state
6. On subsequent runs, the service checks if already registered and skips OAuth
**Alternative methods:**
- **Manual API registration**: Call `POST /register` with your own OAuth token
- **Completely manual**: Create PDS records yourself using any ATProto client
On first run, the hold service creates:
- Captain record in embedded PDS (making you the owner)
- Crew record for owner with all permissions
- DID document at `/.well-known/did.json`
### Deploy to Fly.io
@@ -223,11 +145,11 @@ primary_region = "ord"
[env]
HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev"
HOLD_SERVER_ADDR = ":8080"
STORAGE_DRIVER = "s3"
AWS_REGION = "us-east-1"
S3_BUCKET = "my-blobs"
HOLD_PUBLIC = "false"
HOLD_ALLOW_ALL_CREW = "false"
[http_service]
internal_port = 8080
@@ -250,268 +172,196 @@ fly deploy
fly secrets set AWS_ACCESS_KEY_ID=...
fly secrets set AWS_SECRET_ACCESS_KEY=...
fly secrets set HOLD_OWNER=did:plc:your-did-here
# Check logs for OAuth URL on first run
fly logs
# Visit the OAuth URL shown in logs to authorize
# The hold service will register itself in your PDS
```
## Request Flow
### Push with BYOS
1. **Docker push** `atcr.io/alice/myapp:latest`
2. **AppView** resolves `alice` → `did:plc:alice123`
3. **AppView** discovers hold via priority logic:
- Check alice's `io.atcr.sailor.profile` for `defaultHold`
- If not set, check alice's `io.atcr.hold` records
- Fall back to AppView's `default_storage_endpoint`
4. **Found:** `alice.profile.defaultHold = "https://team-hold.example.com"`
5. **AppView** → team-hold: POST `/put-presigned-url`
```json
{
"did": "did:plc:alice123",
"digest": "sha256:abc123...",
"size": 1048576
}
```
6. **Hold service**:
- Verifies alice is authorized (checks crew records)
- Generates S3 presigned upload URL (15min expiry)
- Returns: `{"url": "https://s3.../blob?signature=..."}`
7. **AppView** → Docker: `307 Redirect` to presigned URL
8. **Docker** → S3: PUT blob directly (no proxy)
9. **Manifest** stored in alice's PDS with `holdEndpoint: "https://team-hold.example.com"`
```
1. Client: docker push atcr.io/alice/myapp:latest
2. AppView resolves alice → did:plc:alice123
3. AppView discovers hold DID:
- Check alice's sailor profile for defaultHold
- Returns: "did:web:alice-storage.fly.dev"
4. AppView gets service token from alice's PDS:
GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:alice-storage.fly.dev
Response: { "token": "eyJ..." }
5. AppView initiates multipart upload to hold:
POST https://alice-storage.fly.dev/xrpc/io.atcr.hold.initiateUpload
Authorization: Bearer {serviceToken}
Body: { "digest": "sha256:abc..." }
Response: { "uploadId": "xyz" }
6. For each part:
- AppView: POST /xrpc/io.atcr.hold.getPartUploadUrl
- Hold validates service token, checks crew membership
- Hold returns: { "url": "https://s3.../presigned" }
- Client uploads directly to S3 presigned URL
7. AppView completes upload:
POST /xrpc/io.atcr.hold.completeUpload
Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] }
8. Manifest stored in alice's PDS:
- holdDid: "did:web:alice-storage.fly.dev"
- holdEndpoint: "https://alice-storage.fly.dev" (backward compat)
```
### Pull with BYOS
1. **Docker pull** `atcr.io/alice/myapp:latest`
2. **AppView** fetches manifest from alice's PDS
3. **Manifest** contains `holdEndpoint: "https://team-hold.example.com"`
4. **AppView** caches: `(alice's DID, "myapp") → "https://team-hold.example.com"` (10min TTL)
5. **Docker** requests blobs: GET `/v2/alice/myapp/blobs/sha256:abc123`
6. **AppView** uses **cached hold from manifest** (not re-discovered)
7. **AppView** → team-hold: POST `/get-presigned-url`
8. **Hold service** returns presigned download URL
9. **AppView** → Docker: `307 Redirect`
10. **Docker** → S3: GET blob directly
```
1. Client: docker pull atcr.io/alice/myapp:latest
**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`.
2. AppView fetches manifest from alice's PDS
## Default Registry
3. Manifest contains:
- holdDid: "did:web:alice-storage.fly.dev"
The AppView can run its own storage service as the default:
4. AppView caches hold DID for 10 minutes (covers pull operation)
### AppView config
5. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123
```yaml
middleware:
- name: registry
options:
atproto-resolver:
default_storage_endpoint: https://storage.atcr.io
6. AppView uses cached hold DID from manifest
7. AppView gets service token from alice's PDS
8. AppView calls hold XRPC:
GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123
Authorization: Bearer {serviceToken}
Response: { "url": "https://s3.../presigned-download" }
9. AppView redirects client to presigned S3 URL
10. Client downloads directly from S3
```
### Default hold service config
**Key insight:** Pull uses the `holdDid` stored in the manifest, ensuring blobs are fetched from where they were originally pushed.
## Access Control
### Read Access
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + authenticated users
- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users with crew membership
### Write Access
- Hold owner (captain) OR crew members only
- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
- Service token proves user identity (from user's PDS)
### Authorization Flow
```go
1. AppView gets service token from user's PDS
2. AppView sends request to hold with service token
3. Hold validates service token (checks it's from user's PDS)
4. Hold extracts user's DID from token
5. Hold checks crew records in its embedded PDS
6. If crew member found allow, else deny
```
## Managing Crew Members
### Add Crew Member
Use ATProto client to create crew record in hold's PDS:
```bash
# Accept any authenticated DID
HOLD_PUBLIC=false # Requires authentication
# Via XRPC (if hold supports it)
POST https://hold.example.com/xrpc/io.atcr.hold.requestCrew
Authorization: Bearer {userOAuthToken}
# Or allow public reads
HOLD_PUBLIC=true # Public reads, auth required for writes
# Or manually via captain's OAuth to hold's PDS
atproto put-record \
--pds https://hold.example.com \
--collection io.atcr.hold.crew \
--rkey "{memberDID}" \
--value '{
"$type": "io.atcr.hold.crew",
"member": "did:plc:bob456",
"role": "admin",
"permissions": ["blob:read", "blob:write"]
}'
```
This provides free-tier shared storage for users who don't want to deploy their own.
### Remove Crew Member
## Storage Drivers Supported
```bash
atproto delete-record \
--pds https://hold.example.com \
--collection io.atcr.hold.crew \
--rkey "{memberDID}"
```
The storage service uses distribution's storage drivers:
## Storage Drivers
Hold service supports all distribution storage drivers:
- **S3** - AWS S3, Minio, Storj (via S3 gateway)
- **Filesystem** - Local disk (for testing)
- **Azure** - Azure Blob Storage
- **GCS** - Google Cloud Storage
- **Swift** - OpenStack Swift
- **OSS** - Alibaba Cloud OSS
## Quotas
Quotas are NOT implemented in the storage service. Instead, use:
- **S3**: Bucket policies, lifecycle rules
- **Storj**: Project limits in Storj dashboard
- **Minio**: Quota enforcement features
- **Filesystem**: Disk quotas at OS level
## Security
### Authorization
Authorization is based on ATProto's public-by-default model:
**Read Authorization:**
- **Public hold** (`public: true` in hold record):
- Anonymous users: ✅ Allowed
- Any authenticated user: ✅ Allowed
- **Private hold** (`public: false` in hold record):
- Anonymous users: ❌ 401 Unauthorized
- Any authenticated ATCR user: ✅ Allowed (no crew membership required)
**Write Authorization:**
- Anonymous users: ❌ 401 Unauthorized
- Authenticated non-crew: ❌ 403 Forbidden
- Authenticated crew member: ✅ Allowed
- Hold owner: ✅ Allowed
**Implementation:**
- Hold service queries owner's PDS for `io.atcr.hold.crew` records
- Crew records are public ATProto records (read without authentication)
- "Private" holds only gate anonymous access, not authenticated user access
- This reflects ATProto's current limitation: no private PDS records
### Presigned URLs
- 15 minute expiry
- Client uploads/downloads directly to storage
- No data flows through AppView or hold service
### Private Holds
"Private" holds gate anonymous access while remaining accessible to authenticated users:
**What "Private" Means:**
- `HOLD_PUBLIC=false` prevents anonymous reads
- Any authenticated ATCR user can still read
- This aligns with ATProto's public records model
**Write Control:**
- Only hold owner and crew members can write
- Crew membership managed via `io.atcr.hold.crew` records in owner's PDS
- Removing crew member immediately revokes write access
**Future: True Private Access**
- When ATProto adds private PDS records, ATCR can support truly private repos
- For now, "private" = "authenticated-only access"
## Example: Personal Storage
Alice wants to use her own Storj account:
1. **Set environment variables**:
```bash
export HOLD_PUBLIC_URL=https://alice-storage.fly.dev
export HOLD_OWNER=did:plc:alice123
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=your_storj_access_key
export AWS_SECRET_ACCESS_KEY=your_storj_secret_key
export S3_ENDPOINT=https://gateway.storjshare.io
export S3_BUCKET=alice-blobs
```
2. **Deploy hold service** to Fly.io - auto-registration creates hold + crew record
3. **Push images** - AppView automatically routes to her storage
## Example: Team Hold
A company wants shared storage for their team:
```bash
# 1. Deploy hold service
export HOLD_PUBLIC_URL=https://team-hold.fly.dev
export HOLD_OWNER=did:plc:admin
export HOLD_PUBLIC=false # Private
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=...
export S3_BUCKET=team-blobs
1. **Deploy hold service** with S3 credentials and auto-registration:
```bash
export HOLD_PUBLIC_URL=https://company-hold.fly.dev
export HOLD_OWNER=did:plc:admin
export HOLD_PUBLIC=false
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export S3_BUCKET=company-blobs
```
fly deploy
2. **Hold service auto-registers** on first run, creating:
- Hold record in admin's PDS
- Crew record making admin the owner
# 2. Hold auto-creates captain + crew records on first run
3. **Admin adds crew members** via ATProto client or manually:
```bash
# Using atproto client
atproto put-record \
--collection io.atcr.hold.crew \
--rkey "company-did:plc:engineer1" \
--value '{
"$type": "io.atcr.hold.crew",
"hold": "at://did:plc:admin/io.atcr.hold/company",
"member": "did:plc:engineer1",
"role": "write"
}'
```
# 3. Admin adds team members via hold's PDS (requires OAuth)
# (TODO: Implement crew management UI/CLI)
4. **Team members set their profile** to use the shared hold:
```bash
# Engineer updates their sailor profile
atproto put-record \
--collection io.atcr.sailor.profile \
--rkey "self" \
--value '{
"$type": "io.atcr.sailor.profile",
"defaultHold": "https://company-hold.fly.dev"
}'
```
# 4. Team members set their sailor profile:
atproto put-record \
--collection io.atcr.sailor.profile \
--rkey "self" \
--value '{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:team-hold.fly.dev"
}'
5. **Hold service queries PDS** for crew records to authorize writes
6. **Engineers push/pull** using `atcr.io/engineer1/myapp` - blobs go to company hold
# 5. Team members can now push/pull using team hold
```
## Limitations
1. **No resume/partial uploads** - Storage service doesn't track upload state
2. **No advanced features** - Just basic put/get, no deduplication logic
3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis)
4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client)
### Current IAM Challenges
## Performance Optimization: S3 Presigned URLs
See [EMBEDDED_PDS.md](./EMBEDDED_PDS.md#iam-challenges) for detailed discussion.
**Status:** Planned implementation (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md))
**Known issues:**
1. **RPC permission format**: Service tokens don't work with IP-based DIDs in local dev
2. **Dynamic hold discovery**: AppView can't dynamically OAuth arbitrary holds from sailor profiles
3. **Manual profile management**: No UI for updating sailor profile (must use ATProto client)
Currently, hold services act as proxies for blob data. With presigned URLs:
- **Downloads:** Docker → S3 direct (via 307 redirect)
- **Uploads:** Docker → AppView → S3 (via presigned URL)
- **Hold service bandwidth:** Reduced by 99.98% (only orchestration)
**Benefits:**
- Hold services can run on minimal infrastructure ($5/month instances)
- Direct S3 transfers at maximum speed
- Scales to arbitrarily large images
- Works with Storj, MinIO, Backblaze B2, Cloudflare R2
See [PRESIGNED_URLS.md](./PRESIGNED_URLS.md) for complete technical details and implementation guide.
**Workaround:** Use hostname-based DIDs (`did:web:hold.example.com`) and public holds for now.
## Future Improvements
1. **S3 Presigned URLs** - Implement direct S3 URLs (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md))
2. **Automatic failover** - Multiple storage endpoints, fallback to default
3. **Storage analytics** - Track usage per DID
4. **Quota integration** - Optional quota tracking in storage service
5. **Profile management UI** - Web interface for users to manage their sailor profile
6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments
## Comparison to Default Storage
| Feature | Default (Shared S3) | BYOS |
|---------|---------------------|------|
| Setup | None required | Deploy storage service |
| Cost | Free (with quota) | User pays for S3/Storj |
| Control | Limited | Full control |
| Performance | Shared | Dedicated |
| Quotas | Enforced by AppView | User managed |
| Privacy | Blobs in shared bucket | Blobs in user's bucket |
1. **Crew management UI** - Web interface for adding/removing crew members
2. **Dynamic OAuth** - Support for arbitrary BYOS holds without pre-configuration
3. **Hold migration** - Tools for moving blobs between holds
4. **Storage analytics** - Track usage per user/repository
5. **Distributed cache** - Redis for hold DID cache in multi-instance deployments
## References
- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Embedded PDS architecture and IAM details
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
- [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/)
- [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)
- [Storj Documentation](https://docs.storj.io/)
+174 -1155
View File
File diff suppressed because it is too large Load Diff
+236 -815
View File
File diff suppressed because it is too large Load Diff
+733
View File
@@ -0,0 +1,733 @@
# Image Signing with ATProto
ATCR can support cryptographic signing of container images to ensure authenticity and integrity. This document explores different approaches and recommends a design based on Notary v2's plugin architecture adapted for ATProto.
## Background: Why Not Cosign?
[Sigstore Cosign](https://github.com/sigstore/cosign) is the most popular OCI image signing tool, but has several incompatibilities with ATProto:
### 1. Key Format Mismatch
**ATProto PDS keys:**
- Format: secp256k1 (K256) for signing
- Purpose: ATProto record signatures, DID authentication
- Access: Private keys never leave the PDS server
- Standard: ATProto specification
**Cosign expected keys:**
- Format: ECDSA P-256, RSA, or Ed25519
- Purpose: Image signing (not ATProto records)
- Access: User-controlled private keys
- Standard: Sigstore/PKIX
**Problem:** Can't use PDS keys directly for Cosign signing - wrong curve, wrong access model, wrong security boundary.
### 2. No Direct PDS Key Access
**Security model:**
- PDS private keys are server-side secrets
- Never exposed to clients (even authenticated users)
- Used only by PDS for ATProto operations
- Exposing them would compromise entire account security
**Cosign requirement:**
- Needs access to private key for signing operations
- Expects user-controlled keys or KMS integration
**Problem:** Can't sign images client-side with PDS keys without fundamentally breaking ATProto security model.
### 3. Keyless Signing Complexity
Cosign supports "keyless" signing via OIDC + Fulcio CA:
**What it requires:**
- OIDC identity provider (Google, GitHub, etc.)
- Fulcio certificate authority (issues short-lived certs)
- Rekor transparency log (immutable signature log)
- All infrastructure managed by Sigstore
**ATProto adaptation would need:**
- **OIDC bridge**: Make ATProto DIDs look like OIDC identities
- Map `did:plc:alice123` → OIDC claims
- PDS as OIDC provider? (not in spec)
- Requires custom OIDC server wrapping ATProto auth
- **Fulcio adaptation**: Issue certs based on ATProto identities
- Deploy and manage CA infrastructure
- Handle DID resolution in cert issuance
- Trust anchor distribution
- **Rekor instance**: Public transparency log for signatures
- High availability requirements
- Storage and indexing at scale
- Replication and backup
**Problem:** Too much infrastructure for ATCR to host and manage. Defeats the purpose of decentralized architecture.
### 4. Signature Storage
**Cosign storage:**
- OCI registry artifacts (signatures as ORAS manifests)
- Stored alongside images in registry
**ATCR ideal:**
- Signatures in ATProto records (user's PDS)
- Discoverable via ATProto queries
- Integrated with ATProto's existing signature/verification model
**Problem:** Would need to patch Cosign or run dual storage (OCI + ATProto) which creates consistency issues.
### Conclusion: Cosign Doesn't Fit
While Cosign is excellent for traditional registries, forcing it into ATProto would require:
- Breaking ATProto security model (exposing PDS keys), OR
- Building massive OIDC/Fulcio/Rekor infrastructure, OR
- Running parallel storage systems with consistency problems
**Better approach:** Use a more flexible signing framework designed for extensibility.
## Notary v2: Plugin-Based Architecture
[Notary v2](https://notaryproject.dev/) (also called "Notation" or "Notary Project") is a CNCF signature specification with a plugin architecture that fits ATProto better.
### Why Notary v2?
**Flexible plugin system:**
- **Trust store plugins**: Custom key resolution (e.g., from ATProto records)
- **Signature plugins**: Custom signature storage (e.g., in PDS)
- **Verification plugins**: Custom verification logic
- Plugins written in any language, communicate via stdio
**Multiple key types supported:**
- ECDSA, RSA, Ed25519 out of box
- Can support custom key types via plugins
- Signature envelope format is extensible
**Designed for extensibility:**
- Not tied to specific PKI (unlike Cosign/Sigstore)
- Trust policies are configurable
- Storage backend is pluggable
- Works with custom identity systems
**Standard CLI:**
- `notation sign` / `notation verify` commands
- Users don't need to learn new tools
- Integration with Docker/containerd
### Notary v2 Architecture
```
┌─────────────────────┐
│ notation CLI │ User signs/verifies images
└──────────┬──────────┘
├─────────────────────────────────────┐
│ │
┌──────────▼─────────┐ ┌───────────▼──────────┐
│ Signing Plugin │ │ Trust Store Plugin │
│ │ │ │
│ - Read private key │ │ - Resolve DID → PDS │
│ - Generate sig │ │ - Fetch public keys │
│ - Store in PDS │ │ - Verify trust │
└────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ User's PDS (ATProto) │
│ │
│ io.atcr.signing.key (public keys) │
│ io.atcr.signature (signatures) │
└─────────────────────────────────────────────────────────┘
```
## Proposed Design: ATProto Signing
### Key Management
**Separate signing keys from PDS keys:**
1. **User generates signing key pair locally:**
```bash
notation key generate --id alice-signing-key --type ecdsa
# Or: --type ed25519, --type rsa
```
2. **Public key published to ATProto:**
```json
{
"$type": "io.atcr.signing.key",
"keyId": "alice-signing-key",
"keyType": "ecdsa-p256",
"publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...",
"validFrom": "2025-10-20T12:00:00Z",
"expiresAt": "2026-10-20T12:00:00Z",
"revoked": false,
"createdAt": "2025-10-20T12:00:00Z"
}
```
3. **Private key stored locally:**
- Docker credential store
- OS keychain (macOS Keychain, Windows Credential Manager)
- File with restrictive permissions
- Hardware security module (future)
**Why separate keys?**
- ✅ No need to access PDS private keys
- ✅ Standard key formats (ECDSA, Ed25519, RSA)
- ✅ User controls key lifecycle
- ✅ Can use hardware tokens (YubiKey, etc.)
- ✅ Security boundary separation (signing ≠ identity)
- ✅ Key rotation without changing DID
### Signing Flow
```
1. User: notation sign atcr.io/alice/myapp:latest --key alice-signing-key
2. notation-atproto plugin:
a. Resolve image → manifest digest
b. Read private key from local keystore
c. Generate signature over manifest digest
d. Get OAuth token for alice's PDS
e. Create signature record in alice's PDS
3. Signature stored in alice's PDS:
{
"$type": "io.atcr.signature",
"repository": "alice/myapp",
"digest": "sha256:abc123...",
"signature": "MEUCIQDx...", // base64 signature bytes
"keyId": "alice-signing-key",
"signatureAlgorithm": "ecdsa-p256-sha256",
"signedAt": "2025-10-20T12:34:56Z"
}
4. Record key: sha256 of (digest + keyId) for deduplication
```
### Verification Flow
```
1. User: notation verify atcr.io/alice/myapp:latest
2. notation-atproto plugin:
a. Resolve "alice" → did:plc:alice123 → pds.alice.com
b. Fetch manifest digest: sha256:abc123
c. Query alice's PDS for signatures:
GET /xrpc/com.atproto.repo.listRecords?
repo=did:plc:alice123&
collection=io.atcr.signature
d. Filter records matching digest: sha256:abc123
e. For each signature:
- Fetch public key from io.atcr.signing.key record
- Check key not revoked, not expired
- Verify signature bytes over digest
- Check trust policy (is this key trusted?)
3. Trust policy evaluation:
- Signature valid cryptographically? ✓
- Key belongs to image owner (alice)? ✓
- Key not revoked? ✓
- Key not expired? ✓
- Trust policy satisfied? ✓
4. Output: Verification succeeded ✓
```
### Trust Policies
Notary v2 uses trust policies to define what signatures are required:
```json
{
"version": "1.0",
"trustPolicies": [
{
"name": "atcr-images",
"registryScopes": ["atcr.io/*/*"],
"signatureVerification": {
"level": "strict"
},
"trustStores": ["atproto:default"],
"trustedIdentities": [
"did:plc:*" // Trust any ATProto DID
]
}
]
}
```
**Policy options:**
- `level: strict` - Signature required, verification must pass
- `level: permissive` - Signature optional, but verified if present
- `level: audit` - Signature logged but doesn't block
- `level: skip` - No verification
**Trust store resolution:**
- `atproto:default` - Use ATProto plugin to resolve keys
- Plugin queries user's PDS for `io.atcr.signing.key` records
- Verifies key is owned by the image owner (DID match)
### ATProto Records
**io.atcr.signing.key** - Public signing keys
```json
{
"$type": "io.atcr.signing.key",
"keyId": "alice-signing-key",
"keyType": "ecdsa-p256",
"publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZI...",
"validFrom": "2025-10-20T12:00:00Z",
"expiresAt": "2026-10-20T12:00:00Z",
"revoked": false,
"purpose": ["image-signing"],
"createdAt": "2025-10-20T12:00:00Z"
}
```
**Record key:** `keyId` (user-chosen identifier)
**Fields:**
- `keyId`: Unique identifier for this key
- `keyType`: Algorithm (ecdsa-p256, ed25519, rsa-2048, rsa-4096)
- `publicKey`: PEM-encoded public key
- `validFrom`: Key becomes valid at this time
- `expiresAt`: Key expires at this time (null = no expiry)
- `revoked`: Key has been revoked (true/false)
- `purpose`: Array of purposes (image-signing, sbom-signing, etc.)
**io.atcr.signature** - Image signatures
```json
{
"$type": "io.atcr.signature",
"repository": "alice/myapp",
"digest": "sha256:abc123...",
"signature": "MEUCIQDxH7...",
"keyId": "alice-signing-key",
"signatureAlgorithm": "ecdsa-p256-sha256",
"signedAt": "2025-10-20T12:34:56Z",
"createdAt": "2025-10-20T12:34:56Z"
}
```
**Record key:** SHA256 hash of `(digest || keyId)` for deduplication
**Fields:**
- `repository`: Image repository (alice/myapp)
- `digest`: Manifest digest being signed
- `signature`: Base64-encoded signature bytes
- `keyId`: Reference to signing key record
- `signatureAlgorithm`: Algorithm used for signing
- `signedAt`: When signature was created
### Plugin Implementation
**notation-atproto** - Notary v2 plugin for ATProto
**Trust store plugin:**
```go
// Implements: notation trust store plugin spec
// https://notaryproject.dev/docs/user-guides/how-to/plugin-management/
type ATProtoTrustStore struct {
resolver *atproto.Resolver
client *atproto.Client
}
// GetKeys resolves public keys for a given identity (DID)
func (t *ATProtoTrustStore) GetKeys(did string) ([]PublicKey, error) {
// 1. Resolve DID → PDS endpoint
pds, err := t.resolver.ResolvePDS(did)
// 2. Query PDS for io.atcr.signing.key records
records, err := t.client.ListRecords(pds, did, "io.atcr.signing.key")
// 3. Filter active keys (not revoked, not expired)
keys := []PublicKey{}
for _, record := range records {
if !record.Revoked && !record.Expired() {
keys = append(keys, ParsePublicKey(record.PublicKey))
}
}
return keys, nil
}
```
**Signature store plugin:**
```go
// Store signature in user's PDS
func (s *ATProtoSignatureStore) StoreSignature(sig Signature) error {
// 1. Get OAuth token for user's PDS
token, err := s.oauthClient.GetToken()
// 2. Create signature record
record := SignatureRecord{
Type: "io.atcr.signature",
Repository: sig.Repository,
Digest: sig.Digest,
Signature: base64.Encode(sig.Bytes),
KeyId: sig.KeyId,
SignatureAlgorithm: sig.Algorithm,
SignedAt: time.Now(),
}
// 3. Generate record key (hash of digest + keyId)
rkey := sha256.Sum256([]byte(sig.Digest + sig.KeyId))
// 4. Write to PDS
err = s.client.PutRecord(pds, did, "io.atcr.signature", hex.Encode(rkey), record)
return err
}
// Retrieve signatures for a digest
func (s *ATProtoSignatureStore) GetSignatures(did, digest string) ([]Signature, error) {
// Query PDS for matching signatures
records, err := s.client.ListRecords(pds, did, "io.atcr.signature")
// Filter by digest
sigs := []Signature{}
for _, record := range records {
if record.Digest == digest {
sigs = append(sigs, ParseSignature(record))
}
}
return sigs, nil
}
```
**Plugin installation:**
```bash
# Install notation CLI
brew install notation
# Install ATProto plugin
notation plugin install notation-atproto --version v1.0.0
# Configure trust policy
cat > ~/.config/notation/trustpolicy.json <<EOF
{
"version": "1.0",
"trustPolicies": [
{
"name": "atcr-images",
"registryScopes": ["atcr.io/*/*"],
"signatureVerification": {"level": "strict"},
"trustStores": ["atproto:default"],
"trustedIdentities": ["did:plc:*"]
}
]
}
EOF
```
## User Workflows
### Initial Setup
```bash
# 1. Generate signing key pair
notation key generate --id alice-signing-key --type ecdsa
# Private key stored in: ~/.config/notation/keys/
# Public key extracted by plugin
# 2. Publish public key to PDS
notation-atproto key publish alice-signing-key
# Plugin uploads io.atcr.signing.key record to alice's PDS
# Requires OAuth authentication to alice's PDS
# 3. Verify key is published
notation-atproto key list
# Output:
# alice-signing-key (ecdsa-p256) - Active
# Published: 2025-10-20T12:00:00Z
# Expires: 2026-10-20T12:00:00Z
# DID: did:plc:alice123
```
### Signing Images
```bash
# Sign an image after pushing
docker push atcr.io/alice/myapp:latest
notation sign atcr.io/alice/myapp:latest \
--key alice-signing-key \
--plugin atproto
# Plugin:
# 1. Reads private key from ~/.config/notation/keys/
# 2. Signs manifest digest
# 3. Uploads signature to alice's PDS (io.atcr.signature record)
# 4. Returns success
# Output:
# Successfully signed atcr.io/alice/myapp:latest
# Signature stored in PDS: did:plc:alice123
```
### Verifying Images
```bash
# Verify before running
notation verify atcr.io/alice/myapp:latest
# Plugin:
# 1. Resolves "alice" → did:plc:alice123 → pds.alice.com
# 2. Fetches manifest digest
# 3. Queries alice's PDS for signatures
# 4. Fetches public key from io.atcr.signing.key
# 5. Verifies signature cryptographically
# 6. Checks trust policy
# Output:
# ✓ Signature verification succeeded
#
# Signed by: did:plc:alice123
# Key ID: alice-signing-key
# Signed at: 2025-10-20T12:34:56Z
# Algorithm: ecdsa-p256-sha256
```
### Key Rotation
```bash
# Generate new key
notation key generate --id alice-signing-key-2 --type ecdsa
# Publish new key
notation-atproto key publish alice-signing-key-2
# Re-sign images with new key
notation sign atcr.io/alice/myapp:latest --key alice-signing-key-2
# Revoke old key
notation-atproto key revoke alice-signing-key
# Plugin updates io.atcr.signing.key record:
# { ..., "revoked": true, "revokedAt": "2025-11-01T..." }
# Old signatures still exist but verification will fail
# (revoked key = untrusted)
```
### Key Expiration
```bash
# Generate key with expiration
notation key generate \
--id alice-signing-key \
--type ecdsa \
--expires 365d # 1 year
# Publish with expiration
notation-atproto key publish alice-signing-key
# PDS record:
# {
# "validFrom": "2025-10-20T12:00:00Z",
# "expiresAt": "2026-10-20T12:00:00Z"
# }
# After expiration, verification fails:
notation verify atcr.io/alice/myapp:latest
# ✗ Signature verification failed
# Signing key expired on 2026-10-20T12:00:00Z
```
## Security Considerations
### Key Storage
**Private keys must be protected:**
- File permissions: `0600` (owner read/write only)
- Use OS keychain when possible (macOS Keychain, Windows Credential Manager)
- Consider hardware tokens (YubiKey, TPM) for production
- Never commit private keys to git
**Public keys are public:**
- Stored in user's PDS (publicly readable)
- Anyone can verify signatures
- Revocation is public and immediate
### Trust Model
**What signatures prove:**
- ✅ Image manifest hasn't been tampered with since signing
- ✅ Signer had access to private key at signing time
- ✅ Signer's DID matches image owner (alice signed alice/myapp)
**What signatures don't prove:**
- ❌ Image is free of vulnerabilities
- ❌ Image contents are safe to run
- ❌ Signer's identity is verified (depends on DID trust)
**Trust anchors:**
- Trust PDS to correctly serve signing key records
- Trust DID resolution (PLC directory, did:web DNS)
- Trust signature algorithms (ECDSA, Ed25519, RSA)
- Trust user to protect their private keys
### Key Compromise
If a private signing key is compromised:
```bash
# 1. Immediately revoke the key
notation-atproto key revoke alice-signing-key --reason "Key compromised"
# 2. Generate new key
notation key generate --id alice-signing-key-new --type ecdsa
# 3. Publish new key
notation-atproto key publish alice-signing-key-new
# 4. Re-sign all images with new key
for image in $(docker images --format "{{.Repository}}:{{.Tag}}"); do
notation sign $image --key alice-signing-key-new
done
# 5. Alert users to only trust new key
# (Old signatures will fail verification due to revocation)
```
**Revocation is immediate:**
- PDS record updated with `"revoked": true`
- All verification attempts fail instantly
- No need to update certificate revocation lists (CRLs)
- ATProto record queries are always fresh
### Multiple Signatures
Images can have multiple signatures:
```bash
# Alice signs with her key
notation sign atcr.io/alice/myapp:latest --key alice-signing-key
# CI/CD system signs with separate key
notation sign atcr.io/alice/myapp:latest --key ci-signing-key
# Both signatures stored in alice's PDS
# Verification requires both (configurable in trust policy)
```
**Trust policy:**
```json
{
"trustPolicies": [{
"name": "require-dual-signature",
"registryScopes": ["atcr.io/alice/*"],
"signatureVerification": {
"level": "strict",
"verifyTimestamp": true,
"override": {
"all": ["alice-signing-key", "ci-signing-key"]
}
}
}]
}
```
## Implementation Roadmap
### Phase 1: Core Plugin (4-6 weeks)
**Week 1-2: Trust store plugin**
- Implement DID resolution
- Query `io.atcr.signing.key` records
- Parse and validate public keys
- Handle revocation and expiration
**Week 3-4: Signature store plugin**
- OAuth integration for PDS writes
- Create `io.atcr.signature` records
- Query signatures for verification
- Handle record key generation
**Week 5-6: Integration testing**
- End-to-end sign/verify workflows
- Key rotation scenarios
- Revocation handling
- Multi-signature support
### Phase 2: Tooling (2-3 weeks)
**CLI commands:**
```bash
notation-atproto key generate
notation-atproto key publish
notation-atproto key list
notation-atproto key revoke
notation-atproto signature list <image>
notation-atproto signature inspect <image>
```
**Helper utilities:**
- Bulk re-signing for key rotation
- Signature audit logs
- Trust policy generators
- Key lifecycle management
### Phase 3: AppView Integration (2-3 weeks)
**Web UI features:**
- Display signature status on repository pages
- Show signing keys for users
- Signature verification badges
- Key management interface
**API endpoints:**
- `GET /v2/alice/myapp/signatures` - List signatures for image
- `GET /v2/alice/keys` - List user's signing keys
- `POST /v2/alice/keys/revoke` - Revoke key via web UI
### Phase 4: Advanced Features (ongoing)
**Hardware token support:**
- YubiKey integration
- TPM-backed keys
- Hardware-backed keystores
**Timestamp verification:**
- Trusted timestamp authorities
- Prove signature was created at specific time
- Long-term signature validity
**SBOM signing:**
- Sign SBOMs with same keys
- Link SBOM signatures to image signatures
- Unified verification workflow
## Comparison: Cosign vs Notary v2 for ATCR
| Feature | Cosign | Notary v2 | Winner |
|---------|--------|-----------|--------|
| **ATProto integration** | Requires OIDC bridge | Plugin system | ✅ Notary |
| **Key format flexibility** | Limited | Extensible | ✅ Notary |
| **Custom storage** | OCI only | Pluggable | ✅ Notary |
| **Infrastructure needs** | Fulcio + Rekor | None | ✅ Notary |
| **Keyless signing** | Yes (complex) | No | ⚠️ Cosign* |
| **Ecosystem maturity** | High | Medium | ⚠️ Cosign* |
| **CLI simplicity** | Very simple | Simple | ⚠️ Cosign* |
| **Plugin development** | N/A | Required | ⚠️ Mixed |
*Cosign advantages don't outweigh ATProto incompatibilities
**Recommendation: Notary v2 with ATProto plugin**
## References
- [Notary v2 Specification](https://notaryproject.dev/)
- [Notation CLI](https://github.com/notaryproject/notation)
- [Notary Plugin Specification](https://notaryproject.dev/docs/user-guides/how-to/plugin-management/)
- [Sigstore Cosign](https://github.com/sigstore/cosign) (for comparison)
- [ATProto Specification](https://atproto.com/)
- [OCI Artifacts](https://github.com/opencontainers/artifacts)
- [RFC 7515 - JSON Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (signature formats)
+365 -72
View File
@@ -1,101 +1,394 @@
User approved Claude's plan:
⎿ Implement Sailor Profile & Hold Discovery System
# Sailor Profile System
Summary
## Overview
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
routing while preserving image ownership semantics.
The sailor profile system allows users to choose which hold (storage service) to use for their container images. This enables:
- **Personal holds** - Use your own S3/Storj/Minio storage
- **Shared holds** - Join a team or community hold
- **Default holds** - Use AppView's default storage (free tier)
- **Transparent infrastructure** - Hold choice doesn't affect image URL
Changes Required
## Concepts
1. Create Sailor Profile Lexicon
**Sailor Profile** (`io.atcr.sailor.profile`):
- Record stored in user's PDS
- Contains `defaultHold` preference (DID or URL)
- Created automatically on first authentication
- Managed via web UI or ATProto client
File: lexicons/io/atcr/sailor/profile.json
- New record type: io.atcr.sailor.profile
- Fields: defaultHold (string, nullable), createdAt, updatedAt
**Hold Discovery Priority**:
1. User's sailor profile `defaultHold` (if set)
2. User's own hold records (`io.atcr.hold`) - legacy
3. AppView's `default_hold_did` configuration
2. Update Manifest Lexicon
## Sailor Profile Record
File: lexicons/io/atcr/manifest.json
- Add holdEndpoint field (string, required)
- This is historical reference (immutable per manifest)
```json
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:hold.example.com",
"createdAt": "2025-10-02T12:00:00Z",
"updatedAt": "2025-10-02T12:00:00Z"
}
```
3. Update Go Types
**Fields:**
- `defaultHold` (string, optional) - Hold DID or URL (auto-normalized to DID)
- `createdAt` (datetime, required) - Profile creation timestamp
- `updatedAt` (datetime, required) - Last update timestamp
File: pkg/atproto/lexicon.go
- Add SailorProfileCollection = "io.atcr.sailor.profile"
- Add SailorProfileRecord struct
- Add NewSailorProfileRecord() constructor
- Update ManifestRecord struct to include HoldEndpoint field
**Record key:** Always `"self"` (only one profile per user)
4. Create Profile Management
**Collection:** `io.atcr.sailor.profile`
File: pkg/atproto/profile.go (new file)
- EnsureProfile(ctx, client, defaultHoldDID) function
- Logic: check if profile exists, create with default if not
## Profile Management
5. Update Auth Handlers
### Automatic Creation
Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go
- Call EnsureProfile() after token validation
- Use authenticated client (has write access to user's PDS)
- Pass AppView's default_hold_did config (format: "did:web:hold01.atcr.io")
Profiles are created automatically on first authentication:
6. Update Hold Resolution
```go
// During OAuth login or Basic Auth token exchange
func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
// ... OAuth flow ...
File: pkg/middleware/registry.go
- Update findStorageEndpoint() priority:
a. Check io.atcr.sailor.profile.defaultHold
b. If null (opted out): check user's io.atcr.hold, then AppView default
c. If no profile: check user's io.atcr.hold, then AppView default
// Create ATProto client with user's OAuth session
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
7. Store Hold in Manifest
// Ensure profile exists (creates with AppView's default if not)
err := atproto.EnsureProfile(ctx, client, appViewDefaultHoldDID)
}
```
File: pkg/atproto/manifest_store.go
- When creating manifest, include resolved holdEndpoint
- Pass hold endpoint through context or parameter
**Behavior:**
- If profile exists → no-op
- If profile doesn't exist → creates with `defaultHold` set to AppView's default
- If AppView has no default configured → creates with empty `defaultHold`
8. Update Pull to Use Manifest Hold
### Web UI Management
File: pkg/atproto/manifest_store.go and pkg/storage/routing_repository.go
- On pull, extract holdEndpoint from manifest
- Route blob requests to that hold (not via discovery)
Users can update their profile via the settings page (`/settings`):
9. Update Documentation
**View current profile:**
```
GET /settings
→ Shows current defaultHold value
```
Files: CLAUDE.md, docs/BYOS.md, .env.example
- Document sailor profile concept
- Explain hold resolution priority
- Update examples for shared holds
- Document how crew members configure profile
**Update defaultHold:**
```
POST /api/settings/update-hold
Form data: hold_endpoint=did:web:team-hold.fly.dev
Benefits
→ Updates sailor profile in user's PDS
→ Returns success confirmation
```
- ✅ URL structure remains atcr.io/<owner>/<image> (ownership clear)
- ✅ Hold is transparent infrastructure (like S3 region)
- ✅ Supports personal, shared, and public holds
- ✅ Historical reference in manifest (pull works even if defaults change)
- ✅ User can opt-out (set defaultHold to null)
- ✅ Future: UI for self-service profile management
**Implementation** (`pkg/appview/handlers/settings.go`):
- Requires OAuth session (user must be logged in)
- Fetches existing profile or creates new one
- Normalizes URLs to DIDs automatically
- Updates `updatedAt` timestamp
### ATProto Client Management
Progress Summary
Users can also manage their profile using standard ATProto tools:
✅ Completed:
1. Created io.atcr.sailor.profile lexicon
2. Updated io.atcr.manifest lexicon with holdEndpoint field
3. Updated Go types in pkg/atproto/lexicon.go
4. Created profile management in pkg/atproto/profile.go
5. Updated /auth/exchange handler to manage profile
**Get profile:**
```bash
atproto get-record \
--collection io.atcr.sailor.profile \
--rkey self
```
⏳ In Progress:
- Need to update /auth/token handler similarly (add defaultHoldDID parameter and profile management)
- Fix compilation error in extractDefaultHoldDID() - should use configuration.Middleware type not any
**Update profile:**
```bash
atproto put-record \
--collection io.atcr.sailor.profile \
--rkey self \
--value '{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:my-hold.example.com",
"updatedAt": "2025-10-20T12:00:00Z"
}'
```
🔜 Remaining:
- Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
- Update manifest_store.go to include holdEndpoint when creating manifests
- Update pull flow to use manifest holdEndpoint
- Update documentation
**Clear default hold** (opt out):
```bash
atproto put-record \
--collection io.atcr.sailor.profile \
--rkey self \
--value '{
"$type": "io.atcr.sailor.profile",
"defaultHold": "",
"updatedAt": "2025-10-20T12:00:00Z"
}'
```
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?
## URL-to-DID Migration
The system automatically migrates old URL-based `defaultHold` values to DID format for consistency:
**Old format (deprecated):**
```json
{
"defaultHold": "https://hold.example.com"
}
```
**New format (preferred):**
```json
{
"defaultHold": "did:web:hold.example.com"
}
```
**Migration behavior:**
- `GetProfile()` detects URL format automatically
- Converts URL → DID transparently (strips protocol, converts to `did:web:`)
- Persists migration to PDS in background goroutine
- Uses locks to prevent duplicate migrations
- Completely transparent to user
**Why DIDs?**
- **Portable**: DIDs work offline, URLs require DNS
- **Canonical**: One DID per hold, multiple URLs possible
- **Standard**: ATProto uses DIDs for identity
## Hold Discovery Flow
When a user pushes an image, AppView discovers which hold to use:
```
1. User: docker push atcr.io/alice/myapp:latest
2. AppView resolves alice → did:plc:alice123
3. AppView calls findHoldDID(did, pdsEndpoint):
a. Query alice's PDS for io.atcr.sailor.profile/self
b. If profile.defaultHold is set → use it
c. Else check alice's io.atcr.hold records (legacy)
d. Else use AppView's default_hold_did
4. Found: alice.profile.defaultHold = "did:web:team-hold.fly.dev"
5. AppView uses team-hold.fly.dev for blob storage
6. Manifest stored in alice's PDS includes:
- holdDid: "did:web:team-hold.fly.dev" (for future pulls)
- holdEndpoint: "https://team-hold.fly.dev" (backward compat)
```
**Implementation** (`pkg/appview/middleware/registry.go:findHoldDID()`):
```go
func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
client := atproto.NewClient(pdsEndpoint, did, "")
// 1. Check sailor profile
profile, err := atproto.GetProfile(ctx, client)
if profile != nil && profile.DefaultHold != "" {
return profile.DefaultHold // DID or URL (auto-normalized)
}
// 2. Check own hold records (legacy)
records, _ := client.ListRecords(ctx, "io.atcr.hold", 10)
for _, record := range records {
// Return first hold's endpoint
if holdRecord.Endpoint != "" {
return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
}
}
// 3. Use AppView default
return nr.defaultHoldDID
}
```
## Use Cases
### 1. Default Hold (Free Tier)
User doesn't need to do anything:
```
1. User authenticates to atcr.io
2. Profile created with defaultHold = AppView's default
3. User pushes images → blobs go to default hold
```
**Profile:**
```json
{
"defaultHold": "did:web:hold01.atcr.io"
}
```
### 2. Join Team Hold
User joins a shared team hold:
```
1. Team admin deploys hold service (did:web:team-hold.fly.dev)
2. Team admin adds user to crew (via hold's PDS)
3. User updates profile:
- Via web UI: /settings → set hold to "did:web:team-hold.fly.dev"
- Or via ATProto client: put-record
4. User pushes images → blobs go to team hold
```
**Profile:**
```json
{
"defaultHold": "did:web:team-hold.fly.dev"
}
```
**Benefits:**
- Team pays for storage (not individual users)
- Centralized access control
- Shared bandwidth limits
### 3. Personal Hold (BYOS)
User deploys their own hold:
```
1. User deploys hold service to Fly.io (did:web:alice-hold.fly.dev)
2. Hold auto-creates captain + crew records on first run
3. User updates profile to use their hold
4. User pushes images → blobs go to personal hold
```
**Profile:**
```json
{
"defaultHold": "did:web:alice-hold.fly.dev"
}
```
**Benefits:**
- Full control over storage
- Choose storage provider (S3, Storj, Minio, etc.)
- No quotas/limits (except what you pay for)
### 4. Opt Out of Defaults
User wants to use only their own hold records (legacy model):
```json
{
"defaultHold": ""
}
```
**Behavior:**
- Skips profile's defaultHold (set to empty/null)
- Falls back to `io.atcr.hold` records in user's PDS
- If no hold records found → uses AppView default
## Architecture Notes
### Why Sailor Profile?
**Problem solved:**
- Users can be crew members of multiple holds
- Need explicit way to choose which hold to use
- Want to support both personal and shared holds
**Without sailor profile:**
```
Alice is crew of:
- team-hold.fly.dev (team storage)
- community-hold.fly.dev (community storage)
Which one should AppView use? 🤔
```
**With sailor profile:**
```
Alice sets profile.defaultHold = "did:web:team-hold.fly.dev"
→ AppView knows to use team hold
→ Alice can change anytime via settings
```
### Image Ownership vs Hold Choice
**Key insight:** Image ownership stays with the user, hold is just infrastructure.
**URL structure:** `atcr.io/<owner>/<image>:<tag>`
- Owner = Alice (clear ownership)
- Hold = Team storage (infrastructure detail)
**Analogy:** Like choosing an S3 region
- Your files, your ownership
- Region is just where bits live
- Can move regions without changing ownership
### Historical Hold References
Manifests store `holdDid` for immutable blob location tracking:
```json
{
"digest": "sha256:abc123",
"holdDid": "did:web:team-hold.fly.dev",
"holdEndpoint": "https://team-hold.fly.dev",
"layers": [...]
}
```
**Why store hold in manifest?**
- Pull uses historical reference (not re-discovered)
- Image stays pullable even if user changes defaultHold
- Blobs fetched from where they were originally pushed
- Immutable references (manifests don't change)
**Hold cache:**
- In-memory cache: `(userDID, repository) → holdDid`
- TTL: 10 minutes (covers typical pull operation)
- Avoids re-querying PDS for every blob
## Configuration
### AppView Configuration
```bash
# Default hold for new users
ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io
# Test mode: fallback to default if user's hold unreachable
ATCR_TEST_MODE=false
```
**Test mode behavior:**
- Checks if user's defaultHold is reachable (HTTP/HTTPS)
- Falls back to AppView default if unreachable
- Useful for local development (prevents errors from unreachable holds)
### Legacy Support
**Old hold registration model** (`io.atcr.hold` records in user's PDS):
- Still supported for backward compatibility
- Checked if profile.defaultHold is empty
- New deployments should use sailor profiles instead
**Migration path:**
- Existing holds continue to work
- Users with `io.atcr.hold` records can set profile.defaultHold
- Profile takes priority over hold records
## Future Improvements
1. **Multi-hold support** - Set different holds for different repositories
2. **Hold suggestions** - Recommend holds based on geography/cost
3. **Hold migration tools** - Move blobs between holds
4. **Profile templates** - Pre-configured profiles for teams
5. **Hold analytics** - Show storage usage per hold in UI
## References
- [BYOS.md](./BYOS.md) - BYOS deployment and hold management
- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Hold's embedded PDS architecture
- [CREW_ACCESS_CONTROL.md](./CREW_ACCESS_CONTROL.md) - Crew membership and permissions
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
+568
View File
@@ -0,0 +1,568 @@
# SBOM Scanning
ATCR supports optional Software Bill of Materials (SBOM) generation for container images stored in holds. This feature enables automated security scanning and vulnerability analysis while maintaining the decentralized architecture.
## Overview
When enabled, holds automatically generate SBOMs for uploaded container images in the background. The scanning process:
- **Async execution**: Scanning happens after upload completes (non-blocking)
- **ORAS artifacts**: SBOMs stored as OCI Registry as Storage (ORAS) artifacts
- **ATProto integration**: Scan results stored as `io.atcr.manifest` records in hold's embedded PDS
- **Tool agnostic**: Results accessible via XRPC, ATProto queries, and direct blob URLs
- **Opt-in**: Disabled by default, enabled per-hold via configuration
### Default Scanner: Syft
ATCR uses [Anchore Syft](https://github.com/anchore/syft) for SBOM generation:
- Industry-standard SBOM generator
- Supports SPDX and CycloneDX formats
- Comprehensive package detection (OS packages, language libraries, etc.)
- Active maintenance and CVE database updates
Future enhancements may include [Grype](https://github.com/anchore/grype) for vulnerability scanning and [Trivy](https://github.com/aquasecurity/trivy) for comprehensive security analysis.
## Trust Model
### Same Trust as Docker Hub
SBOM scanning follows the same trust model as Docker Hub or other centralized registries:
**Docker Hub model:**
- Docker Hub scans your image on their infrastructure
- Results stored in their database
- You trust Docker Hub's scanner version and scan integrity
**ATCR hold model:**
- Hold scans image on their infrastructure
- Results stored in hold's embedded PDS
- You trust hold operator's scanner version and scan integrity
The security comes from **reproducibility** and **transparency**, not storage location:
- Anyone can re-scan the same digest and verify results
- Multiple holds scanning the same image provide independent verification
- Scanner version and scan timestamp are recorded in ATProto records
### Why Hold's PDS?
Scan results are stored in the **hold's embedded PDS** rather than the user's PDS:
**Advantages:**
1. **No OAuth expiry issues**: Hold owns its PDS, no service tokens needed
2. **Hold-scoped metadata**: Scanner version, scan time, hold configuration
3. **Multiple perspectives**: Different holds can scan the same image independently
4. **Simpler auth**: Hold writes directly to its own PDS
5. **Keeps user PDS lean**: Potentially large SBOM data doesn't bloat user's repo
**Security properties:**
- Same trust level as trusting hold to serve correct blobs
- DID signatures prove which hold generated the SBOM
- Reproducible scans enable independent verification
- Multiple holds scanning same digest → compare results for tampering detection
## ORAS Manifest Format
SBOMs are stored as ORAS artifacts that reference their subject image using the OCI referrers specification.
### Example Manifest Record
```json
{
"$type": "io.atcr.manifest",
"repository": "alice/myapp",
"digest": "sha256:4a5e...",
"holdDid": "did:web:hold01.atcr.io",
"holdEndpoint": "https://hold01.atcr.io",
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/spdx+json",
"subject": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc123...",
"size": 1234
},
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136f...",
"size": 2
},
"layers": [
{
"mediaType": "application/spdx+json",
"digest": "sha256:def456...",
"size": 5678,
"annotations": {
"org.opencontainers.image.title": "sbom.spdx.json"
}
}
],
"manifestBlob": {
"$type": "blob",
"ref": { "$link": "bafyrei..." },
"mimeType": "application/vnd.oci.image.manifest.v1+json",
"size": 789
},
"ownerDid": "did:plc:alice123",
"scannedAt": "2025-10-20T12:34:56.789Z",
"scannerVersion": "syft-v1.0.0",
"createdAt": "2025-10-20T12:34:56.789Z"
}
```
### Key Fields
- `artifactType`: Distinguishes SBOM artifact from regular image manifest
- `application/spdx+json` for SPDX format
- `application/vnd.cyclonedx+json` for CycloneDX format
- `subject`: Reference to the original image manifest
- `ownerDid`: DID of the image owner (for multi-tenant holds)
- `scannedAt`: ISO 8601 timestamp of when scan completed
- `scannerVersion`: Tool version for reproducibility tracking
### SBOM Blob
The actual SBOM document is stored as a blob in the hold's storage backend and referenced in the manifest's `layers` array. The blob contains the full SPDX or CycloneDX JSON document.
## Configuration
SBOM scanning is configured via environment variables on the hold service.
### Environment Variables
```bash
# Enable SBOM scanning (opt-in)
HOLD_SBOM_ENABLED=true
# Number of concurrent scan workers (default: 2)
# Higher values = faster scanning, more CPU/memory usage
HOLD_SBOM_WORKERS=4
# SBOM output format (default: spdx-json)
# Options: spdx-json, cyclonedx-json
HOLD_SBOM_FORMAT=spdx-json
# Future: Enable vulnerability scanning with Grype
# HOLD_VULN_ENABLED=true
```
### Example Configuration
```bash
# .env.hold
HOLD_PUBLIC_URL=https://hold01.atcr.io
STORAGE_DRIVER=s3
S3_BUCKET=my-hold-blobs
HOLD_OWNER=did:plc:xyz123
HOLD_DATABASE_PATH=/var/lib/atcr/hold.db
# Enable SBOM scanning
HOLD_SBOM_ENABLED=true
HOLD_SBOM_WORKERS=2
HOLD_SBOM_FORMAT=spdx-json
```
## Scanning Workflow
### 1. Upload Completes
When a container image is successfully pushed to a hold:
```
1. Client: docker push atcr.io/alice/myapp:latest
2. AppView routes blobs to hold service
3. Hold receives multipart upload via XRPC
4. Hold completes upload and stores blobs
5. Hold checks: HOLD_SBOM_ENABLED=true?
6. If yes: enqueue scan job (non-blocking)
7. Upload completes immediately
```
### 2. Background Scanning
Scan workers process jobs from the queue:
```
1. Worker pulls job from queue
2. Extracts image layers from storage
3. Runs Syft on extracted filesystem
4. Generates SBOM in configured format
5. Uploads SBOM blob to storage
6. Creates ORAS manifest record in hold's PDS
7. Job complete
```
### 3. Result Storage
SBOM results are stored in two places:
1. **SBOM blob**: Full JSON document in hold's blob storage
2. **ORAS manifest**: Metadata record in hold's embedded PDS
- Collection: `io.atcr.manifest`
- Record key: SBOM manifest digest
- Contains reference to subject image
## Accessing SBOMs
Multiple methods for discovering and retrieving SBOM data.
### 1. XRPC Query Endpoint
Query for SBOMs by image digest:
```bash
# Get SBOM for a specific image
curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?\
digest=sha256:abc123&\
ownerDid=did:plc:alice123&\
repository=alice/myapp"
# Response: ORAS manifest JSON
{
"manifest": {
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/spdx+json",
"subject": { "digest": "sha256:abc123...", ... },
"layers": [ { "digest": "sha256:def456...", ... } ]
},
"scannedAt": "2025-10-20T12:34:56.789Z",
"scannerVersion": "syft-v1.0.0"
}
```
### 2. ATProto Repository Queries
Use standard ATProto XRPC to list all SBOMs:
```bash
# List all SBOM manifests in hold's PDS
curl "https://hold01.atcr.io/xrpc/com.atproto.repo.listRecords?\
repo=did:web:hold01.atcr.io&\
collection=io.atcr.manifest"
# Filter by artifactType (requires AppView indexing)
# Returns all SBOM artifacts
```
### 3. Direct SBOM Blob Download
Download the full SBOM JSON file:
```bash
# Get SBOM blob CID from manifest layers[0].digest
SBOM_DIGEST="sha256:def456..."
# Request presigned download URL
curl "https://hold01.atcr.io/xrpc/com.atproto.sync.getBlob?\
did=did:web:hold01.atcr.io&\
cid=$SBOM_DIGEST"
# Response: presigned S3 URL or direct blob
{
"url": "https://s3.amazonaws.com/bucket/blob?signature=...",
"expiresAt": "2025-10-20T12:49:56Z"
}
# Download SBOM JSON
curl "$URL" > sbom.spdx.json
```
### 4. ORAS CLI Integration
Use the ORAS CLI to discover and pull SBOMs:
```bash
# Discover referrers (SBOMs) for an image
oras discover atcr.io/alice/myapp:latest
# Output shows SBOM artifacts:
# digest: sha256:abc123...
# referrers:
# - artifactType: application/spdx+json
# digest: sha256:4a5e...
# Pull SBOM artifact
oras pull atcr.io/alice/myapp@sha256:4a5e...
# Downloads sbom.spdx.json to current directory
```
### 5. AppView Web UI (Future)
Future enhancement: AppView web interface will display SBOM information on repository pages:
- Link to SBOM JSON download
- Vulnerability count (if Grype enabled)
- Scanner version and scan timestamp
- Comparison across multiple holds
## Tool Integration
### SPDX/CycloneDX Tools
Any tool that understands SPDX or CycloneDX formats can consume the SBOMs:
**Example tools:**
- [OSV Scanner](https://github.com/google/osv-scanner) - Vulnerability scanning
- [Grype](https://github.com/anchore/grype) - Vulnerability scanning
- [Dependency-Track](https://dependencytrack.org/) - Software composition analysis
- [SBOM Quality Score](https://github.com/eBay/sbom-scorecard) - SBOM completeness
**Usage:**
```bash
# Download SBOM
curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?..." | \
jq -r '.manifest.layers[0].digest' | \
# ... fetch blob ... > sbom.spdx.json
# Scan with OSV
osv-scanner --sbom sbom.spdx.json
# Scan with Grype
grype sbom:./sbom.spdx.json
```
### OCI Registry API
ORAS manifests are fully OCI-compliant and discoverable via standard registry APIs:
```bash
# Discover referrers for an image
curl -H "Accept: application/vnd.oci.image.index.v1+json" \
"https://atcr.io/v2/alice/myapp/referrers/sha256:abc123"
# Returns referrers index with SBOM manifests
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:4a5e...",
"artifactType": "application/spdx+json"
}
]
}
```
### Programmatic Access
Use the ATProto SDK to query SBOMs:
```go
import "github.com/bluesky-social/indigo/atproto"
// List all SBOMs for a hold
records, err := client.RepoListRecords(ctx,
"did:web:hold01.atcr.io",
"io.atcr.manifest",
100, // limit
"", // cursor
)
// Filter for SBOM artifacts
for _, record := range records.Records {
manifest := record.Value.(ManifestRecord)
if manifest.ArtifactType == "application/spdx+json" {
// Process SBOM manifest
}
}
```
## Future Enhancements
### Vulnerability Scanning (Grype)
Add vulnerability scanning to SBOM generation:
```bash
# Configuration
HOLD_VULN_ENABLED=true
HOLD_VULN_DB_UPDATE_INTERVAL=24h
# Extended manifest with vulnerability count
{
"artifactType": "application/spdx+json",
"annotations": {
"io.atcr.vuln.critical": "2",
"io.atcr.vuln.high": "15",
"io.atcr.vuln.medium": "42",
"io.atcr.vuln.low": "8",
"io.atcr.vuln.scannedWith": "grype-v0.74.0",
"io.atcr.vuln.dbVersion": "2025-10-20"
}
}
```
### Multi-Scanner Support (Trivy)
Support multiple scanner backends:
```bash
HOLD_SBOM_SCANNER=trivy # syft (default), trivy, grype
HOLD_TRIVY_SCAN_TYPE=os,library,config,secret
```
### Multi-Hold Verification
Compare SBOMs from different holds for the same image:
```bash
# Alice pushes to hold1 and hold2
docker push atcr.io/alice/myapp:latest
# Both holds scan independently
# Compare results:
atcr-cli compare-sboms \
--image atcr.io/alice/myapp:latest \
--holds hold1.atcr.io,hold2.atcr.io
# Output: Package count differences, version mismatches, etc.
```
### Signature Verification (Cosign)
Sign SBOMs with Sigstore Cosign:
```bash
HOLD_SBOM_SIGN=true
HOLD_COSIGN_KEY_PATH=/var/lib/atcr/cosign.key
# SBOM artifacts get signed
# Verification:
cosign verify --key cosign.pub atcr.io/alice/myapp@sha256:4a5e...
```
## Security Considerations
### Reproducibility
SBOMs should be reproducible for the same image digest:
**Best practices:**
- Pin scanner versions in production holds
- Record scanner version in manifest annotations
- Document vulnerability database versions
- Re-scan periodically to catch new CVEs
**Validation:**
```bash
# Compare SBOMs from different holds
diff <(curl hold1/sbom.json | jq -S) \
<(curl hold2/sbom.json | jq -S)
# Differences indicate:
# - Different scanner versions
# - Different scan times (new CVEs discovered)
# - Potential tampering (investigate)
```
### Multiple Hold Verification
Running multiple holds provides defense in depth:
1. User pushes to hold1 (uses hold1 by default)
2. User also pushes to hold2 (backup/verification)
3. Both holds scan independently
4. Compare SBOM results:
- Similar results = confidence in accuracy
- Divergent results = investigate discrepancy
### Transparency
Hold operators should publish scanning policies:
- Scanner version and update schedule
- Vulnerability database update frequency
- SBOM format and schema version
- Data retention policies
### Trust Anchors
Users can verify scanner integrity:
1. **Scanner version**: Check `scannerVersion` field matches expected version
2. **DID signature**: ATProto record signed by hold's DID
3. **Timestamp**: Check `scannedAt` for stale scans
4. **Reproducibility**: Re-scan locally and compare results
## Example Workflows
### Enable Scanning on Your Hold
```bash
# 1. Configure hold with SBOM enabled
cat > .env.hold <<EOF
HOLD_PUBLIC_URL=https://myhold.example.com
STORAGE_DRIVER=s3
S3_BUCKET=my-blobs
HOLD_OWNER=did:plc:myid
# Enable SBOM scanning
HOLD_SBOM_ENABLED=true
HOLD_SBOM_WORKERS=2
HOLD_SBOM_FORMAT=spdx-json
EOF
# 2. Start hold service
./bin/atcr-hold
# 3. Push an image
docker push atcr.io/alice/myapp:latest
# 4. Wait for background scan (check logs)
# 2025-10-20T12:34:56Z INFO Scanning image sha256:abc123...
# 2025-10-20T12:35:12Z INFO SBOM generated sha256:def456...
# 5. Query for SBOM
curl "https://myhold.example.com/xrpc/io.atcr.hold.getSBOM?..."
```
### Consume SBOMs in CI/CD
```yaml
# .github/workflows/security-scan.yml
name: Security Scan
on: push
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Pull image
run: docker pull atcr.io/alice/myapp:latest
- name: Get SBOM from hold
run: |
IMAGE_DIGEST=$(docker inspect atcr.io/alice/myapp:latest \
--format='{{.RepoDigests}}')
curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?\
digest=$IMAGE_DIGEST&\
ownerDid=did:plc:alice123&\
repository=alice/myapp" \
-o sbom-manifest.json
SBOM_DIGEST=$(jq -r '.manifest.layers[0].digest' sbom-manifest.json)
curl "https://hold01.atcr.io/xrpc/com.atproto.sync.getBlob?\
did=did:web:hold01.atcr.io&\
cid=$SBOM_DIGEST" \
| jq -r '.url' | xargs curl -o sbom.spdx.json
- name: Scan with Grype
uses: anchore/scan-action@v3
with:
sbom: sbom.spdx.json
fail-build: true
severity-cutoff: high
```
## References
- [ORAS Specification](https://oras.land/)
- [OCI Artifacts](https://github.com/opencontainers/artifacts)
- [SPDX Specification](https://spdx.dev/)
- [CycloneDX Specification](https://cyclonedx.org/)
- [Syft Documentation](https://github.com/anchore/syft)
- [ATProto Specification](https://atproto.com/)
-821
View File
@@ -1,821 +0,0 @@
# XRPC Blob Upload Migration
This document describes how to migrate from separate legacy multipart upload endpoints to a unified `com.atproto.repo.uploadBlob` endpoint that supports both standard single-blob uploads and OCI container layer multipart uploads.
## Current State
### Legacy HTTP Endpoints (cmd/hold/main.go)
```go
// Unified presigned URL endpoint (handles upload AND download)
mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
// Internal move operation (used by multipart complete)
mux.HandleFunc("/move", service.HandleMove)
// Multipart upload endpoints
mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart)
// Buffered part upload (when presigned URLs unavailable)
mux.HandleFunc("/multipart-parts/", func(w http.ResponseWriter, r *http.Request) {
// Parse URL: /multipart-parts/{uploadID}/{partNumber}
// ...
service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, service.MultipartMgr)
})
```
### Existing XRPC Endpoint (pkg/hold/pds/xrpc.go)
```go
// Current implementation - redirects to presigned URL
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
digest := r.URL.Query().Get("digest")
uploadURL, err := h.blobStore.GetPresignedUploadURL(digest)
http.Redirect(w, r, uploadURL, http.StatusFound)
}
```
### Supporting Code
**pkg/hold/multipart.go:**
- `MultipartManager` - Tracks upload sessions
- `MultipartSession` - State for each upload (parts, mode, etc.)
- Modes: `S3Native` (presigned URLs), `Buffered` (proxy uploads)
**pkg/hold/blobstore_adapter.go:**
- `HoldServiceBlobStore` - Adapter wrapping HoldService for XRPC handlers
- Implements presigned URL generation
- Currently not used by XRPC handlers
**pkg/hold/handlers.go:**
- `HandlePresignedURL()` - Unified endpoint for GET/HEAD/PUT presigned URLs
- `HandleMove()` - Moves blob from temp to final location (internal operation)
- `HandleStartMultipart()` - Starts upload, returns uploadID
- `HandleGetPartURL()` - Returns presigned URL for part
- `HandleCompleteMultipart()` - Finalizes upload, assembles parts (calls Move internally)
- `HandleAbortMultipart()` - Cancels upload
- `HandleMultipartPartUpload()` - Buffered part upload fallback
## Legacy Endpoint Mapping
### `/presigned-url` → Multiple XRPC Operations
The legacy `/presigned-url` endpoint is a **unified endpoint** that handles both upload and download operations based on the `operation` field in the JSON body:
**Legacy format:**
```
POST /presigned-url
Content-Type: application/json
{
"operation": "GET", // or "HEAD" or "PUT"
"did": "did:plc:alice123",
"digest": "sha256:abc123...",
"size": 1234567890 // Only for PUT operations
}
Response:
{
"url": "https://s3.amazonaws.com/...",
"expires_at": "2025-10-16T..."
}
```
**XRPC mapping:**
- `operation: "GET"``GET /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...`
- `operation: "HEAD"``HEAD /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...`
- `operation: "PUT"``com.atproto.repo.uploadBlob` (single upload via presigned URL)
**Note:** For GET/HEAD operations, AppView passes OCI digest directly as `cid` parameter. Hold detects `sha256:` prefix and uses digest directly (no CID conversion needed).
### `/move` → Internal to Multipart Complete
The legacy `/move` endpoint moves a blob from temporary location to final digest-based location:
**Legacy format:**
```
POST /move?from=uploads/temp-123&to=sha256:abc123...&did=did:plc:alice123
Response: 200 OK
```
**Purpose:** Server-side S3 copy after multipart assembly. Used in this flow:
1. Multipart parts uploaded → `uploads/temp-{uploadID}/part-1`, `part-2`, etc.
2. Complete multipart → S3 assembles parts at `uploads/temp-{uploadID}`
3. **Move operation** → S3 copy from `uploads/temp-{uploadID}``blobs/sha256/ab/abc123...`
**XRPC mapping:**
- **Not a separate endpoint** - becomes internal operation in `uploadBlob?action=complete`
- The `complete` action automatically handles the move after multipart assembly
- AppView doesn't need to call move explicitly in XRPC flow
## New Unified Design
### Single Endpoint: `com.atproto.repo.uploadBlob`
Content-Type discrimination determines operation:
- `application/octet-stream` → Standard blob upload (profile images, small media)
- `application/json` → Multipart operations (large OCI layers)
### Complementary Endpoint: `com.atproto.sync.getBlob`
For blob downloads (maps from legacy `/presigned-url` with operation=GET/HEAD):
**Standard ATProto blobs (CID):**
```
GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid=bafyreib...
Response: 307 Temporary Redirect
Location: https://s3.amazonaws.com/bucket/...?presigned-params
```
**OCI container layers (digest):**
```
GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid=sha256:abc123...
Response: 307 Temporary Redirect
Location: https://s3.amazonaws.com/bucket/...?presigned-params
```
**Implementation - Flexible CID parameter:**
```go
func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
cidOrDigest := r.URL.Query().Get("cid")
var digest string
if strings.HasPrefix(cidOrDigest, "sha256:") {
// OCI digest - use directly (no conversion needed)
digest = cidOrDigest
} else {
// Standard CID - convert to digest
c, _ := cid.Decode(cidOrDigest)
digest = cidToDigest(c) // bafyreib... → sha256:abc...
}
// Generate presigned URL for S3
url := h.blobStore.GetPresignedDownloadURL(digest)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
```
**Key insight:** The `cid` parameter accepts both formats. Hold service checks prefix and handles accordingly. This keeps the endpoint spec-compliant (GET with query params) while supporting OCI digests natively.
### API Specification
#### Standard Single Upload (ATProto Spec Compliant)
```
POST /xrpc/com.atproto.repo.uploadBlob
Content-Type: application/octet-stream
[raw blob bytes]
Response (200 OK):
{
"blob": {
"$type": "blob",
"ref": {
"$link": "bafyreib..." // CID
},
"mimeType": "application/octet-stream",
"size": 12345
}
}
```
**Use case:** Profile images, small media (< 10MB), standard ATProto blobs
#### Multipart Start (ATCR Extension)
```
POST /xrpc/com.atproto.repo.uploadBlob
Content-Type: application/json
{
"action": "start",
"digest": "sha256:abc123...",
"size": 1234567890 // Optional hint for storage allocation
}
Response (200 OK):
{
"uploadId": "upload-1634567890",
"expiresAt": "2025-10-16T12:00:00Z",
"mode": "s3-native" // or "buffered"
}
```
**Implementation:**
- Calls `service.StartMultipartUploadWithManager(ctx, digest, multipartMgr)`
- Returns uploadID and mode from MultipartSession
#### Multipart Get Part URL (ATCR Extension)
```
POST /xrpc/com.atproto.repo.uploadBlob
Content-Type: application/json
{
"action": "part",
"uploadId": "upload-1634567890",
"partNumber": 1,
"digest": "sha256:abc123..."
}
Response (200 OK):
{
"url": "https://s3.amazonaws.com/bucket/...?X-Amz-...",
"expiresAt": "2025-10-16T12:15:00Z",
"method": "PUT"
}
// OR for buffered mode:
{
"url": "https://hold01.atcr.io/xrpc/com.atproto.repo.uploadBlob",
"method": "PUT",
"headers": {
"X-Upload-Id": "upload-1634567890",
"X-Part-Number": "1"
},
"expiresAt": "2025-10-16T12:15:00Z"
}
```
**Implementation:**
- Retrieve session: `multipartMgr.GetSession(uploadID)`
- S3Native mode: Call `service.GetPartUploadURL(ctx, session, partNumber, did)`
- Buffered mode: Return self-referential URL with headers
#### Multipart Upload Part (Buffered Mode)
```
PUT /xrpc/com.atproto.repo.uploadBlob
Content-Type: application/octet-stream
X-Upload-Id: upload-1634567890
X-Part-Number: 1
[part data bytes]
Response (200 OK):
{
"etag": "abc123def456",
"partNumber": 1
}
```
**Implementation:**
- Extract headers: `X-Upload-Id`, `X-Part-Number`
- Call `service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, multipartMgr)`
- Return ETag for completion
#### Multipart Complete (ATCR Extension)
```
POST /xrpc/com.atproto.repo.uploadBlob
Content-Type: application/json
{
"action": "complete",
"uploadId": "upload-1634567890",
"digest": "sha256:abc123...",
"parts": [
{ "partNumber": 1, "etag": "abc123" },
{ "partNumber": 2, "etag": "def456" }
]
}
Response (200 OK):
{
"status": "completed",
"blob": {
"$type": "blob",
"ref": {
"$link": "bafyreib..." // CID computed from digest
},
"mimeType": "application/octet-stream",
"size": 1234567890
}
}
```
**Implementation:**
- Retrieve session: `multipartMgr.GetSession(uploadID)`
- For S3Native: Record parts via `session.RecordS3Part()`
- Call `service.CompleteMultipartUploadWithManager(ctx, session, multipartMgr)`
- This internally calls S3 CompleteMultipartUpload to assemble parts
- Then performs server-side S3 copy from temp location to final digest location
- Equivalent to legacy `/move` endpoint operation
- Convert digest to CID for response
#### Multipart Abort (ATCR Extension)
```
POST /xrpc/com.atproto.repo.uploadBlob
Content-Type: application/json
{
"action": "abort",
"uploadId": "upload-1634567890",
"digest": "sha256:abc123..."
}
Response (200 OK):
{
"status": "aborted"
}
```
**Implementation:**
- Retrieve session: `multipartMgr.GetSession(uploadID)`
- Call `service.AbortMultipartUploadWithManager(ctx, session, multipartMgr)`
## Implementation Strategy
### Phase 1: Add Unified Handler (Keep Legacy Endpoints)
**File:** `pkg/hold/pds/xrpc.go`
```go
// HandleUploadBlob unified handler supporting both single and multipart uploads
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
contentType := r.Header.Get("Content-Type")
// Buffered multipart part upload (PUT with headers)
if r.Method == http.MethodPut && r.Header.Get("X-Upload-Id") != "" {
h.handleBufferedPartUpload(w, r)
return
}
// Multipart operations (JSON body)
if strings.Contains(contentType, "application/json") {
h.handleMultipartOperation(w, r)
return
}
// Standard single blob upload (raw bytes)
h.handleSingleBlobUpload(w, r)
}
func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"`
Digest string `json:"digest,omitempty"`
Size int64 `json:"size,omitempty"`
UploadID string `json:"uploadId,omitempty"`
PartNumber int `json:"partNumber,omitempty"`
Parts []struct {
PartNumber int `json:"partNumber"`
ETag string `json:"etag"`
} `json:"parts,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
// TODO: Add authentication check
// user, err := ValidateDPoPRequest(r)
ctx := r.Context()
switch req.Action {
case "start":
h.handleMultipartStart(w, r, req.Digest, req.Size)
case "part":
h.handleMultipartPart(w, r, req.UploadID, req.PartNumber, req.Digest)
case "complete":
h.handleMultipartComplete(w, r, req.UploadID, req.Digest, req.Parts)
case "abort":
h.handleMultipartAbort(w, r, req.UploadID, req.Digest)
default:
http.Error(w, "invalid action", http.StatusBadRequest)
}
}
func (h *XRPCHandler) handleMultipartStart(w http.ResponseWriter, r *http.Request, digest string, size int64) {
ctx := r.Context()
// Use HoldService multipart manager
// Note: h.blobStore is HoldServiceBlobStore which wraps the service
uploadID, mode, err := h.blobStore.StartMultipart(ctx, digest, size)
if err != nil {
http.Error(w, fmt.Sprintf("failed to start upload: %v", err), http.StatusInternalServerError)
return
}
response := map[string]any{
"uploadId": uploadID,
"expiresAt": time.Now().Add(24 * time.Hour),
"mode": mode, // "s3-native" or "buffered"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *XRPCHandler) handleMultipartPart(w http.ResponseWriter, r *http.Request, uploadID string, partNumber int, digest string) {
ctx := r.Context()
// Get part upload URL (presigned S3 or buffered endpoint)
partURL, err := h.blobStore.GetPartUploadURL(ctx, uploadID, partNumber, digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
return
}
response := map[string]any{
"url": partURL,
"expiresAt": time.Now().Add(15 * time.Minute),
"method": "PUT",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *XRPCHandler) handleMultipartComplete(w http.ResponseWriter, r *http.Request, uploadID string, digest string, parts []struct{ PartNumber int; ETag string }) {
ctx := r.Context()
// Convert parts format
completedParts := make([]hold.CompletedPart, len(parts))
for i, p := range parts {
completedParts[i] = hold.CompletedPart{
PartNumber: p.PartNumber,
ETag: p.ETag,
}
}
// Complete upload
if err := h.blobStore.CompleteMultipart(ctx, uploadID, digest, completedParts); err != nil {
http.Error(w, fmt.Sprintf("failed to complete upload: %v", err), http.StatusInternalServerError)
return
}
// Convert digest to CID for ATProto response format
cid, err := digestToCID(digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate CID: %v", err), http.StatusInternalServerError)
return
}
response := map[string]any{
"status": "completed",
"blob": map[string]any{
"$type": "blob",
"ref": map[string]any{
"$link": cid.String(),
},
"mimeType": "application/octet-stream",
// Size would need to be tracked in session
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *XRPCHandler) handleMultipartAbort(w http.ResponseWriter, r *http.Request, uploadID string, digest string) {
ctx := r.Context()
if err := h.blobStore.AbortMultipart(ctx, uploadID, digest); err != nil {
http.Error(w, fmt.Sprintf("failed to abort upload: %v", err), http.StatusInternalServerError)
return
}
response := map[string]any{
"status": "aborted",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request) {
uploadID := r.Header.Get("X-Upload-Id")
partNumberStr := r.Header.Get("X-Part-Number")
partNumber, err := strconv.Atoi(partNumberStr)
if err != nil {
http.Error(w, "invalid part number", http.StatusBadRequest)
return
}
// Stream part data to storage
etag, err := h.blobStore.UploadPart(r.Context(), uploadID, partNumber, r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to upload part: %v", err), http.StatusInternalServerError)
return
}
response := map[string]any{
"etag": etag,
"partNumber": partNumber,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *XRPCHandler) handleSingleBlobUpload(w http.ResponseWriter, r *http.Request) {
// Standard ATProto uploadBlob behavior
// Read blob data
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read blob", http.StatusInternalServerError)
return
}
// Upload to storage (single operation)
cid, size, err := h.blobStore.UploadBlob(r.Context(), bytes.NewReader(data))
if err != nil {
http.Error(w, fmt.Sprintf("failed to upload blob: %v", err), http.StatusInternalServerError)
return
}
// Standard ATProto blob response format
response := map[string]any{
"blob": map[string]any{
"$type": "blob",
"ref": map[string]any{
"$link": cid.String(),
},
"mimeType": "application/octet-stream",
"size": size,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// digestToCID converts OCI digest (sha256:abc...) to ATProto CID
func digestToCID(digest string) (cid.Cid, error) {
// Implementation in pkg/hold/cid.go or similar
// Strip "sha256:" prefix, decode hex, construct CIDv1 with sha256 multihash
return cid.Undef, fmt.Errorf("not implemented")
}
```
### Phase 2: Extend HoldServiceBlobStore (pkg/hold/blobstore_adapter.go)
The `HoldServiceBlobStore` currently wraps HoldService for presigned URLs. Extend it to support multipart operations:
```go
// Add multipart methods to HoldServiceBlobStore
func (h *HoldServiceBlobStore) StartMultipart(ctx context.Context, digest string, size int64) (uploadID string, mode string, err error) {
uploadID, uploadMode, err := h.service.StartMultipartUploadWithManager(ctx, digest, h.service.MultipartMgr)
if err != nil {
return "", "", err
}
modeStr := "s3-native"
if uploadMode == hold.Buffered {
modeStr = "buffered"
}
return uploadID, modeStr, nil
}
func (h *HoldServiceBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, digest string) (string, error) {
session, err := h.service.MultipartMgr.GetSession(uploadID)
if err != nil {
return "", err
}
// For S3Native: return presigned URL
// For Buffered: return self-referential URL with upload instructions
if session.Mode == hold.S3Native {
return h.service.GetPartUploadURL(ctx, session, partNumber, h.holdDID)
}
// Buffered mode: client will PUT to uploadBlob with headers
return fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", h.publicURL), nil
}
func (h *HoldServiceBlobStore) UploadPart(ctx context.Context, uploadID string, partNumber int, data io.Reader) (string, error) {
// Buffered part upload - streams data to storage
// Used when client PUTs to uploadBlob with X-Upload-Id header
session, err := h.service.MultipartMgr.GetSession(uploadID)
if err != nil {
return "", err
}
// Stream to storage, return ETag
// This wraps HandleMultipartPartUpload logic
etag, err := h.service.UploadPartBuffered(ctx, session, partNumber, data)
return etag, err
}
func (h *HoldServiceBlobStore) CompleteMultipart(ctx context.Context, uploadID string, digest string, parts []hold.CompletedPart) error {
session, err := h.service.MultipartMgr.GetSession(uploadID)
if err != nil {
return err
}
// For S3Native: record parts ETags
if session.Mode == hold.S3Native {
for _, p := range parts {
session.RecordS3Part(p.PartNumber, p.ETag, 0)
}
}
return h.service.CompleteMultipartUploadWithManager(ctx, session, h.service.MultipartMgr)
}
func (h *HoldServiceBlobStore) AbortMultipart(ctx context.Context, uploadID string, digest string) error {
session, err := h.service.MultipartMgr.GetSession(uploadID)
if err != nil {
return err
}
return h.service.AbortMultipartUploadWithManager(ctx, session, h.service.MultipartMgr)
}
func (h *HoldServiceBlobStore) UploadBlob(ctx context.Context, data io.Reader) (cid.Cid, int64, error) {
// Single blob upload for standard ATProto use case
// Compute digest, store via service driver
// Return CID and size
// Implementation TBD
return cid.Undef, 0, fmt.Errorf("not implemented")
}
```
### Phase 3: Update AppView Client (pkg/appview/storage/)
Create new XRPC client or update ProxyBlobStore to use unified endpoint:
**Download (GET/HEAD):**
```go
func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
// Pass digest directly as cid parameter (no conversion)
url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
p.storageEndpoint, p.holdDID, dgst.String()) // cid=sha256:abc...
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
return nil
}
```
**Multipart Upload:**
```go
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
reqBody := map[string]any{
"action": "start",
"digest": digest,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
// ... parse response, return uploadID
}
func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
reqBody := map[string]any{
"action": "part",
"uploadId": uploadID,
"partNumber": partNumber,
"digest": digest,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
// ... parse response, return presigned URL
}
// Similar for complete, abort
```
### Phase 4: Testing Period
**During transition:**
- Both legacy HTTP endpoints AND new XRPC endpoint active
- AppView can use either based on configuration/feature flag
- New deployments use XRPC
- Old deployments continue with legacy
**Detection logic:**
```go
func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
// Try XRPC first (check for /.well-known/did.json)
if supportsXRPC(storageEndpoint) {
return NewXRPCBlobStore(storageEndpoint, ...)
}
// Fallback to legacy
return NewProxyBlobStore(storageEndpoint, ...)
}
```
### Phase 5: Remove Legacy Endpoints
Once all holds migrated and tested:
**cmd/hold/main.go - Remove:**
```go
// DELETE these lines
mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
mux.HandleFunc("/move", service.HandleMove)
mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart)
mux.HandleFunc("/multipart-parts/", ...)
```
**pkg/hold/handlers.go - Remove HTTP handler wrappers:**
```go
// DELETE these functions:
// - HandlePresignedURL() - replaced by uploadBlob + getBlob XRPC endpoints
// - HandleMove() - now internal operation in CompleteMultipartUploadWithManager()
// - HandleStartMultipart() - replaced by uploadBlob?action=start
// - HandleGetPartURL() - replaced by uploadBlob?action=part
// - HandleCompleteMultipart() - replaced by uploadBlob?action=complete
// - HandleAbortMultipart() - replaced by uploadBlob?action=abort
// - HandleMultipartPartUpload() - replaced by uploadBlob PUT with headers
// KEEP internal service methods:
// - s.getPresignedURL() - still used by blobstore_adapter
// - s.driver.Move() - still used for temp→final move
// - s.StartMultipartUploadWithManager() - core multipart logic
// - s.GetPartUploadURL() - presigned URL generation
// - s.CompleteMultipartUploadWithManager() - includes move operation
// - s.AbortMultipartUploadWithManager() - cleanup logic
```
## Key Design Decisions
1. **Content-Type discrimination**: Natural way to distinguish single vs multipart uploads
2. **JSON bodies for all parameters**: Follows XRPC conventions (like putRecord, deleteRecord)
- **No query parameters** - all operation details in request body
- Makes requests more inspectable and debuggable
- Easier to extend with new fields
3. **Preserve standard uploadBlob**: Raw bytes still work for profile images, small media
4. **Reuse existing code**: HoldService multipart logic unchanged, just new HTTP layer
5. **Backward compatibility**: Both endpoints active during transition
6. **Action-based routing**: Clear, extensible JSON structure
7. **Move is internal**: `/move` endpoint logic absorbed into multipart complete operation
- No separate XRPC endpoint needed
- Simplifies AppView client code
8. **Unified presigned URL handling**: Single `uploadBlob`/`getBlob` pair replaces operation-based routing
9. **Flexible CID parameter**: `getBlob` accepts both standard CIDs and OCI digests via prefix detection
- Keeps endpoint spec-compliant (GET with query params)
- No conversion overhead on AppView side
- Hold does simple prefix check: `sha256:` → use directly, else → convert CID
## Benefits
- ✅ Single endpoint for all blob operations
- ✅ Standard ATProto uploadBlob preserved
- ✅ XRPC-like JSON request/response
- ✅ Reuses existing multipart.go logic
- ✅ Gradual migration path
- ✅ Less endpoints to maintain
- ✅ Cleaner AppView client code
## Testing Checklist
- [ ] Single blob upload (< 10MB, raw bytes)
- [ ] Multipart start → part → complete flow
- [ ] S3Native mode (presigned URLs)
- [ ] Buffered mode (proxy uploads)
- [ ] Multipart abort
- [ ] Large blob upload (> 5GB, many parts)
- [ ] Concurrent uploads
- [ ] Upload resume after network failure
- [ ] Legacy endpoint backward compatibility
- [ ] AppView XRPC client integration
- [ ] Performance comparison (XRPC vs legacy)
## Migration Timeline
1. **Week 1**: Implement unified uploadBlob handler (Phase 1-2)
2. **Week 2**: Update AppView client, feature flag (Phase 3)
3. **Week 3**: Deploy to dev/staging, test both paths (Phase 4)
4. **Week 4**: Roll out to production (gradual)
5. **Week 5-6**: Monitor, verify all holds migrated
6. **Week 7**: Remove legacy endpoints (Phase 5)
## References
- ATProto uploadBlob spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
- XRPC conventions: https://atproto.com/specs/xrpc
- Existing multipart implementation: pkg/hold/multipart.go
- Blob store adapter: pkg/hold/blobstore_adapter.go
@@ -0,0 +1,19 @@
description: Normalize hold_endpoint column to store DIDs instead of URLs
query: |
-- Convert any URL-formatted hold_endpoint values to DID format
-- This ensures all hold identifiers are stored consistently as did:web:hostname
-- Convert HTTPS URLs to did:web: format
-- https://hold.example.com → did:web:hold.example.com
UPDATE manifests
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
WHERE hold_endpoint LIKE 'https://%';
-- Convert HTTP URLs to did:web: format
-- http://172.28.0.3:8080 → did:web:172.28.0.3:8080
UPDATE manifests
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
WHERE hold_endpoint LIKE 'http://%';
-- Entries already in did:web: format are left unchanged
-- did:web:hold.example.com → did:web:hold.example.com (no change)
+15 -11
View File
@@ -65,17 +65,19 @@ type Tag struct {
// Push represents a combined tag and manifest for the recent pushes view
type Push struct {
DID string
Handle string
Repository string
Tag string
Digest string
Title string
Description string
IconURL string
StarCount int
PullCount int
CreatedAt time.Time
DID string
Handle string
Repository string
Tag string
Digest string
Title string
Description string
IconURL string
StarCount int
PullCount int
CreatedAt time.Time
HoldEndpoint string // Hold endpoint for health checking
Reachable bool // Whether the hold endpoint is reachable
}
// Repository represents an aggregated view of a user's repository
@@ -156,4 +158,6 @@ type ManifestWithMetadata struct {
Platforms []PlatformInfo
PlatformCount int
IsManifestList bool
Reachable bool // Whether the hold endpoint is reachable
Pending bool // Whether health check is still in progress
}
+6 -4
View File
@@ -44,7 +44,8 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
COALESCE(m.icon_url, ''),
COALESCE(rs.pull_count, 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
t.created_at
t.created_at,
m.hold_endpoint
FROM tags t
JOIN users u ON t.did = u.did
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
@@ -70,7 +71,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
var pushes []Push
for rows.Next() {
var p Push
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil {
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil {
return nil, 0, err
}
pushes = append(pushes, p)
@@ -113,7 +114,8 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err
COALESCE(m.icon_url, ''),
COALESCE(rs.pull_count, 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
t.created_at
t.created_at,
m.hold_endpoint
FROM tags t
JOIN users u ON t.did = u.did
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
@@ -136,7 +138,7 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err
var pushes []Push
for rows.Next() {
var p Push
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil {
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil {
return nil, 0, err
}
pushes = append(pushes, p)
+1 -1
View File
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS manifests (
did TEXT NOT NULL,
repository TEXT NOT NULL,
digest TEXT NOT NULL,
hold_endpoint TEXT NOT NULL,
hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com)
schema_version INTEGER NOT NULL,
media_type TEXT NOT NULL,
config_digest TEXT,
+34 -3
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
)
// HomeHandler handles the home page
@@ -54,9 +55,10 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// RecentPushesHandler handles the HTMX request for recent pushes
type RecentPushesHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
DB *sql.DB
Templates *template.Template
RegistryURL string
HealthChecker *holdhealth.Checker
}
func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -78,6 +80,35 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
// Check health status and filter out unreachable manifests for home page
// Use GetCachedStatus only (no blocking) - background worker keeps cache fresh
if h.HealthChecker != nil {
reachablePushes := []db.Push{}
for i := range pushes {
if pushes[i].HoldEndpoint != "" {
// Use cached status only - don't block on health checks
cached := h.HealthChecker.GetCachedStatus(pushes[i].HoldEndpoint)
if cached != nil {
pushes[i].Reachable = cached.Reachable
// Only show reachable pushes on home page
if cached.Reachable {
reachablePushes = append(reachablePushes, pushes[i])
}
} else {
// No cached status - optimistically show it (background worker will check)
pushes[i].Reachable = true
reachablePushes = append(reachablePushes, pushes[i])
}
}
}
pushes = reachablePushes
} else {
// If no health checker, assume all are reachable (backward compatibility)
for i := range pushes {
pushes[i].Reachable = true
}
}
data := struct {
PageData
Pushes []db.Push
+76
View File
@@ -0,0 +1,76 @@
package handlers
import (
"context"
"net/http"
"net/url"
"time"
"atcr.io/pkg/appview/holdhealth"
)
// ManifestHealthHandler handles HTMX polling for manifest health status
type ManifestHealthHandler struct {
HealthChecker *holdhealth.Checker
}
func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get endpoint from query parameter
endpoint := r.URL.Query().Get("endpoint")
if endpoint == "" {
http.Error(w, "endpoint parameter required", http.StatusBadRequest)
return
}
// Decode URL-encoded endpoint
endpoint, err := url.QueryUnescape(endpoint)
if err != nil {
http.Error(w, "invalid endpoint parameter", http.StatusBadRequest)
return
}
// Try to get cached status first (instant if background worker has checked it)
cached := h.HealthChecker.GetCachedStatus(endpoint)
if cached != nil {
// Cache hit - return final status
h.renderBadge(w, endpoint, cached.Reachable, false)
return
}
// Cache miss - perform quick check with 2 second timeout
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
if ctx.Err() == context.DeadlineExceeded {
// Still pending - render "Checking..." badge with HTMX retry
h.renderBadge(w, endpoint, false, true)
} else if err != nil {
// Error - mark as unreachable
h.renderBadge(w, endpoint, false, false)
} else {
// Success
h.renderBadge(w, endpoint, reachable, false)
}
}
// renderBadge renders the appropriate badge HTML snippet
func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool) {
w.Header().Set("Content-Type", "text/html")
if pending {
// Still checking - render badge with HTMX retry after 3 seconds
retryURL := "/api/manifest-health?endpoint=" + url.QueryEscape(endpoint)
w.Write([]byte(`<span class="checking-badge"
hx-get="` + retryURL + `"
hx-trigger="load delay:3s"
hx-swap="outerHTML">🔄 Checking...</span>`))
} else if !reachable {
// Unreachable - render offline badge
w.Write([]byte(`<span class="offline-badge">⚠️ Offline</span>`))
} else {
// Reachable - no badge (empty response)
w.Write([]byte(``))
}
}
+80 -12
View File
@@ -1,12 +1,16 @@
package handlers
import (
"context"
"database/sql"
"html/template"
"log"
"net/http"
"sync"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
@@ -16,11 +20,12 @@ import (
// RepositoryPageHandler handles the public repository page
type RepositoryPageHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
Directory identity.Directory
Refresher *oauth.Refresher
DB *sql.DB
Templates *template.Template
RegistryURL string
Directory identity.Directory
Refresher *oauth.Refresher
HealthChecker *holdhealth.Checker
}
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -54,6 +59,69 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
// Check health status for each manifest's hold endpoint (concurrent with 1s timeout)
if h.HealthChecker != nil {
// Create context with 1 second deadline for fast-fail
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
for i := range manifests {
if manifests[i].HoldEndpoint == "" {
// No hold endpoint, mark as unreachable
manifests[i].Reachable = false
manifests[i].Pending = false
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
endpoint := manifests[idx].HoldEndpoint
// Try to get cached status first (instant)
if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil {
mu.Lock()
manifests[idx].Reachable = cached.Reachable
manifests[idx].Pending = false
mu.Unlock()
return
}
// Perform health check with timeout context
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
mu.Lock()
if ctx.Err() == context.DeadlineExceeded {
// Timeout - mark as pending for HTMX polling
manifests[idx].Reachable = false
manifests[idx].Pending = true
} else if err != nil {
// Error - mark as unreachable
manifests[idx].Reachable = false
manifests[idx].Pending = false
} else {
// Success
manifests[idx].Reachable = reachable
manifests[idx].Pending = false
}
mu.Unlock()
}(i)
}
// Wait for all checks to complete or timeout
wg.Wait()
} else {
// If no health checker, assume all are reachable (backward compatibility)
for i := range manifests {
manifests[i].Reachable = true
manifests[i].Pending = false
}
}
if len(tagsWithPlatforms) == 0 && len(manifests) == 0 {
http.Error(w, "Repository not found", http.StatusNotFound)
return
@@ -100,13 +168,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
data := struct {
PageData
Owner *db.User // Repository owner
Repository *db.Repository // Repository summary
Tags []db.TagWithPlatforms // Tags with platform info
Manifests []db.ManifestWithMetadata // Top-level manifests only
StarCount int
IsStarred bool
IsOwner bool // Whether current user owns this repository
Owner *db.User // Repository owner
Repository *db.Repository // Repository summary
Tags []db.TagWithPlatforms // Tags with platform info
Manifests []db.ManifestWithMetadata // Top-level manifests only
StarCount int
IsStarred bool
IsOwner bool // Whether current user owns this repository
}{
PageData: NewPageData(r, h.RegistryURL),
Owner: owner,
+179
View File
@@ -0,0 +1,179 @@
package holdhealth
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"atcr.io/pkg/appview"
)
// HealthStatus represents the health status of a hold endpoint
type HealthStatus struct {
Reachable bool
LastChecked time.Time
LastError error
}
// Checker manages health checking for hold endpoints
type Checker struct {
client *http.Client
cache map[string]*HealthStatus
cacheMu sync.RWMutex
cacheTTL time.Duration
cleanupMu sync.Mutex
}
// NewChecker creates a new health checker with the specified cache TTL
func NewChecker(cacheTTL time.Duration) *Checker {
return NewCheckerWithTimeout(cacheTTL, 2*time.Second)
}
// NewCheckerWithTimeout creates a new health checker with custom timeout
// Useful for testing with shorter timeouts
func NewCheckerWithTimeout(cacheTTL, httpTimeout time.Duration) *Checker {
return &Checker{
client: &http.Client{
Timeout: httpTimeout,
},
cache: make(map[string]*HealthStatus),
cacheTTL: cacheTTL,
}
}
// CheckHealth performs an HTTP health check on the hold endpoint
// Accepts either DID (did:web:host) or URL (https://host) format
// Checks {endpoint}/xrpc/_health and returns true if reachable
func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error) {
// Convert DID to HTTP URL if needed
// did:web:hold.example.com → https://hold.example.com
// https://hold.example.com → https://hold.example.com (passthrough)
httpURL := appview.ResolveHoldURL(endpoint)
// Build health check URL
healthURL := httpURL + "/xrpc/_health"
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET", healthURL, nil)
if err != nil {
return false, fmt.Errorf("failed to create request: %w", err)
}
// Perform request
resp, err := c.client.Do(req)
if err != nil {
return false, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return true, nil
}
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// GetStatus returns the cached health status for an endpoint
// If the cache is expired or missing, it performs an on-demand check
func (c *Checker) GetStatus(ctx context.Context, endpoint string) *HealthStatus {
// Check cache first
c.cacheMu.RLock()
status, exists := c.cache[endpoint]
c.cacheMu.RUnlock()
// If cached and not expired, return it
if exists && time.Since(status.LastChecked) < c.cacheTTL {
return status
}
// On-demand check
reachable, err := c.CheckHealth(ctx, endpoint)
// Update cache
newStatus := &HealthStatus{
Reachable: reachable,
LastChecked: time.Now(),
LastError: err,
}
c.cacheMu.Lock()
c.cache[endpoint] = newStatus
c.cacheMu.Unlock()
return newStatus
}
// GetCachedStatus returns the cached status without performing a check
// Returns nil if no cached status exists
func (c *Checker) GetCachedStatus(endpoint string) *HealthStatus {
c.cacheMu.RLock()
defer c.cacheMu.RUnlock()
status, exists := c.cache[endpoint]
if !exists {
return nil
}
// Return nil if expired
if time.Since(status.LastChecked) > c.cacheTTL {
return nil
}
return status
}
// SetStatus manually sets the health status for an endpoint
// Used by the background worker to update cache
func (c *Checker) SetStatus(endpoint string, reachable bool, err error) {
status := &HealthStatus{
Reachable: reachable,
LastChecked: time.Now(),
LastError: err,
}
c.cacheMu.Lock()
c.cache[endpoint] = status
c.cacheMu.Unlock()
}
// Cleanup removes stale cache entries (older than 30 minutes)
func (c *Checker) Cleanup() {
c.cleanupMu.Lock()
defer c.cleanupMu.Unlock()
c.cacheMu.Lock()
defer c.cacheMu.Unlock()
cutoff := time.Now().Add(-30 * time.Minute)
for endpoint, status := range c.cache {
if status.LastChecked.Before(cutoff) {
delete(c.cache, endpoint)
}
}
}
// GetCacheStats returns cache statistics for debugging
func (c *Checker) GetCacheStats() map[string]any {
c.cacheMu.RLock()
defer c.cacheMu.RUnlock()
reachable := 0
unreachable := 0
for _, status := range c.cache {
if status.Reachable {
reachable++
} else {
unreachable++
}
}
return map[string]any{
"total": len(c.cache),
"reachable": reachable,
"unreachable": unreachable,
}
}
+253
View File
@@ -0,0 +1,253 @@
package holdhealth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewChecker(t *testing.T) {
cacheTTL := 15 * time.Minute
checker := NewChecker(cacheTTL)
if checker == nil {
t.Fatal("NewChecker returned nil")
}
if checker.cacheTTL != cacheTTL {
t.Errorf("Expected cache TTL %v, got %v", cacheTTL, checker.cacheTTL)
}
if checker.cache == nil {
t.Error("Cache map not initialized")
}
}
func TestCheckHealth_Success(t *testing.T) {
// Create test server that returns 200
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/xrpc/_health" {
t.Errorf("Expected path /xrpc/_health, got %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"version": "1.0.0"}`))
}))
defer server.Close()
checker := NewChecker(15 * time.Minute)
ctx := context.Background()
reachable, err := checker.CheckHealth(ctx, server.URL)
if err != nil {
t.Errorf("CheckHealth returned error: %v", err)
}
if !reachable {
t.Error("Expected hold to be reachable")
}
}
func TestCheckHealth_WithDID(t *testing.T) {
// Create test server that returns 200
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/xrpc/_health" {
t.Errorf("Expected path /xrpc/_health, got %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"version": "1.0.0"}`))
}))
defer server.Close()
checker := NewChecker(15 * time.Minute)
ctx := context.Background()
// Test with DID format (did:web:host)
// Extract host:port from test server URL
// http://127.0.0.1:12345 → did:web:127.0.0.1:12345
serverURL := server.URL
didFormat := "did:web:" + serverURL[7:] // Remove "http://"
reachable, err := checker.CheckHealth(ctx, didFormat)
if err != nil {
t.Errorf("CheckHealth with DID returned error: %v", err)
}
if !reachable {
t.Error("Expected hold to be reachable with DID format")
}
}
func TestCheckHealth_Failure(t *testing.T) {
// Create test server that returns 500
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
checker := NewChecker(15 * time.Minute)
ctx := context.Background()
reachable, err := checker.CheckHealth(ctx, server.URL)
if err == nil {
t.Error("Expected error for 500 status code")
}
if reachable {
t.Error("Expected hold to be unreachable")
}
}
func TestCheckHealth_Timeout(t *testing.T) {
// Create test server that delays longer than client timeout
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond) // Longer than 100ms test timeout
}))
defer server.Close()
// Use custom timeout of 100ms for faster test
checker := NewCheckerWithTimeout(15*time.Minute, 100*time.Millisecond)
ctx := context.Background()
reachable, err := checker.CheckHealth(ctx, server.URL)
if err == nil {
t.Error("Expected timeout error")
}
if reachable {
t.Error("Expected hold to be unreachable due to timeout")
}
}
func TestGetStatus_CacheHit(t *testing.T) {
checker := NewChecker(15 * time.Minute)
endpoint := "https://example.com"
// Manually set cached status
checker.SetStatus(endpoint, true, nil)
// Get status should return cached value
status := checker.GetStatus(context.Background(), endpoint)
if status == nil {
t.Fatal("GetStatus returned nil")
}
if !status.Reachable {
t.Error("Expected cached status to be reachable")
}
if status.LastError != nil {
t.Errorf("Expected no error, got %v", status.LastError)
}
}
func TestGetStatus_CacheMiss(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
checker := NewChecker(15 * time.Minute)
// Get status should perform check on cache miss
status := checker.GetStatus(context.Background(), server.URL)
if status == nil {
t.Fatal("GetStatus returned nil")
}
if !status.Reachable {
t.Error("Expected status to be reachable")
}
}
func TestGetStatus_CacheExpiry(t *testing.T) {
// Create checker with very short TTL
checker := NewChecker(100 * time.Millisecond)
endpoint := "https://example.com"
// Set cached status
checker.SetStatus(endpoint, true, nil)
// Wait for cache to expire
time.Sleep(150 * time.Millisecond)
// GetCachedStatus should return nil for expired entry
status := checker.GetCachedStatus(endpoint)
if status != nil {
t.Error("Expected nil for expired cache entry")
}
}
func TestSetStatus(t *testing.T) {
checker := NewChecker(15 * time.Minute)
endpoint := "https://example.com"
// Set status
checker.SetStatus(endpoint, true, nil)
// Verify it was set
status := checker.GetCachedStatus(endpoint)
if status == nil {
t.Fatal("Status not found in cache")
}
if !status.Reachable {
t.Error("Expected status to be reachable")
}
}
func TestCleanup(t *testing.T) {
checker := NewChecker(1 * time.Minute)
// Add old entry (simulate old timestamp by manually setting it)
endpoint := "https://example.com"
checker.cache[endpoint] = &HealthStatus{
Reachable: true,
LastChecked: time.Now().Add(-31 * time.Minute), // 31 minutes ago
}
// Add recent entry
recentEndpoint := "https://recent.com"
checker.SetStatus(recentEndpoint, true, nil)
// Run cleanup
checker.Cleanup()
// Old entry should be removed
if checker.GetCachedStatus(endpoint) != nil {
t.Error("Expected old entry to be cleaned up")
}
// Recent entry should remain
if checker.GetCachedStatus(recentEndpoint) == nil {
t.Error("Expected recent entry to remain after cleanup")
}
}
func TestGetCacheStats(t *testing.T) {
checker := NewChecker(15 * time.Minute)
// Add some entries
checker.SetStatus("https://reachable1.com", true, nil)
checker.SetStatus("https://reachable2.com", true, nil)
checker.SetStatus("https://unreachable1.com", false, nil)
stats := checker.GetCacheStats()
total, ok := stats["total"].(int)
if !ok || total != 3 {
t.Errorf("Expected total=3, got %v", stats["total"])
}
reachable, ok := stats["reachable"].(int)
if !ok || reachable != 2 {
t.Errorf("Expected reachable=2, got %v", stats["reachable"])
}
unreachable, ok := stats["unreachable"].(int)
if !ok || unreachable != 1 {
t.Errorf("Expected unreachable=1, got %v", stats["unreachable"])
}
}
+169
View File
@@ -0,0 +1,169 @@
package holdhealth
import (
"context"
"database/sql"
"fmt"
"log"
"sync"
"time"
)
// DBQuerier interface for database queries (allows mocking in tests)
type DBQuerier interface {
GetUniqueHoldEndpoints() ([]string, error)
}
// Worker runs background health checks for hold endpoints
type Worker struct {
checker *Checker
db DBQuerier
refreshTicker *time.Ticker
cleanupTicker *time.Ticker
stopChan chan struct{}
wg sync.WaitGroup
}
// NewWorker creates a new background worker
func NewWorker(checker *Checker, db DBQuerier, refreshInterval time.Duration) *Worker {
return &Worker{
checker: checker,
db: db,
refreshTicker: time.NewTicker(refreshInterval),
cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes
stopChan: make(chan struct{}),
}
}
// Start begins the background worker
func (w *Worker) Start(ctx context.Context) {
w.wg.Add(1)
go func() {
defer w.wg.Done()
log.Println("Hold health worker: Starting background health checks")
// Perform initial check immediately
w.refreshAllHolds(ctx)
for {
select {
case <-ctx.Done():
log.Println("Hold health worker: Context cancelled, stopping")
return
case <-w.stopChan:
log.Println("Hold health worker: Stop signal received")
return
case <-w.refreshTicker.C:
w.refreshAllHolds(ctx)
case <-w.cleanupTicker.C:
log.Println("Hold health worker: Running cache cleanup")
w.checker.Cleanup()
}
}
}()
}
// Stop gracefully stops the worker
func (w *Worker) Stop() {
close(w.stopChan)
w.refreshTicker.Stop()
w.cleanupTicker.Stop()
w.wg.Wait()
log.Println("Hold health worker: Stopped")
}
// refreshAllHolds queries the database for unique hold endpoints and refreshes their health status
func (w *Worker) refreshAllHolds(ctx context.Context) {
log.Println("Hold health worker: Starting refresh cycle")
// Get unique hold endpoints from database
endpoints, err := w.db.GetUniqueHoldEndpoints()
if err != nil {
log.Printf("Hold health worker: Failed to fetch hold endpoints: %v", err)
return
}
if len(endpoints) == 0 {
log.Println("Hold health worker: No hold endpoints to check")
return
}
log.Printf("Hold health worker: Checking %d unique hold endpoints", len(endpoints))
// Check health concurrently with rate limiting
// Use a semaphore to limit concurrent requests (max 10 at a time)
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
reachable := 0
unreachable := 0
var statsMu sync.Mutex
for _, endpoint := range endpoints {
wg.Add(1)
go func(ep string) {
defer wg.Done()
// Acquire semaphore
sem <- struct{}{}
defer func() { <-sem }()
// Check health
isReachable, err := w.checker.CheckHealth(ctx, ep)
// Update cache
w.checker.SetStatus(ep, isReachable, err)
// Update stats
statsMu.Lock()
if isReachable {
reachable++
} else {
unreachable++
log.Printf("Hold health worker: Hold unreachable: %s (error: %v)", ep, err)
}
statsMu.Unlock()
}(endpoint)
}
// Wait for all checks to complete
wg.Wait()
log.Printf("Hold health worker: Refresh complete - %d reachable, %d unreachable", reachable, unreachable)
}
// DBAdapter wraps sql.DB to implement DBQuerier interface
type DBAdapter struct {
db *sql.DB
}
// NewDBAdapter creates a new database adapter
func NewDBAdapter(db *sql.DB) *DBAdapter {
return &DBAdapter{db: db}
}
// GetUniqueHoldEndpoints queries the database for unique hold endpoints
func (a *DBAdapter) GetUniqueHoldEndpoints() ([]string, error) {
rows, err := a.db.Query(`SELECT DISTINCT hold_endpoint FROM manifests WHERE hold_endpoint != ''`)
if err != nil {
return nil, fmt.Errorf("failed to query hold endpoints: %w", err)
}
defer rows.Close()
var endpoints []string
for rows.Next() {
var endpoint string
if err := rows.Scan(&endpoint); err != nil {
return nil, fmt.Errorf("failed to scan endpoint: %w", err)
}
endpoints = append(endpoints, endpoint)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
return endpoints, nil
}
+55
View File
@@ -1044,6 +1044,61 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
margin-top: 0.5rem;
}
/* Offline manifest badge */
.offline-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--warning-bg);
color: var(--warning);
border: 1px solid var(--warning);
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
margin-left: 0.5rem;
}
/* Checking manifest badge (health check in progress) */
.checking-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #e3f2fd;
color: #1976d2;
border: 1px solid #1976d2;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
margin-left: 0.5rem;
}
/* Hide offline manifests by default */
.manifest-item[data-reachable="false"] {
display: none;
}
/* Show offline manifests when toggle is checked */
.manifests-list.show-offline .manifest-item[data-reachable="false"] {
display: block;
opacity: 0.6;
}
/* Show offline images toggle styling */
.show-offline-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.show-offline-toggle input[type="checkbox"] {
cursor: pointer;
}
.show-offline-toggle span {
font-size: 0.9rem;
color: var(--border-dark);
}
.manifest-detail-label {
font-weight: 500;
color: var(--secondary);
+38
View File
@@ -243,3 +243,41 @@ async function loadStarCount(handle, repository) {
console.error('Error loading star count:', err);
}
}
// Toggle offline manifests visibility
function toggleOfflineManifests() {
const checkbox = document.getElementById('show-offline-toggle');
const manifestsList = document.querySelector('.manifests-list');
if (!checkbox || !manifestsList) return;
// Store preference in localStorage
localStorage.setItem('showOfflineManifests', checkbox.checked);
// Toggle visibility of offline manifests
if (checkbox.checked) {
manifestsList.classList.add('show-offline');
} else {
manifestsList.classList.remove('show-offline');
}
}
// Restore offline manifests toggle state on page load
document.addEventListener('DOMContentLoaded', () => {
const checkbox = document.getElementById('show-offline-toggle');
if (!checkbox) return;
// Restore state from localStorage
const showOffline = localStorage.getItem('showOfflineManifests') === 'true';
checkbox.checked = showOffline;
// Apply initial state
const manifestsList = document.querySelector('.manifests-list');
if (manifestsList) {
if (showOffline) {
manifestsList.classList.add('show-offline');
} else {
manifestsList.classList.remove('show-offline');
}
}
});
+4 -15
View File
@@ -8,10 +8,10 @@ import (
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"atcr.io/pkg/appview"
"atcr.io/pkg/atproto"
"github.com/distribution/distribution/v3"
"github.com/opencontainers/go-digest"
@@ -199,21 +199,10 @@ func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
return nil
}
// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
// did:web:hold01.atcr.io → https://hold01.atcr.io
// did:web:172.28.0.3:8080 → http://172.28.0.3:8080
// resolveHoldURL converts a hold identifier (DID or URL) to an HTTP URL
// Deprecated: Use appview.ResolveHoldURL instead
func resolveHoldURL(holdDID string) string {
hostname := strings.TrimPrefix(holdDID, "did:web:")
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
if strings.Contains(hostname, ":") ||
strings.Contains(hostname, "127.0.0.1") ||
strings.Contains(hostname, "localhost") ||
// Check if it's an IP address (contains only digits and dots)
(len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
return "http://" + hostname
}
return "https://" + hostname
return appview.ResolveHoldURL(holdDID)
}
// Stat returns the descriptor for a blob
+18 -2
View File
@@ -140,11 +140,17 @@
<!-- Manifests Section -->
<div class="repo-section">
<h2>Manifests</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2>Manifests</h2>
<label class="show-offline-toggle">
<input type="checkbox" id="show-offline-toggle" onchange="toggleOfflineManifests()">
<span>Show offline images</span>
</label>
</div>
{{ if .Manifests }}
<div class="manifests-list">
{{ range .Manifests }}
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}">
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
<div class="manifest-item-header">
<div>
{{ if .IsManifestList }}
@@ -152,6 +158,16 @@
{{ else }}
<span class="manifest-type">📄 Image</span>
{{ end }}
{{ if .Pending }}
<span class="checking-badge"
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
🔄 Checking...
</span>
{{ else if not .Reachable }}
<span class="offline-badge">⚠️ Offline</span>
{{ end }}
<code class="manifest-digest">{{ .Manifest.Digest }}</code>
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
+33
View File
@@ -0,0 +1,33 @@
package appview
import "strings"
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
// Handles both formats for backward compatibility:
// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
func ResolveHoldURL(holdIdentifier string) string {
// If it's already a URL (has scheme), return as-is
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
return holdIdentifier
}
// If it's a DID, convert to URL
if strings.HasPrefix(holdIdentifier, "did:web:") {
hostname := strings.TrimPrefix(holdIdentifier, "did:web:")
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
if strings.Contains(hostname, ":") ||
strings.Contains(hostname, "127.0.0.1") ||
strings.Contains(hostname, "localhost") ||
// Check if it's an IP address (contains only digits and dots in first part)
(len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
return "http://" + hostname
}
return "https://" + hostname
}
// Fallback: assume it's a hostname and use HTTPS
return "https://" + holdIdentifier
}
+61
View File
@@ -0,0 +1,61 @@
package appview
import "testing"
func TestResolveHoldURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "DID with HTTPS domain",
input: "did:web:hold.example.com",
expected: "https://hold.example.com",
},
{
name: "DID with HTTP and port (IP)",
input: "did:web:172.28.0.3:8080",
expected: "http://172.28.0.3:8080",
},
{
name: "DID with HTTP and port (localhost)",
input: "did:web:127.0.0.1:8080",
expected: "http://127.0.0.1:8080",
},
{
name: "DID with localhost",
input: "did:web:localhost:8080",
expected: "http://localhost:8080",
},
{
name: "Already HTTPS URL (passthrough)",
input: "https://hold.example.com",
expected: "https://hold.example.com",
},
{
name: "Already HTTP URL (passthrough)",
input: "http://172.28.0.3:8080",
expected: "http://172.28.0.3:8080",
},
{
name: "Plain hostname (fallback to HTTPS)",
input: "hold.example.com",
expected: "https://hold.example.com",
},
{
name: "DID with subdomain",
input: "did:web:hold01.atcr.io",
expected: "https://hold01.atcr.io",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ResolveHoldURL(tt.input)
if result != tt.expected {
t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
+13 -13
View File
@@ -149,15 +149,15 @@ func TestEnsureProfile_Exists(t *testing.T) {
// TestGetProfile tests retrieving a user's profile
func TestGetProfile(t *testing.T) {
tests := []struct {
name string
serverResponse string
serverStatus int
wantProfile *SailorProfileRecord
wantNil bool
wantErr bool
expectMigration bool // Whether URL-to-DID migration should happen
originalHoldURL string
expectedHoldDID string
name string
serverResponse string
serverStatus int
wantProfile *SailorProfileRecord
wantNil bool
wantErr bool
expectMigration bool // Whether URL-to-DID migration should happen
originalHoldURL string
expectedHoldDID string
}{
{
name: "profile with DID (no migration needed)",
@@ -359,10 +359,10 @@ func TestGetProfile_MigrationLocking(t *testing.T) {
// TestUpdateProfile tests updating a user's profile
func TestUpdateProfile(t *testing.T) {
tests := []struct {
name string
profile *SailorProfileRecord
wantNormalized string // Expected defaultHold after normalization
wantErr bool
name string
profile *SailorProfileRecord
wantNormalized string // Expected defaultHold after normalization
wantErr bool
}{
{
name: "update with DID",