24 KiB
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:
- Front Page - Federated image discovery via firehose
- Settings Page - Profile and hold configuration
- 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
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
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
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
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
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
// 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.holdrecords (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
- Dropdown showing user's
- 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.tagrecord 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:
- User clicks "Login" on UI
- Redirects to
/auth/oauth/login?return_to=/ui/images - User enters handle (e.g., "alice.bsky.social")
- Server resolves handle → DID → PDS → OAuth server
- Server initiates OAuth flow with PAR + DPoP
- User redirected to PDS for authorization
- OAuth callback to
/auth/oauth/callback - Server exchanges code for token, validates with PDS
- Server creates session cookie (secure, httpOnly, SameSite)
- Redirects to
return_toURL 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:
// 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
- Define SQLite schema
- Implement database layer (pkg/ui/db/)
- Implement firehose worker (pkg/ui/firehose/)
- Test worker with real firehose
Phase 2: API Endpoints
- Implement
/ui/api/recent-pushes(front page data) - Implement
/ui/api/profile(settings page data) - Implement
/ui/api/images(personal page data) - Implement tag/manifest mutation endpoints
Phase 3: Authentication
- Implement OAuth login endpoint
- Implement session management
- Add auth middleware
- Test login flow
Phase 4: Frontend
- Choose framework (templates vs SPA)
- Implement front page
- Implement settings page
- Implement personal page
- Add styling
Phase 5: Polish
- Error handling
- Loading states
- Responsive design
- Testing
Open Questions
- Framework choice: Go templates (Templ?), HTMX, or SPA (React/Vue)?
- Styling: Tailwind, plain CSS, or component library?
- Manifest details: Modal vs dedicated page?
- Search: Full-text search on repository/tag names? Requires FTS in SQLite.
- Real-time updates: WebSocket for firehose events, or polling?
- Image size calculation: Sum of layer sizes, or read from manifest?
- Public profiles: Should
/ui/@aliceshow public view of alice's images? - Firehose resilience: Reconnect logic, backfill on downtime?
Dependencies
New Go packages needed:
github.com/mattn/go-sqlite3- SQLite drivergithub.com/bluesky-social/jetstream- Firehose client (or direct websocket)- Session management library (or custom implementation)
- Frontend framework (TBD)
Configuration
Add to config/config.yml:
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
- Session cookies: Secure, HttpOnly, SameSite=Lax
- CSRF protection: For mutation endpoints (tag/manifest delete)
- Rate limiting: On API endpoints
- Input validation: Sanitize user input for search/filters
- Authorization: Verify authenticated user owns resources before mutation
- SQL injection: Use parameterized queries
Performance Considerations
- Database indexes: On DID, repository, created_at, digest
- Pagination: Limit query results to avoid large payloads
- Caching: Cache profile data, hold list, manifest details
- Firehose buffering: Batch database inserts
- Connection pooling: For SQLite and HTTP clients
Testing Strategy
- Unit tests: Database layer, API handlers
- Integration tests: Firehose worker with mock events
- E2E tests: Full login → browse → manage flow
- Load testing: Firehose worker with high event volume
- Manual testing: Real PDS, real images, real firehose