mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
632 lines
24 KiB
Markdown
632 lines
24 KiB
Markdown
# 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
|