A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1# ATCR AppView UI - Version 1 Specification
2
3## Overview
4
5The 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:
6
71. **Front Page** - Federated image discovery via firehose
82. **Settings Page** - Profile and hold configuration
93. **Personal Page** - Manage your images and tags
10
11## Architecture
12
13### Tech Stack
14
15- **Backend:** Go (existing AppView codebase)
16- **Frontend:** TBD (Go templates/Templ or separate SPA)
17- **Database:** SQLite (firehose data cache)
18- **Styling:** TBD (plain CSS, Tailwind, etc.)
19- **Authentication:** OAuth with DPoP (reuse existing implementation)
20
21### Components
22
23```
24┌─────────────────────────────────────────────────────────────┐
25│ Web UI (Browser) │
26└─────────────────────────────────────────────────────────────┘
27 │
28 ▼
29┌─────────────────────────────────────────────────────────────┐
30│ AppView HTTP Server │
31│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
32│ │ UI Endpoints │ │ OCI API │ │ OAuth Server │ │
33│ │ /ui/* │ │ /v2/* │ │ /auth/* │ │
34│ └──────────────┘ └──────────────┘ └──────────────┘ │
35└─────────────────────────────────────────────────────────────┘
36 │
37 ┌─────────┴─────────┐
38 ▼ ▼
39 ┌──────────────────┐ ┌──────────────────┐
40 │ SQLite Database │ │ ATProto Client │
41 │ (Firehose cache) │ │ (PDS operations) │
42 └──────────────────┘ └──────────────────┘
43 ▲
44 ┌──────────────────┐ │
45 │ Firehose Worker │───────────┘
46 │ (Background) │
47 └──────────────────┘
48 ▲
49 │
50 ┌──────────────────┐
51 │ ATProto Firehose │
52 │ (Jetstream/Relay)│
53 └──────────────────┘
54```
55
56## Database Schema
57
58SQLite database for caching firehose data and enabling fast queries.
59
60### Tables
61
62**users**
63```sql
64CREATE TABLE users (
65 did TEXT PRIMARY KEY,
66 handle TEXT NOT NULL,
67 pds_endpoint TEXT NOT NULL,
68 last_seen TIMESTAMP NOT NULL,
69 UNIQUE(handle)
70);
71CREATE INDEX idx_users_handle ON users(handle);
72```
73
74**manifests**
75```sql
76CREATE TABLE manifests (
77 id INTEGER PRIMARY KEY AUTOINCREMENT,
78 did TEXT NOT NULL,
79 repository TEXT NOT NULL,
80 digest TEXT NOT NULL,
81 hold_endpoint TEXT NOT NULL,
82 schema_version INTEGER NOT NULL,
83 media_type TEXT NOT NULL,
84 config_digest TEXT,
85 config_size INTEGER,
86 raw_manifest TEXT NOT NULL, -- JSON blob
87 created_at TIMESTAMP NOT NULL,
88 UNIQUE(did, repository, digest),
89 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
90);
91CREATE INDEX idx_manifests_did_repo ON manifests(did, repository);
92CREATE INDEX idx_manifests_created_at ON manifests(created_at DESC);
93CREATE INDEX idx_manifests_digest ON manifests(digest);
94```
95
96**layers**
97```sql
98CREATE TABLE layers (
99 manifest_id INTEGER NOT NULL,
100 digest TEXT NOT NULL,
101 size INTEGER NOT NULL,
102 media_type TEXT NOT NULL,
103 layer_index INTEGER NOT NULL,
104 PRIMARY KEY(manifest_id, layer_index),
105 FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
106);
107CREATE INDEX idx_layers_digest ON layers(digest);
108```
109
110**tags**
111```sql
112CREATE TABLE tags (
113 id INTEGER PRIMARY KEY AUTOINCREMENT,
114 did TEXT NOT NULL,
115 repository TEXT NOT NULL,
116 tag TEXT NOT NULL,
117 digest TEXT NOT NULL,
118 created_at TIMESTAMP NOT NULL,
119 UNIQUE(did, repository, tag),
120 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
121);
122CREATE INDEX idx_tags_did_repo ON tags(did, repository);
123```
124
125**firehose_cursor**
126```sql
127CREATE TABLE firehose_cursor (
128 id INTEGER PRIMARY KEY CHECK (id = 1),
129 cursor INTEGER NOT NULL,
130 updated_at TIMESTAMP NOT NULL
131);
132```
133
134## Firehose Worker
135
136Background goroutine that subscribes to ATProto firehose and populates the database.
137
138### Implementation
139
140```go
141// pkg/ui/firehose/worker.go
142
143type Worker struct {
144 db *sql.DB
145 jetstream *JetstreamClient
146 resolver *atproto.Resolver
147 stopCh chan struct{}
148}
149
150func (w *Worker) Start() error {
151 // Load cursor from database
152 cursor := w.loadCursor()
153
154 // Subscribe to firehose
155 events := w.jetstream.Subscribe(cursor, []string{
156 "io.atcr.manifest",
157 "io.atcr.tag",
158 })
159
160 for {
161 select {
162 case event := <-events:
163 w.handleEvent(event)
164 case <-w.stopCh:
165 return nil
166 }
167 }
168}
169
170func (w *Worker) handleEvent(event FirehoseEvent) error {
171 switch event.Collection {
172 case "io.atcr.manifest":
173 return w.handleManifest(event)
174 case "io.atcr.tag":
175 return w.handleTag(event)
176 }
177 return nil
178}
179```
180
181### Event Handling
182
183**Manifest create:**
184- Resolve DID → handle, PDS endpoint
185- Insert/update user record
186- Parse manifest JSON
187- Insert manifest record
188- Insert layer records
189
190**Tag create/update:**
191- Insert/update tag record
192- Link to existing manifest
193
194**Record deletion:**
195- Delete from database (cascade handles related records)
196
197### Firehose Connection
198
199Use Jetstream (bluesky-social/jetstream) or connect directly to relay:
200- **Jetstream:** Websocket to `wss://jetstream.atproto.tools/subscribe`
201- **Relay:** Websocket to relay (e.g., `wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos`)
202
203Jetstream is simpler and filters events server-side.
204
205## Page Specifications
206
207### 1. Front Page - Federated Discovery
208
209**URL:** `/ui/` or `/ui/explore`
210
211**Purpose:** Discover recently pushed images across all ATCR users.
212
213**Layout:**
214```
215┌─────────────────────────────────────────────────────────────┐
216│ ATCR [Search] [@handle] [Login] │
217├─────────────────────────────────────────────────────────────┤
218│ Recent Pushes [Filter ▼]│
219│ │
220│ ┌───────────────────────────────────────────────────────┐ │
221│ │ alice.bsky.social/nginx:latest │ │
222│ │ sha256:abc123... • hold1.alice.com • 2 hours ago │ │
223│ │ [docker pull atcr.io/alice.bsky.social/nginx:latest] │ │
224│ └───────────────────────────────────────────────────────┘ │
225│ │
226│ ┌───────────────────────────────────────────────────────┐ │
227│ │ bob.dev/myapp:v1.2.3 │ │
228│ │ sha256:def456... • atcr-storage.fly.dev • 5 hours ago │ │
229│ │ [docker pull atcr.io/bob.dev/myapp:v1.2.3] │ │
230│ └───────────────────────────────────────────────────────┘ │
231│ │
232│ [Load more...] │
233└─────────────────────────────────────────────────────────────┘
234```
235
236**Features:**
237- List of recent pushes (manifests + tags)
238- Show: handle, repository, tag, digest (truncated), timestamp, hold endpoint
239- Copy-paste pull command with click-to-copy
240- Filter by user (click handle to filter)
241- Search by repository name or tag
242- Click manifest to view details (modal or dedicated page)
243- Pagination (50 items per page)
244
245**API Endpoint:**
246```
247GET /ui/api/recent-pushes
248Query params:
249 - limit (default: 50)
250 - offset (default: 0)
251 - user (optional: filter by DID or handle)
252 - repository (optional: filter by repo name)
253
254Response:
255{
256 "pushes": [
257 {
258 "did": "did:plc:alice123",
259 "handle": "alice.bsky.social",
260 "repository": "nginx",
261 "tag": "latest",
262 "digest": "sha256:abc123...",
263 "hold_endpoint": "https://hold1.alice.com",
264 "created_at": "2025-10-05T12:34:56Z",
265 "pull_command": "docker pull atcr.io/alice.bsky.social/nginx:latest"
266 }
267 ],
268 "total": 1234,
269 "offset": 0,
270 "limit": 50
271}
272```
273
274**Manifest Details Modal:**
275- Full manifest JSON (syntax highlighted)
276- Layer list with digests and sizes
277- Link to ATProto record (at://did/io.atcr.manifest/rkey)
278- Architecture, OS, labels
279- Creation timestamp
280
281### 2. Settings Page
282
283**URL:** `/ui/settings`
284
285**Auth:** Requires login (OAuth)
286
287**Purpose:** Configure profile and hold preferences.
288
289**Layout:**
290```
291┌─────────────────────────────────────────────────────────────┐
292│ ATCR [@alice] [⚙️] │
293├─────────────────────────────────────────────────────────────┤
294│ Settings │
295│ │
296│ ┌─ Identity ───────────────────────────────────────────┐ │
297│ │ Handle: alice.bsky.social │ │
298│ │ DID: did:plc:alice123abc (read-only) │ │
299│ │ PDS: https://bsky.social (read-only) │ │
300│ └───────────────────────────────────────────────────────┘ │
301│ │
302│ ┌─ Default Hold ──────────────────────────────────────┐ │
303│ │ Current: https://hold1.alice.com │ │
304│ │ │ │
305│ │ [Dropdown: Select from your holds ▼] │ │
306│ │ • https://hold1.alice.com (Your BYOS) │ │
307│ │ • https://storage.atcr.io (AppView default) │ │
308│ │ • [Custom URL...] │ │
309│ │ │ │
310│ │ Custom hold URL: [_____________________] │ │
311│ │ │ │
312│ │ [Save] │ │
313│ └───────────────────────────────────────────────────────┘ │
314│ │
315│ ┌─ OAuth Session ─────────────────────────────────────┐ │
316│ │ Logged in as: alice.bsky.social │ │
317│ │ Session expires: 2025-10-06 14:23:00 UTC │ │
318│ │ [Re-authenticate] │ │
319│ └───────────────────────────────────────────────────────┘ │
320└─────────────────────────────────────────────────────────────┘
321```
322
323**Features:**
324- Display current identity (handle, DID, PDS)
325- Default hold configuration:
326 - Dropdown showing user's `io.atcr.hold` records (query from PDS)
327 - Option to select AppView's default storage endpoint
328 - Manual entry for custom hold URL
329 - "Save" button updates `io.atcr.sailor.profile.defaultHold`
330- OAuth session status
331- Re-authenticate button (redirects to OAuth flow)
332
333**API Endpoints:**
334
335```
336GET /ui/api/profile
337Auth: Required (session cookie)
338Response:
339{
340 "did": "did:plc:alice123",
341 "handle": "alice.bsky.social",
342 "pds_endpoint": "https://bsky.social",
343 "default_hold": "https://hold1.alice.com",
344 "holds": [
345 {
346 "endpoint": "https://hold1.alice.com",
347 "name": "My BYOS Storage",
348 "public": false
349 }
350 ],
351 "session_expires_at": "2025-10-06T14:23:00Z"
352}
353
354POST /ui/api/profile/default-hold
355Auth: Required
356Body:
357{
358 "hold_endpoint": "https://hold1.alice.com"
359}
360Response:
361{
362 "success": true
363}
364```
365
366### 3. Personal Page - Your Images
367
368**URL:** `/ui/images` or `/ui/@{handle}`
369
370**Auth:** Requires login (OAuth)
371
372**Purpose:** Manage your container images and tags.
373
374**Layout:**
375```
376┌─────────────────────────────────────────────────────────────┐
377│ ATCR [@alice] [⚙️] │
378├─────────────────────────────────────────────────────────────┤
379│ Your Images │
380│ │
381│ ┌─ nginx ──────────────────────────────────────────────┐ │
382│ │ 3 tags • 5 manifests • Last push: 2 hours ago │ │
383│ │ │ │
384│ │ Tags: │ │
385│ │ ┌────────────────────────────────────────────────┐ │ │
386│ │ │ latest → sha256:abc123... (2 hours ago) [✏️][🗑️]│ │ │
387│ │ │ v1.25 → sha256:def456... (1 day ago) [✏️][🗑️]│ │ │
388│ │ │ alpine → sha256:ghi789... (3 days ago) [✏️][🗑️]│ │ │
389│ │ └────────────────────────────────────────────────┘ │ │
390│ │ │ │
391│ │ Manifests: │ │
392│ │ ┌────────────────────────────────────────────────┐ │ │
393│ │ │ sha256:abc123... • 45MB • hold1.alice.com │ │ │
394│ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │
395│ │ │ sha256:def456... • 42MB • hold1.alice.com │ │ │
396│ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │
397│ │ └────────────────────────────────────────────────┘ │ │
398│ └───────────────────────────────────────────────────────┘ │
399│ │
400│ ┌─ myapp ──────────────────────────────────────────────┐ │
401│ │ 2 tags • 2 manifests • Last push: 1 day ago │ │
402│ │ [Expand ▼] │ │
403│ └───────────────────────────────────────────────────────┘ │
404└─────────────────────────────────────────────────────────────┘
405```
406
407**Features:**
408
409**Repository List:**
410- Group manifests by repository name
411- Show: tag count, manifest count, last push time
412- Collapsible/expandable repository cards
413
414**Repository Details (Expanded):**
415- **Tags:** Table showing tag → manifest digest → timestamp
416 - Edit tag: Modal to re-point tag to different manifest digest
417 - Delete tag: Confirm dialog, removes `io.atcr.tag` record from PDS
418- **Manifests:** List of all manifests in repository
419 - Show: digest (truncated), size, hold endpoint, architecture, layer count
420 - View: Open manifest details modal (same as front page)
421 - Delete: Confirm dialog with warning if manifest is tagged
422
423**Actions:**
424- Copy pull command for each tag
425- Edit tag (re-point to different digest)
426- Delete tag
427- Delete manifest (with validation)
428
429**API Endpoints:**
430
431```
432GET /ui/api/images
433Auth: Required
434Response:
435{
436 "repositories": [
437 {
438 "name": "nginx",
439 "tag_count": 3,
440 "manifest_count": 5,
441 "last_push": "2025-10-05T10:23:45Z",
442 "tags": [
443 {
444 "tag": "latest",
445 "digest": "sha256:abc123...",
446 "created_at": "2025-10-05T10:23:45Z"
447 }
448 ],
449 "manifests": [
450 {
451 "digest": "sha256:abc123...",
452 "size": 47185920,
453 "hold_endpoint": "https://hold1.alice.com",
454 "architecture": "amd64",
455 "os": "linux",
456 "layer_count": 5,
457 "created_at": "2025-10-05T10:23:45Z",
458 "tagged": true
459 }
460 ]
461 }
462 ]
463}
464
465PUT /ui/api/images/{repository}/tags/{tag}
466Auth: Required
467Body:
468{
469 "digest": "sha256:new-digest..."
470}
471Response:
472{
473 "success": true
474}
475
476DELETE /ui/api/images/{repository}/tags/{tag}
477Auth: Required
478Response:
479{
480 "success": true
481}
482
483DELETE /ui/api/images/{repository}/manifests/{digest}
484Auth: Required
485Response:
486{
487 "success": true
488}
489```
490
491## Authentication
492
493### OAuth Login Flow
494
495Reuse existing OAuth implementation from credential helper and AppView.
496
497**Login Endpoint:** `/auth/oauth/login`
498
499**Flow:**
5001. User clicks "Login" on UI
5012. Redirects to `/auth/oauth/login?return_to=/ui/images`
5023. User enters handle (e.g., "alice.bsky.social")
5034. Server resolves handle → DID → PDS → OAuth server
5045. Server initiates OAuth flow with PAR + DPoP
5056. User redirected to PDS for authorization
5067. OAuth callback to `/auth/oauth/callback`
5078. Server exchanges code for token, validates with PDS
5089. Server creates session cookie (secure, httpOnly, SameSite)
50910. Redirects to `return_to` URL or default `/ui/images`
510
511**Session Management:**
512- Session cookie: `atcr_session` (JWT or opaque token)
513- Session storage: In-memory map or SQLite table
514- Session duration: 24 hours (or match OAuth token expiry)
515- Refresh: Auto-refresh OAuth token when needed
516
517**Middleware:**
518```go
519// pkg/ui/middleware/auth.go
520
521func RequireAuth(next http.Handler) http.Handler {
522 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
523 session := getSession(r)
524 if session == nil {
525 http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound)
526 return
527 }
528
529 // Add session info to context
530 ctx := context.WithValue(r.Context(), "session", session)
531 next.ServeHTTP(w, r.WithContext(ctx))
532 })
533}
534```
535
536## Implementation Roadmap
537
538### Phase 1: Database & Firehose
5391. Define SQLite schema
5402. Implement database layer (pkg/ui/db/)
5413. Implement firehose worker (pkg/ui/firehose/)
5424. Test worker with real firehose
543
544### Phase 2: API Endpoints
5451. Implement `/ui/api/recent-pushes` (front page data)
5462. Implement `/ui/api/profile` (settings page data)
5473. Implement `/ui/api/images` (personal page data)
5484. Implement tag/manifest mutation endpoints
549
550### Phase 3: Authentication
5511. Implement OAuth login endpoint
5522. Implement session management
5533. Add auth middleware
5544. Test login flow
555
556### Phase 4: Frontend
5571. Choose framework (templates vs SPA)
5582. Implement front page
5593. Implement settings page
5604. Implement personal page
5615. Add styling
562
563### Phase 5: Polish
5641. Error handling
5652. Loading states
5663. Responsive design
5674. Testing
568
569## Open Questions
570
5711. **Framework choice:** Go templates (Templ?), HTMX, or SPA (React/Vue)?
5722. **Styling:** Tailwind, plain CSS, or component library?
5733. **Manifest details:** Modal vs dedicated page?
5744. **Search:** Full-text search on repository/tag names? Requires FTS in SQLite.
5755. **Real-time updates:** WebSocket for firehose events, or polling?
5766. **Image size calculation:** Sum of layer sizes, or read from manifest?
5777. **Public profiles:** Should `/ui/@alice` show public view of alice's images?
5788. **Firehose resilience:** Reconnect logic, backfill on downtime?
579
580## Dependencies
581
582New Go packages needed:
583- `github.com/mattn/go-sqlite3` - SQLite driver
584- `github.com/bluesky-social/jetstream` - Firehose client (or direct websocket)
585- Session management library (or custom implementation)
586- Frontend framework (TBD)
587
588## Configuration
589
590Add to `config/config.yml`:
591
592```yaml
593ui:
594 enabled: true
595 database_path: /var/lib/atcr/ui.db
596 firehose:
597 enabled: true
598 endpoint: wss://jetstream.atproto.tools/subscribe
599 collections:
600 - io.atcr.manifest
601 - io.atcr.tag
602 session:
603 duration: 24h
604 cookie_name: atcr_session
605 cookie_secure: true
606```
607
608## Security Considerations
609
6101. **Session cookies:** Secure, HttpOnly, SameSite=Lax
6112. **CSRF protection:** For mutation endpoints (tag/manifest delete)
6123. **Rate limiting:** On API endpoints
6134. **Input validation:** Sanitize user input for search/filters
6145. **Authorization:** Verify authenticated user owns resources before mutation
6156. **SQL injection:** Use parameterized queries
616
617## Performance Considerations
618
6191. **Database indexes:** On DID, repository, created_at, digest
6202. **Pagination:** Limit query results to avoid large payloads
6213. **Caching:** Cache profile data, hold list, manifest details
6224. **Firehose buffering:** Batch database inserts
6235. **Connection pooling:** For SQLite and HTTP clients
624
625## Testing Strategy
626
6271. **Unit tests:** Database layer, API handlers
6282. **Integration tests:** Firehose worker with mock events
6293. **E2E tests:** Full login → browse → manage flow
6304. **Load testing:** Firehose worker with high event volume
6315. **Manual testing:** Real PDS, real images, real firehose