A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

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.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:

// 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:

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