# ATCR AppView UI - Version 1 Specification ## Overview The ATCR AppView UI provides a web interface for discovering, managing, and configuring container images in the ATCR registry. Version 1 focuses on three core pages that leverage existing functionality: 1. **Front Page** - Federated image discovery via firehose 2. **Settings Page** - Profile and hold configuration 3. **Personal Page** - Manage your images and tags ## Architecture ### Tech Stack - **Backend:** Go (existing AppView codebase) - **Frontend:** TBD (Go templates/Templ or separate SPA) - **Database:** SQLite (firehose data cache) - **Styling:** TBD (plain CSS, Tailwind, etc.) - **Authentication:** OAuth with DPoP (reuse existing implementation) ### Components ``` ┌─────────────────────────────────────────────────────────────┐ │ Web UI (Browser) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ AppView HTTP Server │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ UI Endpoints │ │ OCI API │ │ OAuth Server │ │ │ │ /ui/* │ │ /v2/* │ │ /auth/* │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────┴─────────┐ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ SQLite Database │ │ ATProto Client │ │ (Firehose cache) │ │ (PDS operations) │ └──────────────────┘ └──────────────────┘ ▲ ┌──────────────────┐ │ │ Firehose Worker │───────────┘ │ (Background) │ └──────────────────┘ ▲ │ ┌──────────────────┐ │ ATProto Firehose │ │ (Jetstream/Relay)│ └──────────────────┘ ``` ## Database Schema SQLite database for caching firehose data and enabling fast queries. ### Tables **users** ```sql CREATE TABLE users ( did TEXT PRIMARY KEY, handle TEXT NOT NULL, pds_endpoint TEXT NOT NULL, last_seen TIMESTAMP NOT NULL, UNIQUE(handle) ); CREATE INDEX idx_users_handle ON users(handle); ``` **manifests** ```sql CREATE TABLE manifests ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, repository TEXT NOT NULL, digest TEXT NOT NULL, hold_endpoint TEXT NOT NULL, schema_version INTEGER NOT NULL, media_type TEXT NOT NULL, config_digest TEXT, config_size INTEGER, raw_manifest TEXT NOT NULL, -- JSON blob created_at TIMESTAMP NOT NULL, UNIQUE(did, repository, digest), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX idx_manifests_did_repo ON manifests(did, repository); CREATE INDEX idx_manifests_created_at ON manifests(created_at DESC); CREATE INDEX idx_manifests_digest ON manifests(digest); ``` **layers** ```sql CREATE TABLE layers ( manifest_id INTEGER NOT NULL, digest TEXT NOT NULL, size INTEGER NOT NULL, media_type TEXT NOT NULL, layer_index INTEGER NOT NULL, PRIMARY KEY(manifest_id, layer_index), FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE ); CREATE INDEX idx_layers_digest ON layers(digest); ``` **tags** ```sql CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, repository TEXT NOT NULL, tag TEXT NOT NULL, digest TEXT NOT NULL, created_at TIMESTAMP NOT NULL, UNIQUE(did, repository, tag), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX idx_tags_did_repo ON tags(did, repository); ``` **firehose_cursor** ```sql CREATE TABLE firehose_cursor ( id INTEGER PRIMARY KEY CHECK (id = 1), cursor INTEGER NOT NULL, updated_at TIMESTAMP NOT NULL ); ``` ## Firehose Worker Background goroutine that subscribes to ATProto firehose and populates the database. ### Implementation ```go // pkg/ui/firehose/worker.go type Worker struct { db *sql.DB jetstream *JetstreamClient resolver *atproto.Resolver stopCh chan struct{} } func (w *Worker) Start() error { // Load cursor from database cursor := w.loadCursor() // Subscribe to firehose events := w.jetstream.Subscribe(cursor, []string{ "io.atcr.manifest", "io.atcr.tag", }) for { select { case event := <-events: w.handleEvent(event) case <-w.stopCh: return nil } } } func (w *Worker) handleEvent(event FirehoseEvent) error { switch event.Collection { case "io.atcr.manifest": return w.handleManifest(event) case "io.atcr.tag": return w.handleTag(event) } return nil } ``` ### Event Handling **Manifest create:** - Resolve DID → handle, PDS endpoint - Insert/update user record - Parse manifest JSON - Insert manifest record - Insert layer records **Tag create/update:** - Insert/update tag record - Link to existing manifest **Record deletion:** - Delete from database (cascade handles related records) ### Firehose Connection Use Jetstream (bluesky-social/jetstream) or connect directly to relay: - **Jetstream:** Websocket to `wss://jetstream.atproto.tools/subscribe` - **Relay:** Websocket to relay (e.g., `wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos`) Jetstream is simpler and filters events server-side. ## Page Specifications ### 1. Front Page - Federated Discovery **URL:** `/ui/` or `/ui/explore` **Purpose:** Discover recently pushed images across all ATCR users. **Layout:** ``` ┌─────────────────────────────────────────────────────────────┐ │ ATCR [Search] [@handle] [Login] │ ├─────────────────────────────────────────────────────────────┤ │ Recent Pushes [Filter ▼]│ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ alice.bsky.social/nginx:latest │ │ │ │ sha256:abc123... • hold1.alice.com • 2 hours ago │ │ │ │ [docker pull atcr.io/alice.bsky.social/nginx:latest] │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ bob.dev/myapp:v1.2.3 │ │ │ │ sha256:def456... • atcr-storage.fly.dev • 5 hours ago │ │ │ │ [docker pull atcr.io/bob.dev/myapp:v1.2.3] │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ [Load more...] │ └─────────────────────────────────────────────────────────────┘ ``` **Features:** - List of recent pushes (manifests + tags) - Show: handle, repository, tag, digest (truncated), timestamp, hold endpoint - Copy-paste pull command with click-to-copy - Filter by user (click handle to filter) - Search by repository name or tag - Click manifest to view details (modal or dedicated page) - Pagination (50 items per page) **API Endpoint:** ``` GET /ui/api/recent-pushes Query params: - limit (default: 50) - offset (default: 0) - user (optional: filter by DID or handle) - repository (optional: filter by repo name) Response: { "pushes": [ { "did": "did:plc:alice123", "handle": "alice.bsky.social", "repository": "nginx", "tag": "latest", "digest": "sha256:abc123...", "hold_endpoint": "https://hold1.alice.com", "created_at": "2025-10-05T12:34:56Z", "pull_command": "docker pull atcr.io/alice.bsky.social/nginx:latest" } ], "total": 1234, "offset": 0, "limit": 50 } ``` **Manifest Details Modal:** - Full manifest JSON (syntax highlighted) - Layer list with digests and sizes - Link to ATProto record (at://did/io.atcr.manifest/rkey) - Architecture, OS, labels - Creation timestamp ### 2. Settings Page **URL:** `/ui/settings` **Auth:** Requires login (OAuth) **Purpose:** Configure profile and hold preferences. **Layout:** ``` ┌─────────────────────────────────────────────────────────────┐ │ ATCR [@alice] [⚙️] │ ├─────────────────────────────────────────────────────────────┤ │ Settings │ │ │ │ ┌─ Identity ───────────────────────────────────────────┐ │ │ │ Handle: alice.bsky.social │ │ │ │ DID: did:plc:alice123abc (read-only) │ │ │ │ PDS: https://bsky.social (read-only) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ ┌─ Default Hold ──────────────────────────────────────┐ │ │ │ Current: https://hold1.alice.com │ │ │ │ │ │ │ │ [Dropdown: Select from your holds ▼] │ │ │ │ • https://hold1.alice.com (Your BYOS) │ │ │ │ • https://storage.atcr.io (AppView default) │ │ │ │ • [Custom URL...] │ │ │ │ │ │ │ │ Custom hold URL: [_____________________] │ │ │ │ │ │ │ │ [Save] │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ ┌─ OAuth Session ─────────────────────────────────────┐ │ │ │ Logged in as: alice.bsky.social │ │ │ │ Session expires: 2025-10-06 14:23:00 UTC │ │ │ │ [Re-authenticate] │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` **Features:** - Display current identity (handle, DID, PDS) - Default hold configuration: - Dropdown showing user's `io.atcr.hold` records (query from PDS) - Option to select AppView's default storage endpoint - Manual entry for custom hold URL - "Save" button updates `io.atcr.sailor.profile.defaultHold` - OAuth session status - Re-authenticate button (redirects to OAuth flow) **API Endpoints:** ``` GET /ui/api/profile Auth: Required (session cookie) Response: { "did": "did:plc:alice123", "handle": "alice.bsky.social", "pds_endpoint": "https://bsky.social", "default_hold": "https://hold1.alice.com", "holds": [ { "endpoint": "https://hold1.alice.com", "name": "My BYOS Storage", "public": false } ], "session_expires_at": "2025-10-06T14:23:00Z" } POST /ui/api/profile/default-hold Auth: Required Body: { "hold_endpoint": "https://hold1.alice.com" } Response: { "success": true } ``` ### 3. Personal Page - Your Images **URL:** `/ui/images` or `/ui/@{handle}` **Auth:** Requires login (OAuth) **Purpose:** Manage your container images and tags. **Layout:** ``` ┌─────────────────────────────────────────────────────────────┐ │ ATCR [@alice] [⚙️] │ ├─────────────────────────────────────────────────────────────┤ │ Your Images │ │ │ │ ┌─ nginx ──────────────────────────────────────────────┐ │ │ │ 3 tags • 5 manifests • Last push: 2 hours ago │ │ │ │ │ │ │ │ Tags: │ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ │ │ latest → sha256:abc123... (2 hours ago) [✏️][🗑️]│ │ │ │ │ │ v1.25 → sha256:def456... (1 day ago) [✏️][🗑️]│ │ │ │ │ │ alpine → sha256:ghi789... (3 days ago) [✏️][🗑️]│ │ │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Manifests: │ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ │ │ sha256:abc123... • 45MB • hold1.alice.com │ │ │ │ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │ │ │ │ sha256:def456... • 42MB • hold1.alice.com │ │ │ │ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │ │ │ └────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ ┌─ myapp ──────────────────────────────────────────────┐ │ │ │ 2 tags • 2 manifests • Last push: 1 day ago │ │ │ │ [Expand ▼] │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` **Features:** **Repository List:** - Group manifests by repository name - Show: tag count, manifest count, last push time - Collapsible/expandable repository cards **Repository Details (Expanded):** - **Tags:** Table showing tag → manifest digest → timestamp - Edit tag: Modal to re-point tag to different manifest digest - Delete tag: Confirm dialog, removes `io.atcr.tag` record from PDS - **Manifests:** List of all manifests in repository - Show: digest (truncated), size, hold endpoint, architecture, layer count - View: Open manifest details modal (same as front page) - Delete: Confirm dialog with warning if manifest is tagged **Actions:** - Copy pull command for each tag - Edit tag (re-point to different digest) - Delete tag - Delete manifest (with validation) **API Endpoints:** ``` GET /ui/api/images Auth: Required Response: { "repositories": [ { "name": "nginx", "tag_count": 3, "manifest_count": 5, "last_push": "2025-10-05T10:23:45Z", "tags": [ { "tag": "latest", "digest": "sha256:abc123...", "created_at": "2025-10-05T10:23:45Z" } ], "manifests": [ { "digest": "sha256:abc123...", "size": 47185920, "hold_endpoint": "https://hold1.alice.com", "architecture": "amd64", "os": "linux", "layer_count": 5, "created_at": "2025-10-05T10:23:45Z", "tagged": true } ] } ] } PUT /ui/api/images/{repository}/tags/{tag} Auth: Required Body: { "digest": "sha256:new-digest..." } Response: { "success": true } DELETE /ui/api/images/{repository}/tags/{tag} Auth: Required Response: { "success": true } DELETE /ui/api/images/{repository}/manifests/{digest} Auth: Required Response: { "success": true } ``` ## Authentication ### OAuth Login Flow Reuse existing OAuth implementation from credential helper and AppView. **Login Endpoint:** `/auth/oauth/login` **Flow:** 1. User clicks "Login" on UI 2. Redirects to `/auth/oauth/login?return_to=/ui/images` 3. User enters handle (e.g., "alice.bsky.social") 4. Server resolves handle → DID → PDS → OAuth server 5. Server initiates OAuth flow with PAR + DPoP 6. User redirected to PDS for authorization 7. OAuth callback to `/auth/oauth/callback` 8. Server exchanges code for token, validates with PDS 9. Server creates session cookie (secure, httpOnly, SameSite) 10. Redirects to `return_to` URL or default `/ui/images` **Session Management:** - Session cookie: `atcr_session` (JWT or opaque token) - Session storage: In-memory map or SQLite table - Session duration: 24 hours (or match OAuth token expiry) - Refresh: Auto-refresh OAuth token when needed **Middleware:** ```go // pkg/ui/middleware/auth.go func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := getSession(r) if session == nil { http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) return } // Add session info to context ctx := context.WithValue(r.Context(), "session", session) next.ServeHTTP(w, r.WithContext(ctx)) }) } ``` ## Implementation Roadmap ### Phase 1: Database & Firehose 1. Define SQLite schema 2. Implement database layer (pkg/ui/db/) 3. Implement firehose worker (pkg/ui/firehose/) 4. Test worker with real firehose ### Phase 2: API Endpoints 1. Implement `/ui/api/recent-pushes` (front page data) 2. Implement `/ui/api/profile` (settings page data) 3. Implement `/ui/api/images` (personal page data) 4. Implement tag/manifest mutation endpoints ### Phase 3: Authentication 1. Implement OAuth login endpoint 2. Implement session management 3. Add auth middleware 4. Test login flow ### Phase 4: Frontend 1. Choose framework (templates vs SPA) 2. Implement front page 3. Implement settings page 4. Implement personal page 5. Add styling ### Phase 5: Polish 1. Error handling 2. Loading states 3. Responsive design 4. Testing ## Open Questions 1. **Framework choice:** Go templates (Templ?), HTMX, or SPA (React/Vue)? 2. **Styling:** Tailwind, plain CSS, or component library? 3. **Manifest details:** Modal vs dedicated page? 4. **Search:** Full-text search on repository/tag names? Requires FTS in SQLite. 5. **Real-time updates:** WebSocket for firehose events, or polling? 6. **Image size calculation:** Sum of layer sizes, or read from manifest? 7. **Public profiles:** Should `/ui/@alice` show public view of alice's images? 8. **Firehose resilience:** Reconnect logic, backfill on downtime? ## Dependencies New Go packages needed: - `github.com/mattn/go-sqlite3` - SQLite driver - `github.com/bluesky-social/jetstream` - Firehose client (or direct websocket) - Session management library (or custom implementation) - Frontend framework (TBD) ## Configuration Add to `config/config.yml`: ```yaml ui: enabled: true database_path: /var/lib/atcr/ui.db firehose: enabled: true endpoint: wss://jetstream.atproto.tools/subscribe collections: - io.atcr.manifest - io.atcr.tag session: duration: 24h cookie_name: atcr_session cookie_secure: true ``` ## Security Considerations 1. **Session cookies:** Secure, HttpOnly, SameSite=Lax 2. **CSRF protection:** For mutation endpoints (tag/manifest delete) 3. **Rate limiting:** On API endpoints 4. **Input validation:** Sanitize user input for search/filters 5. **Authorization:** Verify authenticated user owns resources before mutation 6. **SQL injection:** Use parameterized queries ## Performance Considerations 1. **Database indexes:** On DID, repository, created_at, digest 2. **Pagination:** Limit query results to avoid large payloads 3. **Caching:** Cache profile data, hold list, manifest details 4. **Firehose buffering:** Batch database inserts 5. **Connection pooling:** For SQLite and HTTP clients ## Testing Strategy 1. **Unit tests:** Database layer, API handlers 2. **Integration tests:** Firehose worker with mock events 3. **E2E tests:** Full login → browse → manage flow 4. **Load testing:** Firehose worker with high event volume 5. **Manual testing:** Real PDS, real images, real firehose