From 67589963003f967517f22a5d284008f3e31c77dc Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sat, 13 Jun 2026 12:49:03 -0500 Subject: [PATCH] add SBOM package diffing, verify hold-service captain records - diff view gains a Packages tab with added/removed/changed/unchanged package tables and purl-derived type/license/upstream links - captain records verified against the DID's atcr_hold service before caching (processor + batch backfill), preventing forged holds - fix empty-handle updates clobbering cached handles and colliding on the UNIQUE constraint - move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical, repomgr kept as a test oracle - surface read-only crew status in hold selector - reconcile docs --- CLAUDE.md | 21 +- Dockerfile.hold | 25 +- docker-compose.yml | 1 - docs/ADMIN_PANEL.md | 1403 ------------- docs/APPVIEW-UI-FUTURE.md | 138 +- docs/ATCR_VERIFY_CLI.md | 728 ------- docs/ATPROTO_SIGNATURES.md | 501 ----- docs/BILLING.md | 150 +- docs/BILLING_REFACTOR.md | 348 ---- docs/BYOS.md | 117 +- docs/CREDENTIAL_HELPER.md | 323 ++- docs/CREDENTIAL_HELPER_V2.md | 165 -- docs/DEVELOPMENT.md | 837 ++------ docs/DIRECT_HOLD_ACCESS.md | 4 +- docs/HOLD_AS_CA.md | 756 ------- docs/HOLD_DISCOVERY.md | 1824 ++--------------- docs/HOLD_XRPC_ENDPOINTS.md | 53 +- docs/IMAGE_SIGNING.md | 505 ----- docs/INTEGRATION_STRATEGY.md | 692 ------- docs/KNOWN_RELAYS.md | 9 +- docs/OAUTH.md | 119 +- docs/QUOTAS.md | 426 ++-- docs/REBRAND.md | 558 ----- docs/REPOMGR_MIGRATION.md | 148 -- docs/SBOM_SCANNING.md | 896 +++----- docs/SIGNATURE_INTEGRATION.md | 1210 ----------- docs/TROUBLESHOOTING.md | 52 +- docs/appview.md | 42 +- docs/hold.md | 7 +- docs/research/IMAGE_SIGNING.md | 564 +++++ examples/plugins/README.md | 5 +- .../plugins/gatekeeper-provider/README.md | 3 +- examples/plugins/ratify-verifier/README.md | 3 +- examples/verification/README.md | 5 +- pkg/appview/db/queries.go | 6 + pkg/appview/handlers/diff.go | 316 ++- pkg/appview/handlers/diff_test.go | 212 +- pkg/appview/handlers/purl.go | 199 ++ pkg/appview/handlers/purl_test.go | 178 ++ pkg/appview/handlers/sbom_details.go | 64 +- pkg/appview/handlers/settings.go | 6 +- pkg/appview/handlers/upgrade_banner.go | 2 +- pkg/appview/jetstream/backfill.go | 2 +- pkg/appview/jetstream/backfill_batch.go | 20 +- pkg/appview/jetstream/backfill_batch_test.go | 119 ++ pkg/appview/jetstream/processor.go | 40 +- pkg/appview/jetstream/processor_test.go | 127 ++ pkg/appview/public/icons.svg | 2 + pkg/appview/server.go | 2 +- pkg/appview/templates/pages/diff.html | 28 +- .../templates/partials/diff-content.html | 178 +- .../templates/partials/hold_selector.html | 2 +- .../templates/partials/sbom-details.html | 2 +- pkg/atproto/resolver.go | 26 + pkg/atproto/resolver_test.go | 138 ++ pkg/hold/admin/public/icons.svg | 2 + pkg/hold/pds/repo.go | 34 +- pkg/hold/pds/repo_operator.go | 6 +- pkg/hold/pds/repomgr.go | 36 +- 59 files changed, 3747 insertions(+), 10638 deletions(-) delete mode 100644 docs/ADMIN_PANEL.md delete mode 100644 docs/ATCR_VERIFY_CLI.md delete mode 100644 docs/ATPROTO_SIGNATURES.md delete mode 100644 docs/BILLING_REFACTOR.md delete mode 100644 docs/CREDENTIAL_HELPER_V2.md delete mode 100644 docs/HOLD_AS_CA.md delete mode 100644 docs/IMAGE_SIGNING.md delete mode 100644 docs/INTEGRATION_STRATEGY.md delete mode 100644 docs/REBRAND.md delete mode 100644 docs/REPOMGR_MIGRATION.md delete mode 100644 docs/SIGNATURE_INTEGRATION.md create mode 100644 docs/research/IMAGE_SIGNING.md create mode 100644 pkg/appview/handlers/purl.go create mode 100644 pkg/appview/handlers/purl_test.go create mode 100644 pkg/appview/jetstream/backfill_batch_test.go diff --git a/CLAUDE.md b/CLAUDE.md index c9a3c8d..049cb28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,8 @@ go build -o bin/oauth-helper ./cmd/oauth-helper # Build scanner (separate module) cd scanner && go build -o ../bin/atcr-scanner ./cmd/scanner && cd .. -# Build hold with billing support (optional build tag) -go build -tags billing -o bin/atcr-hold ./cmd/hold +# Build appview with billing support (optional build tag; billing lives in pkg/billing/, appview-side only) +go build -tags billing -o bin/atcr-appview ./cmd/appview # Tests go test ./... # all tests @@ -72,20 +72,21 @@ ATCR uses **distribution/distribution** as a library, extending it via middlewar ### Four Components 1. **AppView** (`cmd/appview`) — OCI Distribution API server. Resolves identities, routes manifests to PDS, routes blobs to hold service, validates OAuth, issues registry JWTs. Includes web UI for browsing. -2. **Hold Service** (`cmd/hold`) — BYOS blob storage. Embedded PDS with captain/crew/stats/scan records (all ATProto records in CAR store), S3-compatible storage, presigned URLs. Supports did:web (default) or did:plc identity with auto-recovery. Optional subsystems: admin UI, quotas, billing (Stripe), GC, scan dispatch, Bluesky status posts. +2. **Hold Service** (`cmd/hold`) — BYOS blob storage. Embedded PDS with captain/crew/stats/scan records (all ATProto records in CAR store), S3-compatible storage, presigned URLs. Supports did:web (default) or did:plc identity with auto-recovery. Optional subsystems: admin UI, quotas, GC, scan dispatch, Bluesky status posts. 3. **Scanner** (`scanner/cmd/scanner`) — Vulnerability scanning. Connects to hold via WebSocket, generates SBOMs (Syft), scans vulnerabilities (Grype). Priority queue with tier-based scheduling. 4. **Credential Helper** (`cmd/credential-helper`) — Docker credential helper implementing ATProto OAuth flow, exchanges OAuth token for registry JWT. ### Request Flow Summary -**Push:** Client pushes to `atcr.io//:`. Registry middleware resolves identity → DID → PDS, discovers hold DID (from sailor profile `defaultHold` → legacy `io.atcr.hold` records → AppView default). Blobs go to hold via XRPC multipart upload (presigned S3 URLs). Manifests stored in user's PDS as `io.atcr.manifest` records with `holdDid` reference. +**Push:** Client pushes to `atcr.io//:`. Registry middleware resolves identity → DID → PDS, discovers hold DID (from sailor profile `defaultHold` → AppView default). Blobs go to hold via XRPC multipart upload (presigned S3 URLs). Manifests stored in user's PDS as `io.atcr.manifest` records with `holdDid` reference. **Pull:** AppView fetches manifest from user's PDS. The manifest's `holdDid` field tells where blobs were stored. Blobs fetched from that hold via presigned download URLs. Pull always uses the historical hold from the manifest, even if the user changed their default since pushing. -**Hold discovery priority** (in `findHoldDID()`, `pkg/appview/middleware/registry.go`): +**Hold discovery priority** (in `findHoldDIDAndProfile()`, `pkg/appview/middleware/registry.go`): 1. Sailor profile's `defaultHold` (user preference) -2. User's `io.atcr.hold` records (legacy) -3. AppView's `default_hold_did` (fallback) +2. AppView's default hold (`server.managed_holds[0]`, the fallback) + +After discovery, `resolveSuccessor()` applies a single-hop redirect: if the chosen hold's captain record declares a `successor` DID (migration redirect), blobs route to the successor instead. Single-hop only — successor chains are not followed. ### Name Resolution @@ -178,7 +179,7 @@ The credential helper never manages OAuth tokens directly — AppView owns the O ATCR uses **Viper** for config. YAML primary, env vars override. Generate defaults with `config init`. **Env var convention:** Prefix + YAML path with `_` separators: -- AppView: `ATCR_` (e.g., `ATCR_SERVER_DEFAULT_HOLD_DID`) +- AppView: `ATCR_` (e.g., `ATCR_SERVER_MANAGED_HOLDS`) - Hold: `HOLD_` (e.g., `HOLD_SERVER_PUBLIC_URL`) - S3: standard AWS names (`AWS_ACCESS_KEY_ID`, `S3_BUCKET`, `S3_ENDPOINT`) - Scanner: `SCANNER_` prefix (env-only, no Viper) @@ -194,7 +195,7 @@ See `config-appview.example.yaml` and `config-hold.example.yaml` for all options - **Hold DID lookups use database** (`manifests` table), not in-memory cache — persistent across restarts - **Context keys** (`auth.method`, `puller.did`) exist because `Repository()` receives `context.Context` from the distribution library interface — context values are the only way to pass data from HTTP middleware into the distribution middleware layer. Both are copied into `RegistryContext` inside `Repository()`. - **OAuth key types**: AppView uses P-256 (ES256) for OAuth, not K-256 like PDS keys -- **Confidential vs public clients**: Production uses P-256 key at `/var/lib/atcr/oauth/client.key` (auto-generated); localhost is always public client +- **Confidential vs public clients**: Production uses a P-256 OAuth key and an RSA JWT signing key, both stored in the appview SQLite DB `crypto_keys` table (keys `oauth_p256` and `jwt_rsa`, auto-generated on first boot — see `pkg/appview/crypto_keys.go`). Only the JWT cert (`auth.cert_path`) is written to disk, regenerated each boot for the distribution library. Localhost is always a public client. - **Hold stats are ATProto records in CAR store** — `io.atcr.hold.stats` records are stored via `repomgr.PutRecord()`, not in SQLite. Lost if CAR store is lost without backup. - **PLC auto-update on boot** — When using did:plc, `LoadOrCreateDID()` calls `EnsurePLCCurrent()` every startup. If local signing key or URL doesn't match plc.directory, it auto-updates (requires rotation key on disk). - **Hold CAR store is the source of truth** — Captain, crew, layer, stats, scan records, Bluesky posts, profiles are all ATProto records in the CAR store. SQLite holds only the records index and events. @@ -216,7 +217,7 @@ See `config-appview.example.yaml` and `config-hold.example.yaml` for all options **Changing name resolution:** 1. Modify `pkg/atproto/resolver.go` for DID/handle resolution 2. Update `pkg/appview/middleware/registry.go` if changing routing -3. `findHoldDID()` checks: sailor profile → `io.atcr.hold` records (legacy) → default hold DID +3. `findHoldDIDAndProfile()` checks: sailor profile `defaultHold` → AppView default hold (`server.managed_holds[0]`), then `resolveSuccessor()` applies a single-hop successor redirect **Working with OAuth client:** - Self-contained: pass `baseURL`, handles client ID/redirect URI/scopes diff --git a/Dockerfile.hold b/Dockerfile.hold index 6ed9913..d1046c9 100644 --- a/Dockerfile.hold +++ b/Dockerfile.hold @@ -1,9 +1,5 @@ FROM mirror.gcr.io/library/golang:1.26.2-trixie AS builder -# Build argument to enable Stripe billing integration -# Usage: docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold . -ARG BILLING_ENABLED=false - ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ @@ -21,22 +17,11 @@ COPY . . RUN npm ci RUN go generate ./... -# Conditionally add billing tag based on build arg -RUN if [ "$BILLING_ENABLED" = "true" ]; then \ - echo "Building with Stripe billing support"; \ - CGO_ENABLED=1 go build \ - -ldflags="-s -w -linkmode external -extldflags '-static'" \ - -tags "sqlite_omit_load_extension,billing" \ - -trimpath \ - -o atcr-hold ./cmd/hold; \ - else \ - echo "Building without billing support"; \ - CGO_ENABLED=1 go build \ - -ldflags="-s -w -linkmode external -extldflags '-static'" \ - -tags sqlite_omit_load_extension \ - -trimpath \ - -o atcr-hold ./cmd/hold; \ - fi +RUN CGO_ENABLED=1 go build \ + -ldflags="-s -w -linkmode external -extldflags '-static'" \ + -tags sqlite_omit_load_extension \ + -trimpath \ + -o atcr-hold ./cmd/hold RUN CGO_ENABLED=0 go build \ -ldflags="-s -w" \ diff --git a/docker-compose.yml b/docker-compose.yml index 5be9d6c..aef7073 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,7 +88,6 @@ services: dockerfile: Dockerfile.dev args: AIR_CONFIG: .air.hold.toml - BILLING_ENABLED: "true" image: atcr-hold-dev:latest container_name: atcr-hold ports: diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md deleted file mode 100644 index 2579c07..0000000 --- a/docs/ADMIN_PANEL.md +++ /dev/null @@ -1,1403 +0,0 @@ -# Hold Admin Panel Implementation Plan - -This document describes the implementation plan for adding an owner-only admin web UI to the ATCR hold service. The admin panel will be embedded directly in the hold service binary for simplified deployment. - -## Table of Contents - -1. [Overview](#overview) -2. [Requirements](#requirements) -3. [Architecture](#architecture) -4. [File Structure](#file-structure) -5. [Authentication](#authentication) -6. [Session Management](#session-management) -7. [Route Structure](#route-structure) -8. [Feature Implementations](#feature-implementations) -9. [Templates](#templates) -10. [Environment Variables](#environment-variables) -11. [Security Considerations](#security-considerations) -12. [Implementation Phases](#implementation-phases) -13. [Testing Strategy](#testing-strategy) - ---- - -## Overview - -The hold admin panel provides a web-based interface for hold owners to: - -- **Manage crew members**: Add, remove, edit permissions and quota tiers -- **Configure hold settings**: Toggle public access, open registration, Bluesky posting -- **View usage metrics**: Storage usage per user, top users, repository statistics -- **Monitor quota utilization**: Track tier distribution and usage percentages - -The admin panel is owner-only - only the DID that matches `captain.Owner` can access it. - ---- - -## Requirements - -### Functional Requirements - -1. **Crew Management** - - List all crew members with their DID, role, permissions, tier, and storage usage - - Add new crew members with specified permissions and tier - - Edit existing crew member permissions and tier - - Remove crew members (with confirmation) - - Display each crew member's quota utilization percentage - -2. **Quota/Tier Management** - - Display available tiers from `quotas.yaml` - - Show tier limits and descriptions - - Allow changing crew member tiers - - Display current vs limit usage for each user - -3. **Usage Metrics** - - Total storage used across all users - - Total unique blobs (deduplicated) - - Number of crew members - - Top 10/50/100 users by storage consumption - - Per-repository statistics (pulls, pushes) - -4. **Hold Settings** - - Toggle `public` (allow anonymous blob reads) - - Toggle `allowAllCrew` (allow any authenticated user to join) - - Toggle `enableBlueskyPosts` (post to Bluesky on image push) - -### Non-Functional Requirements - -- **Single binary**: Embedded in hold service, no separate deployment -- **Responsive UI**: Works on desktop and mobile browsers -- **Low latency**: Dashboard loads in <500ms for typical data volumes -- **Minimal dependencies**: Uses Go templates, HTMX for interactivity - ---- - -## Architecture - -### High-Level Design - -``` -┌─────────────────────────────────────────────────────────┐ -│ Hold Service │ -├─────────────────────────────────────────────────────────┤ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ -│ │ XRPC/PDS │ │ OCI XRPC │ │ Admin Panel │ │ -│ │ Handlers │ │ Handlers │ │ Handlers │ │ -│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │ -│ │ │ │ │ -│ ┌──────┴────────────────┴───────────────────┴────────┐ │ -│ │ Chi Router │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────────┴─────────────────────────┐ │ -│ │ Embedded PDS │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ -│ │ │ Captain │ │ Crew │ │ Layer │ │ │ -│ │ │ Records │ │ Records │ │ Records │ │ │ -│ │ └──────────┘ └──────────┘ └──────────┘ │ │ -│ └───────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### Components - -1. **AdminUI** - Main struct containing all admin dependencies -2. **Session Store** - SQLite-backed session management (separate from carstore) -3. **OAuth Client** - Reuses `pkg/auth/oauth/` for browser-based login -4. **Auth Middleware** - Validates owner-only access -5. **Handlers** - HTTP handlers for each admin page -6. **Templates** - Go html/template with embed.FS - ---- - -## File Structure - -``` -pkg/hold/admin/ -├── admin.go # Main struct, initialization, route registration -├── auth.go # requireOwner middleware, session validation -├── handlers.go # HTTP handlers for all admin pages -├── session.go # SQLite session store implementation -├── metrics.go # Metrics collection and aggregation -├── templates/ -│ ├── base.html # Base layout (html, head, body wrapper) -│ ├── components/ -│ │ ├── head.html # CSS/JS includes (HTMX, Lucide icons) -│ │ ├── nav.html # Admin navigation bar -│ │ └── flash.html # Flash message component -│ ├── pages/ -│ │ ├── login.html # OAuth login page -│ │ ├── dashboard.html # Metrics overview -│ │ ├── crew.html # Crew list with management actions -│ │ ├── crew_add.html # Add crew member form -│ │ ├── crew_edit.html # Edit crew member form -│ │ └── settings.html # Hold settings page -│ └── partials/ -│ ├── crew_row.html # Single crew row (for HTMX updates) -│ ├── usage_stats.html # Usage stats partial -│ └── top_users.html # Top users table partial -└── public/ - ├── css/ - │ └── admin.css # Admin-specific styles - └── js/ - └── admin.js # Admin-specific JavaScript (if needed) -``` - -### Files to Modify - -| File | Changes | -|------|---------| -| `cmd/hold/main.go` | Add admin UI initialization and route registration | -| `pkg/hold/config.go` | Add `Admin.Enabled` and `Admin.SessionDuration` fields | -| `.env.hold.example` | Document `HOLD_ADMIN_ENABLED`, `HOLD_ADMIN_SESSION_DURATION` | - ---- - -## Authentication - -### OAuth Flow for Admin Login - -The admin panel uses ATProto OAuth with DPoP for browser-based authentication: - -``` -┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ -│ Browser │ │ Hold │ │ PDS │ │ Owner │ -│ │ │ Admin │ │ │ │ │ -└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ - │ │ │ │ - │ GET /admin │ │ │ - │───────────────>│ │ │ - │ │ │ │ - │ 302 /admin/auth/login │ │ - │<───────────────│ │ │ - │ │ │ │ - │ GET /admin/auth/login │ │ - │───────────────>│ │ │ - │ │ │ │ - │ Login page (enter handle) │ │ - │<───────────────│ │ │ - │ │ │ │ - │ POST handle │ │ │ - │───────────────>│ │ │ - │ │ │ │ - │ │ StartAuthFlow │ │ - │ │───────────────>│ │ - │ │ │ │ - │ 302 to PDS auth URL │ │ - │<───────────────│ │ │ - │ │ │ │ - │ Authorize in browser │ │ - │────────────────────────────────>│ │ - │ │ │ Approve? │ - │ │ │───────────────>│ - │ │ │ │ - │ │ │ Yes │ - │ │ │<───────────────│ - │ │ │ │ - │ 302 callback with code │ │ - │<────────────────────────────────│ │ - │ │ │ │ - │ GET /admin/auth/oauth/callback │ │ - │───────────────>│ │ │ - │ │ │ │ - │ │ ProcessCallback│ │ - │ │───────────────>│ │ - │ │ │ │ - │ │ OAuth tokens │ │ - │ │<───────────────│ │ - │ │ │ │ - │ │ Check: DID == captain.Owner? │ - │ │─────────────────────────────────│ - │ │ │ │ - │ │ YES: Create session │ - │ │ │ │ - │ 302 /admin + session cookie │ │ - │<───────────────│ │ │ - │ │ │ │ - │ GET /admin (with cookie) │ │ - │───────────────>│ │ │ - │ │ │ │ - │ Dashboard │ │ │ - │<───────────────│ │ │ -``` - -### Owner Validation - -The callback handler performs owner validation: - -```go -func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { - // Process OAuth callback - sessionData, err := ui.clientApp.ProcessCallback(r.Context(), r.URL.Query()) - if err != nil { - ui.renderError(w, "OAuth failed: " + err.Error()) - return - } - - did := sessionData.AccountDID.String() - - // Get captain record to check owner - _, captain, err := ui.pds.GetCaptainRecord(r.Context()) - if err != nil { - ui.renderError(w, "Failed to verify ownership") - return - } - - // CRITICAL: Only allow the hold owner - if did != captain.Owner { - slog.Warn("Non-owner attempted admin access", "did", did, "owner", captain.Owner) - ui.renderError(w, "Access denied: Only the hold owner can access the admin panel") - return - } - - // Create admin session - sessionID, err := ui.sessionStore.Create(did, sessionData.Handle, 24*time.Hour) - if err != nil { - ui.renderError(w, "Failed to create session") - return - } - - // Set session cookie - http.SetCookie(w, &http.Cookie{ - Name: "hold_admin_session", - Value: sessionID, - Path: "/admin", - MaxAge: 86400, // 24 hours - HttpOnly: true, - Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", - SameSite: http.SameSiteLaxMode, - }) - - http.Redirect(w, r, "/admin", http.StatusFound) -} -``` - -### Auth Middleware - -```go -// requireOwner ensures the request is from the hold owner -func (ui *AdminUI) requireOwner(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Get session cookie - cookie, err := r.Cookie("hold_admin_session") - if err != nil { - http.Redirect(w, r, "/admin/auth/login?return_to="+r.URL.Path, http.StatusFound) - return - } - - // Validate session - session, err := ui.sessionStore.Get(cookie.Value) - if err != nil || session == nil || session.ExpiresAt.Before(time.Now()) { - // Clear invalid cookie - http.SetCookie(w, &http.Cookie{ - Name: "hold_admin_session", - Value: "", - Path: "/admin", - MaxAge: -1, - }) - http.Redirect(w, r, "/admin/auth/login", http.StatusFound) - return - } - - // Double-check DID still matches captain.Owner - // (in case ownership transferred while session active) - _, captain, err := ui.pds.GetCaptainRecord(r.Context()) - if err != nil || session.DID != captain.Owner { - ui.sessionStore.Delete(cookie.Value) - http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden) - return - } - - // Add session to context for handlers - ctx := context.WithValue(r.Context(), adminSessionKey, session) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} -``` - ---- - -## Session Management - -### Session Store Schema - -```sql --- Admin sessions (browser login state) -CREATE TABLE IF NOT EXISTS admin_sessions ( - id TEXT PRIMARY KEY, - did TEXT NOT NULL, - handle TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL, - last_accessed DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Index for cleanup queries -CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at); -CREATE INDEX IF NOT EXISTS idx_admin_sessions_did ON admin_sessions(did); - --- OAuth sessions (indigo library storage) -CREATE TABLE IF NOT EXISTS admin_oauth_sessions ( - session_id TEXT PRIMARY KEY, - did TEXT NOT NULL, - data BLOB NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); -``` - -### Session Store Interface - -```go -// AdminSession represents an authenticated admin session -type AdminSession struct { - ID string - DID string - Handle string - CreatedAt time.Time - ExpiresAt time.Time - LastAccessed time.Time -} - -// AdminSessionStore manages admin sessions -type AdminSessionStore struct { - db *sql.DB -} - -func NewAdminSessionStore(dbPath string) (*AdminSessionStore, error) - -func (s *AdminSessionStore) Create(did, handle string, duration time.Duration) (string, error) -func (s *AdminSessionStore) Get(sessionID string) (*AdminSession, error) -func (s *AdminSessionStore) Delete(sessionID string) error -func (s *AdminSessionStore) DeleteForDID(did string) error -func (s *AdminSessionStore) Cleanup() error // Remove expired sessions -func (s *AdminSessionStore) Touch(sessionID string) error // Update last_accessed -``` - -### Database Location - -The admin database should be in the same directory as the carstore database: - -```go -adminDBPath := filepath.Join(cfg.Database.Path, "admin.db") -``` - -This keeps all hold data together while maintaining separation between the carstore (ATProto records) and admin sessions. - ---- - -## Route Structure - -### Complete Route Table - -| Route | Method | Auth | Handler | Description | -|-------|--------|------|---------|-------------| -| `/admin` | GET | Owner | `DashboardHandler` | Main dashboard with metrics | -| `/admin/crew` | GET | Owner | `CrewListHandler` | List all crew members | -| `/admin/crew/add` | GET | Owner | `CrewAddFormHandler` | Add crew form | -| `/admin/crew/add` | POST | Owner | `CrewAddHandler` | Process add crew | -| `/admin/crew/{rkey}` | GET | Owner | `CrewEditFormHandler` | Edit crew form | -| `/admin/crew/{rkey}/update` | POST | Owner | `CrewUpdateHandler` | Process crew update | -| `/admin/crew/{rkey}/delete` | POST | Owner | `CrewDeleteHandler` | Delete crew member | -| `/admin/settings` | GET | Owner | `SettingsHandler` | Hold settings page | -| `/admin/settings/update` | POST | Owner | `SettingsUpdateHandler` | Update settings | -| `/admin/api/stats` | GET | Owner | `StatsAPIHandler` | JSON stats endpoint | -| `/admin/api/top-users` | GET | Owner | `TopUsersAPIHandler` | JSON top users | -| `/admin/auth/login` | GET | Public | `LoginHandler` | Login page | -| `/admin/auth/oauth/authorize` | GET | Public | OAuth authorize | Start OAuth flow | -| `/admin/auth/oauth/callback` | GET | Public | `CallbackHandler` | OAuth callback | -| `/admin/auth/logout` | GET | Owner | `LogoutHandler` | Logout and clear session | -| `/admin/public/*` | GET | Public | Static files | CSS, JS assets | - -### Route Registration - -```go -func (ui *AdminUI) RegisterRoutes(r chi.Router) { - // Public routes (login flow) - r.Get("/admin/auth/login", ui.handleLogin) - r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize) - r.Get("/admin/auth/oauth/callback", ui.handleCallback) - - // Static files (public) - r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", ui.staticHandler())) - - // Protected routes (require owner) - r.Group(func(r chi.Router) { - r.Use(ui.requireOwner) - - // Dashboard - r.Get("/admin", ui.handleDashboard) - - // Crew management - r.Get("/admin/crew", ui.handleCrewList) - r.Get("/admin/crew/add", ui.handleCrewAddForm) - r.Post("/admin/crew/add", ui.handleCrewAdd) - r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm) - r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate) - r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete) - - // Settings - r.Get("/admin/settings", ui.handleSettings) - r.Post("/admin/settings/update", ui.handleSettingsUpdate) - - // API endpoints (for HTMX) - r.Get("/admin/api/stats", ui.handleStatsAPI) - r.Get("/admin/api/top-users", ui.handleTopUsersAPI) - - // Logout - r.Get("/admin/auth/logout", ui.handleLogout) - }) -} -``` - ---- - -## Feature Implementations - -### Dashboard Handler - -```go -type DashboardStats struct { - TotalCrewMembers int - TotalBlobs int64 - TotalStorageBytes int64 - TotalStorageHuman string - TierDistribution map[string]int // tier -> count - RecentActivity []ActivityEntry -} - -func (ui *AdminUI) handleDashboard(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Collect basic stats - crew, _ := ui.pds.ListCrewMembers(ctx) - - stats := DashboardStats{ - TotalCrewMembers: len(crew), - TierDistribution: make(map[string]int), - } - - // Count tier distribution - for _, member := range crew { - tier := member.Tier - if tier == "" { - tier = ui.quotaMgr.GetDefaultTier() - } - stats.TierDistribution[tier]++ - } - - // Storage stats (loaded via HTMX to avoid slow initial load) - // The actual calculation happens in handleStatsAPI - - data := struct { - AdminPageData - Stats DashboardStats - }{ - AdminPageData: ui.newPageData(r), - Stats: stats, - } - - ui.templates.ExecuteTemplate(w, "dashboard", data) -} -``` - -### Crew List Handler - -```go -type CrewMemberView struct { - RKey string - DID string - Handle string // Resolved from DID - Role string - Permissions []string - Tier string - TierLimit string // Human-readable - CurrentUsage int64 - UsageHuman string - UsagePercent int - Plankowner bool - AddedAt time.Time -} - -func (ui *AdminUI) handleCrewList(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - crew, err := ui.pds.ListCrewMembers(ctx) - if err != nil { - ui.renderError(w, "Failed to list crew: "+err.Error()) - return - } - - // Enrich with usage data - var crewViews []CrewMemberView - for _, member := range crew { - view := CrewMemberView{ - RKey: member.RKey, - DID: member.Member, - Role: member.Role, - Permissions: member.Permissions, - Tier: member.Tier, - Plankowner: member.Plankowner, - AddedAt: member.AddedAt, - } - - // Get tier limit - if limit := ui.quotaMgr.GetTierLimit(member.Tier); limit != nil { - view.TierLimit = quota.FormatHumanBytes(*limit) - } else { - view.TierLimit = "Unlimited" - } - - // Get usage (expensive - consider caching) - usage, _, tier, limit, _ := ui.pds.GetQuotaForUserWithTier(ctx, member.Member, ui.quotaMgr) - view.CurrentUsage = usage - view.UsageHuman = quota.FormatHumanBytes(usage) - if limit != nil && *limit > 0 { - view.UsagePercent = int(float64(usage) / float64(*limit) * 100) - } - - crewViews = append(crewViews, view) - } - - // Sort by usage (highest first) - sort.Slice(crewViews, func(i, j int) bool { - return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage - }) - - data := struct { - AdminPageData - Crew []CrewMemberView - Tiers []TierOption - }{ - AdminPageData: ui.newPageData(r), - Crew: crewViews, - Tiers: ui.getTierOptions(), - } - - ui.templates.ExecuteTemplate(w, "crew", data) -} -``` - -### Add Crew Handler - -```go -func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if err := r.ParseForm(); err != nil { - ui.setFlash(w, "error", "Invalid form data") - http.Redirect(w, r, "/admin/crew/add", http.StatusFound) - return - } - - did := strings.TrimSpace(r.FormValue("did")) - role := r.FormValue("role") - tier := r.FormValue("tier") - - // Parse permissions checkboxes - var permissions []string - if r.FormValue("perm_read") == "on" { - permissions = append(permissions, "blob:read") - } - if r.FormValue("perm_write") == "on" { - permissions = append(permissions, "blob:write") - } - if r.FormValue("perm_admin") == "on" { - permissions = append(permissions, "crew:admin") - } - - // Validate DID format - if !strings.HasPrefix(did, "did:") { - ui.setFlash(w, "error", "Invalid DID format") - http.Redirect(w, r, "/admin/crew/add", http.StatusFound) - return - } - - // Add crew member - _, err := ui.pds.AddCrewMember(ctx, did, role, permissions) - if err != nil { - ui.setFlash(w, "error", "Failed to add crew member: "+err.Error()) - http.Redirect(w, r, "/admin/crew/add", http.StatusFound) - return - } - - // Set tier if specified - if tier != "" && tier != ui.quotaMgr.GetDefaultTier() { - if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil { - slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err) - } - } - - ui.setFlash(w, "success", "Crew member added successfully") - http.Redirect(w, r, "/admin/crew", http.StatusFound) -} -``` - -### Update Crew Handler - -```go -func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - rkey := chi.URLParam(r, "rkey") - - if err := r.ParseForm(); err != nil { - ui.setFlash(w, "error", "Invalid form data") - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) - return - } - - // Get current crew member - current, err := ui.pds.GetCrewMemberByRKey(ctx, rkey) - if err != nil { - ui.setFlash(w, "error", "Crew member not found") - http.Redirect(w, r, "/admin/crew", http.StatusFound) - return - } - - // Parse new values - role := r.FormValue("role") - tier := r.FormValue("tier") - - var permissions []string - if r.FormValue("perm_read") == "on" { - permissions = append(permissions, "blob:read") - } - if r.FormValue("perm_write") == "on" { - permissions = append(permissions, "blob:write") - } - if r.FormValue("perm_admin") == "on" { - permissions = append(permissions, "crew:admin") - } - - // Update tier if changed - if tier != current.Tier { - if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { - ui.setFlash(w, "error", "Failed to update tier: "+err.Error()) - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) - return - } - } - - // For role/permissions changes, need to delete and recreate - // (ATProto records are immutable, updates require delete+create) - if role != current.Role || !slicesEqual(permissions, current.Permissions) { - // Delete old record - if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { - ui.setFlash(w, "error", "Failed to update: "+err.Error()) - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) - return - } - - // Create new record with updated values - if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil { - ui.setFlash(w, "error", "Failed to recreate crew record: "+err.Error()) - http.Redirect(w, r, "/admin/crew", http.StatusFound) - return - } - - // Re-apply tier to new record - if tier != "" { - ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier) - } - } - - ui.setFlash(w, "success", "Crew member updated successfully") - http.Redirect(w, r, "/admin/crew", http.StatusFound) -} -``` - -### Delete Crew Handler - -```go -func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - rkey := chi.URLParam(r, "rkey") - - // Get crew member to log who was deleted - member, err := ui.pds.GetCrewMemberByRKey(ctx, rkey) - if err != nil { - ui.setFlash(w, "error", "Crew member not found") - http.Redirect(w, r, "/admin/crew", http.StatusFound) - return - } - - // Prevent deleting self (captain) - session := getAdminSession(ctx) - if member.Member == session.DID { - ui.setFlash(w, "error", "Cannot remove yourself from crew") - http.Redirect(w, r, "/admin/crew", http.StatusFound) - return - } - - // Delete - if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { - ui.setFlash(w, "error", "Failed to remove crew member: "+err.Error()) - http.Redirect(w, r, "/admin/crew", http.StatusFound) - return - } - - slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID) - - // For HTMX requests, return empty response (row will be removed) - if r.Header.Get("HX-Request") == "true" { - w.WriteHeader(http.StatusOK) - return - } - - ui.setFlash(w, "success", "Crew member removed") - http.Redirect(w, r, "/admin/crew", http.StatusFound) -} -``` - -### Settings Handler - -```go -func (ui *AdminUI) handleSettings(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - _, captain, err := ui.pds.GetCaptainRecord(ctx) - if err != nil { - ui.renderError(w, "Failed to load settings: "+err.Error()) - return - } - - data := struct { - AdminPageData - Settings struct { - Public bool - AllowAllCrew bool - EnableBlueskyPosts bool - OwnerDID string - HoldDID string - } - }{ - AdminPageData: ui.newPageData(r), - } - data.Settings.Public = captain.Public - data.Settings.AllowAllCrew = captain.AllowAllCrew - data.Settings.EnableBlueskyPosts = captain.EnableBlueskyPosts - data.Settings.OwnerDID = captain.Owner - data.Settings.HoldDID = ui.pds.DID() - - ui.templates.ExecuteTemplate(w, "settings", data) -} - -func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if err := r.ParseForm(); err != nil { - ui.setFlash(w, "error", "Invalid form data") - http.Redirect(w, r, "/admin/settings", http.StatusFound) - return - } - - public := r.FormValue("public") == "on" - allowAllCrew := r.FormValue("allow_all_crew") == "on" - enablePosts := r.FormValue("enable_bluesky_posts") == "on" - - _, captain, _ := ui.pds.GetCaptainRecord(ctx) - captain.Public = public - captain.AllowAllCrew = allowAllCrew - captain.EnableBlueskyPosts = enablePosts - _, err := ui.pds.UpdateCaptainRecord(ctx, captain) - if err != nil { - ui.setFlash(w, "error", "Failed to update settings: "+err.Error()) - http.Redirect(w, r, "/admin/settings", http.StatusFound) - return - } - - ui.setFlash(w, "success", "Settings updated successfully") - http.Redirect(w, r, "/admin/settings", http.StatusFound) -} -``` - -### Metrics Handler (for HTMX lazy loading) - -```go -func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Calculate total storage (expensive operation) - // Iterate through all layer records - records, _, err := ui.pds.RecordsIndex().ListRecords(atproto.LayerCollection, 100000, "", true) - if err != nil { - http.Error(w, "Failed to load stats", http.StatusInternalServerError) - return - } - - var totalSize int64 - uniqueDigests := make(map[string]bool) - userUsage := make(map[string]int64) - - for _, record := range records { - var layer atproto.LayerRecord - if err := json.Unmarshal(record.Value, &layer); err != nil { - continue - } - - if !uniqueDigests[layer.Digest] { - uniqueDigests[layer.Digest] = true - totalSize += layer.Size - } - - userUsage[layer.UserDID] += layer.Size - } - - stats := struct { - TotalBlobs int `json:"totalBlobs"` - TotalSize int64 `json:"totalSize"` - TotalHuman string `json:"totalHuman"` - }{ - TotalBlobs: len(uniqueDigests), - TotalSize: totalSize, - TotalHuman: quota.FormatHumanBytes(totalSize), - } - - // If HTMX request, return HTML partial - if r.Header.Get("HX-Request") == "true" { - data := struct { - Stats interface{} - }{Stats: stats} - ui.templates.ExecuteTemplate(w, "usage_stats", data) - return - } - - // Otherwise return JSON - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stats) -} -``` - ---- - -## Templates - -### Base Layout (templates/base.html) - -```html -{{ define "base" }} - - - - - - {{ .Title }} - Hold Admin - {{ template "head" . }} - - - {{ template "nav" . }} - -
- {{ template "flash" . }} - {{ template "content" . }} -
- -
-

Hold: {{ .HoldDID }}

-
- - -{{ end }} -``` - -### Head Component (templates/components/head.html) - -```html -{{ define "head" }} - - - -{{ end }} -``` - -### Navigation (templates/components/nav.html) - -```html -{{ define "nav" }} - -{{ end }} -``` - -### Dashboard Page (templates/pages/dashboard.html) - -```html -{{ define "dashboard" }} -{{ template "base" . }} -{{ define "content" }} -

Dashboard

- -
-
-

Crew Members

-

{{ .Stats.TotalCrewMembers }}

-
- -
-

Loading storage stats...

-
-
- -
-

Tier Distribution

-
- {{ range $tier, $count := .Stats.TierDistribution }} -
- {{ $tier }} - {{ $count }} -
- {{ end }} -
-
- -
-

Top Users by Storage

-
-

Loading top users...

-
-
-{{ end }} -{{ end }} -``` - -### Crew List Page (templates/pages/crew.html) - -```html -{{ define "crew" }} -{{ template "base" . }} -{{ define "content" }} - - - - - - - - - - - - - - - {{ range .Crew }} - {{ template "crew_row" . }} - {{ end }} - -
DIDRolePermissionsTierUsageActions
-{{ end }} -{{ end }} -``` - -### Crew Row Partial (templates/partials/crew_row.html) - -```html -{{ define "crew_row" }} - - - {{ .DID | truncate 20 }} - {{ if .Plankowner }}Plankowner{{ end }} - - {{ .Role }} - - {{ range .Permissions }} - {{ . }} - {{ end }} - - - {{ .Tier }} - ({{ .TierLimit }}) - - -
- {{ .UsageHuman }} -
-
-
- {{ .UsagePercent }}% -
- - - Edit - - - -{{ end }} -``` - -### Settings Page (templates/pages/settings.html) - -```html -{{ define "settings" }} -{{ template "base" . }} -{{ define "content" }} -

Hold Settings

- -
-
-

Access Control

- - - - -
- -
-

Integrations

- - -
- -
-

Hold Information

-
-
Hold DID
-
{{ .Settings.HoldDID }}
-
Owner DID
-
{{ .Settings.OwnerDID }}
-
-
- - -
-{{ end }} -{{ end }} -``` - ---- - -## Environment Variables - -Add to `.env.hold.example`: - -```bash -# ============================================================================= -# ADMIN PANEL CONFIGURATION -# ============================================================================= - -# Enable the admin web UI (default: false) -# When enabled, accessible at /admin -HOLD_ADMIN_ENABLED=false - -# Admin session duration (default: 24h) -# How long admin sessions remain valid before requiring re-authentication -# Format: Go duration string (e.g., 24h, 168h for 1 week) -HOLD_ADMIN_SESSION_DURATION=24h -``` - -### Config Struct Updates - -```go -// In pkg/hold/config.go - -type Config struct { - // ... existing fields ... - - Admin AdminConfig -} - -type AdminConfig struct { - Enabled bool `env:"HOLD_ADMIN_ENABLED" envDefault:"false"` - SessionDuration time.Duration `env:"HOLD_ADMIN_SESSION_DURATION" envDefault:"24h"` -} -``` - ---- - -## Security Considerations - -### 1. Owner-Only Access - -All admin routes validate that the authenticated user's DID matches `captain.Owner`. This check happens: -- In the OAuth callback (primary gate) -- In the `requireOwner` middleware (defense in depth) -- Before destructive operations (extra validation) - -### 2. Cookie Security - -```go -http.SetCookie(w, &http.Cookie{ - Name: "hold_admin_session", - Value: sessionID, - Path: "/admin", // Scoped to admin paths only - MaxAge: 86400, // 24 hours - HttpOnly: true, // No JavaScript access - Secure: isHTTPS(r), // HTTPS only in production - SameSite: http.SameSiteLaxMode, // CSRF protection -}) -``` - -### 3. CSRF Protection - -For state-changing operations: -- Forms include hidden CSRF token -- HTMX requests include token in header -- Server validates token before processing - -```html -
- - ... -
-``` - -### 4. Input Validation - -- DID format validation before database operations -- Tier names validated against `quotas.yaml` -- Permission values validated against known set -- All user input sanitized before display - -### 5. Rate Limiting - -Consider adding rate limiting for: -- Login attempts (prevent brute force) -- OAuth flow starts (prevent abuse) -- API endpoints (prevent DoS) - -### 6. Audit Logging - -Log all administrative actions: -```go -slog.Info("Admin action", - "action", "crew_add", - "admin_did", session.DID, - "target_did", newMemberDID, - "permissions", permissions) -``` - ---- - -## Implementation Phases - -### Phase 1: Foundation (Est. 4-6 hours) - -1. Create `pkg/hold/admin/` package structure -2. Implement `AdminSessionStore` with SQLite -3. Implement OAuth client setup (reuse `pkg/auth/oauth/`) -4. Implement `requireOwner` middleware -5. Create basic template loading with embed.FS -6. Add env var configuration to `pkg/hold/config.go` - -**Deliverables:** -- Admin package compiles -- Can start OAuth flow -- Session store creates/validates sessions - -### Phase 2: Authentication (Est. 3-4 hours) - -1. Implement login page handler -2. Implement OAuth authorize redirect -3. Implement callback with owner validation -4. Implement logout handler -5. Wire up routes in `cmd/hold/main.go` - -**Deliverables:** -- Can login as hold owner -- Non-owners rejected at callback -- Sessions persist across requests - -### Phase 3: Dashboard (Est. 3-4 hours) - -1. Create base template and navigation -2. Implement dashboard handler with basic stats -3. Implement stats API for HTMX lazy loading -4. Implement top users API -5. Create dashboard template - -**Deliverables:** -- Dashboard shows crew count, tier distribution -- Storage stats load asynchronously -- Top users table displays - -### Phase 4: Crew Management (Est. 4-6 hours) - -1. Implement crew list handler -2. Create crew list template with HTMX delete -3. Implement add crew form and handler -4. Implement edit crew form and handler -5. Implement delete crew handler - -**Deliverables:** -- Full CRUD for crew members -- Tier and permission editing works -- HTMX updates without page reload - -### Phase 5: Settings (Est. 2-3 hours) - -1. Implement settings handler -2. Create settings template -3. Implement settings update handler - -**Deliverables:** -- Can toggle public/allowAllCrew/enableBlueskyPosts -- Settings persist correctly - -### Phase 6: Polish (Est. 2-4 hours) - -1. Add CSS styling -2. Add flash messages -3. Add CSRF protection -4. Add input validation -5. Add audit logging -6. Update documentation - -**Deliverables:** -- Professional-looking UI -- Security hardening complete -- Documentation updated - -**Total Estimated Time: 18-27 hours** - ---- - -## Testing Strategy - -### Unit Tests - -```go -// pkg/hold/admin/session_test.go -func TestSessionStore_Create(t *testing.T) { - store := newTestSessionStore(t) - - sessionID, err := store.Create("did:plc:test", "test.handle", 24*time.Hour) - require.NoError(t, err) - require.NotEmpty(t, sessionID) - - session, err := store.Get(sessionID) - require.NoError(t, err) - assert.Equal(t, "did:plc:test", session.DID) -} - -// pkg/hold/admin/auth_test.go -func TestRequireOwner_RejectsNonOwner(t *testing.T) { - pds := setupTestPDSWithOwner(t, "did:plc:owner") - store := newTestSessionStore(t) - - // Create session for non-owner - sessionID, _ := store.Create("did:plc:notowner", "notowner", 24*time.Hour) - - middleware := requireOwner(pds, store) - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - req := httptest.NewRequest("GET", "/admin", nil) - req.AddCookie(&http.Cookie{Name: "hold_admin_session", Value: sessionID}) - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) - - assert.Equal(t, http.StatusForbidden, w.Code) -} -``` - -### Integration Tests - -```go -// pkg/hold/admin/integration_test.go -func TestAdminLoginFlow(t *testing.T) { - // Start test hold server - server := startTestHoldWithAdmin(t) - defer server.Close() - - // Verify login page accessible - resp, _ := http.Get(server.URL + "/admin/auth/login") - assert.Equal(t, http.StatusOK, resp.StatusCode) - - // Verify dashboard redirects to login - client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }} - resp, _ = client.Get(server.URL + "/admin") - assert.Equal(t, http.StatusFound, resp.StatusCode) - assert.Contains(t, resp.Header.Get("Location"), "/admin/auth/login") -} -``` - -### Manual Testing Checklist - -- [ ] Login as owner succeeds -- [ ] Login as non-owner fails with clear error -- [ ] Dashboard loads with correct stats -- [ ] Add crew member with all permission combinations -- [ ] Edit crew member permissions -- [ ] Change crew member tier -- [ ] Delete crew member -- [ ] Toggle public setting -- [ ] Toggle allowAllCrew setting -- [ ] Toggle enableBlueskyPosts setting -- [ ] Logout clears session -- [ ] Session expires after configured duration -- [ ] Expired session redirects to login - ---- - -## Future Enhancements - -### Potential Future Features - -1. **Crew Invite Links** - Generate one-time invite URLs for adding crew -2. **Usage Alerts** - Email/webhook when users approach quota -3. **Bulk Operations** - Add/remove multiple crew members at once -4. **Export Data** - Download crew list, usage reports as CSV -5. **Activity Log** - View recent admin actions -6. **API Keys** - Generate programmatic access keys for admin API -7. **Backup/Restore** - Backup crew records, restore from backup -8. **Multi-Hold Management** - Manage multiple holds from one UI (separate feature) - -### Performance Optimizations - -1. **Cache usage stats** - Don't recalculate on every request -2. **Paginate crew list** - Handle holds with 1000+ crew members -3. **Background stat refresh** - Update stats periodically in background -4. **Batch DID resolution** - Resolve multiple DIDs in parallel - ---- - -## References - -- [ATProto OAuth Specification](https://atproto.com/specs/oauth) -- [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) -- [HTMX Documentation](https://htmx.org/docs/) -- [Chi Router](https://github.com/go-chi/chi) -- [Go html/template](https://pkg.go.dev/html/template) diff --git a/docs/APPVIEW-UI-FUTURE.md b/docs/APPVIEW-UI-FUTURE.md index 3a83943..f69701d 100644 --- a/docs/APPVIEW-UI-FUTURE.md +++ b/docs/APPVIEW-UI-FUTURE.md @@ -16,15 +16,19 @@ These features were implemented but weren't in the original future features list | Feature | Location | Notes | |---------|----------|-------| -| **Billing (Stripe)** | `pkg/hold/billing/` | Checkout sessions, customer portal, subscription webhooks, tier upgrades. Build with `-tags billing`. | +| **Billing (Stripe)** | `pkg/billing/` | Checkout sessions, customer portal, subscription webhooks, tier upgrades. Build with `-tags billing`. | | **Garbage collection** | `pkg/hold/gc/` | Mark-and-sweep for orphaned blobs. Preview (dry-run) and execute modes. Triggered from hold admin UI. | | **libSQL embedded replicas** | AppView + Hold | Sync to Turso, Bunny DB, or self-hosted libsql-server. Configurable sync interval. | | **Hold successor/migration** | `pkg/hold/` | Promote a hold as successor to migrate users to new storage. | | **Relay management** | Hold admin | Manage firehose relay connections from admin panel. | | **Data export** | `pkg/appview/handlers/export.go` | GDPR-compliant export of all user data from AppView + all holds where user is member/captain. | | **Dark/light mode** | AppView UI | System preference detection, toggle, localStorage persistence. | -| **Credential helper install page** | `/install` | Install scripts for macOS/Linux/Windows, version API. | -| **Stars** | AppView UI | Star/unstar repos stored as `io.atcr.star` ATProto records, counts displayed. | +| **Credential helper install page** | `/install` | Install scripts for macOS/Linux/Windows, version API, Homebrew formula (`Formula/`), self-updating from tangled releases. | +| **Stars** | AppView UI | Star/unstar repos stored as `io.atcr.sailor.star` ATProto records, counts displayed, starred-repos page at `/u/{handle}/starred`. | +| **Label service** | `pkg/labeler/`, `cmd/labeler/` | Standalone labeler for takedowns by DID/handle/repo/AT URI with audit trail. Holds listen for takedown labels; GC defers deletion for a grace period in case of reversal. | +| **Helm chart UI** | AppView UI | Chart-aware digest page: Chart.yaml metadata, dependencies, helm install/pull command switcher (`handlers/digest_content.go`, `holdclient/helm_config.go`). | +| **AI Image Advisor** | `pkg/appview/handlers/image_advisor.go` | Claude-powered image analysis (config + SBOM + vulns) for paid users. Gated on billing + `ClaudeAPIKey`. Suggestions cached in `advisor_suggestions` table. CLI companion at `cmd/image-advisor`. | +| **Go vanity import paths** | `pkg/appview/middleware/goimport.go` | `go install atcr.io/...` meta tags, browser visits redirect to source repo. Seamark-branded credential helper variant (`cmd/credential-helper/seamark`) and theme (`themes/seamark/`). | --- @@ -43,20 +47,17 @@ These features were implemented but weren't in the original future features list - Automatic platform detection from manifest metadata - Validate that all manifests are for the same image (different platforms) -### Layer Inspection & Visualization — NOT STARTED +### Layer Inspection & Visualization — PARTIAL -DB stores layer metadata (digest, size, media type, layer index) but there's no UI for any of this. +**Layer details — DONE:** +- Digest page shows per-layer Dockerfile commands (from OCI config history), sizes, media types, empty-layer toggle (`handlers/digest.go`, `partials/layers-section.html`) +- Layer diff between two tags/digests: shared/rebuilt/added/removed via LCS on layer commands, with size delta summary (`handlers/diff.go`, `/diff/{handle}/{repo}?from=&to=`) +- Multi-arch aware: diff resolves platform children, intersects common platforms -**Layer details page:** -- Show Dockerfile command that created each layer (if available in history) -- Display layer size and compression ratio -- Show file changes in each layer (added/modified/deleted files) -- Visualize layer hierarchy (parent-child relationships) - -**Layer deduplication stats:** -- Show which layers are shared across images -- Calculate storage savings from layer sharing -- Identify duplicate layers with different digests (potential optimization) +**NOT STARTED:** +- Compression ratio display +- File changes within each layer (added/modified/deleted files) +- Layer deduplication stats (shared layers across images, storage savings) ### Image Operations — PARTIAL (delete only) @@ -80,7 +81,7 @@ DB stores layer metadata (digest, size, media type, layer index) but there's no - Rollback functionality - Audit log of image operations -### Vulnerability Scanning — DONE (backend) / NOT STARTED (UI) +### Vulnerability Scanning — DONE (backend + UI) **Backend — DONE:** - Separate scanner service (`scanner/` module) with Syft (SBOM) + Grype (vulnerabilities) @@ -90,23 +91,26 @@ DB stores layer metadata (digest, size, media type, layer index) but there's no - Automatic scanning dispatched by hold on manifest push - See `docs/SBOM_SCANNING.md` -**AppView UI — NOT STARTED:** -- Display CVE count by severity (critical, high, medium, low) -- Show detailed CVE information (description, CVSS score, affected packages) -- Filter images by vulnerability status -- Subscribe to CVE notifications for your images -- Compare vulnerability status across tags/versions +**AppView UI — DONE:** +- CVE count by severity badge (critical, high, medium, low) — `handlers/scan_result.go`, `partials/vuln-badge.html` +- Detailed CVE view: description, severity, affected packages, fix versions, NVD/GitHub advisory links — `handlers/vuln_details.go`, `partials/vuln-details.html` +- Vulnerability diff across tags/versions: fixed vs new vs unchanged, summarized by severity — `handlers/diff.go` +- Scan-completion webhooks (`scan:first`, `scan:all`, `scan:changed`) — see Webhooks section -### Image Signing & Verification — NOT STARTED +**NOT STARTED:** +- Filter images by vulnerability status (in search/browse) +- Subscribe to CVE notifications for your images (beyond scan webhooks) -Concept doc exists at `docs/SIGNATURE_INTEGRATION.md` but no implementation. +### Image Signing & Verification — NOT STARTED (concept + examples only) + +Consolidated research/POC doc at `docs/research/IMAGE_SIGNING.md` plus example verify scripts and trust policy template in `examples/verification/` (reference an unbuilt `atcr-verify` CLI). No cosign/sigstore integration or active signing implementation. - Sign images - Display signature verification status - Display signature metadata - Require signatures for protected repositories -### SBOM (Software Bill of Materials) — DONE (backend) / NOT STARTED (UI) +### SBOM (Software Bill of Materials) — DONE (backend) / PARTIAL (UI) **Backend — DONE:** - Syft generates SPDX JSON format SBOMs @@ -114,11 +118,13 @@ Concept doc exists at `docs/SIGNATURE_INTEGRATION.md` but no implementation. - Blobs in S3, metadata in hold's PDS - Accessible via ORAS CLI and hold XRPC endpoints -**UI — NOT STARTED:** -- Display package list from SBOM -- Show license information +**UI — DONE:** +- Package list with names, versions, licenses, package types — `handlers/sbom_details.go`, `partials/sbom-details.html` +- Export: copy as CSV, download raw SPDX JSON +- Compare SBOMs across versions: Packages tab on the diff page with changed/added/removed/unchanged sections — `computeSbomDiff` in `handlers/diff.go` + +**NOT STARTED:** - Link to upstream package sources -- Compare SBOMs across versions --- @@ -223,8 +229,9 @@ Hold management is implemented as a separate admin panel on the hold service its ### Social Features — PARTIAL (stars only) **Stars — DONE:** -- Star/unstar repositories stored as `io.atcr.star` ATProto records +- Star/unstar repositories stored as `io.atcr.sailor.star` ATProto records - Star counts displayed on repository pages +- Starred repositories page at `/u/{handle}/starred` **NOT STARTED:** - Follow other sailors @@ -270,16 +277,28 @@ Hold management is implemented as a separate admin panel on the hold service its - Overview of your images, holds, activity - Quick stats, recent activity, alerts -### Pull Analytics — NOT STARTED +### Pull Analytics — PARTIAL -- Pull count per image/tag -- Pull count by client, geography, over time +**DONE:** +- Pull/push counts per repository stored in AppView DB (`repository_stats`), with daily time-series snapshots (`repository_stats_daily`) +- Pull counts displayed on repo cards, repository pages, and OpenGraph metadata + +**NOT STARTED:** +- Growth/time-series charts (daily data is collected but not visualized) +- Per-tag breakdown +- Pull count by client, geography - User analytics (authenticated vs anonymous) -### Alerts & Notifications — NOT STARTED +### Alerts & Notifications — PARTIAL -- Alert types (quota exceeded, vulnerability detected, hold down, etc.) -- Notification channels (email, webhook, ATProto, Slack/Discord) +**DONE:** +- Storage quota alerts in settings UI (warning states at 80%, 95%, 100% usage) — `partials/storage_stats.html` +- Quota threshold webhooks and scan-completion webhooks, with Discord/Slack formatting — see Webhooks section + +**NOT STARTED:** +- Email notifications +- ATProto/DM notification channel +- Hold-down alerts --- @@ -296,15 +315,24 @@ Hold management is implemented as a separate admin panel on the hold service its - Interactive API explorer - Code examples, SDKs -### Webhooks — NOT STARTED +### Webhooks — DONE -- Repository-level webhook registration -- Events: manifest.pushed, tag.created, scan.completed, etc. -- Test, retry, delivery history +- Webhook registration UI in settings — `handlers/webhooks.go`, `partials/webhooks_list.html` +- Triggers: `push`, `scan:first`, `scan:all`, `scan:changed`, `quota` (configurable threshold percent) — `pkg/appview/webhooks/` +- HMAC signing, retry with backoff, test delivery, Discord/Slack auto-detection and formatting +- Tier limits: free 1 webhook (`push` + `scan:first`), paid per plan, captain unlimited +- See `docs/WEBHOOKS.md` -### CI/CD Integration — NOT STARTED +**NOT STARTED:** +- Pull-event webhooks (scalability concern, needs batching/throttling — see WEBHOOKS.md) +- Delivery history UI -- GitHub Actions, GitLab CI, CircleCI example workflows +### CI/CD Integration — PARTIAL + +**DONE:** +- Example GitHub Actions and GitLab CI workflows in `examples/plugins/ci-cd/` (signature verification + deploy; reference the unbuilt `atcr-verify` CLI) + +**NOT STARTED:** - Pre-built actions/plugins - Build status badges @@ -328,6 +356,7 @@ Hold management is implemented as a separate admin panel on the hold service its - Install page with credential helper setup - Learn more page - Internal developer docs (`docs/`) +- Signup flow: PDS provider picker with curated list, branded OAuth handoff interstitial — `handlers/signup.go` **NOT STARTED:** - Interactive onboarding wizard @@ -399,7 +428,7 @@ Hold management is implemented as a separate admin panel on the hold service its ### Billing — DONE -- Stripe integration (`pkg/hold/billing/`, requires `-tags billing` build tag) +- Stripe integration (`pkg/billing/`, requires `-tags billing` build tag) - Checkout sessions, customer portal, subscription webhooks - Tier upgrades/downgrades @@ -433,20 +462,21 @@ These remain future ideas with no implementation: 2. ~~Vulnerability scanning integration~~ — backend complete 3. ~~Hold management dashboard~~ — implemented on hold admin panel 4. ~~Basic search~~ — working +5. ~~Scan results UI in AppView~~ — badges, CVE details, diff across versions +6. ~~SBOM display UI in AppView~~ — package list, licenses, CSV/SPDX export +7. ~~Webhooks~~ — push/scan/quota triggers, Discord/Slack, tier limits +8. ~~Layer inspection UI~~ (was medium) — layer details + diff on digest page **Remaining high priority:** -1. Scan results UI in AppView (backend exists, just needs frontend) -2. SBOM display UI in AppView (backend exists, just needs frontend) -3. Webhooks for CI/CD integration -4. Enhanced search (filters, sorting, advanced queries) -5. Richer sailor profiles (bio, stats, pinned repos) +1. Enhanced search (filters, sorting, advanced queries, filter by vuln status) +2. Richer sailor profiles (bio, stats, pinned repos) **Medium priority:** -1. Layer inspection UI -2. Pull analytics and monitoring -3. API documentation (Swagger/OpenAPI) -4. Tag management (promotion, protection, aliases) -5. Onboarding wizard / getting started guide +1. Pull analytics charts (daily time-series data already collected, needs UI) +2. API documentation (Swagger/OpenAPI) +3. Tag management (promotion, protection, aliases) +4. Onboarding wizard / getting started guide (signup flow exists) +5. Crew invitations (invite by handle, invitation links, self-request flow) **Low priority / long-term:** 1. Team/organization accounts @@ -463,4 +493,4 @@ These remain future ideas with no implementation: **Note:** This is a living document. Features may be added, removed, or reprioritized based on user feedback, technical feasibility, and ATProto ecosystem evolution. -*Last audited: 2026-02-12* +*Last audited: 2026-06-11* diff --git a/docs/ATCR_VERIFY_CLI.md b/docs/ATCR_VERIFY_CLI.md deleted file mode 100644 index 595ace7..0000000 --- a/docs/ATCR_VERIFY_CLI.md +++ /dev/null @@ -1,728 +0,0 @@ -# atcr-verify CLI Tool - -## Overview - -`atcr-verify` is a command-line tool for verifying ATProto signatures on container images stored in ATCR. It provides cryptographic verification of image manifests using ATProto's DID-based trust model. - -## Features - -- ✅ Verify ATProto signatures via OCI Referrers API -- ✅ DID resolution and public key extraction -- ✅ PDS query and commit signature verification -- ✅ Trust policy enforcement -- ✅ Offline verification mode (with cached data) -- ✅ Multiple output formats (human-readable, JSON, quiet) -- ✅ Exit codes for CI/CD integration -- ✅ Kubernetes admission controller integration - -## Installation - -### Binary Release - -```bash -# Linux (x86_64) -curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-linux-amd64 -o atcr-verify -chmod +x atcr-verify -sudo mv atcr-verify /usr/local/bin/ - -# macOS (Apple Silicon) -curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-darwin-arm64 -o atcr-verify -chmod +x atcr-verify -sudo mv atcr-verify /usr/local/bin/ - -# Windows -curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-windows-amd64.exe -o atcr-verify.exe -``` - -### From Source - -```bash -git clone https://github.com/atcr-io/atcr.git -cd atcr -go install ./cmd/atcr-verify -``` - -### Container Image - -```bash -docker pull atcr.io/atcr/verify:latest - -# Run -docker run --rm atcr.io/atcr/verify:latest verify IMAGE -``` - -## Usage - -### Basic Verification - -```bash -# Verify an image -atcr-verify atcr.io/alice/myapp:latest - -# Output: -# ✓ Image verified successfully -# Signed by: alice.bsky.social (did:plc:alice123) -# Signed at: 2025-10-31T12:34:56.789Z -``` - -### With Trust Policy - -```bash -# Verify against trust policy -atcr-verify atcr.io/alice/myapp:latest --policy trust-policy.yaml - -# Output: -# ✓ Image verified successfully -# ✓ Trust policy satisfied -# Policy: production-images -# Trusted DID: did:plc:alice123 -``` - -### JSON Output - -```bash -atcr-verify atcr.io/alice/myapp:latest --output json - -# Output: -{ - "verified": true, - "image": "atcr.io/alice/myapp:latest", - "digest": "sha256:abc123...", - "signature": { - "did": "did:plc:alice123", - "handle": "alice.bsky.social", - "pds": "https://bsky.social", - "recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123", - "commitCid": "bafyreih8...", - "signedAt": "2025-10-31T12:34:56.789Z", - "algorithm": "ECDSA-K256-SHA256" - }, - "trustPolicy": { - "satisfied": true, - "policy": "production-images", - "trustedDID": true - } -} -``` - -### Quiet Mode - -```bash -# Exit code only (for scripts) -atcr-verify atcr.io/alice/myapp:latest --quiet -echo $? # 0 = verified, 1 = failed -``` - -### Offline Mode - -```bash -# Export verification bundle -atcr-verify export atcr.io/alice/myapp:latest -o bundle.json - -# Verify offline (in air-gapped environment) -atcr-verify atcr.io/alice/myapp:latest --offline --bundle bundle.json -``` - -## Command Reference - -### verify - -Verify ATProto signature for an image. - -```bash -atcr-verify verify IMAGE [flags] -atcr-verify IMAGE [flags] # 'verify' subcommand is optional -``` - -**Arguments:** -- `IMAGE` - Image reference (registry/owner/repo:tag or @digest) - -**Flags:** -- `--policy FILE` - Trust policy file (default: none) -- `--output FORMAT` - Output format: text, json, quiet (default: text) -- `--offline` - Offline mode (requires --bundle) -- `--bundle FILE` - Verification bundle for offline mode -- `--cache-dir DIR` - Cache directory for DID documents (default: ~/.atcr/cache) -- `--no-cache` - Disable caching -- `--timeout DURATION` - Verification timeout (default: 30s) -- `--verbose` - Verbose output - -**Exit Codes:** -- `0` - Verification succeeded -- `1` - Verification failed -- `2` - Invalid arguments -- `3` - Network error -- `4` - Trust policy violation - -**Examples:** - -```bash -# Basic verification -atcr-verify atcr.io/alice/myapp:latest - -# With specific digest -atcr-verify atcr.io/alice/myapp@sha256:abc123... - -# With trust policy -atcr-verify atcr.io/alice/myapp:latest --policy production-policy.yaml - -# JSON output for scripting -atcr-verify atcr.io/alice/myapp:latest --output json | jq .verified - -# Quiet mode for CI/CD -if atcr-verify atcr.io/alice/myapp:latest --quiet; then - echo "Deploy approved" -fi -``` - -### export - -Export verification bundle for offline verification. - -```bash -atcr-verify export IMAGE [flags] -``` - -**Arguments:** -- `IMAGE` - Image reference to export bundle for - -**Flags:** -- `-o, --output FILE` - Output file (default: stdout) -- `--include-did-docs` - Include DID documents in bundle -- `--include-commit` - Include ATProto commit data - -**Examples:** - -```bash -# Export to file -atcr-verify export atcr.io/alice/myapp:latest -o myapp-bundle.json - -# Export with all verification data -atcr-verify export atcr.io/alice/myapp:latest \ - --include-did-docs \ - --include-commit \ - -o complete-bundle.json - -# Export for multiple images -for img in $(cat images.txt); do - atcr-verify export $img -o bundles/$(echo $img | tr '/:' '_').json -done -``` - -### trust - -Manage trust policies and trusted DIDs. - -```bash -atcr-verify trust COMMAND [flags] -``` - -**Subcommands:** - -**`trust list`** - List trusted DIDs -```bash -atcr-verify trust list - -# Output: -# Trusted DIDs: -# - did:plc:alice123 (alice.bsky.social) -# - did:plc:bob456 (bob.example.com) -``` - -**`trust add DID`** - Add trusted DID -```bash -atcr-verify trust add did:plc:alice123 -atcr-verify trust add did:plc:alice123 --name "Alice (DevOps)" -``` - -**`trust remove DID`** - Remove trusted DID -```bash -atcr-verify trust remove did:plc:alice123 -``` - -**`trust policy validate`** - Validate trust policy file -```bash -atcr-verify trust policy validate policy.yaml -``` - -### version - -Show version information. - -```bash -atcr-verify version - -# Output: -# atcr-verify version 1.0.0 -# Go version: go1.21.5 -# Commit: 3b5b89b -# Built: 2025-10-31T12:00:00Z -``` - -## Trust Policy - -Trust policies define which signatures to trust and what to do when verification fails. - -### Policy File Format - -```yaml -version: 1.0 - -# Global settings -defaultAction: enforce # enforce, audit, allow -requireSignature: true - -# Policies matched by image pattern (first match wins) -policies: - - name: production-images - description: "Production images must be signed by DevOps or Security" - scope: "atcr.io/*/prod-*" - require: - signature: true - trustedDIDs: - - did:plc:devops-team - - did:plc:security-team - minSignatures: 1 - maxAge: 2592000 # 30 days in seconds - action: enforce - - - name: staging-images - scope: "atcr.io/*/staging-*" - require: - signature: true - trustedDIDs: - - did:plc:devops-team - - did:plc:developers - minSignatures: 1 - action: enforce - - - name: dev-images - scope: "atcr.io/*/dev-*" - require: - signature: false - action: audit # Log but don't fail - -# Trusted DID registry -trustedDIDs: - did:plc:devops-team: - name: "DevOps Team" - validFrom: "2024-01-01T00:00:00Z" - expiresAt: null - contact: "devops@example.com" - - did:plc:security-team: - name: "Security Team" - validFrom: "2024-01-01T00:00:00Z" - expiresAt: null - - did:plc:developers: - name: "Developer Team" - validFrom: "2024-06-01T00:00:00Z" - expiresAt: "2025-12-31T23:59:59Z" -``` - -### Policy Matching - -Policies are evaluated in order. First match wins. - -**Scope patterns:** -- `atcr.io/*/*` - All ATCR images -- `atcr.io/myorg/*` - All images from myorg -- `atcr.io/*/prod-*` - All images with "prod-" prefix -- `atcr.io/myorg/myapp` - Specific repository -- `atcr.io/myorg/myapp:v*` - Tag pattern matching - -### Policy Actions - -**`enforce`** - Reject if policy fails -- Exit code 4 -- Blocks deployment - -**`audit`** - Log but allow -- Exit code 0 (success) -- Warning message printed - -**`allow`** - Always allow -- No verification performed -- Exit code 0 - -### Policy Requirements - -**`signature: true`** - Require signature present - -**`trustedDIDs`** - List of trusted DIDs -```yaml -trustedDIDs: - - did:plc:alice123 - - did:web:example.com -``` - -**`minSignatures`** - Minimum number of signatures required -```yaml -minSignatures: 2 # Require 2 signatures -``` - -**`maxAge`** - Maximum signature age in seconds -```yaml -maxAge: 2592000 # 30 days -``` - -**`algorithms`** - Allowed signature algorithms -```yaml -algorithms: - - ECDSA-K256-SHA256 -``` - -## Verification Flow - -### 1. Image Resolution - -``` -Input: atcr.io/alice/myapp:latest - ↓ -Resolve tag to digest - ↓ -Output: sha256:abc123... -``` - -### 2. Signature Discovery - -``` -Query OCI Referrers API: - GET /v2/alice/myapp/referrers/sha256:abc123 - ?artifactType=application/vnd.atproto.signature.v1+json - ↓ -Returns: List of signature artifacts - ↓ -Download signature metadata blobs -``` - -### 3. DID Resolution - -``` -Extract DID from signature: did:plc:alice123 - ↓ -Query PLC directory: - GET https://plc.directory/did:plc:alice123 - ↓ -Extract public key from DID document -``` - -### 4. PDS Query - -``` -Get PDS endpoint from DID document - ↓ -Query for manifest record: - GET {pds}/xrpc/com.atproto.repo.getRecord - ?repo=did:plc:alice123 - &collection=io.atcr.manifest - &rkey=abc123 - ↓ -Get commit CID from record - ↓ -Fetch commit data (includes signature) -``` - -### 5. Signature Verification - -``` -Extract signature bytes from commit - ↓ -Compute commit hash (SHA-256) - ↓ -Verify: ECDSA_K256(hash, signature, publicKey) - ↓ -Result: Valid or Invalid -``` - -### 6. Trust Policy Evaluation - -``` -Check if DID is in trustedDIDs list - ↓ -Check signature age < maxAge - ↓ -Check minSignatures satisfied - ↓ -Apply policy action (enforce/audit/allow) -``` - -## Integration Examples - -### CI/CD Pipeline - -**GitHub Actions:** -```yaml -name: Deploy - -on: - push: - branches: [main] - -jobs: - verify-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Install atcr-verify - run: | - curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-linux-amd64 -o atcr-verify - chmod +x atcr-verify - sudo mv atcr-verify /usr/local/bin/ - - - name: Verify image signature - run: | - atcr-verify ${{ env.IMAGE }} --policy .github/trust-policy.yaml - - - name: Deploy to production - if: success() - run: kubectl set image deployment/app app=${{ env.IMAGE }} -``` - -**GitLab CI:** -```yaml -verify: - stage: verify - image: atcr.io/atcr/verify:latest - script: - - atcr-verify ${IMAGE} --policy trust-policy.yaml - -deploy: - stage: deploy - dependencies: - - verify - script: - - kubectl set image deployment/app app=${IMAGE} -``` - -**Jenkins:** -```groovy -pipeline { - agent any - - stages { - stage('Verify') { - steps { - sh 'atcr-verify ${IMAGE} --policy trust-policy.yaml' - } - } - - stage('Deploy') { - when { - expression { currentBuild.result == 'SUCCESS' } - } - steps { - sh 'kubectl set image deployment/app app=${IMAGE}' - } - } - } -} -``` - -### Kubernetes Admission Controller - -**Using as webhook backend:** - -```go -// webhook server -func (h *Handler) ValidatePod(w http.ResponseWriter, r *http.Request) { - var admReq admissionv1.AdmissionReview - json.NewDecoder(r.Body).Decode(&admReq) - - pod := &corev1.Pod{} - json.Unmarshal(admReq.Request.Object.Raw, pod) - - // Verify each container image - for _, container := range pod.Spec.Containers { - cmd := exec.Command("atcr-verify", container.Image, - "--policy", "/etc/atcr/trust-policy.yaml", - "--quiet") - - if err := cmd.Run(); err != nil { - // Verification failed - admResp := admissionv1.AdmissionReview{ - Response: &admissionv1.AdmissionResponse{ - UID: admReq.Request.UID, - Allowed: false, - Result: &metav1.Status{ - Message: fmt.Sprintf("Image %s failed signature verification", container.Image), - }, - }, - } - json.NewEncoder(w).Encode(admResp) - return - } - } - - // All images verified - admResp := admissionv1.AdmissionReview{ - Response: &admissionv1.AdmissionResponse{ - UID: admReq.Request.UID, - Allowed: true, - }, - } - json.NewEncoder(w).Encode(admResp) -} -``` - -### Pre-Pull Verification - -**Systemd service:** -```ini -# /etc/systemd/system/myapp.service -[Unit] -Description=My Application -After=docker.service - -[Service] -Type=oneshot -ExecStartPre=/usr/local/bin/atcr-verify atcr.io/myorg/myapp:latest --policy /etc/atcr/policy.yaml -ExecStartPre=/usr/bin/docker pull atcr.io/myorg/myapp:latest -ExecStart=/usr/bin/docker run atcr.io/myorg/myapp:latest -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` - -**Docker wrapper script:** -```bash -#!/bin/bash -# docker-secure-pull.sh - -IMAGE="$1" - -# Verify before pulling -if ! atcr-verify "$IMAGE" --policy ~/.atcr/trust-policy.yaml; then - echo "ERROR: Image signature verification failed" - exit 1 -fi - -# Pull if verified -docker pull "$IMAGE" -``` - -## Configuration - -### Config File - -Location: `~/.atcr/config.yaml` - -```yaml -# Default trust policy -defaultPolicy: ~/.atcr/trust-policy.yaml - -# Cache settings -cache: - enabled: true - directory: ~/.atcr/cache - ttl: - didDocuments: 3600 # 1 hour - commits: 600 # 10 minutes - -# Network settings -timeout: 30s -retries: 3 - -# Output settings -output: - format: text # text, json, quiet - color: auto # auto, always, never - -# Registry settings -registries: - atcr.io: - insecure: false - credentialsFile: ~/.docker/config.json -``` - -### Environment Variables - -- `ATCR_CONFIG` - Config file path -- `ATCR_POLICY` - Default trust policy file -- `ATCR_CACHE_DIR` - Cache directory -- `ATCR_OUTPUT` - Output format (text, json, quiet) -- `ATCR_TIMEOUT` - Verification timeout -- `HTTP_PROXY` / `HTTPS_PROXY` - Proxy settings -- `NO_CACHE` - Disable caching - -## Library Usage - -`atcr-verify` can also be used as a Go library: - -```go -import "github.com/atcr-io/atcr/pkg/verify" - -func main() { - verifier := verify.NewVerifier(verify.Config{ - Policy: policy, - Timeout: 30 * time.Second, - }) - - result, err := verifier.Verify(ctx, "atcr.io/alice/myapp:latest") - if err != nil { - log.Fatal(err) - } - - if !result.Verified { - log.Fatal("Verification failed") - } - - fmt.Printf("Verified by %s\n", result.Signature.DID) -} -``` - -## Performance - -### Typical Verification Times - -- **First verification:** 500-1000ms - - OCI Referrers API: 50-100ms - - DID resolution: 50-150ms - - PDS query: 100-300ms - - Signature verification: 1-5ms - -- **Cached verification:** 50-150ms - - DID document cached - - Signature metadata cached - -### Optimization Tips - -1. **Enable caching** - DID documents change rarely -2. **Use offline bundles** - For air-gapped environments -3. **Parallel verification** - Verify multiple images concurrently -4. **Local trust policy** - Avoid remote policy fetches - -## Troubleshooting - -### Verification Fails - -```bash -atcr-verify atcr.io/alice/myapp:latest --verbose -``` - -Common issues: -- **No signature found** - Image not signed, check Referrers API -- **DID resolution failed** - Network issue, check PLC directory -- **PDS unreachable** - Network issue, check PDS endpoint -- **Signature invalid** - Tampering detected or key mismatch -- **Trust policy violation** - DID not in trusted list - -### Enable Debug Logging - -```bash -ATCR_LOG_LEVEL=debug atcr-verify IMAGE -``` - -### Clear Cache - -```bash -rm -rf ~/.atcr/cache -``` - -## See Also - -- [ATProto Signatures](./ATPROTO_SIGNATURES.md) - How ATProto signing works -- [Integration Strategy](./INTEGRATION_STRATEGY.md) - Overview of integration approaches -- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Tool-specific guides -- [Trust Policy Examples](../examples/verification/trust-policy.yaml) diff --git a/docs/ATPROTO_SIGNATURES.md b/docs/ATPROTO_SIGNATURES.md deleted file mode 100644 index 05f42f5..0000000 --- a/docs/ATPROTO_SIGNATURES.md +++ /dev/null @@ -1,501 +0,0 @@ -# ATProto Signatures for Container Images - -## Overview - -ATCR container images are **already cryptographically signed** through ATProto's repository commit system. Every manifest stored in a user's PDS is signed with the user's ATProto signing key, providing cryptographic proof of authorship and integrity. - -This document explains: -- How ATProto signing works -- Why additional signing tools aren't needed -- How to bridge ATProto signatures to the OCI/ORAS ecosystem -- Trust model and security considerations - -## Key Insight: Manifests Are Already Signed - -When you push an image to ATCR: - -```bash -docker push atcr.io/alice/myapp:latest -``` - -The following happens: - -1. **AppView stores manifest** as an `io.atcr.manifest` record in alice's PDS -2. **PDS creates repository commit** containing the manifest record -3. **PDS signs the commit** with alice's ATProto signing key (ECDSA K-256) -4. **Signature is stored** in the repository commit object - -**Result:** The manifest is cryptographically signed with alice's private key, and anyone can verify it using alice's public key from her DID document. - -## ATProto Signing Mechanism - -### Repository Commit Signing - -ATProto uses a Merkle Search Tree (MST) to store records, and every modification creates a signed commit: - -``` -┌─────────────────────────────────────────────┐ -│ Repository Commit │ -├─────────────────────────────────────────────┤ -│ DID: did:plc:alice123 │ -│ Version: 3jzfkjqwdwa2a │ -│ Previous: bafyreig7... (parent commit) │ -│ Data CID: bafyreih8... (MST root) │ -│ ┌───────────────────────────────────────┐ │ -│ │ Signature (ECDSA K-256 + SHA-256) │ │ -│ │ Signed with: alice's private key │ │ -│ │ Value: 0x3045022100... (DER format) │ │ -│ └───────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ - │ - ↓ - ┌─────────────────────┐ - │ Merkle Search Tree │ - │ (contains records) │ - └─────────────────────┘ - │ - ↓ - ┌────────────────────────────┐ - │ io.atcr.manifest record │ - │ Repository: myapp │ - │ Digest: sha256:abc123... │ - │ Layers: [...] │ - └────────────────────────────┘ -``` - -### Signature Algorithm - -**Algorithm:** ECDSA with K-256 (secp256k1) curve + SHA-256 hash -- **Curve:** secp256k1 (same as Bitcoin, Ethereum) -- **Hash:** SHA-256 -- **Format:** DER-encoded signature bytes -- **Variant:** "low-S" signatures (per BIP-0062) - -**Signing process:** -1. Serialize commit data as DAG-CBOR -2. Hash with SHA-256 -3. Sign hash with ECDSA K-256 private key -4. Store signature in commit object - -### Public Key Distribution - -Public keys are distributed via DID documents, accessible through DID resolution: - -**DID Resolution Flow:** -``` -did:plc:alice123 - ↓ -Query PLC directory: https://plc.directory/did:plc:alice123 - ↓ -DID Document: -{ - "@context": ["https://www.w3.org/ns/did/v1"], - "id": "did:plc:alice123", - "verificationMethod": [{ - "id": "did:plc:alice123#atproto", - "type": "Multikey", - "controller": "did:plc:alice123", - "publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z" - }], - "service": [{ - "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", - "serviceEndpoint": "https://bsky.social" - }] -} -``` - -**Public key format:** -- **Encoding:** Multibase (base58btc with `z` prefix) -- **Codec:** Multicodec `0xE701` for K-256 keys -- **Example:** `zQ3sh...` decodes to 33-byte compressed public key - -## Verification Process - -To verify a manifest's signature: - -### Step 1: Resolve Image to Manifest Digest - -```bash -# Get manifest digest -DIGEST=$(crane digest atcr.io/alice/myapp:latest) -# Result: sha256:abc123... -``` - -### Step 2: Fetch Manifest Record from PDS - -```bash -# Extract repository name from image reference -REPO="myapp" - -# Query PDS for manifest record -curl "https://bsky.social/xrpc/com.atproto.repo.listRecords?\ - repo=did:plc:alice123&\ - collection=io.atcr.manifest&\ - limit=100" | jq -r '.records[] | select(.value.digest == "sha256:abc123...")' -``` - -Response includes: -```json -{ - "uri": "at://did:plc:alice123/io.atcr.manifest/abc123", - "cid": "bafyreig7...", - "value": { - "$type": "io.atcr.manifest", - "repository": "myapp", - "digest": "sha256:abc123...", - ... - } -} -``` - -### Step 3: Fetch Repository Commit - -```bash -# Get current repository state -curl "https://bsky.social/xrpc/com.atproto.sync.getRepo?\ - did=did:plc:alice123" --output repo.car - -# Extract commit from CAR file (requires ATProto tools) -# Commit includes signature over repository state -``` - -### Step 4: Resolve DID to Public Key - -```bash -# Resolve DID document -curl "https://plc.directory/did:plc:alice123" | jq -r '.verificationMethod[0].publicKeyMultibase' -# Result: zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z -``` - -### Step 5: Verify Signature - -```go -// Pseudocode for verification -import "github.com/bluesky-social/indigo/atproto/crypto" - -// 1. Parse commit -commit := parseCommitFromCAR(repoCAR) - -// 2. Extract signature bytes -signature := commit.Sig - -// 3. Get bytes that were signed -bytesToVerify := commit.Unsigned().BytesForSigning() - -// 4. Decode public key from multibase -pubKey := decodeMultibasePublicKey(publicKeyMultibase) - -// 5. Verify ECDSA signature -valid := crypto.VerifySignature(pubKey, bytesToVerify, signature) -``` - -### Step 6: Verify Manifest Integrity - -```bash -# Verify the manifest record's CID matches the content -# CID is content-addressed, so tampering changes the CID -``` - -## Bridging to OCI/ORAS Ecosystem - -While ATProto signatures are cryptographically sound, the OCI ecosystem doesn't understand ATProto records. To make signatures discoverable, we create **ORAS signature artifacts** that reference the ATProto signature. - -### ORAS Signature Artifact Format - -```json -{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "artifactType": "application/vnd.atproto.signature.v1+json", - "config": { - "mediaType": "application/vnd.oci.empty.v1+json", - "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", - "size": 2 - }, - "subject": { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "digest": "sha256:abc123...", - "size": 1234 - }, - "layers": [ - { - "mediaType": "application/vnd.atproto.signature.v1+json", - "digest": "sha256:sig789...", - "size": 512, - "annotations": { - "org.opencontainers.image.title": "atproto-signature.json" - } - } - ], - "annotations": { - "io.atcr.atproto.did": "did:plc:alice123", - "io.atcr.atproto.pds": "https://bsky.social", - "io.atcr.atproto.recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123", - "io.atcr.atproto.commitCid": "bafyreih8...", - "io.atcr.atproto.signedAt": "2025-10-31T12:34:56.789Z", - "io.atcr.atproto.keyId": "did:plc:alice123#atproto" - } -} -``` - -**Key elements:** - -1. **artifactType**: `application/vnd.atproto.signature.v1+json` - identifies this as an ATProto signature -2. **subject**: Links to the image manifest being signed -3. **layers**: Contains signature metadata blob -4. **annotations**: Quick-access metadata for verification - -### Signature Metadata Blob - -The layer blob contains detailed verification information: - -```json -{ - "$type": "io.atcr.atproto.signature", - "version": "1.0", - "subject": { - "digest": "sha256:abc123...", - "mediaType": "application/vnd.oci.image.manifest.v1+json" - }, - "atproto": { - "did": "did:plc:alice123", - "handle": "alice.bsky.social", - "pdsEndpoint": "https://bsky.social", - "recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123", - "recordCid": "bafyreig7...", - "commitCid": "bafyreih8...", - "commitRev": "3jzfkjqwdwa2a", - "signedAt": "2025-10-31T12:34:56.789Z" - }, - "signature": { - "algorithm": "ECDSA-K256-SHA256", - "keyId": "did:plc:alice123#atproto", - "publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z" - }, - "verification": { - "method": "atproto-repo-commit", - "instructions": "Fetch repository commit from PDS and verify signature using public key from DID document" - } -} -``` - -### Discovery via Referrers API - -ORAS artifacts are discoverable via the OCI Referrers API: - -```bash -# Query for signature artifacts -curl "https://atcr.io/v2/alice/myapp/referrers/sha256:abc123?\ - artifactType=application/vnd.atproto.signature.v1+json" -``` - -Response: -```json -{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.index.v1+json", - "manifests": [ - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "digest": "sha256:sig789...", - "size": 1234, - "artifactType": "application/vnd.atproto.signature.v1+json", - "annotations": { - "io.atcr.atproto.did": "did:plc:alice123", - "io.atcr.atproto.signedAt": "2025-10-31T12:34:56.789Z" - } - } - ] -} -``` - -## Trust Model - -### What ATProto Signatures Prove - -✅ **Authenticity**: Image was published by the DID owner -✅ **Integrity**: Image manifest hasn't been tampered with since signing -✅ **Non-repudiation**: Only the DID owner could have created this signature -✅ **Timestamp**: When the image was signed (commit timestamp) - -### What ATProto Signatures Don't Prove - -❌ **Safety**: Image doesn't contain vulnerabilities (use vulnerability scanning) -❌ **DID trustworthiness**: Whether the DID owner is trustworthy (trust policy decision) -❌ **Key security**: Private key wasn't compromised (same limitation as all PKI) -❌ **PDS honesty**: PDS operator serves correct data (verify across multiple sources) - -### Trust Dependencies - -1. **DID Resolution**: Must correctly resolve DID to public key - - **Mitigation**: Use multiple resolvers, cache DID documents - -2. **PDS Availability**: Must query PDS to verify signatures - - **Mitigation**: Embed signature bytes in ORAS blob for offline verification - -3. **PDS Honesty**: PDS could serve fake/unsigned records - - **Mitigation**: Signature verification prevents this (can't forge signature) - -4. **Key Security**: User's private key could be compromised - - **Mitigation**: Key rotation via DID document updates, short-lived credentials - -5. **Algorithm Security**: ECDSA K-256 must remain secure - - **Status**: Well-studied, same as Bitcoin/Ethereum (widely trusted) - -### Comparison with Other Signing Systems - -| Aspect | ATProto Signatures | Cosign (Keyless) | Notary v2 | -|--------|-------------------|------------------|-----------| -| **Identity** | DID (decentralized) | OIDC (federated) | X.509 (PKI) | -| **Key Management** | PDS signing keys | Ephemeral (Fulcio) | User-managed | -| **Trust Anchor** | DID resolution | Fulcio CA + Rekor | Certificate chain | -| **Transparency Log** | ATProto firehose | Rekor | Optional | -| **Offline Verification** | Limited* | No | Yes | -| **Decentralization** | High | Medium | Low | -| **Complexity** | Low | High | Medium | - -*Can be improved by embedding signature bytes in ORAS blob - -### Security Considerations - -**Threat: Man-in-the-Middle Attack** -- **Attack**: Intercept PDS queries, serve fake records -- **Defense**: TLS for PDS communication, verify signature with public key from DID document -- **Result**: Attacker can't forge signature without private key - -**Threat: Compromised PDS** -- **Attack**: PDS operator serves unsigned/fake manifests -- **Defense**: Signature verification fails (PDS can't sign without user's private key) -- **Result**: Protected - -**Threat: Key Compromise** -- **Attack**: Attacker steals user's ATProto signing key -- **Defense**: Key rotation via DID document, revoke old keys -- **Result**: Same as any PKI system (rotate keys quickly) - -**Threat: Replay Attack** -- **Attack**: Replay old signed manifest to rollback to vulnerable version -- **Defense**: Check commit timestamp, verify commit is in current repository DAG -- **Result**: Protected (commits form immutable chain) - -**Threat: DID Takeover** -- **Attack**: Attacker gains control of user's DID (rotation keys) -- **Defense**: Monitor DID document changes, verify key history -- **Result**: Serious but requires compromising rotation keys (harder than signing keys) - -## Implementation Strategy - -### Automatic Signature Artifact Creation - -When AppView stores a manifest in a user's PDS: - -1. **Store manifest record** (existing behavior) -2. **Get commit response** with commit CID and revision -3. **Create ORAS signature artifact**: - - Build metadata blob (JSON) - - Upload blob to hold storage - - Create ORAS manifest with subject = image manifest - - Store ORAS manifest (creates referrer link) - -### Storage Location - -Signature artifacts follow the same pattern as SBOMs: -- **Metadata blobs**: Stored in hold's blob storage -- **ORAS manifests**: Stored in hold's embedded PDS -- **Discovery**: Via OCI Referrers API - -### Verification Tools - -**Option 1: Custom CLI tool (`atcr-verify`)** -```bash -atcr-verify atcr.io/alice/myapp:latest -# → Queries referrers API -# → Fetches signature metadata -# → Resolves DID → public key -# → Queries PDS for commit -# → Verifies signature -``` - -**Option 2: Shell script (curl + jq)** -- See `docs/SIGNATURE_INTEGRATION.md` for examples - -**Option 3: Kubernetes admission controller** -- Custom webhook that runs verification -- Rejects pods with unsigned/invalid signatures - -## Benefits of ATProto Signatures - -### Compared to No Signing - -✅ **Cryptographic proof** of image authorship -✅ **Tamper detection** for manifests -✅ **Identity binding** via DIDs -✅ **Audit trail** via ATProto repository history - -### Compared to Cosign/Notary - -✅ **No additional signing required** (already signed by PDS) -✅ **Decentralized identity** (DIDs, not CAs) -✅ **Simpler infrastructure** (no Fulcio, no Rekor, no TUF) -✅ **Consistent with ATCR's architecture** (ATProto-native) -✅ **Lower operational overhead** (reuse existing PDS infrastructure) - -### Trade-offs - -⚠️ **Custom verification tools required** (standard tools won't work) -⚠️ **Online verification preferred** (need to query PDS) -⚠️ **Different trust model** (trust DIDs, not CAs) -⚠️ **Ecosystem maturity** (newer approach, less tooling) - -## Future Enhancements - -### Short-term - -1. **Offline verification**: Embed signature bytes in ORAS blob -2. **Multi-PDS verification**: Check signature across multiple PDSs -3. **Key rotation support**: Handle historical key validity - -### Medium-term - -4. **Timestamp service**: RFC 3161 timestamps for long-term validity -5. **Multi-signature**: Require N signatures from M DIDs -6. **Transparency log integration**: Record verifications in public log - -### Long-term - -7. **IANA registration**: Register `application/vnd.atproto.signature.v1+json` -8. **Standards proposal**: ATProto signature spec to ORAS/OCI -9. **Cross-ecosystem bridges**: Convert to Cosign/Notary formats - -## Conclusion - -ATCR images are already cryptographically signed through ATProto's repository commit system. By creating ORAS signature artifacts that reference these existing signatures, we can: - -- ✅ Make signatures discoverable to OCI tooling -- ✅ Maintain ATProto as the source of truth -- ✅ Provide verification tools for users and clusters -- ✅ Avoid duplicating signing infrastructure - -This approach leverages ATProto's strengths (decentralized identity, built-in signing) while bridging to the OCI ecosystem through standard ORAS artifacts. - -## References - -### ATProto Specifications -- [ATProto Repository Specification](https://atproto.com/specs/repository) -- [ATProto Data Model](https://atproto.com/specs/data-model) -- [ATProto DID Methods](https://atproto.com/specs/did) - -### OCI/ORAS Specifications -- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec) -- [OCI Referrers API](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers) -- [ORAS Artifacts](https://oras.land/docs/) - -### Cryptography -- [ECDSA (secp256k1)](https://en.bitcoin.it/wiki/Secp256k1) -- [Multibase Encoding](https://github.com/multiformats/multibase) -- [Multicodec](https://github.com/multiformats/multicodec) - -### Related Documentation -- [SBOM Scanning](./SBOM_SCANNING.md) - Similar ORAS artifact pattern -- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Practical integration examples diff --git a/docs/BILLING.md b/docs/BILLING.md index 20e45fe..d1fc454 100644 --- a/docs/BILLING.md +++ b/docs/BILLING.md @@ -1,33 +1,40 @@ -# Hold Service Billing Integration +# Billing Integration -Optional Stripe billing integration for hold services. Allows hold operators to charge for storage tiers via subscriptions. +Optional Stripe billing integration. Allows charging for subscription tiers, which map to storage quotas and feature gates on managed holds. ## Overview -- **Compile-time optional**: Build with `-tags billing` to enable Stripe support -- **Hold owns billing**: Each hold operator has their own Stripe account -- **AppView aggregates UI**: Fetches subscription info from holds, displays in settings -- **Customer-DID mapping**: DIDs stored in Stripe customer metadata (no extra database) +- **Compile-time optional**: Build the appview with `-tags billing` to enable Stripe support +- **AppView owns billing**: All Stripe interaction (checkout, customer portal, webhook handling) lives in the appview (`pkg/billing/`) +- **Holds enforce quota**: On a subscription change, the appview pushes a tier update to each managed hold; the hold maps the tier rank to its own quota tier and enforces it +- **Customer-DID mapping**: User DIDs are stored in Stripe customer metadata (no extra database) ## Architecture ``` -User → AppView Settings UI → Hold XRPC endpoints → Stripe +User → AppView Settings UI → AppView (pkg/billing) → Stripe + ↑ + Stripe webhook → POST /api/stripe/webhook (AppView) ↓ - Stripe webhook → Hold → Update crew tier + io.atcr.hold.updateCrewTier (signed appview token) → Hold → update crew tier / enforce quota ``` +The appview is the sole billing authority: it creates checkout and portal sessions, receives Stripe webhooks at `POST /api/stripe/webhook`, and resolves the subscription's price ID to a tier rank. On a subscription change it calls each managed hold's `io.atcr.hold.updateCrewTier` endpoint (`pkg/appview/holdclient/tier_update.go`), authenticated with a short-lived JWT signed by the appview's P-256 key. The hold verifies that token against its configured appview DID and only then updates the crew member's quota tier (`pkg/hold/pds/xrpc.go`, `HandleUpdateCrewTier`). Holds never talk to Stripe and trust nothing but a valid appview-signed token; their job is quota enforcement, not payment. + ## Building with Billing Support +Billing lives entirely in the AppView (`pkg/billing/`). The hold binary does not need a special build tag. + ```bash -# Without billing (default) -go build ./cmd/hold +# AppView without billing (default) +go build -o bin/atcr-appview ./cmd/appview -# With billing -go build -tags billing ./cmd/hold +# AppView with billing +go build -tags billing -o bin/atcr-appview ./cmd/appview -# Docker with billing -docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold . +# Docker (Dockerfile.appview does not include -tags billing by default; +# build locally with the tag if you need billing support) +go build -tags billing -o bin/atcr-appview ./cmd/appview ``` ## Configuration @@ -43,35 +50,67 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx # from Stripe Dashboard or CLI STRIPE_PUBLISHABLE_KEY=pk_live_xxx # for client-side (not currently used) ``` -### quotas.yaml +### Billing tiers (appview config) + +Stripe tiers are configured as a **list** under the `billing:` section of the appview config (`pkg/billing/config.go`). Position in the list determines tier rank (0-based, lowest to highest). Billing auto-enables when a Stripe secret key is set and at least one tier is configured. ```yaml -tiers: - swabbie: - quota: 2GB - description: "Starter storage" - # No stripe_price = free tier - - deckhand: - quota: 5GB - description: "Standard storage" - stripe_price_yearly: price_xxx # Price ID from Stripe - - bosun: - quota: 10GB - description: "Mid-level storage" - stripe_price_monthly: price_xxx - stripe_price_yearly: price_xxx - -defaults: - new_crew_tier: swabbie - plankowner_crew_tier: deckhand # Early adopters get this free - billing: - enabled: true + # Can also be set via STRIPE_SECRET_KEY env var (takes precedence). + stripe_secret_key: sk_live_xxx + # Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence). + webhook_secret: whsec_xxx currency: usd - success_url: "{hold_url}/billing/success" - cancel_url: "{hold_url}/billing/cancel" + success_url: "{base_url}/settings/billing" + cancel_url: "{base_url}/settings/billing" + tiers: + - name: Free + description: Get started with basic storage + features: [] + stripe_price_monthly: "" # empty = free tier + stripe_price_yearly: "" + max_webhooks: 1 + webhook_all_triggers: false + ai_advisor: false + supporter_badge: false + - name: Supporter + description: Support the project + stripe_price_yearly: price_xxx + max_webhooks: 1 + webhook_all_triggers: true + ai_advisor: true + supporter_badge: true + - name: Pro + description: More storage with scan-on-push + stripe_price_monthly: price_xxx + stripe_price_yearly: price_xxx + max_webhooks: 10 + webhook_all_triggers: true + ai_advisor: true + supporter_badge: true +``` + +### Quota tiers (hold config) + +Storage quotas are configured separately, in the `quota:` section of each **hold's** config (`pkg/hold/quota/config.go`). These are also a position-ranked list. The appview pushes a tier *rank* to the hold via `updateCrewTier`; the hold maps that rank onto its own quota tier list, so the billing tier names and quota tier names do not need to match (only ranks line up). Real quota tier names are `deckhand`, `bosun`, `quartermaster` (there is no "swabbie" tier). + +```yaml +quota: + tiers: + - name: free + quota: 5GB + scan_on_push: false + - name: deckhand + quota: 5GB + scan_on_push: false + - name: bosun + quota: 50GB + scan_on_push: true + - name: quartermaster + quota: 100GB + scan_on_push: true + defaults: + new_crew_tier: deckhand ``` ### Stripe Price IDs @@ -173,9 +212,9 @@ endpoint or auditing an existing one. | `invoice.payment_failed` | Log only (Stripe Smart Retries handle retry + customer email) | | `charge.dispute.created` | Log only (Stripe emails the account owner by default) | -## Plankowners (Grandfathering) +## Plankowners (planned) -Early adopters can be marked as "plankowners" to get a paid tier for free: +`io.atcr.hold.crew` records carry a `plankowner` boolean flag (`Plankowner` on `CrewRecord` in `pkg/atproto/lexicon.go`) intended to mark early adopters: ```json { @@ -188,25 +227,23 @@ Early adopters can be marked as "plankowners" to get a paid tier for free: } ``` -Plankowners: -- Get `plankowner_crew_tier` (e.g., deckhand) without paying -- Still see upgrade options in UI if they want to support -- Can upgrade to higher tiers normally +The flag exists on the record, but automated grandfathering behavior is **not implemented**. There is no `plankowner_crew_tier` config field, and nothing currently grants a paid tier for free or treats plankowners differently from other crew members at billing time. Their assigned `tier` is whatever is set on the crew record. Treat this section as a placeholder for future grandfathering logic. ## Customer-DID Mapping -DIDs are stored in Stripe customer metadata: +The user's DID is stored in Stripe customer metadata (set by `getOrCreateCustomer` in `pkg/billing/billing.go`): ```json { "metadata": { - "user_did": "did:plc:xxx", - "hold_did": "did:web:hold.example.com" + "user_did": "did:plc:xxx" } } ``` -The hold uses an in-memory cache (10 min TTL) to reduce Stripe API calls. On webhook events, the cache is invalidated for the affected customer. +Only `user_did` is stored. The appview resolves the customer for a DID by searching Stripe customer metadata, and reads `user_did` back from webhook events to know which user to update. + +The appview uses an in-memory customer cache (10 min TTL) to reduce Stripe API calls. On webhook events, the cache is invalidated for the affected user. ## Production Checklist @@ -216,7 +253,7 @@ The hold uses an in-memory cache (10 min TTL) to reduce Stripe API calls. On web - URL: `https://your-appview.com/api/stripe/webhook` - Events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.paused`, `customer.subscription.resumed`, `customer.subscription.deleted`, `invoice.payment_failed` - [ ] Set `STRIPE_WEBHOOK_SECRET` from Dashboard webhook settings -- [ ] Update `quotas.yaml` with live price IDs +- [ ] Update the appview config `billing.tiers` with live price IDs - [ ] Build appview with `-tags billing` - [ ] Test with a real payment (can refund immediately) @@ -232,11 +269,12 @@ The hold uses an in-memory cache (10 min TTL) to reduce Stripe API calls. On web ### Tier not updating after payment - Check appview logs for webhook processing errors -- Verify price ID in `quotas.yaml` matches Stripe -- Ensure `billing.enabled: true` in appview config -- Confirm appview was built with `-tags billing` (otherwise `/api/stripe/webhook` returns 404) +- Verify the price ID in the appview config `billing.tiers` matches Stripe +- Confirm the appview was built with `-tags billing` (otherwise `/api/stripe/webhook` returns 404) +- Confirm each managed hold has the appview DID configured (so it accepts the signed `updateCrewTier` call) and has matching quota tier ranks ### "Billing not enabled" error -- Build with `-tags billing` -- Set `billing.enabled: true` in `quotas.yaml` -- Ensure `STRIPE_SECRET_KEY` is set +There is no `billing.enabled` flag. Billing auto-enables when all of the following hold (see `Manager.Enabled()` in `pkg/billing/billing.go`): +- The appview was built with `-tags billing` +- A Stripe secret key is set (via `STRIPE_SECRET_KEY` env var or `billing.stripe_secret_key`) +- At least one tier is configured under `billing.tiers` diff --git a/docs/BILLING_REFACTOR.md b/docs/BILLING_REFACTOR.md deleted file mode 100644 index 6adaff0..0000000 --- a/docs/BILLING_REFACTOR.md +++ /dev/null @@ -1,348 +0,0 @@ -# Billing & Webhooks Refactor: Move to AppView - -## Motivation - -The current billing model is **per-hold**: each hold operator runs their own Stripe integration, manages their own tiers, and users pay each hold separately. This creates problems: - -1. **Multi-hold confusion**: A user on 3 holds could have 3 separate Stripe subscriptions with no unified view -2. **Orphaned subscriptions**: Users can end up paying for holds they no longer use after switching their active hold -3. **Complex UI**: The settings page needs to surface billing per-hold, with separate "Manage Billing" links for each -4. **Captain-only billing**: Only hold captains can set up Stripe. Self-hosted hold operators who want to charge users would need their own Stripe account per hold - -The proposed model is **per-appview**: a single Stripe integration on the appview, one subscription per user, covering all holds that appview manages. - -## Current Architecture - -``` -User ──Settings UI──→ AppView ──XRPC──→ Hold ──Stripe API──→ Stripe - ↑ - Stripe Webhooks -``` - -### What lives where today - -| Component | Location | Notes | -|-----------|----------|-------| -| Stripe customer management | Hold (`pkg/hold/billing/`) | Build tag: `-tags billing` | -| Stripe checkout/portal | Hold XRPC endpoints | Authenticated via service token | -| Stripe webhook receiver | Hold (`stripeWebhook` endpoint) | Updates crew tier on subscription change | -| Tier definitions + pricing | Hold config (`quotas.yaml`, `billing` section) | Captain configures | -| Quota enforcement | Hold (`pkg/hold/quota/`) | Checks tier limit on push | -| Storage quota calculation | Hold PDS layer records | Deduped per-user | -| Subscription UI | AppView handlers | Proxies all calls to hold | -| Webhook management (scan) | Hold PDS + SQLite | URL/secret in SQLite, metadata in PDS record | -| Webhook dispatch | Hold (`scan_broadcaster.go`) | Sends on scan completion | -| Sailor webhook record | User's PDS | Links to hold's private webhook record | - -## Proposed Architecture - -``` -User ──Settings UI──→ AppView ──Stripe API──→ Stripe - │ ↑ - │ Stripe Webhooks - │ - ├──XRPC──→ Hold A (quota enforcement, scan results) - ├──XRPC──→ Hold B - └──XRPC──→ Hold C - - AppView signs attestation - │ - └──→ Hold stores in PDS (trust anchor) -``` - -### What moves to AppView - -| Component | From | To | Notes | -|-----------|------|----|-------| -| Stripe customer management | Hold | AppView | One customer per user, not per hold | -| Stripe checkout/portal | Hold | AppView | Single subscription covers all holds | -| Stripe webhook receiver | Hold | AppView | AppView updates tier across all holds | -| Tier definitions + pricing | Hold config | AppView config | AppView defines billing tiers | -| Scan webhooks (storage + dispatch) | Hold | AppView | AppView has user context, scan data comes via Jetstream/XRPC | - -### What stays on the hold - -| Component | Notes | -|-----------|-------| -| Quota enforcement | Hold still checks tier limit on push | -| Storage quota calculation | Layer records stay in hold PDS | -| Tier definitions (quota only) | Hold defines storage limits per tier, no pricing | -| Scan execution + results | Scanner still talks to hold, results stored in hold PDS | -| Crew tier field | Source of truth for enforcement, updated by appview | - -## Billing Model - -### One subscription, all holds - -A user pays the appview once. Their subscription tier applies across every hold the appview manages. - -``` -AppView billing tiers: [Free] [Tier 1] [Tier 2] - │ │ │ - ▼ ▼ ▼ -Hold A tiers (3GB/10GB/50GB): deckhand bosun quartermaster -Hold B tiers (5GB/20GB/∞): deckhand bosun quartermaster -``` - -### Tier pairing - -The appview defines N billing slots. Each hold defines its own tier list with storage quotas. The appview maps its billing slots to each hold's lowest N tiers by rank order. - -- AppView doesn't need to know tier names — just "slot 1, slot 2, slot 3" -- Each hold independently decides what storage limit each tier gets -- The settings UI shows the range: "5-10 GB depending on region" or "minimum 5 GB" - -### Hold captains who want to charge - -If a hold captain wants to charge their own users (not through the shared appview), they spin up their own appview instance with their own Stripe account. The billing code stays the same — it just runs on their appview instead of the shared one. - -## AppView-Hold Trust Model - -### Problem - -The appview needs to tell holds "user X is tier Y." The hold needs to trust that instruction. If domains change, the hold needs to verify the appview's identity. - -### Attestation handshake - -1. **Hold config** already has `server.appview_url` (preferred appview) -2. **AppView config** gains a `managed_holds` list (DIDs of holds it manages) -3. On first connection, the appview signs an attestation with its private key: - ```json - { - "$type": "io.atcr.appview.attestation", - "appviewDid": "did:web:atcr.io", - "holdDid": "did:web:hold01.atcr.io", - "issuedAt": "2026-02-23T...", - "signature": "" - } - ``` -4. The hold stores this attestation in its embedded PDS -5. On subsequent requests, the hold can challenge the appview: present the attestation, appview proves it holds the matching private key -6. If the appview's domain changes, the attestation (tied to DID, not URL) remains valid - -### Trust verification flow - -``` -AppView boots → checks managed_holds list - → for each hold: - → calls hold's describeServer endpoint to verify DID - → signs attestation { appviewDid, holdDid, issuedAt } - → sends to hold via XRPC - → hold stores in PDS as io.atcr.hold.appview record - -Hold receives tier update from appview: - → checks: does this request come from my preferred appview? - → verifies: signature on stored attestation matches appview's current key - → if valid: updates crew tier - → if invalid: rejects, logs warning -``` - -### Key material - -- **AppView**: P-256 key (already exists at `/var/lib/atcr/oauth/client.key`, used for OAuth) -- **Hold**: K-256 key (PDS signing key) -- Attestation is signed by appview's P-256 key, verifiable by anyone with the appview's public key (available via DID document) - -## Webhooks: Move to AppView - -### Why move - -Scan webhooks currently live on the hold, but: -- The webhook payload needs user handles, repository names, tags — all resolved by the appview -- The hold only has DIDs and digests -- The appview already processes scan records via Jetstream (backfill + live) -- Webhook secrets shouldn't need to live on every hold the user pushes to - -### New flow - -``` -Scanner completes scan - → Hold stores scan record in PDS - → Jetstream delivers scan record to AppView - → AppView resolves user handle, repo name, tags - → AppView dispatches webhooks with full context -``` - -### What changes - -| Aspect | Current (hold) | Proposed (appview) | -|--------|---------------|-------------------| -| Webhook storage | Hold SQLite + PDS record | AppView DB + user's PDS record | -| Webhook secrets | Hold SQLite (`webhook_secrets` table) | AppView DB | -| Dispatch trigger | `scan_broadcaster.go` on scan completion | Jetstream processor on `io.atcr.hold.scan` record | -| Payload enrichment | Hold fetches handle from appview metadata | AppView has full context natively | -| Discord/Slack formatting | Hold (`webhooks.go`) | AppView (same code, moved) | -| Tier-based limits | Hold quota manager | AppView billing tier | -| XRPC endpoints | Hold (`listWebhooks`, `addWebhook`, etc.) | AppView API endpoints (already exist as proxies) | - -### Webhook record changes - -The `io.atcr.sailor.webhook` record in the user's PDS stays. It already stores `holdDid` and `triggers`. The `privateCid` field (linking to hold's internal record) becomes unnecessary since appview owns the full webhook now. - -The `io.atcr.hold.webhook` record in the hold's PDS is no longer needed. Webhooks are appview-scoped, not hold-scoped. - -### Migration path - -1. AppView gains webhook storage in its own DB (new table) -2. AppView gains webhook dispatch in its Jetstream processor -3. Hold's webhook endpoints deprecated (return 410 Gone after transition period) -4. Existing hold webhook records migrated via one-time script reading from hold XRPC + user PDS - -## Config Changes - -### AppView config additions - -```yaml -server: - # Existing - default_hold_did: "did:web:hold01.atcr.io" - - # New - managed_holds: - - "did:web:hold01.atcr.io" - - "did:plc:abc123..." - -# New section -billing: - enabled: true - currency: usd - success_url: "{base_url}/settings/billing" - cancel_url: "{base_url}/settings/billing" - tiers: - - name: "Free" - # No stripe_price = free tier - - name: "Standard" - stripe_price_monthly: price_xxx - stripe_price_yearly: price_yyy - - name: "Pro" - stripe_price_monthly: price_xxx - stripe_price_yearly: price_yyy -``` - -### AppView environment additions - -```bash -STRIPE_SECRET_KEY=sk_live_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx -``` - -### Hold config changes - -```yaml -# Removed -billing: - # entire section removed from hold config - -# Stays (quota enforcement only) -quota: - tiers: - - name: deckhand - quota: 5GB - - name: bosun - quota: 50GB - - name: quartermaster - quota: 100GB - defaults: - new_crew_tier: deckhand -``` - -The hold no longer has Stripe config. It just defines storage limits per tier and enforces them. - -## AppView DB Schema Additions - -```sql --- Webhook configurations (moved from hold SQLite) -CREATE TABLE webhooks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_did TEXT NOT NULL, - url TEXT NOT NULL, - secret_hash TEXT, -- bcrypt hash of HMAC secret - triggers INTEGER NOT NULL DEFAULT 1, -- bitmask: first=1, all=2, changed=4 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_did, url) -); - --- Billing: track which holds have been attested -CREATE TABLE hold_attestations ( - hold_did TEXT PRIMARY KEY, - attestation_cid TEXT NOT NULL, -- CID of attestation record in hold's PDS - issued_at DATETIME NOT NULL, - verified_at DATETIME -); -``` - -Stripe customer/subscription data continues to live in Stripe (queried via API, cached in memory). No local subscription table needed — same pattern as current hold billing, just on appview. - -## Implementation Phases - -### Phase 1: Trust foundation -- Add `managed_holds` to appview config -- Implement attestation signing (appview) and storage (hold) -- Add attestation verification to hold's tier-update endpoint -- New XRPC endpoint on hold: `io.atcr.hold.updateCrewTier` (appview-authenticated) - -### Phase 2: Billing migration -- Move Stripe integration from hold to appview (reuse `pkg/hold/billing/` code) -- AppView billing uses `-tags billing` build tag (same pattern) -- Implement tier pairing: appview billing slots mapped to hold tier lists -- New appview endpoints: checkout, portal, stripe webhook receiver -- Settings UI: single subscription section (not per-hold) - -### Phase 3: Webhook migration ✅ -- Add webhook + scans tables to appview DB -- Implement webhook dispatch in appview's Jetstream processor -- Move Discord/Slack formatting code to `pkg/appview/webhooks/` -- Deprecate hold webhook XRPC endpoints (X-Deprecated header) -- Webhooks now user-scoped (global across all holds) in appview DB -- Scan records cached from Jetstream for change detection - -### Phase 4: Cleanup ✅ -- Removed hold webhook XRPC endpoints, dispatch code, and `webhooks.go` -- Removed `io.atcr.hold.webhook` and `io.atcr.sailor.webhook` record types + lexicons -- Removed `webhook_secrets` SQLite schema from scan_broadcaster -- Removed `MaxWebhooks`/`WebhookAllTriggers` from hold quota config -- Removed sailor webhook from OAuth scopes - -## Settings UI Impact - -The storage tab simplifies significantly: - -``` -┌──────────────────────────────────────────────────────┐ -│ Active Hold: [▼ hold01.atcr.io (Crew) ] │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ Subscription: Standard ($5/mo) [Manage Billing] │ -│ Storage: 3-5 GB depending on region │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ ★ hold01.atcr.io [Active] [Crew] [Online] │ -│ Tier: bosun · 281.5 MB / 5.0 GB (5%) │ -│ ▸ Webhooks (2 configured) │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ Other Holds Role Status Storage │ -│ hold02.atcr.io Crew ● 230 MB / 3 GB │ -│ hold03.atcr.io Owner ● No data │ -└──────────────────────────────────────────────────────┘ -``` - -Key changes: -- **One subscription section** at the top (not per-hold) -- **Webhooks section** under active hold card (managed by appview now) -- **No "Paid" badge per hold** — subscription is global -- **Storage range** shown on subscription card ("3-5 GB depending on region") -- **Per-hold quota** still shown (each hold enforces its own limit for the user's tier) - -## Open Questions - -1. **Tier list endpoint**: Holds need a new XRPC endpoint that returns their tier list with quotas (without pricing). The appview calls this to build the "3-5 GB depending on region" display. Something like `io.atcr.hold.listTiers`. - -2. **Existing Stripe customers**: Holds with existing Stripe subscriptions need a migration plan. Options: honor existing subscriptions until they expire, or bulk-migrate customers to appview's Stripe account. - -3. **Webhook delivery guarantees**: Moving dispatch to appview adds latency (scan record → Jetstream → appview → webhook). For time-sensitive notifications, consider the hold sending a lightweight "scan completed" signal directly to appview via XRPC rather than waiting for Jetstream propagation. - -4. **Self-hosted appviews**: The attestation model assumes one appview per set of holds. If multiple appviews try to manage the same hold, the hold should only trust the most recent attestation (or maintain a list). diff --git a/docs/BYOS.md b/docs/BYOS.md index 2b91629..290495a 100644 --- a/docs/BYOS.md +++ b/docs/BYOS.md @@ -18,10 +18,13 @@ ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. Users can: │ - Profile management │ └────────────┬─────────────────────────────┘ │ - │ Hold discovery priority: + │ Hold discovery (findHoldDIDAndProfile): │ 1. io.atcr.sailor.profile.defaultHold (DID) - │ 2. io.atcr.hold records (legacy) - │ 3. AppView default_hold_did + │ 2. AppView default hold (server.managed_holds[0]) + │ + │ Then resolveSuccessor: if the chosen hold's + │ captain record sets a successor DID, apply a + │ single-hop redirect to it (hold migration). ▼ ┌──────────────────────────────────────────┐ │ User's PDS │ @@ -57,23 +60,31 @@ Each hold is a full ATProto actor with: "$type": "io.atcr.hold.captain", "owner": "did:plc:alice123", "public": false, + "allowAllCrew": false, + "enableBlueskyPosts": false, "deployedAt": "2025-10-14T...", "region": "iad", - "provider": "fly.io" + "successor": "" } ``` +`region` and `successor` are optional. `successor` holds the DID of a replacement hold; when set, the AppView applies a single-hop redirect to it during hold discovery (see the Architecture diagram above). + **Crew records** (`io.atcr.hold.crew/{rkey}`): ```json { "$type": "io.atcr.hold.crew", "member": "did:plc:bob456", - "role": "admin", + "role": "captain", "permissions": ["blob:read", "blob:write"], + "tier": "bosun", + "plankowner": false, "addedAt": "2025-10-14T..." } ``` +Authorization is driven by the `permissions` array (`blob:read`, `blob:write`, `crew:admin`), not the `role` string. `blob:write` implicitly grants `blob:read` (you can't push without being able to pull). `tier` and `plankowner` are optional and feed quota limits. + ### Sailor Profile (User's PDS) Users set their preferred hold in their sailor profile: @@ -91,28 +102,43 @@ Users set their preferred hold in their sailor profile: ### Configuration -Hold service is configured entirely via environment variables: +The hold service is configured with Viper: a YAML file is the primary source, and +environment variables override individual fields. Env var names are `HOLD_` plus the +YAML path with `_` separators (e.g. `server.public_url` → `HOLD_SERVER_PUBLIC_URL`). +S3 credentials use the standard AWS names. + +Generate a fully commented config and run with it: ```bash -# Hold identity (REQUIRED) -HOLD_PUBLIC_URL=https://hold.example.com -HOLD_OWNER=did:plc:your-did-here - -# S3 storage backend (REQUIRED) -AWS_ACCESS_KEY_ID=your_access_key -AWS_SECRET_ACCESS_KEY=your_secret_key -AWS_REGION=us-east-1 -S3_BUCKET=my-blobs - -# Access control -HOLD_PUBLIC=false # Require authentication for reads -HOLD_ALLOW_ALL_CREW=false # Only explicit crew members can write - -# Embedded PDS -HOLD_DATABASE_PATH=/var/lib/atcr-hold/hold.db -HOLD_DATABASE_KEY_PATH=/var/lib/atcr-hold/keys +./bin/atcr-hold config init config-hold.yaml +# edit config-hold.yaml, then: +./bin/atcr-hold serve --config config-hold.yaml ``` +Key fields (YAML on the left, env override on the right): + +```yaml +server: + public_url: https://hold.example.com # HOLD_SERVER_PUBLIC_URL (REQUIRED) + public: false # HOLD_SERVER_PUBLIC (allow anonymous reads) + +registration: + owner_did: did:plc:your-did-here # HOLD_REGISTRATION_OWNER_DID + allow_all_crew: false # HOLD_REGISTRATION_ALLOW_ALL_CREW + +database: + path: /var/lib/atcr-hold # HOLD_DATABASE_PATH (carstore + SQLite) + key_path: "" # HOLD_DATABASE_KEY_PATH (defaults to {path}/signing.key) + +storage: + bucket: my-blobs # S3_BUCKET (REQUIRED) + region: us-east-1 # AWS_REGION + endpoint: "" # S3_ENDPOINT (for non-AWS providers) +``` + +S3 credentials are read from the standard AWS env vars (`AWS_ACCESS_KEY_ID`, +`AWS_SECRET_ACCESS_KEY`). + ### Running Locally For local development, use Minio as an S3-compatible storage: @@ -124,16 +150,16 @@ docker run -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address # Build go build -o bin/atcr-hold ./cmd/hold -# Run (with env vars or .env file) -export HOLD_PUBLIC_URL=http://localhost:8080 -export HOLD_OWNER=did:plc:your-did-here +# Run (env overrides shown; a YAML config works too) +export HOLD_SERVER_PUBLIC_URL=http://localhost:8080 +export HOLD_REGISTRATION_OWNER_DID=did:plc:your-did-here export AWS_ACCESS_KEY_ID=minioadmin export AWS_SECRET_ACCESS_KEY=minioadmin export S3_BUCKET=test export S3_ENDPOINT=http://localhost:9000 -export HOLD_DATABASE_PATH=/tmp/atcr-hold/hold.db +export HOLD_DATABASE_PATH=/tmp/atcr-hold -./bin/atcr-hold +./bin/atcr-hold serve ``` On first run, the hold service creates: @@ -150,11 +176,11 @@ app = "my-atcr-hold" primary_region = "ord" [env] - HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev" + HOLD_SERVER_PUBLIC_URL = "https://my-atcr-hold.fly.dev" AWS_REGION = "us-east-1" S3_BUCKET = "my-blobs" - HOLD_PUBLIC = "false" - HOLD_ALLOW_ALL_CREW = "false" + HOLD_SERVER_PUBLIC = "false" + HOLD_REGISTRATION_ALLOW_ALL_CREW = "false" [http_service] internal_port = 8080 @@ -176,7 +202,7 @@ fly deploy # Set secrets fly secrets set AWS_ACCESS_KEY_ID=... fly secrets set AWS_SECRET_ACCESS_KEY=... -fly secrets set HOLD_OWNER=did:plc:your-did-here +fly secrets set HOLD_REGISTRATION_OWNER_DID=did:plc:your-did-here ``` ## Request Flow @@ -227,22 +253,21 @@ fly secrets set HOLD_OWNER=did:plc:your-did-here 3. Manifest contains: - holdDid: "did:web:alice-storage.fly.dev" -4. AppView caches hold DID for 10 minutes (covers pull operation) +4. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123 -5. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123 +5. AppView reads the hold DID from the manifest's holdDid field (per request) -6. AppView uses cached hold DID from manifest +6. AppView gets service token from alice's PDS + (validated service tokens are cached ~45s to absorb a burst of blob requests) -7. AppView gets service token from alice's PDS - -8. AppView calls hold XRPC: +7. 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 +8. AppView redirects client to presigned S3 URL -10. Client downloads directly from S3 +9. Client downloads directly from S3 ``` **Key insight:** Pull uses the `holdDid` stored in the manifest, ensuring blobs are fetched from where they were originally pushed. @@ -251,8 +276,8 @@ fly secrets set HOLD_OWNER=did:plc:your-did-here ### Read Access -- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + authenticated users -- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users with crew membership +- **Public hold** (`server.public: true`): Anonymous + authenticated users +- **Private hold** (`server.public: false`): Authenticated users with crew membership ### Write Access @@ -290,7 +315,7 @@ atproto put-record \ --value '{ "$type": "io.atcr.hold.crew", "member": "did:plc:bob456", - "role": "admin", + "role": "crew", "permissions": ["blob:read", "blob:write"] }' ``` @@ -318,9 +343,9 @@ Hold service requires S3-compatible storage. Supported providers: ```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 HOLD_SERVER_PUBLIC_URL=https://team-hold.fly.dev +export HOLD_REGISTRATION_OWNER_DID=did:plc:admin +export HOLD_SERVER_PUBLIC=false # Private export AWS_ACCESS_KEY_ID=... export AWS_SECRET_ACCESS_KEY=... export S3_BUCKET=team-blobs diff --git a/docs/CREDENTIAL_HELPER.md b/docs/CREDENTIAL_HELPER.md index 4291e33..bac6141 100644 --- a/docs/CREDENTIAL_HELPER.md +++ b/docs/CREDENTIAL_HELPER.md @@ -2,11 +2,15 @@ ## Overview -The ATCR credential helper is distributed as pre-built binaries for all major platforms using GoReleaser and GitHub Actions. This document outlines the complete distribution pipeline. +The ATCR credential helper is distributed as pre-built binaries for Linux, +macOS, and Windows. Builds are produced with GoReleaser and the resulting +artifacts are published to the project's Tangled repository as ATProto records +(`sh.tangled.repo.artifact`) on the repo owner's PDS. There is no GitHub +release pipeline. This document describes the actual distribution flow. ## Why Go is Ideal for Credential Helpers -Go is actually the **perfect choice** for Docker credential helpers: +Go is a natural fit for Docker credential helpers: 1. **Cross-compilation** - Single command builds for all platforms 2. **Static binaries** - No runtime dependencies (unlike Node.js, Python, Ruby) @@ -16,22 +20,50 @@ Go is actually the **perfect choice** for Docker credential helpers: - docker-credential-ecr-login (AWS) - docker-credential-pass (community) +## Multi-Brand Structure + +The credential helper is built as multiple brand-specific binaries from a +single shared implementation: + +- **`pkg/credhelper/`** holds the entire implementation (Docker protocol + commands, device-flow auth, config storage, self-update). It exposes + `credhelper.Run(credhelper.Config{...})`. +- **`cmd/credential-helper/atcr/`** and **`cmd/credential-helper/seamark/`** + are thin `main` packages (each with its own `go.mod`) that supply a per-brand + `Config` and call `Run`. They differ only in brand identity: + + | Field | atcr binary | seamark binary | + |---|---|---| + | Binary name | `docker-credential-atcr` | `docker-credential-seamark` | + | Default registry | `atcr.io` | `seamark.cr` | + | Config dir (under `$HOME`) | `~/.atcr` | `~/.seamark` | + | Secret prefix | `atcr_device_` | `seamark_device_` | + + Both brands point `ReleasesBaseURL` at the same Tangled repo + (`https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64`) for self-update and + download. + +Each brand module is independently installable via `go install` (see +[From Source](#5-from-source)). The atcr module path is +`atcr.io/cmd/credential-helper/atcr`; the seamark module path is +`seamark.dev/cmd/credential-helper/seamark`. + ## Supported Platforms | Platform | Arch | Format | Status | |----------|------|--------|--------| -| Linux | amd64 | tar.gz | ✅ | -| Linux | arm64 | tar.gz | ✅ | -| macOS | amd64 (Intel) | tar.gz | ✅ | -| macOS | arm64 (Apple Silicon) | tar.gz | ✅ | -| Windows | amd64 | zip | ✅ | -| Windows | arm64 | zip | ✅ | +| Linux | amd64 | tar.gz | Available | +| Linux | arm64 | tar.gz | Available | +| macOS | amd64 (Intel) | tar.gz | Available | +| macOS | arm64 (Apple Silicon) | tar.gz | Available | +| Windows | amd64 | tar.gz | Available | +| Windows | arm64 | tar.gz | Available | ## Distribution Methods -### 1. GitHub Releases (Automated) +### 1. Tangled Releases (Automated) -**Trigger:** Push a git tag (e.g., `v1.0.0`) +**Trigger:** Push a version tag (e.g. `v1.0.0`). ```bash git tag v1.0.0 @@ -39,31 +71,48 @@ git push origin v1.0.0 ``` **What happens:** -1. GitHub Actions runs (`.github/workflows/release.yml`) -2. GoReleaser builds binaries for all platforms -3. Creates GitHub release with: - - Pre-built binaries (tar.gz/zip) - - Checksums file - - Changelog -4. Updates Homebrew tap (if configured) +1. The Tangled CI workflow `.tangled/workflows/release-credential-helper.yml` + runs on tags matching `v*`. +2. It installs `goat` (ATProto CLI) and `goreleaser`, then logs into the repo + owner's PDS once with `goat account login`. +3. `goreleaser release --clean` builds binaries for all platforms. +4. GoReleaser's `release` block is disabled (`release.disable: true`), so no + GitHub/forge release is created. Instead, a custom `publishers` block runs + `./scripts/publish-artifact.sh` for each built archive and the checksums + file. +5. `publish-artifact.sh` uploads each artifact as a PDS blob (`goat blob + upload`) and creates an `sh.tangled.repo.artifact` record referencing it. + The record uses a deterministic rkey derived from `(tag, artifact name)` so + retries are idempotent (Tangled's PDS returns HTTP 500, not 409, on + duplicate rkey). -**Workflow file:** `.github/workflows/release.yml` +**Workflow file:** `.tangled/workflows/release-credential-helper.yml` **Config file:** `.goreleaser.yaml` +**Publisher script:** `scripts/publish-artifact.sh` + +The artifacts become downloadable from the Tangled repo's tag download path +(see [Manual Download](#4-manual-download)). ### 2. Install Scripts +Both scripts are served by the AppView from its static directory +(`pkg/appview/public/static/`) at `/static/install.sh` and +`/static/install.ps1`. They resolve the latest version by following the +`{repo}/tags/latest` redirect chain on Tangled, then download the matching +archive from the tag download path. + **Linux/macOS:** `install.sh` - Detects OS and architecture -- Downloads latest release from GitHub -- Installs to `/usr/local/bin` (or custom dir) +- Resolves the latest tag from Tangled and downloads the archive +- Installs to `/usr/local/bin` (override with `INSTALL_DIR`) - Makes executable and verifies installation **Windows:** `install.ps1` - Detects architecture -- Downloads latest release -- Installs to `C:\Program Files\ATCR` (or custom dir) -- Adds to system PATH -- Requires Administrator privileges +- Resolves the latest tag from Tangled and downloads the archive +- Installs to `%ProgramFiles%\ATCR` (override with `ATCR_INSTALL_DIR`) +- Adds to system PATH (requires Administrator to modify the machine PATH) +- Uses the bundled `tar.exe` to extract the `.tar.gz` **Usage:** ```bash @@ -71,55 +120,71 @@ git push origin v1.0.0 curl -fsSL https://atcr.io/static/install.sh | bash # Windows (PowerShell) -iwr -useb https://atcr.io/install.ps1 | iex +iwr -useb https://atcr.io/static/install.ps1 | iex ``` -### 3. Homebrew (macOS) +Pin a specific version by setting `ATCR_VERSION` (e.g. `ATCR_VERSION=v1.0.0`) +before running either script. -**Setup required:** -1. Create `atcr-io/homebrew-tap` repository -2. Set `HOMEBREW_TAP_TOKEN` secret in GitHub Actions -3. GoReleaser automatically updates formula on release +### 3. Homebrew (macOS) — Not Currently Available -**Usage:** -```bash -brew tap atcr-io/tap -brew install docker-credential-atcr -``` - -**Benefits:** -- Automatic updates via `brew upgrade` -- Handles PATH configuration -- Native macOS experience +Homebrew distribution is **not available yet**. A `brews:` block exists in +`.goreleaser.yaml` but is commented out. If/when it is enabled, the formula +would live in the project's Tangled repo under `Formula/` and pull artifacts +from the Tangled tag download path. There is no published tap to `brew tap` +today; use one of the other methods. ### 4. Manual Download -Users can download directly from GitHub Releases: +Download the archive directly from the Tangled repo's tag download path. The +artifacts are published as `sh.tangled.repo.artifact` records on the repo +owner's PDS and served by Tangled at: + +``` +https://tangled.org///tags//download/ +``` ```bash # Example: Linux amd64 +REPO=https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64 VERSION=v1.0.0 -curl -LO https://github.com/atcr-io/atcr/releases/download/${VERSION}/docker-credential-atcr_${VERSION#v}_Linux_x86_64.tar.gz +curl -LO "${REPO}/tags/${VERSION}/download/docker-credential-atcr_${VERSION#v}_Linux_x86_64.tar.gz" tar -xzf docker-credential-atcr_${VERSION#v}_Linux_x86_64.tar.gz sudo install -m 755 docker-credential-atcr /usr/local/bin/ ``` +Tangled redirects DID to handle for the repo URL, so `curl -L` is needed to +follow the redirect. + ### 5. From Source -For users with Go installed: +For users with Go installed. Each brand is a separate installable module: ```bash -go install atcr.io/cmd/credential-helper@latest -sudo mv $(go env GOPATH)/bin/credential-helper /usr/local/bin/docker-credential-atcr +# atcr.io brand +go install atcr.io/cmd/credential-helper/atcr@latest +sudo mv "$(go env GOPATH)/bin/atcr" /usr/local/bin/docker-credential-atcr + +# seamark.dev brand +go install seamark.dev/cmd/credential-helper/seamark@latest +sudo mv "$(go env GOPATH)/bin/seamark" /usr/local/bin/docker-credential-seamark ``` -**Note:** This requires Go 1.26+ and compiles locally. +The installed binary takes the name of the leaf package directory (`atcr` / +`seamark`); rename it to `docker-credential-` so Docker can discover it. + +**Note:** This requires Go 1.26+ and compiles locally. Locally (inside the repo +workspace) `go.work` resolves the `atcr.io` dependency; standalone installs +resolve the `require atcr.io vX.Y.Z` line pinned in each brand's `go.mod`. ## Release Process ### Creating a New Release -1. **Update version** (if using version consts anywhere) +1. **Bump the pinned `atcr.io` version** in each brand's `go.mod` + (`cmd/credential-helper/atcr/go.mod`, + `cmd/credential-helper/seamark/go.mod`) if `go install` consumers need the + new code. 2. **Commit and tag:** ```bash @@ -131,12 +196,11 @@ sudo mv $(go env GOPATH)/bin/credential-helper /usr/local/bin/docker-credential- ``` 3. **Wait for CI:** - - GitHub Actions builds and releases automatically - - Check: https://github.com/atcr-io/atcr/actions + - The Tangled workflow builds and publishes artifacts automatically. 4. **Verify release:** - - Visit: https://github.com/atcr-io/atcr/releases - - Test install script: + - Confirm the `tags/latest` redirect resolves to the new tag and the archive + downloads: ```bash ATCR_VERSION=v1.0.0 curl -fsSL https://atcr.io/static/install.sh | bash docker-credential-atcr version @@ -144,7 +208,8 @@ sudo mv $(go env GOPATH)/bin/credential-helper /usr/local/bin/docker-credential- ### Version Information -GoReleaser injects version info at build time: +GoReleaser injects version info at build time via ldflags into the brand +`main` packages: ```go var ( @@ -167,20 +232,28 @@ docker-credential-atcr v1.0.0 (commit: abc123, built: 2025-01-15T10:30:00Z) Key sections: **Builds:** -- Binary name: `docker-credential-atcr` (Windows: `.exe` auto-added) +- One `credential-helper` build with `dir: ./cmd/credential-helper/atcr`, + binary name `docker-credential-atcr` (Windows: `.exe` auto-added) - Targets: Linux, macOS, Windows (amd64, arm64) - CGO disabled for static binaries -- Ldflags inject version info +- Ldflags inject version/commit/date **Archives:** -- Format: tar.gz (Linux/macOS), zip (Windows) +- Format: tar.gz for all platforms - Naming: `docker-credential-atcr_VERSION_OS_ARCH.tar.gz` -- Includes: LICENSE, README, INSTALLATION.md +- Includes: LICENSE, README, INSTALLATION -**Homebrew:** -- Auto-updates tap repository -- Formula includes version check -- Installs to Homebrew prefix +**Release:** +- `release.disable: true` — no GitHub/forge release is created. + +**Publishers:** +- A custom `atproto-pds` publisher runs `./scripts/publish-artifact.sh` for + each artifact, forwarding the Tangled-provided `TANGLED_REF_NAME`, + `TANGLED_REPO_DID`, and `REPO_URL` env vars (GoReleaser publishers run in a + sanitized sub-shell, so these must be forwarded explicitly). + +**Brews:** +- Present but commented out (Homebrew not currently enabled). **Changelog:** - Auto-generated from commits @@ -202,8 +275,14 @@ Docker looks for binaries named `docker-credential-*` in PATH: } ``` 3. Docker looks for `docker-credential-atcr` in PATH -4. Calls: `docker-credential-atcr get` (with `atcr.io` on stdin) -5. Helper returns credentials (JSON on stdout) +4. Calls `docker-credential-atcr get` with `atcr.io` on stdin (a plain string, + not JSON) +5. Helper returns credentials as JSON on stdout + +The `credHelpers` map value is the binary-name suffix after +`docker-credential-` (so `atcr` for `docker-credential-atcr`). The +`configure-docker` command (and the prompt at the end of `login`) writes this +entry automatically. ### PATH Requirements @@ -212,31 +291,84 @@ Docker looks for binaries named `docker-credential-*` in PATH: - Check with: `which docker-credential-atcr` **Windows:** -- Common locations: `C:\Windows\System32`, `C:\Program Files\ATCR` +- Common locations: `C:\Windows\System32`, `%ProgramFiles%\ATCR` - Check with: `where docker-credential-atcr` ## CI/CD Secrets -### Required GitHub Secrets +### Required Tangled Secret -1. **`GITHUB_TOKEN`** (automatic) - - Provided by GitHub Actions - - Used to create releases +- **`PUBLISH_APP_PASSWORD`** — an ATProto app password for the account that + owns the repo's artifact records. The workflow runs `goat account login -u + "$TANGLED_REPO_DID" -p "$PUBLISH_APP_PASSWORD"` once before GoReleaser, and + every `publish-artifact.sh` invocation reuses that session. -2. **`HOMEBREW_TAP_TOKEN`** (manual setup) - - Personal access token with `repo` scope - - Used to update Homebrew tap - - Can skip if not using Homebrew +There is no `GITHUB_TOKEN` or `HOMEBREW_TAP_TOKEN` — the project does not +release through GitHub. -### Setup Instructions +## Helper Behavior -```bash -# Create PAT with repo scope at: -# https://github.com/settings/tokens +The helper implements the standard Docker credential helper protocol plus a +few user-facing commands. Implementation lives in `pkg/credhelper/`. -# Add to repository secrets: -# https://github.com/atcr-io/atcr/settings/secrets/actions -``` +### Docker Protocol Commands (hidden) + +Called by Docker, not users (`pkg/credhelper/protocol.go`): + +- **`get`** — reads the server URL from stdin, resolves the stored account, + validates the device secret against the AppView, and returns + `{ServerURL, Username, Secret}` JSON. If the OAuth session has expired it + prints the login URL and fails so Docker re-prompts; on a generic invalid + result it removes the bad account. +- **`store`** — reads `{ServerURL, Username, Secret}` from stdin. Only stores + the credential if `Secret` carries the brand's secret prefix (e.g. + `atcr_device_`); other secrets (e.g. an app password from `docker login`) are + ignored. +- **`erase`** — removes the active (or sole) account for the server URL. +- **`list`** — returns `{ "host": "username", ... }` for all stored registries. + +### Device-Flow Authentication + +`login` (`pkg/credhelper/cmd_login.go`, `pkg/credhelper/device_auth.go`) runs +an OAuth-style device authorization flow against the AppView: + +1. `POST {appview}/auth/device/code` with `{"device_name": ""}`. + Response: `device_code`, `user_code`, `verification_uri`, `expires_in`, + `interval`. +2. The helper shows the `user_code`, then opens (or prints) + `{verification_uri}?user_code=` for the user to approve in a browser. +3. The helper polls `POST {appview}/auth/device/token` with + `{"device_code": ...}` every `interval` seconds until `expires_in`. + `authorization_pending` means keep polling; any other error aborts. On + success the response carries `device_secret`, `handle`, and `did`. +4. The account (handle, did, device secret) is saved to the brand config dir, + and the user is offered automatic Docker configuration. + +### Credential Validation + +`get` validates a stored device secret by calling `GET +{appview}/auth/token?service={appview}` with HTTP Basic auth +(`handle:device_secret`) and a 5s timeout (`validateCredentials` in +`device_auth.go`): + +- `200` → valid. +- `401` with body `{"error":"oauth_session_expired", "login_url": ...}` → + expired; the user is told to re-login. +- `401` otherwise → invalid; the account is removed. +- Network errors or other status codes → treated as valid (don't re-auth on + transient server issues). + +### Other User Commands + +- **`login [registry]`** — device-flow auth (default registry from brand Config). +- **`logout [registry]`** — remove a stored account. +- **`status`** — show configured registries and accounts. +- **`switch`** — change the active account for a registry. +- **`configure-docker`** — write `credHelpers` entries to + `~/.docker/config.json` for all configured registries. +- **`update [--check]`** — self-update by resolving `{ReleasesBaseURL}/tags/latest` + and downloading the matching archive. `get` also performs a cached + (24h) background update check and prints a notice if a newer version exists. ## Testing the Distribution @@ -251,10 +383,10 @@ Docker looks for binaries named `docker-credential-*` in PATH: 2. **Test specific platform:** ```bash goreleaser build --snapshot --clean --single-target - ./dist/docker-credential-atcr_*/docker-credential-atcr version + ./dist/credential-helper_*/docker-credential-atcr version ``` -3. **Test full release (dry run):** +3. **Test full release without publishing:** ```bash goreleaser release --snapshot --clean --skip=publish ``` @@ -279,33 +411,14 @@ Docker looks for binaries named `docker-credential-*` in PATH: ### Package Managers -**Linux:** -- `.deb` packages for Debian/Ubuntu (via GoReleaser) -- `.rpm` packages for RHEL/Fedora/CentOS -- AUR package for Arch Linux - -**macOS:** -- Official Homebrew core (requires popularity/maturity) - -**Windows:** -- Chocolatey package -- Scoop manifest -- Winget package +- Homebrew (enable the commented `brews:` block in `.goreleaser.yaml`) +- `.deb` / `.rpm` packages (via GoReleaser nfpm) +- Arch AUR, Chocolatey, Scoop, Winget ### Docker Distribution -Could also distribute the credential helper via container: - -```bash -docker run --rm atcr.io/credential-helper:latest version - -# Install from container -docker run --rm -v /usr/local/bin:/install \ - atcr.io/credential-helper:latest \ - cp /usr/local/bin/docker-credential-atcr /install/ -``` - -**Note:** Not recommended as primary method (users want native binaries), but useful for CI/CD pipelines. +The credential helper could also ship as a container image for CI/CD use, but +native binaries remain the primary distribution method. ## Troubleshooting @@ -357,4 +470,4 @@ uname -m - [Docker Credential Helpers Spec](https://github.com/docker/docker-credential-helpers) - [GoReleaser Documentation](https://goreleaser.com) -- [GitHub Actions: Publishing](https://docs.github.com/en/actions/publishing-packages) +- [Tangled](https://tangled.org) diff --git a/docs/CREDENTIAL_HELPER_V2.md b/docs/CREDENTIAL_HELPER_V2.md deleted file mode 100644 index 582c302..0000000 --- a/docs/CREDENTIAL_HELPER_V2.md +++ /dev/null @@ -1,165 +0,0 @@ -# Credential Helper Rewrite - -## Context - -The current credential helper (`cmd/credential-helper/main.go`, ~1070 lines) is a monolithic single-file binary with a manual `switch` dispatch. It has no help text, hangs silently when run without stdin, embeds interactive device auth inside the Docker protocol `get` command (blocking pushes for up to 2 minutes while polling), and only supports one account per registry. Users want multi-account support (e.g., `evan.jarrett.net` and `michelle.jarrett.net` on the same `atcr.io`) and multi-registry support (e.g., `atcr.io` + `buoy.cr`). - -## Approach - -Rewrite using **Cobra** (already a project dependency) for the CLI framework and **charmbracelet/huh** for interactive prompts (select menus, confirmations, spinners). Separate Docker protocol commands (machine-readable, hidden) from user-facing commands (interactive, discoverable). Model after `gh auth` UX patterns. - -**Smart account auto-detection**: The `get` command inspects the parent process command line (`/proc//cmdline` on Linux, `ps` on macOS) to determine which image Docker is pushing/pulling. Since ATCR URLs are `host//repo:tag`, we can extract the identity and auto-select the matching account — no prompts, no manual switching needed in the common case. - -## Command Tree - -``` -docker-credential-atcr - ├── get (Docker protocol — stdin/stdout, hidden, smart account detection) - ├── store (Docker protocol — stdin, hidden) - ├── erase (Docker protocol — stdin, hidden) - ├── list (Docker protocol extension, hidden) - ├── login (Interactive device flow with huh prompts) - ├── logout (Remove account credentials) - ├── status (Show all accounts with active indicators) - ├── switch (Switch active account — auto-toggle for 2, select for 3+) - ├── configure-docker (Auto-edit ~/.docker/config.json credHelpers) - ├── update (Self-update, existing logic preserved) - └── version (Built-in via cobra) -``` - -## Smart Account Resolution (`get` command) - -The `get` command resolves which account to use with this priority chain — fully non-interactive: - -``` -1. Parse parent process cmdline → extract identity from image ref - docker push atcr.io/evan.jarrett.net/test:latest - → parent cmdline contains "evan.jarrett.net" → use that account - -2. Fall back to active account (set by `switch` command) - -3. Fall back to sole account (if only one exists for this registry) - -4. Error with helpful message: - "Multiple accounts for atcr.io. Run: docker-credential-atcr switch" -``` - -**Parent process detection** (in `helpers.go`): -- Linux: read `/proc//cmdline` (null-separated args) -- macOS: `ps -o args= -p ` -- Windows: best-effort via `wmic` or skip (fall to active account) -- Parse image ref: find the arg matching `//...`, extract `` -- Graceful failure: if parent isn't Docker, cmdline unreadable, or image ref not parseable → fall through to active account - -## File Structure - -``` -cmd/credential-helper/ - main.go — Cobra root command, version vars, subcommand registration - config.go — Config types, load/save/migrate, getConfigPath - device_auth.go — authorizeDevice(), validateCredentials() HTTP logic - protocol.go — Docker protocol: get, store, erase, list (all hidden) - cmd_login.go — login command (huh prompts + device flow) - cmd_logout.go — logout command (huh confirm) - cmd_status.go — status display - cmd_switch.go — switch command (huh select) - cmd_configure.go — configure-docker (edit ~/.docker/config.json) - cmd_update.go — update command (moved from existing code) - helpers.go — openBrowser, buildAppViewURL, isInsecureRegistry, parentCmdline, etc. -``` - -## Config Format (`~/.atcr/device.json`) - -```json -{ - "version": 2, - "registries": { - "https://atcr.io": { - "active": "evan.jarrett.net", - "accounts": { - "evan.jarrett.net": { - "handle": "evan.jarrett.net", - "did": "did:plc:abc123", - "device_secret": "atcr_device_..." - }, - "michelle.jarrett.net": { - "handle": "michelle.jarrett.net", - "did": "did:plc:def456", - "device_secret": "atcr_device_..." - } - } - }, - "https://buoy.cr": { - "active": "evan.jarrett.net", - "accounts": { ... } - } - } -} -``` - -**Migration**: `loadConfig()` auto-detects and migrates from old formats: -- Legacy single-device `{handle, device_secret, appview_url}` → v2 -- Current multi-registry `{credentials: {url: {...}}}` → v2 -- Writes back migrated config on first load - -## Key Behavioral Changes - -| Command | Current | New | -|---------|---------|-----| -| `get` | Opens browser, polls 2min if no creds | Smart detection → active account → error | -| `get` (multi-account) | N/A (single account only) | Auto-detects identity from parent cmdline | -| `get` (no stdin) | Hangs forever | Detects terminal, prints help, exits 1 | -| `get` (OAuth expired) | Auto-opens browser, polls | Prints login URL, exits 1 | -| `store` | No-op | Stores if secret is device secret (`atcr_device_*`) | -| `erase` | Removes all creds for host | Removes active account only | -| No args | Prints bare usage | Prints full cobra help with all commands | - -## Dependencies - -- `github.com/spf13/cobra` — already in go.mod -- `github.com/charmbracelet/huh` — new (pure Go, CGO_ENABLED=0 safe) - -No changes to `.goreleaser.yaml` needed. - -## Implementation Order - -### Phase 1: Foundation -1. `helpers.go` — move utility functions verbatim + add `getParentCmdline()` and `detectIdentityFromParent(registryHost)` -2. `config.go` — new config types + migration from old formats -3. `main.go` — Cobra root command, register all subcommands - -### Phase 2: Docker Protocol (must work for existing users) -4. `device_auth.go` — extract `authorizeDevice()` + `validateCredentials()` -5. `protocol.go` — `get`/`store`/`erase`/`list` using new config with smart account resolution - -### Phase 3: User Commands -6. `cmd_login.go` — interactive device flow with huh spinner -7. `cmd_status.go` — display all registries/accounts -8. `cmd_switch.go` — huh select for account switching -9. `cmd_logout.go` — huh confirm for removal -10. `cmd_configure.go` — Docker config.json manipulation -11. `cmd_update.go` — move existing update logic - -### Phase 4: Polish -12. Add `huh` to go.mod -13. Delete old `main.go` contents (replaced by new files) - -## What to Keep vs Rewrite - -**Keep** (move to new files): `openBrowser()`, `buildAppViewURL()`, `isInsecureRegistry()`, `getDockerInsecureRegistries()`, `readDockerDaemonConfig()`, `stripPort()`, `isTerminal()`, `authorizeDevice()` HTTP logic, `validateCredentials()`, all update/version check functions. - -**Rewrite**: `main()`, `handleGet()` (split into non-interactive `get` with smart detection + interactive `login`), `handleStore()` (implement actual storage), `handleErase()` (multi-account aware), config types and loading. - -**New**: `list`, `login`, `logout`, `status`, `switch`, `configure-docker` commands. Config migration. Parent process identity detection. huh integration. - -## Verification - -1. Build: `go build -o bin/docker-credential-atcr ./cmd/credential-helper` -2. Help works: `bin/docker-credential-atcr --help` shows all user commands -3. Protocol works: `echo "atcr.io" | bin/docker-credential-atcr get` returns credentials or helpful error -4. No hang: `bin/docker-credential-atcr get` (no stdin pipe) detects terminal, prints help, exits -5. Smart detection: `docker push atcr.io/evan.jarrett.net/test:latest` auto-selects `evan.jarrett.net` -6. Login flow: `bin/docker-credential-atcr login` triggers device auth with huh prompts -7. Status: `bin/docker-credential-atcr status` shows configured accounts -8. Config migration: Place old-format `~/.atcr/device.json`, run any command, verify auto-migration -9. GoReleaser: `CGO_ENABLED=0 go build ./cmd/credential-helper` succeeds diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 43a540a..f217aa9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,724 +1,339 @@ # Development Workflow for ATCR -## The Problem +## Goal -**Current development cycle with Docker:** -1. Edit CSS, JS, template, or Go file -2. Run `docker compose build` (rebuilds entire image) -3. Run `docker compose up` (restart container) -4. Wait **2-3 minutes** for changes to appear -5. Test, find issue, repeat... +Run the ATCR services (AppView, Hold, Labeler) locally with hot reload so that +Go, template, CSS, and JS changes show up after a fast incremental rebuild +instead of a full production image rebuild. -**Why it's slow:** -- All assets embedded via `embed.FS` at compile time -- Multi-stage Docker build compiles everything from scratch -- No development mode exists -- Final image uses `scratch` base (no tools, no hot reload) - -## The Solution - -**Development setup combining:** -1. **Dockerfile.devel** - Development-focused container (golang base, not scratch) -2. **Volume mounts** - Live code editing (changes appear instantly in container) -3. **DirFS** - Skip embed, read templates/CSS/JS from filesystem -4. **Air** - Auto-rebuild on Go code changes - -**Results:** -- CSS/JS/Template changes: **Instant** (0 seconds, just refresh browser) -- Go code changes: **2-5 seconds** (vs 2-3 minutes) -- Production builds: **Unchanged** (still optimized with embed.FS) +The mechanism is **Air** (`github.com/air-verse/air`) running inside a +development container. Air watches the mounted source tree and rebuilds the +relevant binary on change. Production images are unaffected — they use the +multi-stage `Dockerfile.appview` / `Dockerfile.hold` / `Dockerfile.scanner` +builds with embedded assets. ## How It Works +All UI assets are embedded into the binary via `//go:embed` in +`pkg/appview/ui.go` (`//go:embed public` and `//go:embed templates/**/*.html`). +There is **no** filesystem-vs-embed toggle and no `ATCR_DEV_MODE` switch — the +binary always serves embedded assets. Hot reload therefore works by having Air +**rebuild the binary** whenever a watched file changes, not by reading templates +off disk at request time. + +When Air rebuilds the AppView binary it runs a `pre_cmd` of +`go generate ./pkg/appview/...`. The `//go:generate` directive in +`pkg/appview/ui.go` shells out to `npm run build:appview`, which regenerates the +CSS bundle (`pkg/appview/public/css/style.css`), the JS bundle +(`pkg/appview/public/js/bundle.min.js`), and the icon sprite +(`pkg/appview/public/icons.svg`) before they are re-embedded into the new +binary. + +> Do **not** run `npm run css:build` / `npm run js:build` manually. The +> `go generate` step driven by Air handles asset builds. Editing a source asset +> (CSS/JS/template) and saving triggers an Air rebuild, which regenerates and +> re-embeds the assets automatically. + ### Architecture Flow ``` ┌─────────────────────────────────────────────────────┐ -│ Your Editor (VSCode, etc) │ -│ Edit: style.css, app.js, *.html, *.go files │ +│ Your editor │ +│ Edit: *.go, templates/*.html, src/css/*, src/js/* │ └─────────────────┬───────────────────────────────────┘ │ (files saved to disk) ▼ ┌─────────────────────────────────────────────────────┐ -│ Volume Mount (docker-compose.dev.yml) │ -│ volumes: │ -│ - .:/app (entire codebase mounted) │ +│ Volume mount (docker-compose.yml) │ +│ volumes: │ +│ - .:/app:z (entire codebase mounted) │ └─────────────────┬───────────────────────────────────┘ - │ (changes appear instantly in container) + │ (changes appear in container) ▼ ┌─────────────────────────────────────────────────────┐ -│ Container (golang:1.25.7 base, has all tools) │ -│ │ -│ ┌──────────────────────────────────────┐ │ -│ │ Air (hot reload tool) │ │ -│ │ Watches: *.go, *.html, *.css, *.js │ │ -│ │ │ │ -│ │ On change: │ │ -│ │ - *.go → rebuild binary (2-5s) │ │ -│ │ - templates/css/js → restart only │ │ -│ └──────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────┐ │ -│ │ ATCR AppView (ATCR_DEV_MODE=true) │ │ -│ │ │ │ -│ │ ui.go checks DEV_MODE: │ │ -│ │ if DEV_MODE: │ │ -│ │ templatesFS = os.DirFS("...") │ │ -│ │ publicFS = os.DirFS("...") │ │ -│ │ else: │ │ -│ │ use embed.FS (production) │ │ -│ │ │ │ -│ │ Result: Reads from mounted files │ │ -│ └──────────────────────────────────────┘ │ +│ Container (mirror.gcr.io/library/golang:1.26.2) │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Air (github.com/air-verse/air) │ │ +│ │ poll = true, poll_interval = 500 │ │ +│ │ Watches: *.go *.html *.css *.js │ │ +│ │ │ │ +│ │ On change: │ │ +│ │ 1. pre_cmd: go generate (npm build) │ │ +│ │ 2. cmd: go build → ./tmp/atcr-* │ │ +│ │ 3. restart binary (entrypoint) │ │ +│ └──────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ATCR AppView (serves embedded assets from │ +│ the freshly built binary) │ └─────────────────────────────────────────────────────┘ ``` -### Change Scenarios +Polling (`poll = true`, `poll_interval = 500`) is **required**: inotify/fsnotify +events do not propagate reliably across Docker bind mounts, so Air polls the +mounted tree every 500ms instead. -#### Scenario 1: Edit CSS/JS/Templates -``` -1. Edit pkg/appview/public/css/style.css in VSCode -2. Save file -3. Change appears in container via volume mount (instant) -4. App uses os.DirFS → reads new file from disk (instant) -5. Refresh browser → see changes -``` -**Time:** **Instant** (0 seconds) -**No rebuild, no restart!** +## Files Involved -#### Scenario 2: Edit Go Code -``` -1. Edit pkg/appview/handlers/home.go -2. Save file -3. Air detects .go file change -4. Air runs: go build -o ./tmp/atcr-appview ./cmd/appview -5. Air kills old process and starts new binary -6. App runs with new code -``` -**Time:** **2-5 seconds** -**Fast incremental build!** +| File | Purpose | +|------|---------| +| `Dockerfile.dev` | Single dev image used by all three services. `golang:1.26.2-trixie` base with Air, Node/npm, and SQLite installed. Source comes from a volume mount, not `COPY`. Accepts an `AIR_CONFIG` build arg to select which `.air.*.toml` to run. | +| `docker-compose.yml` | The dev compose file (this *is* the primary compose file — there is no separate `docker-compose.dev.yml`). Defines `atcr-appview`, `atcr-hold`, `atcr-labeler`, and `victorialogs`, all on a fixed `172.28.0.0/24` network. | +| `.air.toml` | AppView Air config (default `AIR_CONFIG`). | +| `.air.hold.toml` | Hold Air config (selected via `AIR_CONFIG=.air.hold.toml`). | +| `.air.labeler.toml` | Labeler Air config (selected via `AIR_CONFIG=.air.labeler.toml`). | -## Implementation - -### Step 1: Create Dockerfile.devel - -Create `Dockerfile.devel` in project root: +## `Dockerfile.dev` ```dockerfile -# Development Dockerfile with hot reload support -FROM golang:1.25.7-trixie +# Development image with Air hot reload +FROM mirror.gcr.io/library/golang:1.26.2-trixie -# Install Air for hot reload -RUN go install github.com/cosmtrek/air@latest +ARG AIR_CONFIG=.air.toml -# Install SQLite (required for CGO in ATCR) -RUN apt-get update && apt-get install -y \ - sqlite3 \ - libsqlite3-dev \ - && rm -rf /var/lib/apt/lists/* +ENV DEBIAN_FRONTEND=noninteractive +ENV AIR_CONFIG=${AIR_CONFIG} + +RUN apt-get update && \ + apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl nodejs npm && \ + rm -rf /var/lib/apt/lists/* && \ + go install github.com/air-verse/air@latest WORKDIR /app -# Copy dependency files and download (cached layer) +# Copy go.mod first for layer caching COPY go.mod go.sum ./ RUN go mod download -# Note: Source code comes from volume mount -# (no COPY . . needed - that's the whole point!) - -# Air will handle building and running -CMD ["air", "-c", ".air.toml"] +# For development: source mounted as volume, Air handles builds +CMD ["sh", "-c", "air -c ${AIR_CONFIG}"] ``` -### Step 2: Create docker-compose.dev.yml +Note the Air install path is `github.com/air-verse/air@latest`. The old +`github.com/cosmtrek/air` module is archived and must not be used. -Create `docker-compose.dev.yml` in project root: +## `.air.toml` (AppView) -```yaml -version: '3.8' - -services: - atcr-appview: - build: - context: . - dockerfile: Dockerfile.devel - volumes: - # Mount entire codebase (live editing) - - .:/app - # Cache Go modules (faster rebuilds) - - go-cache:/go/pkg/mod - # Persist SQLite database - - atcr-ui-dev:/var/lib/atcr - environment: - # Enable development mode (uses os.DirFS) - ATCR_DEV_MODE: "true" - - # AppView configuration - ATCR_HTTP_ADDR: ":5000" - ATCR_BASE_URL: "http://localhost:5000" - ATCR_DEFAULT_HOLD_DID: "did:web:hold01.atcr.io" - - # Database - ATCR_UI_DATABASE_PATH: "/var/lib/atcr/ui.db" - - # Auth - ATCR_AUTH_KEY_PATH: "/var/lib/atcr/auth/private-key.pem" - - # Jetstream (optional) - # JETSTREAM_URL: "wss://jetstream2.us-east.bsky.network/subscribe" - # ATCR_BACKFILL_ENABLED: "false" - ports: - - "5000:5000" - networks: - - atcr-dev - - # Add other services as needed (postgres, hold, etc) - # atcr-hold: - # ... - -networks: - atcr-dev: - driver: bridge - -volumes: - go-cache: - atcr-ui-dev: -``` - -### Step 3: Create .air.toml - -Create `.air.toml` in project root: +This is the real file — keep it in sync rather than copying a hand-written +version. Load-bearing settings: ```toml -# Air configuration for hot reload -# https://github.com/cosmtrek/air - root = "." -testdata_dir = "testdata" tmp_dir = "tmp" [build] - # Arguments to pass to binary (AppView needs "serve") - args_bin = ["serve"] - - # Where to output the built binary - bin = "./tmp/atcr-appview" - - # Build command - cmd = "go build -o ./tmp/atcr-appview ./cmd/appview" - - # Delay before rebuilding (ms) - debounce rapid saves - delay = 1000 - - # Directories to exclude from watching - exclude_dir = [ - "tmp", - "vendor", - "bin", - ".git", - "node_modules", - "testdata" - ] - - # Files to exclude from watching - exclude_file = [] - - # Regex patterns to exclude - exclude_regex = ["_test\\.go"] - - # Don't rebuild if file content unchanged - exclude_unchanged = false - - # Follow symlinks - follow_symlink = false - - # Full command to run (leave empty to use cmd + bin) - full_bin = "" - - # Directories to include (empty = all) - include_dir = [] - - # File extensions to watch - include_ext = ["go", "html", "css", "js"] - - # Specific files to watch - include_file = [] - - # Delay before killing old process (s) - kill_delay = "0s" - - # Log file for build errors - log = "build-errors.log" - - # Use polling instead of fsnotify (for Docker/VM) - poll = false - poll_interval = 0 - - # Rerun binary if it exits - rerun = false - rerun_delay = 500 - - # Send interrupt signal instead of kill - send_interrupt = false - - # Stop on build error - stop_on_error = false - -[color] - # Colorize output - app = "" - build = "yellow" - main = "magenta" - runner = "green" - watcher = "cyan" - -[log] - # Show only app logs (not build logs) - main_only = false - - # Add timestamp to logs - time = false - -[misc] - # Clean tmp directory on exit - clean_on_exit = false - -[screen] - # Clear screen on rebuild - clear_on_rebuild = false - - # Keep scrollback - keep_scroll = true +# Use polling for Docker volume mounts (inotify doesn't work across mounts) +poll = true +poll_interval = 500 +# Pre-build: generate assets if missing (each string is a shell command) +pre_cmd = ["go generate ./pkg/appview/..."] +cmd = "go build -tags billing -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview" +entrypoint = ["./tmp/atcr-appview", "serve", "--config", "config-appview.example.yaml"] +include_ext = ["go", "html", "css", "js"] +exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules", "scanner", "pkg/hold", "pkg/labeler"] +exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"] +delay = 3000 +stop_on_error = true +send_interrupt = true +kill_delay = 3000 ``` -### Step 4: Modify pkg/appview/ui.go +Key points that differ from a naive config: -Add conditional filesystem loading to `pkg/appview/ui.go`: +- `poll = true` / `poll_interval = 500` — needed for Docker bind mounts. +- `pre_cmd` runs `go generate ./pkg/appview/...`, which regenerates and + re-embeds CSS/JS/icons before the build. +- `cmd` builds with `-tags billing` (AppView dev runs with billing support) and + `-buildvcs=false`. +- `entrypoint` is the full argv for the built binary: it runs + `serve --config config-appview.example.yaml`. (The example config is the dev + base config; env vars in `docker-compose.yml` override it.) +- The `exclude_regex` deliberately ignores the *generated* asset outputs + (`*.min.js`, `public/css/style.css`, `public/icons.svg`) so regeneration does + not trigger an infinite rebuild loop. -```go -package appview +`.air.hold.toml` and `.air.labeler.toml` are analogous: they build +`./cmd/hold` / `./cmd/labeler`, generate `./pkg/hold/...` (the labeler has no +generate step), and exclude the other services' packages from watching. -import ( - "embed" - "html/template" - "io/fs" - "log" - "net/http" - "os" -) +## Configuration via Environment Variables -// Embedded assets (used in production) -//go:embed templates/**/*.html -var embeddedTemplatesFS embed.FS +`docker-compose.yml` sets a base config file per service via the Air +`entrypoint` (`config-appview.example.yaml`, `config-hold.example.yaml`, +`config-labeler.example.yaml`) and overrides specific values with environment +variables. Viper maps env var names from the YAML path, prefixed with the +service prefix and joined with `_`. -//go:embed static -var embeddedpublicFS embed.FS +Real AppView env vars (note these are the *Viper-mapped* names, not invented +shorthand): -// Actual filesystems used at runtime (conditional) -var templatesFS fs.FS -var publicFS fs.FS +| Env var | Maps to | +|---------|---------| +| `ATCR_SERVER_ADDR` | `server.addr` (listen address, e.g. `:5000`) | +| `ATCR_SERVER_BASE_URL` | `server.base_url` | +| `ATCR_SERVER_MANAGED_HOLDS` | `server.managed_holds` — comma-separated DID list; **the first entry is the default blob-storage hold**. Viper splits on commas. | +| `ATCR_AUTH_CERT_PATH` | `auth.cert_path` | +| `ATCR_JETSTREAM_BACKFILL_ENABLED` | `jetstream.backfill_enabled` | +| `ATCR_LABELER_DID` | `labeler.did` | +| `ATCR_SERVER_TEST_MODE` | `server.test_mode` | +| `ATCR_LOG_LEVEL` | `log.level` | -func init() { - // Development mode: read from filesystem for instant updates - if os.Getenv("ATCR_DEV_MODE") == "true" { - log.Println("🔧 DEV MODE: Using filesystem for templates and static assets") - templatesFS = os.DirFS("pkg/appview/templates") - publicFS = os.DirFS("pkg/appview/static") - } else { - // Production mode: use embedded assets - log.Println("📦 PRODUCTION MODE: Using embedded assets") - templatesFS = embeddedTemplatesFS - publicFS = embeddedpublicFS - } -} +There is **no** `ATCR_DEV_MODE` variable anywhere in the codebase. Likewise +`ATCR_HTTP_ADDR`, `ATCR_BASE_URL`, `ATCR_DEFAULT_HOLD_DID`, `ATCR_AUTH_KEY_PATH`, +and `ATCR_BACKFILL_ENABLED` are *not* real — use the Viper-mapped names above. -// Templates returns parsed HTML templates -func Templates() *template.Template { - tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html") - if err != nil { - log.Fatalf("Failed to parse templates: %v", err) - } - return tmpl -} +Hold and Labeler use the `HOLD_` and `LABELER_` prefixes respectively +(e.g. `HOLD_SERVER_PUBLIC_URL`, `HOLD_SERVER_APPVIEW_DID`, +`LABELER_LABELER_PUBLIC_URL`). See `docker-compose.yml` for the dev values. -// StaticHandler returns a handler for static files -func StaticHandler() http.Handler { - sub, err := fs.Sub(publicFS, "static") - if err != nil { - log.Fatalf("Failed to create static sub-filesystem: %v", err) - } - return http.FileServer(http.FS(sub)) -} -``` - -**Important:** Update the `Templates()` function to NOT cache templates in dev mode: - -```go -// Templates returns parsed HTML templates -func Templates() *template.Template { - // In dev mode, reparse templates on every request (instant updates) - // In production, this could be cached - tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html") - if err != nil { - log.Fatalf("Failed to parse templates: %v", err) - } - return tmpl -} -``` - -If you're caching templates, wrap it with a dev mode check: - -```go -var templateCache *template.Template - -func Templates() *template.Template { - // Development: reparse every time (instant updates) - if os.Getenv("ATCR_DEV_MODE") == "true" { - tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html") - if err != nil { - log.Printf("Template parse error: %v", err) - return template.New("error") - } - return tmpl - } - - // Production: use cached templates - if templateCache == nil { - tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html") - if err != nil { - log.Fatalf("Failed to parse templates: %v", err) - } - templateCache = tmpl - } - return templateCache -} -``` - -### Step 5: Add to .gitignore - -Add Air's temporary directory to `.gitignore`: - -``` -# Air hot reload -tmp/ -build-errors.log -``` +S3/Storj credentials and shared secrets are loaded from an external +`../atcr-secrets.env` file referenced via `env_file:` in `docker-compose.yml`. ## Usage -### Starting Development Environment +### Start the dev environment + +`docker-compose.yml` is the dev compose file, so no `-f` flag is needed: ```bash -# Build and start dev container -docker compose -f docker-compose.dev.yml up --build +# Build and start everything (appview, hold, labeler, victorialogs) +docker compose up --build -# Or run in background -docker compose -f docker-compose.dev.yml up -d +# Or in the background +docker compose up -d -# View logs -docker compose -f docker-compose.dev.yml logs -f atcr-appview +# Tail a single service +docker compose logs -f atcr-appview ``` -You should see Air starting: +Services bind to fixed ports on the host: + +- AppView: http://localhost:5000 +- Hold: http://localhost:8080 +- Labeler: http://localhost:5002 +- Victoria Logs: http://localhost:9428 + +On a clean start you should see Air bootstrap, run the pre-build generate step, +build, and launch the binary, e.g.: ``` -atcr-appview | 🔧 DEV MODE: Using filesystem for templates and static assets -atcr-appview | -atcr-appview | __ _ ___ -atcr-appview | / /\ | | | |_) -atcr-appview | /_/--\ |_| |_| \_ , built with Go -atcr-appview | atcr-appview | watching . atcr-appview | !exclude tmp +atcr-appview | running pre_cmd: go generate ./pkg/appview/... atcr-appview | building... atcr-appview | running... +atcr-appview | ``` -### Development Workflow +### Daily workflow -#### 1. Edit Templates/CSS/JS (Instant Updates) +- **Edit Go code** → save → Air rebuilds (`go build`) and restarts the binary in + a few seconds. +- **Edit a template** (`pkg/appview/templates/**/*.html`) → save → Air rebuilds + so the new template is re-embedded. +- **Edit CSS source** (`pkg/appview/src/css/main.css`) → save → the `pre_cmd` + `go generate` regenerates `pkg/appview/public/css/style.css` via `npm run + build:appview`, then the binary rebuilds. +- **Edit JS source** (`pkg/appview/src/js/main.js`) → save → `go generate` + regenerates `pkg/appview/public/js/bundle.min.js`, then the binary rebuilds. -```bash -# Edit any template, CSS, or JS file -vim pkg/appview/templates/pages/home.html -vim pkg/appview/public/css/style.css -vim pkg/appview/public/js/app.js +Important asset-source vs. generated-output distinctions: -# Save file → changes appear instantly -# Just refresh browser (Cmd+R / Ctrl+R) -``` +| You edit (source) | Do NOT edit (generated) | +|-------------------|--------------------------| +| `pkg/appview/src/css/main.css` | `pkg/appview/public/css/style.css` | +| `pkg/appview/src/js/main.js` | `pkg/appview/public/js/bundle.min.js` | +| icon references in templates | `pkg/appview/public/icons.svg` | -**No rebuild, no restart!** Air might restart the app, but it's instant since no compilation is needed. +Refresh the browser after the rebuild completes. -#### 2. Edit Go Code (Fast Rebuild) - -```bash -# Edit any Go file -vim pkg/appview/handlers/home.go - -# Save file → Air detects change -# Air output shows: -# building... -# build successful in 2.3s -# restarting... - -# Refresh browser to see changes -``` - -**2-5 second rebuild** instead of 2-3 minutes! - -### Stopping Development Environment +### Stop the dev environment ```bash # Stop containers -docker compose -f docker-compose.dev.yml down +docker compose down -# Stop and remove volumes (fresh start) -docker compose -f docker-compose.dev.yml down -v +# Stop and wipe volumes (fresh DB / PDS / labeler state) +docker compose down -v ``` -## Production Builds +## Local Development (No Docker) -**Production builds are completely unchanged:** +For a tighter loop you can run a single service on the host. The `make dev` +target runs the AppView under Air using `.air.toml`: ```bash -# Production uses normal Dockerfile (embed.FS, scratch base) -docker compose build - -# Or specific service -docker compose build atcr-appview - -# Run production -docker compose up +make dev ``` -**Why it works:** -- Production doesn't set `ATCR_DEV_MODE=true` -- `ui.go` defaults to embedded assets when env var is unset -- Production Dockerfile still uses multi-stage build to scratch -- No development dependencies in production image +`make dev` ensures Air is installed (`go install github.com/air-verse/air@latest`), +builds the generated assets, and runs `air -c .air.toml`. -## Comparison - -| Change Type | Before (docker compose) | After (dev setup) | Improvement | -|-------------|------------------------|-------------------|-------------| -| Edit CSS | 2-3 minutes | **Instant (0s)** | ♾️x faster | -| Edit JS | 2-3 minutes | **Instant (0s)** | ♾️x faster | -| Edit Template | 2-3 minutes | **Instant (0s)** | ♾️x faster | -| Edit Go Code | 2-3 minutes | **2-5 seconds** | 24-90x faster | -| Production Build | Same | **Same** | No change | - -## Advanced: Local Development (No Docker) - -For even faster development, run locally without Docker: +You can also run Air directly, or skip hot reload entirely: ```bash -# Set environment variables -export ATCR_DEV_MODE=true -export ATCR_HTTP_ADDR=:5000 -export ATCR_BASE_URL=http://localhost:5000 -export ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io -export ATCR_UI_DATABASE_PATH=/tmp/atcr-ui.db -export ATCR_AUTH_KEY_PATH=/tmp/atcr-auth-key.pem - -# Or use .env file -source .env.appview - -# Run with Air +# Air, AppView config air -c .air.toml -# Or run directly (no hot reload) -go run ./cmd/appview serve +# No hot reload — build and run once +go build -tags billing -o bin/atcr-appview ./cmd/appview +./bin/atcr-appview serve --config config-appview.example.yaml ``` -**Advantages:** -- Even faster (no Docker overhead) -- Native debugging with delve -- Direct filesystem access -- Full IDE integration +Running on the host requires a working toolchain for the build: +Go 1.26.2 (see `go.work`), Node/npm (for the `go generate` asset step), and +SQLite headers. Override config values with the `ATCR_*` env vars listed above, +or edit your local config file. -**Disadvantages:** -- Need to manage dependencies locally (SQLite, etc) -- May differ from production environment +## Production Builds (Unchanged) + +Production images use the multi-stage Dockerfiles and embed all assets at +compile time: + +```bash +make docker # build appview + hold + scanner images +make docker-appview # just the appview image +``` + +These do not involve Air, do not bind-mount source, and serve embedded assets +exactly as the dev binary does — the only difference is that the dev container +rebuilds on change. ## Troubleshooting -### Air Not Rebuilding - -**Problem:** Air doesn't detect changes - -**Solution:** -```bash -# Check if Air is actually running -docker compose -f docker-compose.dev.yml logs atcr-appview - -# Check .air.toml include_ext includes your file type -# Default: ["go", "html", "css", "js"] - -# Restart container -docker compose -f docker-compose.dev.yml restart atcr-appview -``` - -### Templates Not Updating - -**Problem:** Template changes don't appear - -**Solution:** -```bash -# Check ATCR_DEV_MODE is set -docker compose -f docker-compose.dev.yml exec atcr-appview env | grep DEV_MODE - -# Should output: ATCR_DEV_MODE=true - -# Check templates aren't cached (see Step 4 above) -# Templates() should reparse in dev mode -``` - -### Go Build Failing - -**Problem:** Air shows build errors - -**Solution:** -```bash -# Check build logs -docker compose -f docker-compose.dev.yml logs atcr-appview - -# Or check build-errors.log in container -docker compose -f docker-compose.dev.yml exec atcr-appview cat build-errors.log - -# Fix the Go error, save file, Air will retry -``` - -### Volume Mount Not Working - -**Problem:** Changes don't appear in container - -**Solution:** -```bash -# Verify volume mount -docker compose -f docker-compose.dev.yml exec atcr-appview ls -la /app - -# Should show your source files - -# On Windows/Mac, check Docker Desktop file sharing settings -# Settings → Resources → File Sharing → add project directory -``` - -### Permission Errors - -**Problem:** Cannot write to /var/lib/atcr - -**Solution:** -```bash -# In Dockerfile.devel, add: -RUN mkdir -p /var/lib/atcr && chmod 777 /var/lib/atcr - -# Or use named volumes (already in docker-compose.dev.yml) -volumes: - - atcr-ui-dev:/var/lib/atcr -``` - -### Slow Builds Even with Air - -**Problem:** Air rebuilds slowly - -**Solution:** -```bash -# Use Go module cache volume (already in docker-compose.dev.yml) -volumes: - - go-cache:/go/pkg/mod - -# Increase Air delay to debounce rapid saves -# In .air.toml: -delay = 2000 # 2 seconds - -# Or check if CGO is slowing builds -# AppView needs CGO for SQLite, but you can try: -CGO_ENABLED=0 go build # (won't work for ATCR, but good to know) -``` - -## Tips & Tricks - -### Browser Auto-Reload (LiveReload) - -Add LiveReload for automatic browser refresh: +### Air not rebuilding ```bash -# Install browser extension -# Chrome: https://chrome.google.com/webstore/detail/livereload -# Firefox: https://addons.mozilla.org/en-US/firefox/addon/livereload-web-extension/ - -# Add livereload to .air.toml (future Air feature) -# Or use a separate tool like browsersync +docker compose logs atcr-appview +# Confirm Air is running and polling. poll=true is required for bind mounts; +# without it, saved files are never detected. +docker compose restart atcr-appview ``` -### Database Resets +Confirm your file type is in `include_ext` (`go`, `html`, `css`, `js`) and that +you are editing a *source* file, not a generated output excluded by +`exclude_regex`. -Development database is in a named volume: +### Go build failing ```bash -# Reset database (fresh start) -docker compose -f docker-compose.dev.yml down -v -docker compose -f docker-compose.dev.yml up - -# Or delete specific volume -docker volume rm atcr_atcr-ui-dev +docker compose logs atcr-appview +# Air prints build errors inline and (with stop_on_error=true) holds the old +# binary until the build succeeds again. Fix the error and save. ``` -### Multiple Environments - -Run dev and production side-by-side: +### Volume mount not working ```bash -# Development on port 5000 -docker compose -f docker-compose.dev.yml up -d - -# Production on port 5001 -docker compose up -d - -# Now you can compare behavior +docker compose exec atcr-appview ls -la /app +# Should show your source tree. On macOS/Windows check Docker Desktop file +# sharing for the project directory. ``` -### Debugging with Delve +### Asset changes not showing -Add delve to Dockerfile.devel: - -```dockerfile -RUN go install github.com/go-delve/delve/cmd/dlv@latest - -# Change CMD to use delve -CMD ["dlv", "debug", "./cmd/appview", "--headless", "--listen=:2345", "--api-version=2", "--accept-multiclient", "--", "serve"] -``` - -Then connect with VSCode or GoLand. - -## Summary - -**Development Setup (One-Time):** -1. Create `Dockerfile.devel` -2. Create `docker-compose.dev.yml` -3. Create `.air.toml` -4. Modify `pkg/appview/ui.go` for conditional DirFS -5. Add `tmp/` to `.gitignore` - -**Daily Development:** -```bash -# Start -docker compose -f docker-compose.dev.yml up - -# Edit files in your editor -# Changes appear instantly (CSS/JS/templates) -# Or in 2-5 seconds (Go code) - -# Stop -docker compose -f docker-compose.dev.yml down -``` - -**Production (Unchanged):** -```bash -docker compose build -docker compose up -``` - -**Result:** 100x faster development iteration! 🚀 +CSS/JS/icon changes only take effect after the `go generate` pre-build runs and +the binary rebuilds. If a save did not trigger a rebuild, you likely edited a +generated output file (excluded from watching) instead of its source under +`pkg/appview/src/`. diff --git a/docs/DIRECT_HOLD_ACCESS.md b/docs/DIRECT_HOLD_ACCESS.md index 40cd58c..8495150 100644 --- a/docs/DIRECT_HOLD_ACCESS.md +++ b/docs/DIRECT_HOLD_ACCESS.md @@ -247,7 +247,7 @@ Each step after #3 requires generating a fresh DPoP proof JWT, which is why libr ### "Invalid token" or "Token expired" -Service tokens are only valid for ~60 seconds. Get a fresh one: +Service tokens are requested with a 5-minute (300 second) expiry, though the PDS may grant less. Get a fresh one: ```bash SERVICE_TOKEN=$(curl -s "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \ -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token') @@ -290,7 +290,7 @@ curl -s "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$DID&collect ## Security Notes - **App passwords** are scoped tokens that can be revoked without changing your main password -- **Service tokens** are short-lived (60 seconds) and scoped to a specific hold +- **Service tokens** are short-lived (requested with a 5-minute expiry; the PDS may grant less) and scoped to a specific hold - **Never share** your app password or access tokens - Service tokens can only be used for the specific hold they were requested for (`aud` claim) diff --git a/docs/HOLD_AS_CA.md b/docs/HOLD_AS_CA.md deleted file mode 100644 index 8ee0337..0000000 --- a/docs/HOLD_AS_CA.md +++ /dev/null @@ -1,756 +0,0 @@ -# Hold-as-Certificate-Authority Architecture - -## ⚠️ Important Notice - -This document describes an **optional enterprise feature** for X.509 PKI compliance. The hold-as-CA approach introduces **centralization trade-offs** that contradict ATProto's decentralized philosophy. - -**Default Recommendation:** Use [plugin-based integration](./INTEGRATION_STRATEGY.md) instead. Only implement hold-as-CA if your organization has specific X.509 PKI compliance requirements. - -## Overview - -The hold-as-CA architecture allows ATCR to generate Notation/Notary v2-compatible signatures by having hold services act as Certificate Authorities that issue X.509 certificates for users. - -### The Problem - -- **ATProto signatures** use K-256 (secp256k1) elliptic curve -- **Notation** only supports P-256, P-384, P-521 elliptic curves -- **Cannot convert** K-256 signatures to P-256 (different cryptographic curves) -- **Must re-sign** with P-256 keys for Notation compatibility - -### The Solution - -Hold services act as trusted Certificate Authorities (CAs): - -1. User pushes image → Manifest signed by PDS with K-256 (ATProto) -2. Hold verifies ATProto signature is valid -3. Hold generates ephemeral P-256 key pair for user -4. Hold issues X.509 certificate to user's DID -5. Hold signs manifest with P-256 key -6. Hold creates Notation signature envelope (JWS format) -7. Stores both ATProto and Notation signatures - -**Result:** Images have two signatures: -- **ATProto signature** (K-256) - Decentralized, DID-based -- **Notation signature** (P-256) - Centralized, X.509 PKI - -## Architecture - -### Certificate Chain - -``` -Hold Root CA Certificate (self-signed, P-256) - └── User Certificate (issued to DID, P-256) - └── Image Manifest Signature -``` - -**Hold Root CA:** -``` -Subject: CN=ATCR Hold CA - did:web:hold01.atcr.io -Issuer: Self (self-signed) -Key Usage: Digital Signature, Certificate Sign -Basic Constraints: CA=true, pathLen=1 -Algorithm: ECDSA P-256 -Validity: 10 years -``` - -**User Certificate:** -``` -Subject: CN=did:plc:alice123 -SAN: URI:did:plc:alice123 -Issuer: Hold Root CA -Key Usage: Digital Signature -Extended Key Usage: Code Signing -Algorithm: ECDSA P-256 -Validity: 24 hours (short-lived) -``` - -### Push Flow - -``` -┌──────────────────────────────────────────────────────┐ -│ 1. User: docker push atcr.io/alice/myapp:latest │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 2. AppView stores manifest in alice's PDS │ -│ - PDS signs with K-256 (ATProto standard) │ -│ - Signature stored in repository commit │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 3. AppView requests hold to co-sign │ -│ POST /xrpc/io.atcr.hold.coSignManifest │ -│ { │ -│ "userDid": "did:plc:alice123", │ -│ "manifestDigest": "sha256:abc123...", │ -│ "atprotoSignature": {...} │ -│ } │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 4. Hold verifies ATProto signature │ -│ a. Resolve alice's DID → public key │ -│ b. Fetch commit from alice's PDS │ -│ c. Verify K-256 signature │ -│ d. Ensure signature is valid │ -│ │ -│ If verification fails → REJECT │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 5. Hold generates ephemeral P-256 key pair │ -│ privateKey := ecdsa.GenerateKey(elliptic.P256()) │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 6. Hold issues X.509 certificate │ -│ Subject: CN=did:plc:alice123 │ -│ SAN: URI:did:plc:alice123 │ -│ Issuer: Hold CA │ -│ NotBefore: now │ -│ NotAfter: now + 24 hours │ -│ KeyUsage: Digital Signature │ -│ ExtKeyUsage: Code Signing │ -│ │ -│ Sign certificate with hold's CA private key │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 7. Hold signs manifest digest │ -│ hash := SHA256(manifestBytes) │ -│ signature := ECDSA_P256(hash, privateKey) │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 8. Hold creates Notation JWS envelope │ -│ { │ -│ "protected": {...}, │ -│ "payload": "base64(manifestDigest)", │ -│ "signature": "base64(p256Signature)", │ -│ "header": { │ -│ "x5c": [ │ -│ "base64(userCert)", │ -│ "base64(holdCACert)" │ -│ ] │ -│ } │ -│ } │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 9. Hold returns signature to AppView │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 10. AppView stores Notation signature │ -│ - Create ORAS artifact manifest │ -│ - Upload JWS envelope as layer blob │ -│ - Link to image via subject field │ -│ - artifactType: application/vnd.cncf.notary... │ -└──────────────────────────────────────────────────────┘ -``` - -### Verification Flow - -``` -┌──────────────────────────────────────────────────────┐ -│ User: notation verify atcr.io/alice/myapp:latest │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 1. Notation queries Referrers API │ -│ GET /v2/alice/myapp/referrers/sha256:abc123 │ -│ → Discovers Notation signature artifact │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 2. Notation downloads JWS envelope │ -│ - Parses JSON Web Signature │ -│ - Extracts certificate chain from x5c header │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 3. Notation validates certificate chain │ -│ a. User cert issued by Hold CA? ✓ │ -│ b. Hold CA cert in trust store? ✓ │ -│ c. Certificate not expired? ✓ │ -│ d. Key usage correct? ✓ │ -│ e. Subject matches policy? ✓ │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 4. Notation verifies signature │ -│ a. Extract public key from user certificate │ -│ b. Compute manifest hash: SHA256(manifest) │ -│ c. Verify: ECDSA_P256(hash, sig, pubKey) ✓ │ -└────────────────────┬─────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────┐ -│ 5. Success: Image verified ✓ │ -│ Signed by: did:plc:alice123 (via Hold CA) │ -└──────────────────────────────────────────────────────┘ -``` - -## Implementation - -### Hold CA Certificate Generation - -```go -// cmd/hold/main.go - CA initialization -func (h *Hold) initializeCA(ctx context.Context) error { - caKeyPath := filepath.Join(h.config.DataDir, "ca-private-key.pem") - caCertPath := filepath.Join(h.config.DataDir, "ca-certificate.pem") - - // Load existing CA or generate new one - if exists(caKeyPath) && exists(caCertPath) { - h.caKey = loadPrivateKey(caKeyPath) - h.caCert = loadCertificate(caCertPath) - return nil - } - - // Generate P-256 key pair for CA - caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return fmt.Errorf("failed to generate CA key: %w", err) - } - - // Create CA certificate template - serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - - template := &x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - CommonName: fmt.Sprintf("ATCR Hold CA - %s", h.DID), - }, - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(10, 0, 0), // 10 years - - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, - BasicConstraintsValid: true, - IsCA: true, - MaxPathLen: 1, // Can only issue end-entity certificates - } - - // Self-sign - certDER, err := x509.CreateCertificate( - rand.Reader, - template, - template, // Self-signed: issuer = subject - &caKey.PublicKey, - caKey, - ) - if err != nil { - return fmt.Errorf("failed to create CA certificate: %w", err) - } - - caCert, _ := x509.ParseCertificate(certDER) - - // Save to disk (0600 permissions) - savePrivateKey(caKeyPath, caKey) - saveCertificate(caCertPath, caCert) - - h.caKey = caKey - h.caCert = caCert - - log.Info("Generated new CA certificate", "did", h.DID, "expires", caCert.NotAfter) - return nil -} -``` - -### User Certificate Issuance - -```go -// pkg/hold/cosign.go -func (h *Hold) issueUserCertificate(userDID string) (*x509.Certificate, *ecdsa.PrivateKey, error) { - // Generate ephemeral P-256 key for user - userKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, fmt.Errorf("failed to generate user key: %w", err) - } - - serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - - // Parse DID for SAN - sanURI, _ := url.Parse(userDID) - - template := &x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - CommonName: userDID, - }, - URIs: []*url.URL{sanURI}, // Subject Alternative Name - - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour), // Short-lived: 24 hours - - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, - BasicConstraintsValid: true, - IsCA: false, - } - - // Sign with hold's CA key - certDER, err := x509.CreateCertificate( - rand.Reader, - template, - h.caCert, // Issuer: Hold CA - &userKey.PublicKey, - h.caKey, // Sign with CA private key - ) - if err != nil { - return nil, nil, fmt.Errorf("failed to create user certificate: %w", err) - } - - userCert, _ := x509.ParseCertificate(certDER) - - return userCert, userKey, nil -} -``` - -### Co-Signing XRPC Endpoint - -```go -// pkg/hold/oci/xrpc.go -func (s *Server) handleCoSignManifest(ctx context.Context, req *CoSignRequest) (*CoSignResponse, error) { - // 1. Verify caller is authenticated - did, err := s.auth.VerifyToken(ctx, req.Token) - if err != nil { - return nil, fmt.Errorf("authentication failed: %w", err) - } - - // 2. Verify ATProto signature - valid, err := s.verifyATProtoSignature(ctx, req.UserDID, req.ManifestDigest, req.ATProtoSignature) - if err != nil || !valid { - return nil, fmt.Errorf("ATProto signature verification failed: %w", err) - } - - // 3. Issue certificate for user - userCert, userKey, err := s.hold.issueUserCertificate(req.UserDID) - if err != nil { - return nil, fmt.Errorf("failed to issue certificate: %w", err) - } - - // 4. Sign manifest with user's key - manifestHash := sha256.Sum256([]byte(req.ManifestDigest)) - signature, err := ecdsa.SignASN1(rand.Reader, userKey, manifestHash[:]) - if err != nil { - return nil, fmt.Errorf("failed to sign manifest: %w", err) - } - - // 5. Create JWS envelope - jws, err := s.createJWSEnvelope(signature, userCert, s.hold.caCert, req.ManifestDigest) - if err != nil { - return nil, fmt.Errorf("failed to create JWS: %w", err) - } - - return &CoSignResponse{ - JWS: jws, - Certificate: encodeCertificate(userCert), - CACertificate: encodeCertificate(s.hold.caCert), - }, nil -} -``` - -## Trust Model - -### Centralization Analysis - -**ATProto Model (Decentralized):** -- Each PDS is independent -- User controls which PDS to use -- Trust user's DID, not specific infrastructure -- PDS compromise affects only that PDS's users -- Multiple PDSs provide redundancy - -**Hold-as-CA Model (Centralized):** -- Hold acts as single Certificate Authority -- All users must trust hold's CA certificate -- Hold compromise = attacker can issue certificates for ANY user -- Hold becomes single point of failure -- Users depend on hold operator honesty - -### What Hold Vouches For - -When hold issues a certificate, it attests: - -✅ **"I verified that [DID] signed this manifest with ATProto"** -- Hold validated ATProto signature -- Hold confirmed signature matches user's DID -- Hold checked signature at specific time - -❌ **"This image is safe"** -- Hold does NOT audit image contents -- Certificate ≠ vulnerability scan -- Signature ≠ security guarantee - -❌ **"I control this DID"** -- Hold does NOT control user's DID -- DID ownership is independent -- Hold cannot revoke DIDs - -### Threat Model - -**Scenario 1: Hold Private Key Compromise** - -**Attack:** -- Attacker steals hold's CA private key -- Can issue certificates for any DID -- Can sign malicious images as any user - -**Impact:** -- **CRITICAL** - All users affected -- Attacker can impersonate any user -- All signatures become untrustworthy - -**Detection:** -- Certificate Transparency logs (if implemented) -- Unusual certificate issuance patterns -- Users report unexpected signatures - -**Mitigation:** -- Store CA key in Hardware Security Module (HSM) -- Strict access controls -- Audit logging -- Regular key rotation - -**Recovery:** -- Revoke compromised CA certificate -- Generate new CA certificate -- Re-issue all active certificates -- Notify all users -- Update trust stores - ---- - -**Scenario 2: Malicious Hold Operator** - -**Attack:** -- Hold operator issues certificates without verifying ATProto signatures -- Hold operator signs malicious images -- Hold operator backdates certificates - -**Impact:** -- **HIGH** - Trust model broken -- Users receive signed malicious images -- Difficult to detect without ATProto cross-check - -**Detection:** -- Compare Notation signature timestamp with ATProto commit time -- Verify ATProto signature exists independently -- Monitor hold's signing patterns - -**Mitigation:** -- Audit trail linking certificates to ATProto signatures -- Public transparency logs -- Multi-signature requirements -- Periodically verify ATProto signatures - -**Recovery:** -- Identify malicious certificates -- Revoke hold's CA trust -- Switch to different hold -- Re-verify all images - ---- - -**Scenario 3: Certificate Theft** - -**Attack:** -- Attacker steals issued user certificate + private key -- Uses it to sign malicious images - -**Impact:** -- **LOW-MEDIUM** - Limited scope -- Affects only specific user/image -- Short validity period (24 hours) - -**Detection:** -- Unexpected signature timestamps -- Images signed from unknown locations - -**Mitigation:** -- Short certificate validity (24 hours) -- Ephemeral keys (not stored long-term) -- Certificate revocation if detected - -**Recovery:** -- Wait for certificate expiration (24 hours) -- Revoke specific certificate -- Investigate compromise source - -## Certificate Management - -### Expiration Strategy - -**Short-Lived Certificates (24 hours):** - -**Pros:** -- ✅ Minimal revocation infrastructure needed -- ✅ Compromise window is tiny -- ✅ Automatic cleanup -- ✅ Lower CRL/OCSP overhead - -**Cons:** -- ❌ Old images become unverifiable quickly -- ❌ Requires re-signing for historical verification -- ❌ Storage: multiple signatures for same image - -**Solution: On-Demand Re-Signing** -``` -User pulls old image → Notation verification fails (expired cert) -→ User requests re-signing: POST /xrpc/io.atcr.hold.reSignManifest -→ Hold verifies ATProto signature still valid -→ Hold issues new certificate (24 hours) -→ Hold creates new Notation signature -→ User can verify with fresh certificate -``` - -### Revocation - -**Certificate Revocation List (CRL):** -``` -Hold publishes CRL at: https://hold01.atcr.io/ca.crl - -Notation configured to check CRL: -{ - "trustPolicies": [{ - "name": "atcr-images", - "signatureVerification": { - "verificationLevel": "strict", - "override": { - "revocationValidation": "strict" - } - } - }] -} -``` - -**OCSP (Online Certificate Status Protocol):** -- Hold runs OCSP responder: `https://hold01.atcr.io/ocsp` -- Real-time certificate status checks -- Lower overhead than CRL downloads - -**Revocation Triggers:** -- Key compromise detected -- Malicious signing detected -- User request -- DID ownership change - -### CA Key Rotation - -**Rotation Procedure:** - -1. **Generate new CA key pair** -2. **Create new CA certificate** -3. **Cross-sign old CA with new CA** (transition period) -4. **Distribute new CA certificate** to all users -5. **Begin issuing with new CA** for new signatures -6. **Grace period** (30 days): Accept both old and new CA -7. **Retire old CA** after grace period - -**Frequency:** Every 2-3 years (longer than short-lived certs) - -## Trust Store Distribution - -### Problem - -Users must add hold's CA certificate to their Notation trust store for verification to work. - -### Manual Distribution - -```bash -# 1. Download hold's CA certificate -curl https://hold01.atcr.io/ca.crt -o hold01-ca.crt - -# 2. Verify fingerprint (out-of-band) -openssl x509 -in hold01-ca.crt -fingerprint -noout -# Compare with published fingerprint - -# 3. Add to Notation trust store -notation cert add --type ca --store atcr-holds hold01-ca.crt -``` - -### Automated Distribution - -**ATCR CLI tool:** -```bash -atcr trust add hold01.atcr.io -# → Fetches CA certificate -# → Verifies via HTTPS + DNSSEC -# → Adds to Notation trust store -# → Configures trust policy - -atcr trust list -# → Shows trusted holds with fingerprints -``` - -### System-Wide Trust - -**For enterprise deployments:** - -**Debian/Ubuntu:** -```bash -# Install CA certificate system-wide -cp hold01-ca.crt /usr/local/share/ca-certificates/atcr-hold01.crt -update-ca-certificates -``` - -**RHEL/CentOS:** -```bash -cp hold01-ca.crt /etc/pki/ca-trust/source/anchors/ -update-ca-trust -``` - -**Container images:** -```dockerfile -FROM ubuntu:22.04 -COPY hold01-ca.crt /usr/local/share/ca-certificates/ -RUN update-ca-certificates -``` - -## Configuration - -### Hold Service - -**Environment variables:** -```bash -# Enable co-signing feature -HOLD_COSIGN_ENABLED=true - -# CA certificate and key paths -HOLD_CA_CERT_PATH=/var/lib/atcr/hold/ca-certificate.pem -HOLD_CA_KEY_PATH=/var/lib/atcr/hold/ca-private-key.pem - -# Certificate validity -HOLD_CERT_VALIDITY_HOURS=24 - -# OCSP responder -HOLD_OCSP_ENABLED=true -HOLD_OCSP_URL=https://hold01.atcr.io/ocsp - -# CRL distribution -HOLD_CRL_ENABLED=true -HOLD_CRL_URL=https://hold01.atcr.io/ca.crl -``` - -### Notation Trust Policy - -```json -{ - "version": "1.0", - "trustPolicies": [{ - "name": "atcr-images", - "registryScopes": ["atcr.io/*/*"], - "signatureVerification": { - "level": "strict", - "override": { - "revocationValidation": "strict" - } - }, - "trustStores": ["ca:atcr-holds"], - "trustedIdentities": [ - "x509.subject: CN=did:plc:*", - "x509.subject: CN=did:web:*" - ] - }] -} -``` - -## When to Use Hold-as-CA - -### ✅ Use When - -**Enterprise X.509 PKI Compliance:** -- Organization requires standard X.509 certificates -- Existing security policies mandate PKI -- Audit requirements for certificate chains -- Integration with existing CA infrastructure - -**Tool Compatibility:** -- Must use standard Notation without plugins -- Cannot deploy custom verification tools -- Existing tooling expects X.509 signatures - -**Centralized Trust Acceptable:** -- Organization already uses centralized trust model -- Hold operator is internal/trusted team -- Centralization risk is acceptable trade-off - -### ❌ Don't Use When - -**Default Deployment:** -- Most users should use [plugin-based approach](./INTEGRATION_STRATEGY.md) -- Plugins maintain decentralization -- Plugins reuse existing ATProto signatures - -**Small Teams / Startups:** -- Certificate management overhead too high -- Don't need X.509 compliance -- Prefer simpler architecture - -**Maximum Decentralization Required:** -- Cannot accept hold as single trust point -- Must maintain pure ATProto model -- Centralization contradicts project goals - -## Comparison: Hold-as-CA vs. Plugins - -| Aspect | Hold-as-CA | Plugin Approach | -|--------|------------|----------------| -| **Standard compliance** | ✅ Full X.509/PKI | ⚠️ Custom verification | -| **Tool compatibility** | ✅ Notation works unchanged | ❌ Requires plugin install | -| **Decentralization** | ❌ Centralized (hold CA) | ✅ Decentralized (DIDs) | -| **ATProto alignment** | ❌ Against philosophy | ✅ ATProto-native | -| **Signature reuse** | ❌ Must re-sign (P-256) | ✅ Reuses ATProto (K-256) | -| **Certificate mgmt** | 🔴 High overhead | 🟢 None | -| **Trust distribution** | 🔴 Must distribute CA cert | 🟢 DID resolution | -| **Hold compromise** | 🔴 All users affected | 🟢 Metadata only | -| **Operational cost** | 🔴 High | 🟢 Low | -| **Use case** | Enterprise PKI | General purpose | - -## Recommendations - -### Default Approach: Plugins - -For most deployments, use plugin-based verification: -- **Ratify plugin** for Kubernetes -- **OPA Gatekeeper provider** for policy enforcement -- **Containerd verifier** for runtime checks -- **atcr-verify CLI** for general purpose - -See [Integration Strategy](./INTEGRATION_STRATEGY.md) for details. - -### Optional: Hold-as-CA for Enterprise - -Only implement hold-as-CA if you have specific requirements: -- Enterprise X.509 PKI mandates -- Cannot use plugins (restricted environments) -- Accept centralization trade-off - -**Implement as opt-in feature:** -```bash -# Users explicitly enable co-signing -docker push atcr.io/alice/myapp:latest --sign=notation - -# Or via environment variable -export ATCR_ENABLE_COSIGN=true -docker push atcr.io/alice/myapp:latest -``` - -### Security Best Practices - -**If implementing hold-as-CA:** - -1. **Store CA key in HSM** - Never on filesystem -2. **Audit all certificate issuance** - Log every cert -3. **Public transparency log** - Publish all certificates -4. **Short certificate validity** - 24 hours max -5. **Monitor unusual patterns** - Alert on anomalies -6. **Regular CA key rotation** - Every 2-3 years -7. **Cross-check ATProto** - Verify both signatures match -8. **Incident response plan** - Prepare for compromise - -## See Also - -- [ATProto Signatures](./ATPROTO_SIGNATURES.md) - How ATProto signing works -- [Integration Strategy](./INTEGRATION_STRATEGY.md) - Overview of integration approaches -- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Tool-specific integration guides diff --git a/docs/HOLD_DISCOVERY.md b/docs/HOLD_DISCOVERY.md index b80c066..46ce382 100644 --- a/docs/HOLD_DISCOVERY.md +++ b/docs/HOLD_DISCOVERY.md @@ -1,44 +1,17 @@ # Hold Discovery -This document describes how AppView discovers available holds and presents them to users for selection. +> **Status: implemented.** This document describes the hold discovery system as built. +> It was originally written as a design proposal; the design has since shipped with some +> deliberate divergences, noted in [Divergences from the original proposal](#divergences-from-the-original-proposal). +> Remaining gaps are listed in [Remaining work](#remaining-work). ## TL;DR -**Problem:** Users currently enter hold URLs manually in a text field. They don't know what holds exist or which ones they can access. - -**Solution:** -1. Subscribe to Jetstream for `io.atcr.hold.captain` and `io.atcr.hold.crew` collections -2. Cache discovered holds and crew memberships in SQLite -3. Replace the text input with a dropdown showing available holds grouped by access level - -**Key Changes:** -- New table: `hold_crew_members` (hold_did, member_did, rkey, permissions, ...) -- Jetstream collections: `io.atcr.hold.captain`, `io.atcr.hold.crew` -- Settings UI: Text input → ` - Leave empty to use AppView default storage - - - - -``` - -**Problems with the current approach:** - -1. **Users must know hold URLs** - Requires users to manually find and copy hold endpoint URLs -2. **No validation** - Users can enter invalid or inaccessible URLs -3. **No discovery** - Users don't know what holds are available to them -4. **Poor UX** - Text input is error-prone and unfriendly -5. **No membership visibility** - Users can't see which holds they're crew on - -### Proposed Change: Dropdown with Discovered Holds - -Replace the text input with a ` - - - {{if .OwnedHolds}} - - {{range .OwnedHolds}} - - {{end}} - - {{end}} - - {{if .CrewHolds}} - - {{range .CrewHolds}} - - {{end}} - - {{end}} - - {{if .EligibleHolds}} - - {{range .EligibleHolds}} - - {{end}} - - {{end}} - - {{if .PublicHolds}} - - {{range .PublicHolds}} - - {{end}} - - {{end}} - - Your images will be stored on the selected hold - - - - - -
- - - - -``` - -### Dropdown Option Groups - -The dropdown organizes holds into logical groups based on user's relationship: - -| Group | Description | Access Level | -|-------|-------------|--------------| -| **Your Holds** | Holds where user is the captain (owner) | Full control | -| **Crew Member** | Holds where user has explicit crew membership | Based on permissions | -| **Open Registration** | Holds with `allowAllCrew=true` | Can self-register | -| **Public Holds** | Holds with `public=true` | Anyone can use | - -### Visual Indicators - -Each option should show relevant context: - -``` -┌─ Storage Hold: ─────────────────────────────────────┐ -│ ▼ hold01.atcr.io (us-east) │ -├─────────────────────────────────────────────────────┤ -│ AppView Default (hold01.atcr.io) │ -│ ───────────────────────────────────── │ -│ Your Holds │ -│ my-hold.fly.dev (us-west) │ -│ ───────────────────────────────────── │ -│ Crew Member │ -│ team-hold.company.com (eu-central) │ -│ shared-hold.org (asia-pacific) [read-only] │ -│ ───────────────────────────────────── │ -│ Open Registration │ -│ community-hold.dev (us-east) │ -│ ───────────────────────────────────── │ -│ Public Holds │ -│ public-hold.example.com (global) │ -└─────────────────────────────────────────────────────┘ -``` - -### Form Submission Change - -The form now submits `hold_did` (a DID) instead of `hold_endpoint` (a URL): - -**Before:** -``` -POST /api/profile/default-hold -Content-Type: application/x-www-form-urlencoded - -hold_endpoint=https://hold01.atcr.io -``` - -**After:** -``` -POST /api/profile/default-hold -Content-Type: application/x-www-form-urlencoded - -hold_did=did:web:hold01.atcr.io -``` - -The `UpdateDefaultHoldHandler` needs to be updated to accept DIDs: - -```go -// pkg/appview/handlers/settings.go - -func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Accept DID (new) or endpoint (legacy/fallback) - holdDID := r.FormValue("hold_did") - if holdDID == "" { - // Fallback for legacy form submissions - holdDID = r.FormValue("hold_endpoint") - } - - // Validate the hold DID if provided - if holdDID != "" { - // Check it's in our discovered holds cache - captain, err := h.DB.GetCaptainRecord(holdDID) - if err != nil { - http.Error(w, "Unknown hold: "+holdDID, http.StatusBadRequest) - return - } - - // Verify user has access to this hold - available, err := db.GetAvailableHolds(h.DB, user.DID) - if err != nil { - http.Error(w, "Failed to check hold access", http.StatusInternalServerError) - return - } - - hasAccess := false - for _, h := range available { - if h.DID == holdDID { - hasAccess = true - break - } - } - - if !hasAccess { - http.Error(w, "You don't have access to this hold", http.StatusForbidden) - return - } - } - - // ... rest of profile update logic -} -``` - -### Settings Handler - -Update the settings handler to include available holds: - -```go -// pkg/appview/handlers/settings.go - -func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userDID := auth.GetDID(ctx) - - // Get user's current profile - profile, err := h.storage.GetProfile(ctx, userDID) - if err != nil { - // Handle error - } - - // Get available holds for dropdown - availableHolds, err := db.GetAvailableHolds(h.db, userDID) - if err != nil { - // Handle error - } - - data := SettingsPageData{ - Profile: profile, - AvailableHolds: availableHolds, - CurrentHoldDID: profile.DefaultHold, - } - - h.renderTemplate(w, "settings.html", data) -} -``` - -### Settings Template - -```html - - -
-

Default Hold

-

- Select where your container images will be stored by default. -

- -
- - - -
-
-``` - -### Template Data Preparation - -```go -// pkg/appview/handlers/settings.go - -type SettingsPageData struct { - Profile *atproto.SailorProfile - CurrentHoldDID string - OwnedHolds []HoldDisplay - CrewHolds []HoldDisplay - EligibleHolds []HoldDisplay - PublicHolds []HoldDisplay -} - -type HoldDisplay struct { - DID string - DisplayName string // Derived from DID or endpoint - Region string - Provider string - Permissions []string -} - -func (h *Handler) prepareSettingsData(userDID string, holds []db.AvailableHold, currentHold string) SettingsPageData { - data := SettingsPageData{ - CurrentHoldDID: currentHold, - } - - for _, hold := range holds { - display := HoldDisplay{ - DID: hold.DID, - DisplayName: deriveDisplayName(hold.DID, hold.Endpoint), - Region: hold.Region, - Provider: hold.Provider, - Permissions: hold.Permissions, - } - - switch hold.Membership { - case "owner": - data.OwnedHolds = append(data.OwnedHolds, display) - case "crew": - data.CrewHolds = append(data.CrewHolds, display) - case "eligible": - data.EligibleHolds = append(data.EligibleHolds, display) - case "public": - data.PublicHolds = append(data.PublicHolds, display) - } - } - - return data -} - -func deriveDisplayName(did, endpoint string) string { - // For did:web, extract the domain - if strings.HasPrefix(did, "did:web:") { - return strings.TrimPrefix(did, "did:web:") - } - - // For did:plc, use the endpoint hostname if available - if endpoint != "" { - if u, err := url.Parse(endpoint); err == nil { - return u.Host - } - } - - // Fallback to truncated DID - if len(did) > 20 { - return did[:20] + "..." - } - return did -} -``` - -### CSS Styles - -Add styles for the hold dropdown and details panel: - -```css -/* pkg/appview/templates/pages/settings.html - add to