···6464 }
65656666 // Bootstrap PDS with captain record, hold owner as first crew member, and profile
6767- if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL); err != nil {
6767+ if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil {
6868 slog.Error("Failed to bootstrap PDS", "error", err)
6969 os.Exit(1)
7070 }
+304
docs/DIRECT_HOLD_ACCESS.md
···11+# Accessing Hold Data Without AppView
22+33+This document explains how to retrieve your data directly from a hold service without going through the ATCR AppView. This is useful for:
44+- GDPR data export requests
55+- Backup and migration
66+- Debugging and development
77+- Building alternative clients
88+99+## Quick Start: App Passwords (Recommended)
1010+1111+The simplest way to authenticate is using an ATProto app password. This avoids the complexity of OAuth + DPoP.
1212+1313+### Step 1: Create an App Password
1414+1515+1. Go to your Bluesky settings: https://bsky.app/settings/app-passwords
1616+2. Create a new app password
1717+3. Save it securely (you'll only see it once)
1818+1919+### Step 2: Get a Session Token
2020+2121+```bash
2222+# Replace with your handle and app password
2323+HANDLE="yourhandle.bsky.social"
2424+APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
2525+2626+# Create session with your PDS
2727+SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
2828+ -H "Content-Type: application/json" \
2929+ -d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}")
3030+3131+# Extract tokens
3232+ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
3333+DID=$(echo "$SESSION" | jq -r '.did')
3434+PDS=$(echo "$SESSION" | jq -r '.didDoc.service[0].serviceEndpoint')
3535+3636+echo "DID: $DID"
3737+echo "PDS: $PDS"
3838+```
3939+4040+### Step 3: Get a Service Token for the Hold
4141+4242+```bash
4343+# The hold DID you want to access (e.g., did:web:hold01.atcr.io)
4444+HOLD_DID="did:web:hold01.atcr.io"
4545+4646+# Get a service token from your PDS
4747+SERVICE_TOKEN=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
4848+ -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')
4949+5050+echo "Service Token: $SERVICE_TOKEN"
5151+```
5252+5353+### Step 4: Call Hold Endpoints
5454+5555+Now you can call any authenticated hold endpoint with the service token:
5656+5757+```bash
5858+# Export your data from the hold
5959+curl -s "https://hold01.atcr.io/xrpc/io.atcr.hold.exportUserData" \
6060+ -H "Authorization: Bearer $SERVICE_TOKEN" | jq .
6161+```
6262+6363+### Complete Script
6464+6565+Here's a complete script that does all the above:
6666+6767+```bash
6868+#!/bin/bash
6969+# export-hold-data.sh - Export your data from an ATCR hold
7070+7171+set -e
7272+7373+# Configuration
7474+HANDLE="${1:-yourhandle.bsky.social}"
7575+APP_PASSWORD="${2:-xxxx-xxxx-xxxx-xxxx}"
7676+HOLD_DID="${3:-did:web:hold01.atcr.io}"
7777+7878+# Default PDS (Bluesky's main PDS)
7979+DEFAULT_PDS="https://bsky.social"
8080+8181+echo "Authenticating as $HANDLE..."
8282+8383+# Step 1: Create session
8484+SESSION=$(curl -s -X POST "$DEFAULT_PDS/xrpc/com.atproto.server.createSession" \
8585+ -H "Content-Type: application/json" \
8686+ -d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}")
8787+8888+# Check for errors
8989+if echo "$SESSION" | jq -e '.error' > /dev/null 2>&1; then
9090+ echo "Error: $(echo "$SESSION" | jq -r '.message')"
9191+ exit 1
9292+fi
9393+9494+ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
9595+DID=$(echo "$SESSION" | jq -r '.did')
9696+9797+# Try to get PDS from didDoc, fall back to default
9898+PDS=$(echo "$SESSION" | jq -r '.didDoc.service[] | select(.id == "#atproto_pds") | .serviceEndpoint' 2>/dev/null || echo "$DEFAULT_PDS")
9999+if [ "$PDS" = "null" ] || [ -z "$PDS" ]; then
100100+ PDS="$DEFAULT_PDS"
101101+fi
102102+103103+echo "Authenticated as $DID"
104104+echo "PDS: $PDS"
105105+106106+# Step 2: Get service token for the hold
107107+echo "Getting service token for $HOLD_DID..."
108108+SERVICE_RESPONSE=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
109109+ -H "Authorization: Bearer $ACCESS_JWT")
110110+111111+if echo "$SERVICE_RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
112112+ echo "Error getting service token: $(echo "$SERVICE_RESPONSE" | jq -r '.message')"
113113+ exit 1
114114+fi
115115+116116+SERVICE_TOKEN=$(echo "$SERVICE_RESPONSE" | jq -r '.token')
117117+118118+# Step 3: Resolve hold DID to URL
119119+if [[ "$HOLD_DID" == did:web:* ]]; then
120120+ # did:web:example.com -> https://example.com
121121+ HOLD_HOST="${HOLD_DID#did:web:}"
122122+ HOLD_URL="https://$HOLD_HOST"
123123+else
124124+ echo "Error: Only did:web holds are currently supported for direct resolution"
125125+ exit 1
126126+fi
127127+128128+echo "Hold URL: $HOLD_URL"
129129+130130+# Step 4: Export data
131131+echo "Exporting data from $HOLD_URL..."
132132+curl -s "$HOLD_URL/xrpc/io.atcr.hold.exportUserData" \
133133+ -H "Authorization: Bearer $SERVICE_TOKEN" | jq .
134134+```
135135+136136+Usage:
137137+```bash
138138+chmod +x export-hold-data.sh
139139+./export-hold-data.sh yourhandle.bsky.social xxxx-xxxx-xxxx-xxxx did:web:hold01.atcr.io
140140+```
141141+142142+---
143143+144144+## Available Hold Endpoints
145145+146146+Once you have a service token, you can call these endpoints:
147147+148148+### Data Export (GDPR)
149149+```bash
150150+GET /xrpc/io.atcr.hold.exportUserData
151151+Authorization: Bearer {service_token}
152152+```
153153+154154+Returns all your data stored on that hold:
155155+- Layer records (blobs you've pushed)
156156+- Crew membership status
157157+- Usage statistics
158158+- Whether you're the hold captain
159159+160160+### Quota Information
161161+```bash
162162+GET /xrpc/io.atcr.hold.getQuota?userDid={your_did}
163163+# No auth required - just needs your DID
164164+```
165165+166166+### Blob Download (if you have read access)
167167+```bash
168168+GET /xrpc/com.atproto.sync.getBlob?did={owner_did}&cid={blob_digest}
169169+Authorization: Bearer {service_token}
170170+```
171171+172172+Returns a presigned URL to download the blob directly from storage.
173173+174174+---
175175+176176+## OAuth + DPoP (Advanced)
177177+178178+App passwords are the simplest option, but OAuth with DPoP is the "proper" way to authenticate in ATProto. However, it's significantly more complex because:
179179+180180+1. **DPoP (Demonstrating Proof of Possession)** - Every request requires a cryptographically signed JWT proving you control a specific key
181181+2. **PAR (Pushed Authorization Requests)** - Authorization parameters are sent server-to-server
182182+3. **PKCE (Proof Key for Code Exchange)** - Prevents authorization code interception
183183+184184+### Why DPoP Makes Curl Impractical
185185+186186+Each request requires a fresh DPoP proof JWT with:
187187+- Unique `jti` (request ID)
188188+- Current `iat` timestamp
189189+- HTTP method and URL bound to the request
190190+- Server-provided `nonce`
191191+- Signature using your P-256 private key
192192+193193+Example DPoP proof structure:
194194+```json
195195+{
196196+ "alg": "ES256",
197197+ "typ": "dpop+jwt",
198198+ "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
199199+}
200200+{
201201+ "htm": "GET",
202202+ "htu": "https://bsky.social/xrpc/com.atproto.server.getServiceAuth",
203203+ "jti": "550e8400-e29b-41d4-a716-446655440000",
204204+ "iat": 1735689100,
205205+ "nonce": "server-provided-nonce"
206206+}
207207+```
208208+209209+### If You Need OAuth
210210+211211+If you need OAuth (e.g., for a production application), you'll want to use a library:
212212+213213+**Go:**
214214+```go
215215+import "github.com/bluesky-social/indigo/atproto/auth/oauth"
216216+```
217217+218218+**TypeScript/JavaScript:**
219219+```bash
220220+npm install @atproto/oauth-client-node
221221+```
222222+223223+**Python:**
224224+```bash
225225+pip install atproto
226226+```
227227+228228+These libraries handle all the DPoP complexity for you.
229229+230230+### High-Level OAuth Flow
231231+232232+For documentation purposes, here's what the flow looks like:
233233+234234+1. **Resolve identity**: `handle` → `DID` → `PDS endpoint`
235235+2. **Discover OAuth server**: `GET {pds}/.well-known/oauth-authorization-server`
236236+3. **Generate DPoP key**: Create P-256 key pair
237237+4. **PAR request**: Send authorization parameters (with DPoP proof)
238238+5. **User authorization**: Browser-based login
239239+6. **Token exchange**: Exchange code for tokens (with DPoP proof)
240240+7. **Use tokens**: All subsequent requests include DPoP proofs
241241+242242+Each step after #3 requires generating a fresh DPoP proof JWT, which is why libraries are essential.
243243+244244+---
245245+246246+## Troubleshooting
247247+248248+### "Invalid token" or "Token expired"
249249+250250+Service tokens are only valid for ~60 seconds. Get a fresh one:
251251+```bash
252252+SERVICE_TOKEN=$(curl -s "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
253253+ -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')
254254+```
255255+256256+### "Session expired"
257257+258258+Your access JWT from `createSession` has expired. Create a new session:
259259+```bash
260260+SESSION=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" ...)
261261+ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
262262+```
263263+264264+### "Audience mismatch"
265265+266266+The service token is scoped to a specific hold. Make sure `HOLD_DID` matches exactly what's in the `aud` claim of your token.
267267+268268+### "Access denied: user is not a crew member"
269269+270270+You don't have access to this hold. You need to either:
271271+- Be the hold captain (owner)
272272+- Be a crew member with appropriate permissions
273273+274274+### Finding Your Hold DID
275275+276276+Check your sailor profile to find your default hold:
277277+```bash
278278+curl -s "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=io.atcr.sailor.profile&rkey=self" \
279279+ -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.value.defaultHold'
280280+```
281281+282282+Or check your manifest records for the hold where your images are stored:
283283+```bash
284284+curl -s "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.manifest&limit=1" \
285285+ -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.records[0].value.holdDid'
286286+```
287287+288288+---
289289+290290+## Security Notes
291291+292292+- **App passwords** are scoped tokens that can be revoked without changing your main password
293293+- **Service tokens** are short-lived (60 seconds) and scoped to a specific hold
294294+- **Never share** your app password or access tokens
295295+- Service tokens can only be used for the specific hold they were requested for (`aud` claim)
296296+297297+---
298298+299299+## References
300300+301301+- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
302302+- [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449)
303303+- [Bluesky OAuth Guide](https://docs.bsky.app/docs/advanced-guides/oauth-client)
304304+- [ATCR BYOS Documentation](./BYOS.md)
+1721
docs/HOLD_DISCOVERY.md
···11+# Hold Discovery
22+33+This document describes how AppView discovers available holds and presents them to users for selection.
44+55+## TL;DR
66+77+**Problem:** Users currently enter hold URLs manually in a text field. They don't know what holds exist or which ones they can access.
88+99+**Solution:**
1010+1. Subscribe to Jetstream for `io.atcr.hold.captain` and `io.atcr.hold.crew` collections
1111+2. Cache discovered holds and crew memberships in SQLite
1212+3. Replace the text input with a dropdown showing available holds grouped by access level
1313+1414+**Key Changes:**
1515+- New table: `hold_crew_members` (hold_did, member_did, rkey, permissions, ...)
1616+- Jetstream collections: `io.atcr.hold.captain`, `io.atcr.hold.crew`
1717+- Settings UI: Text input → `<select>` dropdown with optgroups
1818+- Form field: `hold_endpoint` (URL) → `hold_did` (DID)
1919+2020+**Hold Categories in Dropdown:**
2121+| Group | Who Can Use |
2222+|-------|-------------|
2323+| Your Holds | User is captain (owner) |
2424+| Crew Member | User has explicit crew record |
2525+| Open Registration | `allowAllCrew=true` |
2626+| Public Holds | `public=true` |
2727+2828+## Overview
2929+3030+Users need to select a "default hold" for blob storage. The AppView must discover available holds and determine which ones each user can access. This enables a dropdown in user settings showing:
3131+3232+- Holds the user owns (captain)
3333+- Holds where the user is a crew member
3434+- Holds that allow all crew members (open registration)
3535+- Public holds (anyone can read/write)
3636+3737+## Architecture
3838+3939+### Discovery Sources
4040+4141+Hold discovery leverages the ATProto network infrastructure:
4242+4343+```
4444+┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
4545+│ Hold Service │────▶│ Relay │────▶│ Jetstream │
4646+│ (embedded PDS) │ │ (BGS/bigsky) │ │ │
4747+└─────────────────┘ └─────────────────┘ └────────┬────────┘
4848+ │
4949+ ▼
5050+ ┌─────────────────┐
5151+ │ AppView │
5252+ │ (subscriber) │
5353+ └────────┬────────┘
5454+ │
5555+ ▼
5656+ ┌─────────────────┐
5757+ │ SQLite │
5858+ │ (cache) │
5959+ └─────────────────┘
6060+```
6161+6262+1. **Hold services** run embedded PDSes that store captain and crew records
6363+2. **Relays** crawl hold PDSes after `request-crawl.sh` is run
6464+3. **Jetstream** streams record events filtered by collection
6565+4. **AppView** subscribes to Jetstream and caches records in SQLite
6666+6767+### Record Types
6868+6969+Two ATProto record collections are relevant for discovery:
7070+7171+#### `io.atcr.hold.captain`
7272+7373+Singleton record (rkey: `self`) in each hold's embedded PDS describing the hold:
7474+7575+```json
7676+{
7777+ "$type": "io.atcr.hold.captain",
7878+ "ownerDid": "did:plc:abc123",
7979+ "public": false,
8080+ "allowAllCrew": true,
8181+ "deployedAt": "2025-01-07T12:00:00Z",
8282+ "region": "us-east-1",
8383+ "provider": "fly.io"
8484+}
8585+```
8686+8787+| Field | Type | Description |
8888+|-------|------|-------------|
8989+| `ownerDid` | string | DID of the hold owner (captain) |
9090+| `public` | boolean | If true, anyone can read and write blobs |
9191+| `allowAllCrew` | boolean | If true, any authenticated user can self-register as crew |
9292+| `deployedAt` | string | ISO 8601 timestamp of deployment |
9393+| `region` | string | Optional geographic region identifier |
9494+| `provider` | string | Optional hosting provider name |
9595+9696+#### `io.atcr.hold.crew`
9797+9898+One record per crew member in the hold's embedded PDS:
9999+100100+```json
101101+{
102102+ "$type": "io.atcr.hold.crew",
103103+ "memberDid": "did:plc:xyz789",
104104+ "role": "contributor",
105105+ "permissions": ["blob:read", "blob:write"],
106106+ "tier": "standard",
107107+ "addedAt": "2025-01-07T12:00:00Z"
108108+}
109109+```
110110+111111+| Field | Type | Description |
112112+|-------|------|-------------|
113113+| `memberDid` | string | DID of the crew member |
114114+| `role` | string | Human-readable role name |
115115+| `permissions` | string[] | Permission grants: `blob:read`, `blob:write`, `crew:admin` |
116116+| `tier` | string | Optional tier for quota management |
117117+| `addedAt` | string | ISO 8601 timestamp when added |
118118+119119+**Record key derivation:** Crew records use a deterministic rkey based on the member's DID:
120120+121121+```go
122122+func CrewRecordKey(memberDID string) string {
123123+ hash := sha256.Sum256([]byte(memberDID))
124124+ return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16])
125125+}
126126+```
127127+128128+This enables O(1) lookup of a specific member's crew record.
129129+130130+## Data Model
131131+132132+### Database Schema
133133+134134+Add to `pkg/appview/db/schema.sql`:
135135+136136+```sql
137137+-- Cached hold captain records from Jetstream
138138+-- Primary discovery source for available holds
139139+CREATE TABLE IF NOT EXISTS hold_captain_records (
140140+ did TEXT PRIMARY KEY, -- Hold's DID (did:web:hold01.atcr.io)
141141+ owner_did TEXT NOT NULL, -- Captain's DID
142142+ public INTEGER NOT NULL DEFAULT 0, -- 1 if public hold
143143+ allow_all_crew INTEGER NOT NULL DEFAULT 0, -- 1 if open registration
144144+ deployed_at TEXT, -- ISO 8601 deployment timestamp
145145+ region TEXT, -- Geographic region
146146+ provider TEXT, -- Hosting provider
147147+ endpoint TEXT, -- Resolved HTTP endpoint (cached)
148148+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
149149+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
150150+);
151151+152152+CREATE INDEX IF NOT EXISTS idx_hold_captain_owner ON hold_captain_records(owner_did);
153153+CREATE INDEX IF NOT EXISTS idx_hold_captain_public ON hold_captain_records(public);
154154+CREATE INDEX IF NOT EXISTS idx_hold_captain_allow_all ON hold_captain_records(allow_all_crew);
155155+156156+-- Cached hold crew memberships from Jetstream
157157+-- Enables reverse lookup: "which holds is user X a member of?"
158158+CREATE TABLE IF NOT EXISTS hold_crew_members (
159159+ hold_did TEXT NOT NULL, -- Hold's DID
160160+ member_did TEXT NOT NULL, -- Crew member's DID
161161+ rkey TEXT NOT NULL, -- ATProto record key (for delete handling)
162162+ role TEXT, -- Human-readable role
163163+ permissions TEXT, -- JSON array of permissions
164164+ tier TEXT, -- Optional quota tier
165165+ added_at TEXT, -- ISO 8601 timestamp
166166+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
167167+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
168168+ PRIMARY KEY (hold_did, member_did),
169169+ FOREIGN KEY (hold_did) REFERENCES hold_captain_records(did) ON DELETE CASCADE
170170+);
171171+172172+CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
173173+CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
174174+CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
175175+```
176176+177177+### Migration
178178+179179+Add to `pkg/appview/db/migrations/`:
180180+181181+```yaml
182182+# 006_hold_discovery.yaml
183183+id: 006_hold_discovery
184184+description: Add hold crew members table for discovery
185185+up: |
186186+ CREATE TABLE IF NOT EXISTS hold_crew_members (
187187+ hold_did TEXT NOT NULL,
188188+ member_did TEXT NOT NULL,
189189+ rkey TEXT NOT NULL,
190190+ role TEXT,
191191+ permissions TEXT,
192192+ tier TEXT,
193193+ added_at TEXT,
194194+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
195195+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
196196+ PRIMARY KEY (hold_did, member_did),
197197+ FOREIGN KEY (hold_did) REFERENCES hold_captain_records(did) ON DELETE CASCADE
198198+ );
199199+ CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
200200+ CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
201201+ CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
202202+down: |
203203+ DROP INDEX IF EXISTS idx_hold_crew_rkey;
204204+ DROP INDEX IF EXISTS idx_hold_crew_hold;
205205+ DROP INDEX IF EXISTS idx_hold_crew_member;
206206+ DROP TABLE IF EXISTS hold_crew_members;
207207+```
208208+209209+## Jetstream Integration
210210+211211+### Subscription Configuration
212212+213213+Update the Jetstream worker to subscribe to hold collections:
214214+215215+```go
216216+// pkg/appview/jetstream/worker.go
217217+218218+var wantedCollections = []string{
219219+ "io.atcr.manifest",
220220+ "io.atcr.tag",
221221+ "io.atcr.hold.stats",
222222+ "io.atcr.hold.captain", // NEW: Hold discovery
223223+ "io.atcr.hold.crew", // NEW: Crew membership discovery
224224+}
225225+```
226226+227227+### Event Processing
228228+229229+Add processors for captain and crew records:
230230+231231+```go
232232+// pkg/appview/jetstream/processor.go
233233+234234+func (p *Processor) ProcessEvent(evt *Event) error {
235235+ switch evt.Collection {
236236+ case "io.atcr.manifest":
237237+ return p.ProcessManifest(evt)
238238+ case "io.atcr.tag":
239239+ return p.ProcessTag(evt)
240240+ case "io.atcr.hold.stats":
241241+ return p.ProcessStats(evt)
242242+ case "io.atcr.hold.captain":
243243+ return p.ProcessCaptain(evt)
244244+ case "io.atcr.hold.crew":
245245+ return p.ProcessCrew(evt)
246246+ default:
247247+ return nil
248248+ }
249249+}
250250+251251+func (p *Processor) ProcessCaptain(evt *Event) error {
252252+ // The repo DID IS the hold DID (hold's embedded PDS)
253253+ holdDID := evt.DID
254254+255255+ if evt.Operation == "delete" {
256256+ return p.db.DeleteCaptainRecord(holdDID)
257257+ }
258258+259259+ var record atproto.CaptainRecord
260260+ if err := json.Unmarshal(evt.Record, &record); err != nil {
261261+ return fmt.Errorf("unmarshal captain record: %w", err)
262262+ }
263263+264264+ // Resolve hold DID to HTTP endpoint for caching
265265+ endpoint, err := p.resolver.ResolveHoldURL(holdDID)
266266+ if err != nil {
267267+ // Log but don't fail - endpoint can be resolved later
268268+ log.Warn().Err(err).Str("did", holdDID).Msg("failed to resolve hold endpoint")
269269+ }
270270+271271+ // Verify this is actually a hold by checking /.well-known/did.json
272272+ // for #atcr_hold service type
273273+ if !p.verifyHoldService(holdDID, endpoint) {
274274+ log.Debug().Str("did", holdDID).Msg("skipping non-hold captain record")
275275+ return nil
276276+ }
277277+278278+ return p.db.UpsertCaptainRecord(holdDID, &db.CaptainRecord{
279279+ DID: holdDID,
280280+ OwnerDID: record.OwnerDID,
281281+ Public: record.Public,
282282+ AllowAllCrew: record.AllowAllCrew,
283283+ DeployedAt: record.DeployedAt,
284284+ Region: record.Region,
285285+ Provider: record.Provider,
286286+ Endpoint: endpoint,
287287+ })
288288+}
289289+290290+func (p *Processor) ProcessCrew(evt *Event) error {
291291+ // The repo DID IS the hold DID (hold's embedded PDS)
292292+ holdDID := evt.DID
293293+294294+ if evt.Operation == "delete" {
295295+ // Need to determine member DID from rkey or record
296296+ // For delete events, we may not have the record body
297297+ return p.db.DeleteCrewMemberByRkey(holdDID, evt.Rkey)
298298+ }
299299+300300+ var record atproto.CrewRecord
301301+ if err := json.Unmarshal(evt.Record, &record); err != nil {
302302+ return fmt.Errorf("unmarshal crew record: %w", err)
303303+ }
304304+305305+ // Verify the hold exists in our captain records
306306+ // If not, this crew record is for an unknown hold - skip it
307307+ if _, err := p.db.GetCaptainRecord(holdDID); err != nil {
308308+ log.Debug().Str("hold", holdDID).Msg("skipping crew record for unknown hold")
309309+ return nil
310310+ }
311311+312312+ permissionsJSON, _ := json.Marshal(record.Permissions)
313313+314314+ return p.db.UpsertCrewMember(holdDID, &db.CrewMember{
315315+ HoldDID: holdDID,
316316+ MemberDID: record.MemberDID,
317317+ Role: record.Role,
318318+ Permissions: string(permissionsJSON),
319319+ Tier: record.Tier,
320320+ AddedAt: record.AddedAt,
321321+ })
322322+}
323323+324324+func (p *Processor) verifyHoldService(did, endpoint string) bool {
325325+ // Fetch /.well-known/did.json and check for #atcr_hold service
326326+ didDoc, err := p.resolver.ResolveDIDDocument(did)
327327+ if err != nil {
328328+ return false
329329+ }
330330+331331+ for _, svc := range didDoc.Service {
332332+ if svc.ID == did+"#atcr_hold" || svc.Type == "AtcrHold" {
333333+ return true
334334+ }
335335+ }
336336+337337+ return false
338338+}
339339+```
340340+341341+### Hold Service Verification
342342+343343+Before caching a captain record, verify the DID document contains the `#atcr_hold` service:
344344+345345+```go
346346+// pkg/atproto/resolver.go
347347+348348+type DIDDocument struct {
349349+ ID string `json:"id"`
350350+ Service []Service `json:"service"`
351351+ // ... other fields
352352+}
353353+354354+type Service struct {
355355+ ID string `json:"id"`
356356+ Type string `json:"type"`
357357+ ServiceEndpoint string `json:"serviceEndpoint"`
358358+}
359359+360360+func (r *Resolver) HasHoldService(did string) (bool, string, error) {
361361+ doc, err := r.ResolveDIDDocument(did)
362362+ if err != nil {
363363+ return false, "", err
364364+ }
365365+366366+ for _, svc := range doc.Service {
367367+ // Check for #atcr_hold fragment or AtcrHold type
368368+ if strings.HasSuffix(svc.ID, "#atcr_hold") || svc.Type == "AtcrHold" {
369369+ return true, svc.ServiceEndpoint, nil
370370+ }
371371+ }
372372+373373+ return false, "", nil
374374+}
375375+```
376376+377377+## Backfill Strategy
378378+379379+### Initial Backfill
380380+381381+For holds that existed before AppView started listening to Jetstream, use the existing backfill mechanism:
382382+383383+```go
384384+// pkg/appview/jetstream/backfill.go
385385+386386+func (b *Backfiller) BackfillHolds(ctx context.Context) error {
387387+ // List all repos from relay that have io.atcr.hold.captain collection
388388+ repos, err := b.listReposWithCollection(ctx, "io.atcr.hold.captain")
389389+ if err != nil {
390390+ return err
391391+ }
392392+393393+ for _, repo := range repos {
394394+ // Fetch captain record
395395+ captain, err := b.fetchRecord(ctx, repo.DID, "io.atcr.hold.captain", "self")
396396+ if err != nil {
397397+ log.Warn().Err(err).Str("did", repo.DID).Msg("failed to fetch captain record")
398398+ continue
399399+ }
400400+401401+ // Verify it's a hold service
402402+ hasService, endpoint, _ := b.resolver.HasHoldService(repo.DID)
403403+ if !hasService {
404404+ continue
405405+ }
406406+407407+ // Upsert captain record
408408+ if err := b.db.UpsertCaptainRecord(repo.DID, captain); err != nil {
409409+ log.Warn().Err(err).Str("did", repo.DID).Msg("failed to upsert captain record")
410410+ continue
411411+ }
412412+413413+ // Fetch and upsert all crew records for this hold
414414+ if err := b.backfillCrewRecords(ctx, repo.DID); err != nil {
415415+ log.Warn().Err(err).Str("did", repo.DID).Msg("failed to backfill crew records")
416416+ }
417417+ }
418418+419419+ return nil
420420+}
421421+422422+func (b *Backfiller) backfillCrewRecords(ctx context.Context, holdDID string) error {
423423+ // List all records in io.atcr.hold.crew collection
424424+ records, err := b.listRecords(ctx, holdDID, "io.atcr.hold.crew")
425425+ if err != nil {
426426+ return err
427427+ }
428428+429429+ for _, record := range records {
430430+ var crew atproto.CrewRecord
431431+ if err := json.Unmarshal(record.Value, &crew); err != nil {
432432+ continue
433433+ }
434434+435435+ permissionsJSON, _ := json.Marshal(crew.Permissions)
436436+437437+ if err := b.db.UpsertCrewMember(holdDID, &db.CrewMember{
438438+ HoldDID: holdDID,
439439+ MemberDID: crew.MemberDID,
440440+ Role: crew.Role,
441441+ Permissions: string(permissionsJSON),
442442+ Tier: crew.Tier,
443443+ AddedAt: crew.AddedAt,
444444+ }); err != nil {
445445+ log.Warn().Err(err).Msg("failed to upsert crew member")
446446+ }
447447+ }
448448+449449+ return nil
450450+}
451451+```
452452+453453+### Listing Repos by Collection
454454+455455+Query the relay for repos that have a specific collection:
456456+457457+```go
458458+func (b *Backfiller) listReposWithCollection(ctx context.Context, collection string) ([]Repo, error) {
459459+ // Use com.atproto.sync.listRepos to get all repos
460460+ // Then filter to those with the target collection
461461+ //
462462+ // Note: This is O(n) over all repos on the relay.
463463+ // For efficiency, could maintain a separate index or use
464464+ // Jetstream historical replay if available.
465465+466466+ var repos []Repo
467467+ cursor := ""
468468+469469+ for {
470470+ resp, err := b.client.SyncListRepos(ctx, cursor, 1000)
471471+ if err != nil {
472472+ return nil, err
473473+ }
474474+475475+ for _, repo := range resp.Repos {
476476+ // Check if repo has the collection by attempting to list records
477477+ records, err := b.client.RepoListRecords(ctx, repo.DID, collection, "", 1)
478478+ if err == nil && len(records.Records) > 0 {
479479+ repos = append(repos, Repo{DID: repo.DID})
480480+ }
481481+ }
482482+483483+ if resp.Cursor == nil || *resp.Cursor == "" {
484484+ break
485485+ }
486486+ cursor = *resp.Cursor
487487+ }
488488+489489+ return repos, nil
490490+}
491491+```
492492+493493+### Bootstrap Configuration
494494+495495+For known holds that may not yet be on relays, support a bootstrap list in configuration:
496496+497497+```bash
498498+# Environment variable
499499+ATCR_BOOTSTRAP_HOLDS="did:web:hold01.atcr.io,did:web:hold02.atcr.io"
500500+```
501501+502502+```go
503503+func (b *Backfiller) BackfillBootstrapHolds(ctx context.Context, holdDIDs []string) error {
504504+ for _, did := range holdDIDs {
505505+ // Verify it's a hold
506506+ hasService, endpoint, err := b.resolver.HasHoldService(did)
507507+ if err != nil || !hasService {
508508+ log.Warn().Str("did", did).Msg("bootstrap hold is not a valid hold service")
509509+ continue
510510+ }
511511+512512+ // Fetch captain record directly from hold's PDS
513513+ captain, err := b.fetchCaptainFromHold(ctx, did, endpoint)
514514+ if err != nil {
515515+ log.Warn().Err(err).Str("did", did).Msg("failed to fetch captain from hold")
516516+ continue
517517+ }
518518+519519+ if err := b.db.UpsertCaptainRecord(did, captain); err != nil {
520520+ log.Warn().Err(err).Str("did", did).Msg("failed to upsert bootstrap captain")
521521+ continue
522522+ }
523523+524524+ // Also backfill crew records
525525+ if err := b.backfillCrewFromHold(ctx, did, endpoint); err != nil {
526526+ log.Warn().Err(err).Str("did", did).Msg("failed to backfill bootstrap crew")
527527+ }
528528+ }
529529+530530+ return nil
531531+}
532532+533533+func (b *Backfiller) fetchCaptainFromHold(ctx context.Context, did, endpoint string) (*db.CaptainRecord, error) {
534534+ // GET {endpoint}/xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
535535+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=io.atcr.hold.captain&rkey=self",
536536+ endpoint, did)
537537+538538+ resp, err := http.Get(url)
539539+ if err != nil {
540540+ return nil, err
541541+ }
542542+ defer resp.Body.Close()
543543+544544+ var result struct {
545545+ Value atproto.CaptainRecord `json:"value"`
546546+ }
547547+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
548548+ return nil, err
549549+ }
550550+551551+ return &db.CaptainRecord{
552552+ DID: did,
553553+ OwnerDID: result.Value.OwnerDID,
554554+ Public: result.Value.Public,
555555+ AllowAllCrew: result.Value.AllowAllCrew,
556556+ DeployedAt: result.Value.DeployedAt,
557557+ Region: result.Value.Region,
558558+ Provider: result.Value.Provider,
559559+ Endpoint: endpoint,
560560+ }, nil
561561+}
562562+```
563563+564564+## Database Queries
565565+566566+### Hold Store Functions
567567+568568+Add to `pkg/appview/db/hold_store.go`:
569569+570570+```go
571571+// CrewMember represents a cached crew membership
572572+type CrewMember struct {
573573+ HoldDID string
574574+ MemberDID string
575575+ Role string
576576+ Permissions string // JSON array
577577+ Tier string
578578+ AddedAt string
579579+ CreatedAt string
580580+ UpdatedAt string
581581+}
582582+583583+// UpsertCrewMember inserts or updates a crew member record
584584+func UpsertCrewMember(db *sql.DB, holdDID string, member *CrewMember) error {
585585+ _, err := db.Exec(`
586586+ INSERT INTO hold_crew_members (hold_did, member_did, role, permissions, tier, added_at, updated_at)
587587+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
588588+ ON CONFLICT(hold_did, member_did) DO UPDATE SET
589589+ role = excluded.role,
590590+ permissions = excluded.permissions,
591591+ tier = excluded.tier,
592592+ added_at = excluded.added_at,
593593+ updated_at = datetime('now')
594594+ `, holdDID, member.MemberDID, member.Role, member.Permissions, member.Tier, member.AddedAt)
595595+ return err
596596+}
597597+598598+// DeleteCrewMember removes a crew member record
599599+func DeleteCrewMember(db *sql.DB, holdDID, memberDID string) error {
600600+ _, err := db.Exec(`
601601+ DELETE FROM hold_crew_members WHERE hold_did = ? AND member_did = ?
602602+ `, holdDID, memberDID)
603603+ return err
604604+}
605605+606606+// DeleteCrewMemberByRkey removes a crew member by rkey (for delete events)
607607+func DeleteCrewMemberByRkey(db *sql.DB, holdDID, rkey string) error {
608608+ // We need to find the member by rkey hash
609609+ // This is tricky because we store member_did, not rkey
610610+ // Option 1: Store rkey in the table
611611+ // Option 2: Iterate and check (slow)
612612+ // Option 3: Store both member_did and rkey
613613+614614+ // For now, we'll need to add rkey to the schema
615615+ _, err := db.Exec(`
616616+ DELETE FROM hold_crew_members WHERE hold_did = ? AND rkey = ?
617617+ `, holdDID, rkey)
618618+ return err
619619+}
620620+621621+// AvailableHold represents a hold available to a user
622622+type AvailableHold struct {
623623+ DID string
624624+ OwnerDID string
625625+ Public bool
626626+ AllowAllCrew bool
627627+ Region string
628628+ Provider string
629629+ Endpoint string
630630+ Membership string // "owner", "crew", "eligible", "public"
631631+ Permissions []string // nil if not crew
632632+}
633633+634634+// GetAvailableHolds returns all holds available to a user
635635+func GetAvailableHolds(db *sql.DB, userDID string) ([]AvailableHold, error) {
636636+ rows, err := db.Query(`
637637+ SELECT
638638+ h.did,
639639+ h.owner_did,
640640+ h.public,
641641+ h.allow_all_crew,
642642+ h.region,
643643+ h.provider,
644644+ h.endpoint,
645645+ CASE
646646+ WHEN h.owner_did = ?1 THEN 'owner'
647647+ WHEN c.member_did IS NOT NULL THEN 'crew'
648648+ WHEN h.allow_all_crew = 1 THEN 'eligible'
649649+ WHEN h.public = 1 THEN 'public'
650650+ ELSE 'none'
651651+ END as membership,
652652+ c.permissions
653653+ FROM hold_captain_records h
654654+ LEFT JOIN hold_crew_members c
655655+ ON h.did = c.hold_did AND c.member_did = ?1
656656+ WHERE h.public = 1
657657+ OR h.allow_all_crew = 1
658658+ OR h.owner_did = ?1
659659+ OR c.member_did IS NOT NULL
660660+ ORDER BY
661661+ CASE
662662+ WHEN h.owner_did = ?1 THEN 0
663663+ WHEN c.member_did IS NOT NULL THEN 1
664664+ WHEN h.allow_all_crew = 1 THEN 2
665665+ ELSE 3
666666+ END,
667667+ h.did
668668+ `, userDID)
669669+ if err != nil {
670670+ return nil, err
671671+ }
672672+ defer rows.Close()
673673+674674+ var holds []AvailableHold
675675+ for rows.Next() {
676676+ var h AvailableHold
677677+ var permissionsJSON sql.NullString
678678+679679+ err := rows.Scan(
680680+ &h.DID,
681681+ &h.OwnerDID,
682682+ &h.Public,
683683+ &h.AllowAllCrew,
684684+ &h.Region,
685685+ &h.Provider,
686686+ &h.Endpoint,
687687+ &h.Membership,
688688+ &permissionsJSON,
689689+ )
690690+ if err != nil {
691691+ return nil, err
692692+ }
693693+694694+ if permissionsJSON.Valid {
695695+ json.Unmarshal([]byte(permissionsJSON.String), &h.Permissions)
696696+ }
697697+698698+ holds = append(holds, h)
699699+ }
700700+701701+ return holds, rows.Err()
702702+}
703703+704704+// GetHoldsOwnedBy returns holds owned by a specific DID
705705+func GetHoldsOwnedBy(db *sql.DB, ownerDID string) ([]CaptainRecord, error) {
706706+ rows, err := db.Query(`
707707+ SELECT did, owner_did, public, allow_all_crew, deployed_at, region, provider, endpoint
708708+ FROM hold_captain_records
709709+ WHERE owner_did = ?
710710+ ORDER BY deployed_at DESC
711711+ `, ownerDID)
712712+ if err != nil {
713713+ return nil, err
714714+ }
715715+ defer rows.Close()
716716+717717+ var holds []CaptainRecord
718718+ for rows.Next() {
719719+ var h CaptainRecord
720720+ err := rows.Scan(&h.DID, &h.OwnerDID, &h.Public, &h.AllowAllCrew,
721721+ &h.DeployedAt, &h.Region, &h.Provider, &h.Endpoint)
722722+ if err != nil {
723723+ return nil, err
724724+ }
725725+ holds = append(holds, h)
726726+ }
727727+728728+ return holds, rows.Err()
729729+}
730730+731731+// GetCrewMemberships returns all holds where a user is a crew member
732732+func GetCrewMemberships(db *sql.DB, memberDID string) ([]CrewMember, error) {
733733+ rows, err := db.Query(`
734734+ SELECT hold_did, member_did, role, permissions, tier, added_at
735735+ FROM hold_crew_members
736736+ WHERE member_did = ?
737737+ ORDER BY added_at DESC
738738+ `, memberDID)
739739+ if err != nil {
740740+ return nil, err
741741+ }
742742+ defer rows.Close()
743743+744744+ var memberships []CrewMember
745745+ for rows.Next() {
746746+ var m CrewMember
747747+ err := rows.Scan(&m.HoldDID, &m.MemberDID, &m.Role, &m.Permissions, &m.Tier, &m.AddedAt)
748748+ if err != nil {
749749+ return nil, err
750750+ }
751751+ memberships = append(memberships, m)
752752+ }
753753+754754+ return memberships, rows.Err()
755755+}
756756+```
757757+758758+## UI Integration
759759+760760+### Current State
761761+762762+The settings page (`pkg/appview/templates/pages/settings.html`) currently has a **text input field** for the default hold:
763763+764764+```html
765765+<!-- Current implementation (to be replaced) -->
766766+<section class="settings-section">
767767+ <h2>Default Hold</h2>
768768+ <p>Current: <strong id="current-hold">{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p>
769769+770770+ <form hx-post="/api/profile/default-hold" ...>
771771+ <div class="form-group">
772772+ <label for="hold-endpoint">Hold Endpoint:</label>
773773+ <input type="text"
774774+ id="hold-endpoint"
775775+ name="hold_endpoint"
776776+ value="{{ .Profile.DefaultHold }}"
777777+ placeholder="https://hold.example.com" />
778778+ <small>Leave empty to use AppView default storage</small>
779779+ </div>
780780+ <button type="submit" class="btn-primary">Save</button>
781781+ </form>
782782+</section>
783783+```
784784+785785+**Problems with the current approach:**
786786+787787+1. **Users must know hold URLs** - Requires users to manually find and copy hold endpoint URLs
788788+2. **No validation** - Users can enter invalid or inaccessible URLs
789789+3. **No discovery** - Users don't know what holds are available to them
790790+4. **Poor UX** - Text input is error-prone and unfriendly
791791+5. **No membership visibility** - Users can't see which holds they're crew on
792792+793793+### Proposed Change: Dropdown with Discovered Holds
794794+795795+Replace the text input with a `<select>` dropdown populated from the hold discovery cache:
796796+797797+```html
798798+<!-- New implementation -->
799799+<section class="settings-section">
800800+ <h2>Default Hold</h2>
801801+ <p class="help-text">
802802+ Select where your container images will be stored. Holds are organized by your access level.
803803+ </p>
804804+805805+ <form hx-post="/api/profile/default-hold"
806806+ hx-target="#hold-status"
807807+ hx-swap="innerHTML"
808808+ id="hold-form">
809809+810810+ <div class="form-group">
811811+ <label for="default-hold">Storage Hold:</label>
812812+ <select id="default-hold" name="hold_did" class="form-select">
813813+ <option value="">AppView Default ({{ .DefaultHoldDisplayName }})</option>
814814+815815+ {{if .OwnedHolds}}
816816+ <optgroup label="Your Holds">
817817+ {{range .OwnedHolds}}
818818+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
819819+ {{.DisplayName}}
820820+ {{if .Region}} ({{.Region}}){{end}}
821821+ </option>
822822+ {{end}}
823823+ </optgroup>
824824+ {{end}}
825825+826826+ {{if .CrewHolds}}
827827+ <optgroup label="Crew Member">
828828+ {{range .CrewHolds}}
829829+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
830830+ {{.DisplayName}}
831831+ {{if .Region}} ({{.Region}}){{end}}
832832+ {{if not .HasWritePermission}}[read-only]{{end}}
833833+ </option>
834834+ {{end}}
835835+ </optgroup>
836836+ {{end}}
837837+838838+ {{if .EligibleHolds}}
839839+ <optgroup label="Open Registration">
840840+ {{range .EligibleHolds}}
841841+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
842842+ {{.DisplayName}}
843843+ {{if .Region}} ({{.Region}}){{end}}
844844+ </option>
845845+ {{end}}
846846+ </optgroup>
847847+ {{end}}
848848+849849+ {{if .PublicHolds}}
850850+ <optgroup label="Public Holds">
851851+ {{range .PublicHolds}}
852852+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
853853+ {{.DisplayName}}
854854+ {{if .Region}} ({{.Region}}){{end}}
855855+ </option>
856856+ {{end}}
857857+ </optgroup>
858858+ {{end}}
859859+ </select>
860860+ <small>Your images will be stored on the selected hold</small>
861861+ </div>
862862+863863+ <button type="submit" class="btn-primary">Save</button>
864864+ </form>
865865+866866+ <div id="hold-status"></div>
867867+868868+ <!-- Hold details panel (shows when hold selected) -->
869869+ <div id="hold-details" class="hold-details" style="display: none;">
870870+ <h3>Hold Details</h3>
871871+ <dl>
872872+ <dt>DID:</dt>
873873+ <dd id="hold-did"></dd>
874874+ <dt>Provider:</dt>
875875+ <dd id="hold-provider"></dd>
876876+ <dt>Region:</dt>
877877+ <dd id="hold-region"></dd>
878878+ <dt>Your Access:</dt>
879879+ <dd id="hold-access"></dd>
880880+ </dl>
881881+ </div>
882882+</section>
883883+```
884884+885885+### Dropdown Option Groups
886886+887887+The dropdown organizes holds into logical groups based on user's relationship:
888888+889889+| Group | Description | Access Level |
890890+|-------|-------------|--------------|
891891+| **Your Holds** | Holds where user is the captain (owner) | Full control |
892892+| **Crew Member** | Holds where user has explicit crew membership | Based on permissions |
893893+| **Open Registration** | Holds with `allowAllCrew=true` | Can self-register |
894894+| **Public Holds** | Holds with `public=true` | Anyone can use |
895895+896896+### Visual Indicators
897897+898898+Each option should show relevant context:
899899+900900+```
901901+┌─ Storage Hold: ─────────────────────────────────────┐
902902+│ ▼ hold01.atcr.io (us-east) │
903903+├─────────────────────────────────────────────────────┤
904904+│ AppView Default (hold01.atcr.io) │
905905+│ ───────────────────────────────────── │
906906+│ Your Holds │
907907+│ my-hold.fly.dev (us-west) │
908908+│ ───────────────────────────────────── │
909909+│ Crew Member │
910910+│ team-hold.company.com (eu-central) │
911911+│ shared-hold.org (asia-pacific) [read-only] │
912912+│ ───────────────────────────────────── │
913913+│ Open Registration │
914914+│ community-hold.dev (us-east) │
915915+│ ───────────────────────────────────── │
916916+│ Public Holds │
917917+│ public-hold.example.com (global) │
918918+└─────────────────────────────────────────────────────┘
919919+```
920920+921921+### Form Submission Change
922922+923923+The form now submits `hold_did` (a DID) instead of `hold_endpoint` (a URL):
924924+925925+**Before:**
926926+```
927927+POST /api/profile/default-hold
928928+Content-Type: application/x-www-form-urlencoded
929929+930930+hold_endpoint=https://hold01.atcr.io
931931+```
932932+933933+**After:**
934934+```
935935+POST /api/profile/default-hold
936936+Content-Type: application/x-www-form-urlencoded
937937+938938+hold_did=did:web:hold01.atcr.io
939939+```
940940+941941+The `UpdateDefaultHoldHandler` needs to be updated to accept DIDs:
942942+943943+```go
944944+// pkg/appview/handlers/settings.go
945945+946946+func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
947947+ user := middleware.GetUser(r)
948948+ if user == nil {
949949+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
950950+ return
951951+ }
952952+953953+ // Accept DID (new) or endpoint (legacy/fallback)
954954+ holdDID := r.FormValue("hold_did")
955955+ if holdDID == "" {
956956+ // Fallback for legacy form submissions
957957+ holdDID = r.FormValue("hold_endpoint")
958958+ }
959959+960960+ // Validate the hold DID if provided
961961+ if holdDID != "" {
962962+ // Check it's in our discovered holds cache
963963+ captain, err := h.DB.GetCaptainRecord(holdDID)
964964+ if err != nil {
965965+ http.Error(w, "Unknown hold: "+holdDID, http.StatusBadRequest)
966966+ return
967967+ }
968968+969969+ // Verify user has access to this hold
970970+ available, err := db.GetAvailableHolds(h.DB, user.DID)
971971+ if err != nil {
972972+ http.Error(w, "Failed to check hold access", http.StatusInternalServerError)
973973+ return
974974+ }
975975+976976+ hasAccess := false
977977+ for _, h := range available {
978978+ if h.DID == holdDID {
979979+ hasAccess = true
980980+ break
981981+ }
982982+ }
983983+984984+ if !hasAccess {
985985+ http.Error(w, "You don't have access to this hold", http.StatusForbidden)
986986+ return
987987+ }
988988+ }
989989+990990+ // ... rest of profile update logic
991991+}
992992+```
993993+994994+### Settings Handler
995995+996996+Update the settings handler to include available holds:
997997+998998+```go
999999+// pkg/appview/handlers/settings.go
10001000+10011001+func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) {
10021002+ ctx := r.Context()
10031003+ userDID := auth.GetDID(ctx)
10041004+10051005+ // Get user's current profile
10061006+ profile, err := h.storage.GetProfile(ctx, userDID)
10071007+ if err != nil {
10081008+ // Handle error
10091009+ }
10101010+10111011+ // Get available holds for dropdown
10121012+ availableHolds, err := db.GetAvailableHolds(h.db, userDID)
10131013+ if err != nil {
10141014+ // Handle error
10151015+ }
10161016+10171017+ data := SettingsPageData{
10181018+ Profile: profile,
10191019+ AvailableHolds: availableHolds,
10201020+ CurrentHoldDID: profile.DefaultHold,
10211021+ }
10221022+10231023+ h.renderTemplate(w, "settings.html", data)
10241024+}
10251025+```
10261026+10271027+### Settings Template
10281028+10291029+```html
10301030+<!-- pkg/appview/templates/pages/settings.html -->
10311031+10321032+<div class="settings-section">
10331033+ <h2>Default Hold</h2>
10341034+ <p class="help-text">
10351035+ Select where your container images will be stored by default.
10361036+ </p>
10371037+10381038+ <form method="POST" action="/settings/hold">
10391039+ <select name="defaultHold" id="defaultHold" class="form-select">
10401040+ <option value="">-- Select a Hold --</option>
10411041+10421042+ {{if .OwnedHolds}}
10431043+ <optgroup label="Your Holds">
10441044+ {{range .OwnedHolds}}
10451045+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
10461046+ {{.DisplayName}} (Owner)
10471047+ {{if .Region}} - {{.Region}}{{end}}
10481048+ </option>
10491049+ {{end}}
10501050+ </optgroup>
10511051+ {{end}}
10521052+10531053+ {{if .CrewHolds}}
10541054+ <optgroup label="Crew Member">
10551055+ {{range .CrewHolds}}
10561056+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
10571057+ {{.DisplayName}}
10581058+ {{if .Region}} - {{.Region}}{{end}}
10591059+ </option>
10601060+ {{end}}
10611061+ </optgroup>
10621062+ {{end}}
10631063+10641064+ {{if .EligibleHolds}}
10651065+ <optgroup label="Open Registration">
10661066+ {{range .EligibleHolds}}
10671067+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
10681068+ {{.DisplayName}}
10691069+ {{if .Region}} - {{.Region}}{{end}}
10701070+ </option>
10711071+ {{end}}
10721072+ </optgroup>
10731073+ {{end}}
10741074+10751075+ {{if .PublicHolds}}
10761076+ <optgroup label="Public Holds">
10771077+ {{range .PublicHolds}}
10781078+ <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
10791079+ {{.DisplayName}}
10801080+ {{if .Region}} - {{.Region}}{{end}}
10811081+ </option>
10821082+ {{end}}
10831083+ </optgroup>
10841084+ {{end}}
10851085+ </select>
10861086+10871087+ <button type="submit" class="btn btn-primary">Save</button>
10881088+ </form>
10891089+</div>
10901090+```
10911091+10921092+### Template Data Preparation
10931093+10941094+```go
10951095+// pkg/appview/handlers/settings.go
10961096+10971097+type SettingsPageData struct {
10981098+ Profile *atproto.SailorProfile
10991099+ CurrentHoldDID string
11001100+ OwnedHolds []HoldDisplay
11011101+ CrewHolds []HoldDisplay
11021102+ EligibleHolds []HoldDisplay
11031103+ PublicHolds []HoldDisplay
11041104+}
11051105+11061106+type HoldDisplay struct {
11071107+ DID string
11081108+ DisplayName string // Derived from DID or endpoint
11091109+ Region string
11101110+ Provider string
11111111+ Permissions []string
11121112+}
11131113+11141114+func (h *Handler) prepareSettingsData(userDID string, holds []db.AvailableHold, currentHold string) SettingsPageData {
11151115+ data := SettingsPageData{
11161116+ CurrentHoldDID: currentHold,
11171117+ }
11181118+11191119+ for _, hold := range holds {
11201120+ display := HoldDisplay{
11211121+ DID: hold.DID,
11221122+ DisplayName: deriveDisplayName(hold.DID, hold.Endpoint),
11231123+ Region: hold.Region,
11241124+ Provider: hold.Provider,
11251125+ Permissions: hold.Permissions,
11261126+ }
11271127+11281128+ switch hold.Membership {
11291129+ case "owner":
11301130+ data.OwnedHolds = append(data.OwnedHolds, display)
11311131+ case "crew":
11321132+ data.CrewHolds = append(data.CrewHolds, display)
11331133+ case "eligible":
11341134+ data.EligibleHolds = append(data.EligibleHolds, display)
11351135+ case "public":
11361136+ data.PublicHolds = append(data.PublicHolds, display)
11371137+ }
11381138+ }
11391139+11401140+ return data
11411141+}
11421142+11431143+func deriveDisplayName(did, endpoint string) string {
11441144+ // For did:web, extract the domain
11451145+ if strings.HasPrefix(did, "did:web:") {
11461146+ return strings.TrimPrefix(did, "did:web:")
11471147+ }
11481148+11491149+ // For did:plc, use the endpoint hostname if available
11501150+ if endpoint != "" {
11511151+ if u, err := url.Parse(endpoint); err == nil {
11521152+ return u.Host
11531153+ }
11541154+ }
11551155+11561156+ // Fallback to truncated DID
11571157+ if len(did) > 20 {
11581158+ return did[:20] + "..."
11591159+ }
11601160+ return did
11611161+}
11621162+```
11631163+11641164+### CSS Styles
11651165+11661166+Add styles for the hold dropdown and details panel:
11671167+11681168+```css
11691169+/* pkg/appview/templates/pages/settings.html - add to <style> section */
11701170+11711171+/* Hold Selection Styles */
11721172+.form-select {
11731173+ width: 100%;
11741174+ padding: 0.75rem;
11751175+ font-size: 1rem;
11761176+ border: 1px solid var(--border);
11771177+ border-radius: 4px;
11781178+ background: var(--bg);
11791179+ color: var(--fg);
11801180+ cursor: pointer;
11811181+}
11821182+11831183+.form-select:focus {
11841184+ outline: none;
11851185+ border-color: var(--primary);
11861186+ box-shadow: 0 0 0 2px var(--primary-bg);
11871187+}
11881188+11891189+.form-select optgroup {
11901190+ font-weight: bold;
11911191+ color: var(--fg-muted);
11921192+ padding-top: 0.5rem;
11931193+}
11941194+11951195+.form-select option {
11961196+ padding: 0.5rem;
11971197+ font-weight: normal;
11981198+ color: var(--fg);
11991199+}
12001200+12011201+/* Hold Details Panel */
12021202+.hold-details {
12031203+ margin-top: 1rem;
12041204+ padding: 1rem;
12051205+ background: var(--code-bg);
12061206+ border-radius: 4px;
12071207+ border: 1px solid var(--border);
12081208+}
12091209+12101210+.hold-details h3 {
12111211+ margin-top: 0;
12121212+ margin-bottom: 0.75rem;
12131213+ font-size: 0.9rem;
12141214+ color: var(--fg-muted);
12151215+ text-transform: uppercase;
12161216+ letter-spacing: 0.05em;
12171217+}
12181218+12191219+.hold-details dl {
12201220+ display: grid;
12211221+ grid-template-columns: auto 1fr;
12221222+ gap: 0.5rem 1rem;
12231223+ margin: 0;
12241224+}
12251225+12261226+.hold-details dt {
12271227+ color: var(--fg-muted);
12281228+ font-weight: 500;
12291229+}
12301230+12311231+.hold-details dd {
12321232+ margin: 0;
12331233+ font-family: monospace;
12341234+}
12351235+12361236+/* Access Level Badges */
12371237+.access-badge {
12381238+ display: inline-block;
12391239+ padding: 0.125rem 0.5rem;
12401240+ border-radius: 4px;
12411241+ font-size: 0.85rem;
12421242+ font-weight: 500;
12431243+}
12441244+12451245+.access-owner {
12461246+ background: #fef3c7;
12471247+ color: #92400e;
12481248+}
12491249+12501250+.access-crew {
12511251+ background: #dcfce7;
12521252+ color: #166534;
12531253+}
12541254+12551255+.access-eligible {
12561256+ background: #e0e7ff;
12571257+ color: #3730a3;
12581258+}
12591259+12601260+.access-public {
12611261+ background: #f3f4f6;
12621262+ color: #374151;
12631263+}
12641264+12651265+/* Read-only indicator */
12661266+.read-only-indicator {
12671267+ color: var(--warning);
12681268+ font-size: 0.85rem;
12691269+ margin-left: 0.25rem;
12701270+}
12711271+```
12721272+12731273+### JavaScript Interaction
12741274+12751275+Add JavaScript to show hold details when selection changes:
12761276+12771277+```html
12781278+<!-- Add to settings.html <script> section -->
12791279+<script>
12801280+(function() {
12811281+ // Hold selection and details display
12821282+ const holdSelect = document.getElementById('default-hold');
12831283+ const holdDetails = document.getElementById('hold-details');
12841284+12851285+ // Hold data embedded from server (JSON in data attribute or inline)
12861286+ const holdData = {{ .HoldDataJSON }};
12871287+12881288+ if (holdSelect) {
12891289+ holdSelect.addEventListener('change', function() {
12901290+ const selectedDID = this.value;
12911291+12921292+ if (!selectedDID || !holdData[selectedDID]) {
12931293+ holdDetails.style.display = 'none';
12941294+ return;
12951295+ }
12961296+12971297+ const hold = holdData[selectedDID];
12981298+12991299+ document.getElementById('hold-did').textContent = hold.did;
13001300+ document.getElementById('hold-provider').textContent = hold.provider || 'Unknown';
13011301+ document.getElementById('hold-region').textContent = hold.region || 'Global';
13021302+13031303+ // Set access level with badge
13041304+ const accessEl = document.getElementById('hold-access');
13051305+ const accessClass = 'access-' + hold.membership;
13061306+ const accessLabel = {
13071307+ 'owner': 'Owner (Full Control)',
13081308+ 'crew': 'Crew Member',
13091309+ 'eligible': 'Open Registration',
13101310+ 'public': 'Public Access'
13111311+ }[hold.membership] || hold.membership;
13121312+13131313+ accessEl.innerHTML = `<span class="access-badge ${accessClass}">${accessLabel}</span>`;
13141314+13151315+ // Show permissions for crew members
13161316+ if (hold.membership === 'crew' && hold.permissions) {
13171317+ const perms = hold.permissions.join(', ');
13181318+ accessEl.innerHTML += `<br><small>Permissions: ${perms}</small>`;
13191319+ }
13201320+13211321+ holdDetails.style.display = 'block';
13221322+ });
13231323+13241324+ // Trigger on page load if a hold is already selected
13251325+ if (holdSelect.value) {
13261326+ holdSelect.dispatchEvent(new Event('change'));
13271327+ }
13281328+ }
13291329+})();
13301330+</script>
13311331+```
13321332+13331333+### Server-Side Hold Data
13341334+13351335+The handler needs to serialize hold data for the JavaScript:
13361336+13371337+```go
13381338+// pkg/appview/handlers/settings.go
13391339+13401340+import "encoding/json"
13411341+13421342+type HoldDataEntry struct {
13431343+ DID string `json:"did"`
13441344+ DisplayName string `json:"displayName"`
13451345+ Provider string `json:"provider"`
13461346+ Region string `json:"region"`
13471347+ Membership string `json:"membership"`
13481348+ Permissions []string `json:"permissions,omitempty"`
13491349+}
13501350+13511351+func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
13521352+ // ... existing code ...
13531353+13541354+ // Get available holds
13551355+ availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
13561356+ if err != nil {
13571357+ slog.Error("Failed to get available holds", "error", err)
13581358+ availableHolds = []db.AvailableHold{}
13591359+ }
13601360+13611361+ // Build hold data map for JavaScript
13621362+ holdDataMap := make(map[string]HoldDataEntry)
13631363+ for _, hold := range availableHolds {
13641364+ holdDataMap[hold.DID] = HoldDataEntry{
13651365+ DID: hold.DID,
13661366+ DisplayName: deriveDisplayName(hold.DID, hold.Endpoint),
13671367+ Provider: hold.Provider,
13681368+ Region: hold.Region,
13691369+ Membership: hold.Membership,
13701370+ Permissions: hold.Permissions,
13711371+ }
13721372+ }
13731373+13741374+ holdDataJSON, _ := json.Marshal(holdDataMap)
13751375+13761376+ data := SettingsPageData{
13771377+ // ... existing fields ...
13781378+ HoldDataJSON: template.JS(holdDataJSON), // Safe for embedding in <script>
13791379+ }
13801380+13811381+ // ... render template ...
13821382+}
13831383+```
13841384+13851385+### Empty State Handling
13861386+13871387+When no holds are discovered yet, show a helpful message:
13881388+13891389+```html
13901390+{{if and (not .OwnedHolds) (not .CrewHolds) (not .EligibleHolds) (not .PublicHolds)}}
13911391+<div class="empty-holds-notice">
13921392+ <p>
13931393+ <i data-lucide="info"></i>
13941394+ No holds discovered yet. Using AppView default storage.
13951395+ </p>
13961396+ <p class="help-text">
13971397+ Holds are discovered automatically via the ATProto network.
13981398+ If you've deployed your own hold, make sure it has requested a relay crawl.
13991399+ </p>
14001400+</div>
14011401+{{else}}
14021402+<!-- Show the dropdown -->
14031403+{{end}}
14041404+```
14051405+14061406+### Refresh Button
14071407+14081408+Allow users to manually trigger hold refresh:
14091409+14101410+```html
14111411+<div class="hold-actions">
14121412+ <button type="button"
14131413+ class="btn-secondary"
14141414+ hx-post="/api/holds/refresh"
14151415+ hx-target="#hold-refresh-status"
14161416+ hx-swap="innerHTML">
14171417+ <i data-lucide="refresh-cw"></i> Refresh Holds
14181418+ </button>
14191419+ <span id="hold-refresh-status"></span>
14201420+</div>
14211421+```
14221422+14231423+```go
14241424+// pkg/appview/handlers/settings.go
14251425+14261426+func (h *RefreshHoldsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
14271427+ user := middleware.GetUser(r)
14281428+ if user == nil {
14291429+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
14301430+ return
14311431+ }
14321432+14331433+ // Trigger async refresh of hold cache
14341434+ go func() {
14351435+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
14361436+ defer cancel()
14371437+14381438+ if err := h.Backfiller.RefreshAllHolds(ctx); err != nil {
14391439+ slog.Error("Failed to refresh holds", "error", err)
14401440+ }
14411441+ }()
14421442+14431443+ w.Header().Set("Content-Type", "text/html")
14441444+ w.Write([]byte(`<span class="success">Refreshing... reload page in a moment</span>`))
14451445+}
14461446+```
14471447+14481448+## Cache Invalidation
14491449+14501450+### Real-time Updates via Jetstream
14511451+14521452+Jetstream events automatically update the cache:
14531453+14541454+- **Captain record created/updated**: Upsert to `hold_captain_records`
14551455+- **Captain record deleted**: Delete from `hold_captain_records` (cascades to crew)
14561456+- **Crew record created/updated**: Upsert to `hold_crew_members`
14571457+- **Crew record deleted**: Delete from `hold_crew_members`
14581458+14591459+### Manual Refresh
14601460+14611461+For cases where Jetstream may be delayed or missed events:
14621462+14631463+```go
14641464+// pkg/appview/handlers/settings.go
14651465+14661466+func (h *Handler) RefreshHoldCache(w http.ResponseWriter, r *http.Request) {
14671467+ holdDID := r.URL.Query().Get("did")
14681468+ if holdDID == "" {
14691469+ http.Error(w, "missing did parameter", http.StatusBadRequest)
14701470+ return
14711471+ }
14721472+14731473+ // Verify it's a hold service
14741474+ hasService, endpoint, err := h.resolver.HasHoldService(holdDID)
14751475+ if err != nil || !hasService {
14761476+ http.Error(w, "invalid hold DID", http.StatusBadRequest)
14771477+ return
14781478+ }
14791479+14801480+ // Fetch and update captain record
14811481+ captain, err := h.backfiller.fetchCaptainFromHold(r.Context(), holdDID, endpoint)
14821482+ if err != nil {
14831483+ http.Error(w, "failed to fetch captain record", http.StatusInternalServerError)
14841484+ return
14851485+ }
14861486+14871487+ if err := h.db.UpsertCaptainRecord(holdDID, captain); err != nil {
14881488+ http.Error(w, "failed to update cache", http.StatusInternalServerError)
14891489+ return
14901490+ }
14911491+14921492+ // Also refresh crew records
14931493+ if err := h.backfiller.backfillCrewFromHold(r.Context(), holdDID, endpoint); err != nil {
14941494+ log.Warn().Err(err).Str("did", holdDID).Msg("failed to refresh crew records")
14951495+ }
14961496+14971497+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
14981498+}
14991499+```
15001500+15011501+### TTL-based Refresh
15021502+15031503+Optionally, run periodic refresh of cached records:
15041504+15051505+```go
15061506+// pkg/appview/jetstream/backfill.go
15071507+15081508+func (b *Backfiller) RefreshStaleHolds(ctx context.Context, maxAge time.Duration) error {
15091509+ // Find holds not updated recently
15101510+ rows, err := b.db.Query(`
15111511+ SELECT did, endpoint FROM hold_captain_records
15121512+ WHERE updated_at < datetime('now', ?)
15131513+ `, fmt.Sprintf("-%d seconds", int(maxAge.Seconds())))
15141514+ if err != nil {
15151515+ return err
15161516+ }
15171517+ defer rows.Close()
15181518+15191519+ for rows.Next() {
15201520+ var did, endpoint string
15211521+ if err := rows.Scan(&did, &endpoint); err != nil {
15221522+ continue
15231523+ }
15241524+15251525+ // Refresh this hold's data
15261526+ if err := b.refreshHold(ctx, did, endpoint); err != nil {
15271527+ log.Warn().Err(err).Str("did", did).Msg("failed to refresh stale hold")
15281528+ }
15291529+ }
15301530+15311531+ return rows.Err()
15321532+}
15331533+```
15341534+15351535+## Security Considerations
15361536+15371537+### Trust Model
15381538+15391539+- **Captain records are authoritative**: The hold's embedded PDS is the source of truth
15401540+- **Crew records are authoritative**: Same as captain records
15411541+- **Cache is for performance**: Always validate against source for sensitive operations
15421542+- **No user-provided data**: All data comes from Jetstream or direct PDS queries
15431543+15441544+### Access Control
15451545+15461546+- **Read access**: Any authenticated user can view available holds
15471547+- **Write access**: Only hold owners can modify captain records
15481548+- **Crew management**: Only hold owners and crew admins can add/remove crew
15491549+15501550+### Data Validation
15511551+15521552+```go
15531553+func validateCaptainRecord(record *atproto.CaptainRecord) error {
15541554+ if record.OwnerDID == "" {
15551555+ return errors.New("owner DID is required")
15561556+ }
15571557+ if !strings.HasPrefix(record.OwnerDID, "did:") {
15581558+ return errors.New("invalid owner DID format")
15591559+ }
15601560+ return nil
15611561+}
15621562+15631563+func validateCrewRecord(record *atproto.CrewRecord) error {
15641564+ if record.MemberDID == "" {
15651565+ return errors.New("member DID is required")
15661566+ }
15671567+ if !strings.HasPrefix(record.MemberDID, "did:") {
15681568+ return errors.New("invalid member DID format")
15691569+ }
15701570+ for _, perm := range record.Permissions {
15711571+ if !isValidPermission(perm) {
15721572+ return fmt.Errorf("invalid permission: %s", perm)
15731573+ }
15741574+ }
15751575+ return nil
15761576+}
15771577+15781578+func isValidPermission(perm string) bool {
15791579+ valid := map[string]bool{
15801580+ "blob:read": true,
15811581+ "blob:write": true,
15821582+ "crew:admin": true,
15831583+ }
15841584+ return valid[perm]
15851585+}
15861586+```
15871587+15881588+## Implementation Checklist
15891589+15901590+### Phase 1: Database Schema
15911591+15921592+- [ ] Add `hold_crew_members` table to `pkg/appview/db/schema.sql`
15931593+- [ ] Create migration file `pkg/appview/db/migrations/006_hold_discovery.yaml`
15941594+- [ ] Verify `rkey` column included for delete event handling
15951595+- [ ] Run migration on dev/staging databases
15961596+- [ ] Verify foreign key cascade works correctly
15971597+15981598+### Phase 2: Jetstream Integration
15991599+16001600+- [ ] Add `io.atcr.hold.captain` to wanted collections in `pkg/appview/jetstream/worker.go`
16011601+- [ ] Add `io.atcr.hold.crew` to wanted collections
16021602+- [ ] Implement `ProcessCaptain` function in `pkg/appview/jetstream/processor.go`
16031603+- [ ] Implement `ProcessCrew` function
16041604+- [ ] Add hold service verification (`#atcr_hold` check via DID document)
16051605+- [ ] Handle delete events for captain records (cascade to crew)
16061606+- [ ] Handle delete events for crew records (by rkey lookup)
16071607+- [ ] Test with local hold service connected to local relay
16081608+16091609+### Phase 3: Backfill
16101610+16111611+- [ ] Implement `BackfillHolds` function in `pkg/appview/jetstream/backfill.go`
16121612+- [ ] Implement `backfillCrewRecords` function
16131613+- [ ] Implement `listReposWithCollection` helper
16141614+- [ ] Add `ATCR_BOOTSTRAP_HOLDS` environment variable support
16151615+- [ ] Implement `BackfillBootstrapHolds` function
16161616+- [ ] Implement `fetchCaptainFromHold` direct fetch
16171617+- [ ] Test backfill with production relay
16181618+- [ ] Add backfill command to CLI (optional)
16191619+16201620+### Phase 4: Database Queries
16211621+16221622+- [ ] Implement `UpsertCrewMember` in `pkg/appview/db/hold_store.go`
16231623+- [ ] Implement `DeleteCrewMember(holdDID, memberDID)`
16241624+- [ ] Implement `DeleteCrewMemberByRkey(holdDID, rkey)`
16251625+- [ ] Implement `GetAvailableHolds(userDID)` with membership categorization
16261626+- [ ] Implement `GetHoldsOwnedBy(ownerDID)`
16271627+- [ ] Implement `GetCrewMemberships(memberDID)`
16281628+- [ ] Add unit tests for all queries
16291629+16301630+### Phase 5: UI Integration - Settings Handler
16311631+16321632+- [ ] Add `DB *sql.DB` field to `SettingsHandler` struct
16331633+- [ ] Call `db.GetAvailableHolds()` in handler
16341634+- [ ] Create `SettingsPageData` struct with hold lists
16351635+- [ ] Implement `prepareSettingsData` helper function
16361636+- [ ] Implement `deriveDisplayName(did, endpoint)` helper
16371637+- [ ] Create `HoldDataEntry` struct for JSON serialization
16381638+- [ ] Serialize hold data to JSON for JavaScript
16391639+16401640+### Phase 6: UI Integration - Template Changes
16411641+16421642+- [ ] Replace text input with `<select>` dropdown in `settings.html`
16431643+- [ ] Add `<optgroup>` sections: Your Holds, Crew Member, Open Registration, Public
16441644+- [ ] Add `[read-only]` indicator for crew without write permission
16451645+- [ ] Add hold details panel (`#hold-details` div)
16461646+- [ ] Add empty state notice when no holds discovered
16471647+- [ ] Add "Refresh Holds" button
16481648+- [ ] Update form to submit `hold_did` instead of `hold_endpoint`
16491649+16501650+### Phase 7: UI Integration - Styles & JavaScript
16511651+16521652+- [ ] Add `.form-select` styles for dropdown
16531653+- [ ] Add `.hold-details` styles for details panel
16541654+- [ ] Add `.access-badge` styles (owner, crew, eligible, public)
16551655+- [ ] Add JavaScript for hold selection change handler
16561656+- [ ] Show hold details on selection change
16571657+- [ ] Display permissions for crew members
16581658+- [ ] Handle initial page load with pre-selected hold
16591659+16601660+### Phase 8: Form Handler Updates
16611661+16621662+- [ ] Update `UpdateDefaultHoldHandler` to accept `hold_did` parameter
16631663+- [ ] Add fallback for legacy `hold_endpoint` parameter
16641664+- [ ] Validate hold DID exists in cache
16651665+- [ ] Verify user has access to selected hold
16661666+- [ ] Return appropriate error for unknown/inaccessible holds
16671667+- [ ] Add `RefreshHoldsHandler` for manual refresh button
16681668+16691669+### Phase 9: Testing
16701670+16711671+- [ ] Unit tests for database queries
16721672+- [ ] Unit tests for Jetstream processors
16731673+- [ ] Integration test: discover hold via Jetstream
16741674+- [ ] Integration test: backfill existing holds
16751675+- [ ] E2E test: settings page displays holds
16761676+- [ ] E2E test: change default hold via dropdown
16771677+- [ ] E2E test: verify push uses new default hold
16781678+16791679+### Phase 10: Cache Management & Monitoring
16801680+16811681+- [ ] Implement `RefreshStaleHolds` for TTL-based refresh (optional)
16821682+- [ ] Add Prometheus metrics for cache operations
16831683+- [ ] Monitor cache hit/miss rates
16841684+- [ ] Add logging for discovery events
16851685+- [ ] Document operational procedures
16861686+16871687+## Future Enhancements
16881688+16891689+### Hold Search
16901690+16911691+Add search/filter capabilities:
16921692+16931693+```sql
16941694+SELECT * FROM hold_captain_records
16951695+WHERE region LIKE ?
16961696+ OR provider LIKE ?
16971697+ORDER BY ...
16981698+```
16991699+17001700+### Hold Recommendations
17011701+17021702+Suggest holds based on:
17031703+- Geographic proximity (region matching)
17041704+- Provider preference
17051705+- Existing crew memberships
17061706+17071707+### Hold Statistics
17081708+17091709+Display usage information:
17101710+- Storage used
17111711+- Number of images
17121712+- Number of crew members
17131713+- Uptime/availability
17141714+17151715+### Hold Comparison
17161716+17171717+Side-by-side comparison of:
17181718+- Storage limits
17191719+- Supported features
17201720+- Geographic regions
17211721+- Pricing (if applicable)
+26-1
docs/HOLD_XRPC_ENDPOINTS.md
···3737| `/xrpc/com.atproto.repo.deleteRecord` | POST | Delete a record |
3838| `/xrpc/com.atproto.repo.uploadBlob` | POST | Upload ATProto blob |
39394040-### DPoP Auth Required
4040+### Auth Required (Service Token or DPoP)
41414242| Endpoint | Method | Description |
4343|----------|--------|-------------|
4444| `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership |
4545+| `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) |
45464647---
4748···60616162---
62636464+## ATCR Hold-Specific Endpoints (`io.atcr.hold.*`)
6565+6666+| Endpoint | Method | Auth | Description |
6767+|----------|--------|------|-------------|
6868+| `/xrpc/io.atcr.hold.initiateUpload` | POST | blob:write | Start multipart upload |
6969+| `/xrpc/io.atcr.hold.getPartUploadUrl` | POST | blob:write | Get presigned URL for part |
7070+| `/xrpc/io.atcr.hold.uploadPart` | PUT | blob:write | Direct buffered part upload |
7171+| `/xrpc/io.atcr.hold.completeUpload` | POST | blob:write | Finalize multipart upload |
7272+| `/xrpc/io.atcr.hold.abortUpload` | POST | blob:write | Cancel multipart upload |
7373+| `/xrpc/io.atcr.hold.notifyManifest` | POST | blob:write | Notify manifest push |
7474+| `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership |
7575+| `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export |
7676+| `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info |
7777+7878+---
7979+6380## Standard ATProto Endpoints (excluding io.atcr.hold.*)
64816582| Endpoint |
···8299| /xrpc/app.bsky.actor.getProfiles |
83100| /.well-known/did.json |
84101| /.well-known/atproto-did |
102102+103103+---
104104+105105+## See Also
106106+107107+- [DIRECT_HOLD_ACCESS.md](./DIRECT_HOLD_ACCESS.md) - How to call hold endpoints directly without AppView (app passwords, curl examples)
108108+- [BYOS.md](./BYOS.md) - Bring Your Own Storage architecture
109109+- [OAUTH.md](./OAUTH.md) - OAuth + DPoP authentication details
···11+description: Add hold_crew_members table for cached crew memberships from Jetstream
22+query: |
33+ -- Cached hold crew memberships from Jetstream
44+ -- Enables reverse lookup: "which holds is user X a member of?"
55+ CREATE TABLE IF NOT EXISTS hold_crew_members (
66+ hold_did TEXT NOT NULL,
77+ member_did TEXT NOT NULL,
88+ rkey TEXT NOT NULL,
99+ role TEXT,
1010+ permissions TEXT, -- JSON array
1111+ tier TEXT,
1212+ added_at TEXT,
1313+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1414+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1515+ PRIMARY KEY (hold_did, member_did)
1616+ );
1717+ CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
1818+ CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
1919+ CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
+18-1
pkg/appview/db/schema.sql
···183183 allow_all_crew BOOLEAN NOT NULL,
184184 deployed_at TEXT,
185185 region TEXT,
186186- provider TEXT,
187186 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
188187);
189188CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
···206205 PRIMARY KEY(hold_did, user_did)
207206);
208207CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
208208+209209+-- Cached hold crew memberships from Jetstream
210210+-- Enables reverse lookup: "which holds is user X a member of?"
211211+CREATE TABLE IF NOT EXISTS hold_crew_members (
212212+ hold_did TEXT NOT NULL,
213213+ member_did TEXT NOT NULL,
214214+ rkey TEXT NOT NULL,
215215+ role TEXT,
216216+ permissions TEXT, -- JSON array
217217+ tier TEXT,
218218+ added_at TEXT,
219219+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
220220+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
221221+ PRIMARY KEY (hold_did, member_did)
222222+);
223223+CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
224224+CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
225225+CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
209226210227CREATE TABLE IF NOT EXISTS repo_pages (
211228 did TEXT NOT NULL,
+230
pkg/appview/handlers/export.go
···11+package handlers
22+33+import (
44+ "context"
55+ "database/sql"
66+ "encoding/json"
77+ "fmt"
88+ "io"
99+ "log/slog"
1010+ "net/http"
1111+ "sync"
1212+ "time"
1313+1414+ "atcr.io/pkg/appview/db"
1515+ "atcr.io/pkg/appview/middleware"
1616+ "atcr.io/pkg/atproto"
1717+ "atcr.io/pkg/auth"
1818+ "atcr.io/pkg/auth/oauth"
1919+)
2020+2121+// HoldExportResult represents the result of fetching export from a hold
2222+type HoldExportResult struct {
2323+ HoldDID string `json:"hold_did"`
2424+ Endpoint string `json:"endpoint"`
2525+ Status string `json:"status"` // "success", "failed", "offline"
2626+ Error string `json:"error,omitempty"`
2727+ Data json.RawMessage `json:"data,omitempty"` // Raw JSON from hold
2828+}
2929+3030+// FullUserDataExport represents the complete GDPR export including hold data
3131+type FullUserDataExport struct {
3232+ AppViewData *db.UserDataExport `json:"appview_data"`
3333+ HoldExports []HoldExportResult `json:"hold_exports"`
3434+}
3535+3636+// ExportUserDataHandler handles GDPR data export requests
3737+type ExportUserDataHandler struct {
3838+ DB *sql.DB
3939+ Refresher *oauth.Refresher
4040+}
4141+4242+func (h *ExportUserDataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4343+ // Get authenticated user from middleware
4444+ user := middleware.GetUser(r)
4545+ if user == nil {
4646+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
4747+ return
4848+ }
4949+5050+ slog.Info("Processing data export request", "component", "export", "did", user.DID)
5151+5252+ // Export all user data from database
5353+ appViewData, err := db.ExportUserData(h.DB, user.DID)
5454+ if err != nil {
5555+ slog.Error("Failed to export user data", "component", "export", "did", user.DID, "error", err)
5656+ http.Error(w, "Failed to export data", http.StatusInternalServerError)
5757+ return
5858+ }
5959+6060+ // Get all holds where user is a member (from cached crew memberships)
6161+ holdExports := h.fetchHoldExports(r.Context(), user)
6262+6363+ // Build full export
6464+ fullExport := FullUserDataExport{
6565+ AppViewData: appViewData,
6666+ HoldExports: holdExports,
6767+ }
6868+6969+ // Set headers for file download
7070+ filename := fmt.Sprintf("atcr-data-export-%s.json", time.Now().Format("2006-01-02"))
7171+ w.Header().Set("Content-Type", "application/json")
7272+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
7373+7474+ // Write JSON with indentation for readability
7575+ encoder := json.NewEncoder(w)
7676+ encoder.SetIndent("", " ")
7777+ if err := encoder.Encode(fullExport); err != nil {
7878+ slog.Error("Failed to encode export data", "component", "export", "did", user.DID, "error", err)
7979+ // Can't send error response at this point, headers already sent
8080+ return
8181+ }
8282+8383+ slog.Info("Data export completed successfully",
8484+ "component", "export",
8585+ "did", user.DID,
8686+ "hold_count", len(holdExports))
8787+}
8888+8989+// fetchHoldExports fetches export data from all holds where user is a member
9090+func (h *ExportUserDataHandler) fetchHoldExports(ctx context.Context, user *db.User) []HoldExportResult {
9191+ var results []HoldExportResult
9292+9393+ // Get crew memberships from database
9494+ memberships, err := db.GetCrewMemberships(h.DB, user.DID)
9595+ if err != nil {
9696+ slog.Warn("Failed to get crew memberships for export",
9797+ "component", "export",
9898+ "did", user.DID,
9999+ "error", err)
100100+ return results
101101+ }
102102+103103+ if len(memberships) == 0 {
104104+ return results
105105+ }
106106+107107+ // Collect unique hold DIDs
108108+ holdDIDs := make(map[string]bool)
109109+ for _, m := range memberships {
110110+ holdDIDs[m.HoldDID] = true
111111+ }
112112+113113+ // Also check captain records (holds owned by user)
114114+ if h.DB != nil {
115115+ captainHolds, err := db.GetCaptainRecordsForOwner(h.DB, user.DID)
116116+ if err == nil {
117117+ for _, hold := range captainHolds {
118118+ holdDIDs[hold.HoldDID] = true
119119+ }
120120+ }
121121+ }
122122+123123+ // Fetch from each hold concurrently with timeout
124124+ var wg sync.WaitGroup
125125+ resultChan := make(chan HoldExportResult, len(holdDIDs))
126126+127127+ for holdDID := range holdDIDs {
128128+ wg.Add(1)
129129+ go func(holdDID string) {
130130+ defer wg.Done()
131131+ result := h.fetchSingleHoldExport(ctx, user, holdDID)
132132+ resultChan <- result
133133+ }(holdDID)
134134+ }
135135+136136+ // Wait for all goroutines to complete
137137+ wg.Wait()
138138+ close(resultChan)
139139+140140+ // Collect results
141141+ for result := range resultChan {
142142+ results = append(results, result)
143143+ }
144144+145145+ return results
146146+}
147147+148148+// fetchSingleHoldExport fetches export data from a single hold
149149+func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string) HoldExportResult {
150150+ // Resolve hold DID to URL
151151+ holdURL := atproto.ResolveHoldURL(holdDID)
152152+ endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData"
153153+154154+ result := HoldExportResult{
155155+ HoldDID: holdDID,
156156+ Endpoint: endpoint,
157157+ Status: "failed",
158158+ }
159159+160160+ // Check if we have OAuth refresher (needed for service tokens)
161161+ if h.Refresher == nil {
162162+ result.Error = "OAuth not configured - cannot authenticate to hold"
163163+ return result
164164+ }
165165+166166+ // Create context with timeout (5 seconds per hold)
167167+ timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
168168+ defer cancel()
169169+170170+ // Get service token from user's PDS
171171+ serviceToken, err := auth.GetOrFetchServiceToken(timeoutCtx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
172172+ if err != nil {
173173+ slog.Warn("Failed to get service token for hold export",
174174+ "component", "export",
175175+ "hold_did", holdDID,
176176+ "user_did", user.DID,
177177+ "error", err)
178178+ result.Error = fmt.Sprintf("Failed to authenticate: %v", err)
179179+ return result
180180+ }
181181+182182+ // Create request
183183+ req, err := http.NewRequestWithContext(timeoutCtx, "GET", endpoint, nil)
184184+ if err != nil {
185185+ result.Error = fmt.Sprintf("Failed to create request: %v", err)
186186+ return result
187187+ }
188188+189189+ // Set auth header
190190+ req.Header.Set("Authorization", "Bearer "+serviceToken)
191191+192192+ // Make request
193193+ resp, err := http.DefaultClient.Do(req)
194194+ if err != nil {
195195+ slog.Warn("Hold export request failed",
196196+ "component", "export",
197197+ "hold_did", holdDID,
198198+ "endpoint", endpoint,
199199+ "error", err)
200200+ result.Status = "offline"
201201+ result.Error = fmt.Sprintf("Could not contact hold. Please request export directly at: %s", endpoint)
202202+ return result
203203+ }
204204+ defer resp.Body.Close()
205205+206206+ // Check response status
207207+ if resp.StatusCode != http.StatusOK {
208208+ body, _ := io.ReadAll(resp.Body)
209209+ result.Error = fmt.Sprintf("Hold returned status %d: %s", resp.StatusCode, string(body))
210210+ return result
211211+ }
212212+213213+ // Read response body
214214+ body, err := io.ReadAll(resp.Body)
215215+ if err != nil {
216216+ result.Error = fmt.Sprintf("Failed to read response: %v", err)
217217+ return result
218218+ }
219219+220220+ // Store raw JSON data
221221+ result.Status = "success"
222222+ result.Data = json.RawMessage(body)
223223+224224+ slog.Debug("Successfully fetched hold export",
225225+ "component", "export",
226226+ "hold_did", holdDID,
227227+ "user_did", user.DID)
228228+229229+ return result
230230+}
+153-7
pkg/appview/handlers/settings.go
···11package handlers
2233import (
44+ "database/sql"
55+ "encoding/json"
46 "html/template"
57 "log/slog"
68 "net/http"
99+ "net/url"
1010+ "strings"
711 "time"
8121313+ "atcr.io/pkg/appview/db"
914 "atcr.io/pkg/appview/middleware"
1015 "atcr.io/pkg/appview/storage"
1116 "atcr.io/pkg/atproto"
1217 "atcr.io/pkg/auth/oauth"
1318)
14192020+// HoldDisplay represents a hold for display in the UI
2121+type HoldDisplay struct {
2222+ DID string `json:"did"`
2323+ DisplayName string `json:"displayName"`
2424+ Region string `json:"region"`
2525+ Membership string `json:"membership"`
2626+ Permissions []string `json:"permissions,omitempty"`
2727+}
2828+1529// SettingsHandler handles the settings page
1630type SettingsHandler struct {
1717- Templates *template.Template
1818- Refresher *oauth.Refresher
1919- RegistryURL string
3131+ Templates *template.Template
3232+ Refresher *oauth.Refresher
3333+ RegistryURL string
3434+ DB *sql.DB
3535+ DefaultHoldDID string
2036}
21372238func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···47634864 slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
49656666+ // Get available holds for dropdown
6767+ var ownedHolds, crewHolds, eligibleHolds, publicHolds []HoldDisplay
6868+ holdDataMap := make(map[string]HoldDisplay)
6969+7070+ if h.DB != nil {
7171+ availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
7272+ if err != nil {
7373+ slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err)
7474+ } else {
7575+ // Group holds by membership type
7676+ for _, hold := range availableHolds {
7777+ display := HoldDisplay{
7878+ DID: hold.HoldDID,
7979+ DisplayName: deriveDisplayName(hold.HoldDID),
8080+ Region: hold.Region,
8181+ Membership: hold.Membership,
8282+ }
8383+8484+ // Parse permissions JSON if present
8585+ if hold.Permissions != "" {
8686+ json.Unmarshal([]byte(hold.Permissions), &display.Permissions)
8787+ }
8888+8989+ // Add to data map for JavaScript
9090+ holdDataMap[hold.HoldDID] = display
9191+9292+ // Group by membership type
9393+ switch hold.Membership {
9494+ case "owner":
9595+ ownedHolds = append(ownedHolds, display)
9696+ case "crew":
9797+ crewHolds = append(crewHolds, display)
9898+ case "eligible":
9999+ eligibleHolds = append(eligibleHolds, display)
100100+ case "public":
101101+ publicHolds = append(publicHolds, display)
102102+ }
103103+ }
104104+ }
105105+ }
106106+107107+ // Serialize hold data for JavaScript
108108+ holdDataJSON, _ := json.Marshal(holdDataMap)
109109+110110+ // Check if current hold needs to be shown separately (not in discovered holds)
111111+ _, currentHoldDiscovered := holdDataMap[profile.DefaultHold]
112112+ showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered
113113+114114+ // Look up AppView default hold details from database
115115+ appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID)
116116+ var appViewDefaultRegion string
117117+ if h.DefaultHoldDID != "" && h.DB != nil {
118118+ if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil {
119119+ appViewDefaultRegion = captain.Region
120120+ }
121121+ }
122122+50123 data := struct {
51124 PageData
52125 Profile struct {
···55128 PDSEndpoint string
56129 DefaultHold string
57130 }
131131+ CurrentHoldDID string
132132+ CurrentHoldDisplay string
133133+ ShowCurrentHold bool
134134+ AppViewDefaultHoldDisplay string
135135+ AppViewDefaultRegion string
136136+ OwnedHolds []HoldDisplay
137137+ CrewHolds []HoldDisplay
138138+ EligibleHolds []HoldDisplay
139139+ PublicHolds []HoldDisplay
140140+ HoldDataJSON template.JS
58141 }{
5959- PageData: NewPageData(r, h.RegistryURL),
142142+ PageData: NewPageData(r, h.RegistryURL),
143143+ CurrentHoldDID: profile.DefaultHold,
144144+ CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
145145+ ShowCurrentHold: showCurrentHold,
146146+ AppViewDefaultHoldDisplay: appViewDefaultDisplay,
147147+ AppViewDefaultRegion: appViewDefaultRegion,
148148+ OwnedHolds: ownedHolds,
149149+ CrewHolds: crewHolds,
150150+ EligibleHolds: eligibleHolds,
151151+ PublicHolds: publicHolds,
152152+ HoldDataJSON: template.JS(holdDataJSON),
60153 }
6115462155 data.Profile.Handle = user.Handle
···70163 }
71164}
72165166166+// deriveDisplayName derives a human-readable name from a hold DID
167167+func deriveDisplayName(did string) string {
168168+ // For did:web, extract the domain
169169+ if strings.HasPrefix(did, "did:web:") {
170170+ domain := strings.TrimPrefix(did, "did:web:")
171171+ // URL-decode the domain (did:web encodes : as %3A)
172172+ decoded, err := url.QueryUnescape(domain)
173173+ if err == nil {
174174+ return decoded
175175+ }
176176+ return domain
177177+ }
178178+179179+ // For did:plc, truncate for display
180180+ if len(did) > 24 {
181181+ return did[:24] + "..."
182182+ }
183183+ return did
184184+}
185185+73186// UpdateDefaultHoldHandler handles updating the default hold
74187type UpdateDefaultHoldHandler struct {
75188 Refresher *oauth.Refresher
76189 Templates *template.Template
190190+ DB *sql.DB
77191}
7819279193func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···83197 return
84198 }
851998686- holdEndpoint := r.FormValue("hold_endpoint")
200200+ // Accept hold_did (new dropdown) or hold_endpoint (legacy text input)
201201+ holdDID := r.FormValue("hold_did")
202202+ if holdDID == "" {
203203+ holdDID = r.FormValue("hold_endpoint")
204204+ }
205205+206206+ // Validate hold DID if provided and database is available
207207+ if holdDID != "" && h.DB != nil {
208208+ // Check if user has access to this hold
209209+ availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
210210+ if err != nil {
211211+ slog.Warn("Failed to validate hold access", "component", "settings", "did", user.DID, "error", err)
212212+ // Don't block - fall through to allow the update
213213+ } else {
214214+ hasAccess := false
215215+ for _, hold := range availableHolds {
216216+ if hold.HoldDID == holdDID {
217217+ hasAccess = true
218218+ break
219219+ }
220220+ }
221221+222222+ if !hasAccess {
223223+ w.Header().Set("Content-Type", "text/html")
224224+ h.Templates.ExecuteTemplate(w, "alert", map[string]string{
225225+ "Class": "error",
226226+ "Icon": "alert-circle",
227227+ "Message": "You don't have access to this hold",
228228+ })
229229+ return
230230+ }
231231+ }
232232+ }
8723388234 // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
89235 client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
···92238 profile, err := storage.GetProfile(r.Context(), client)
93239 if err != nil || profile == nil {
94240 // Profile doesn't exist, create new one
9595- profile = atproto.NewSailorProfileRecord(holdEndpoint)
241241+ profile = atproto.NewSailorProfileRecord(holdDID)
96242 } else {
97243 // Update existing profile
9898- profile.DefaultHold = holdEndpoint
244244+ profile.DefaultHold = holdDID
99245 profile.UpdatedAt = time.Now()
100246 }
101247
+75-2
pkg/appview/jetstream/backfill.go
···6161func (b *BackfillWorker) Start(ctx context.Context) error {
6262 slog.Info("Backfill: Starting sync-based backfill...")
63636464- // First, query and cache the default hold's captain record
6464+ // First, query and cache the default hold's captain and crew records
6565+ // This is necessary for localhost/private holds not discoverable via relay
6566 if b.defaultHoldDID != "" {
6666- slog.Info("Backfill querying default hold captain record", "hold_did", b.defaultHoldDID)
6767+ slog.Info("Backfill querying default hold records", "hold_did", b.defaultHoldDID)
6768 if err := b.queryCaptainRecord(ctx, b.defaultHoldDID); err != nil {
6869 slog.Warn("Backfill failed to query default hold captain record", "error", err)
6970 // Don't fail the whole backfill - just warn
7071 }
7272+ if err := b.queryCrewRecords(ctx, b.defaultHoldDID); err != nil {
7373+ slog.Warn("Backfill failed to query default hold crew records", "error", err)
7474+ // Don't fail the whole backfill - just warn
7575+ }
7176 }
72777378 collections := []string{
···7782 atproto.SailorProfileCollection, // io.atcr.sailor.profile
7883 atproto.RepoPageCollection, // io.atcr.repo.page
7984 atproto.StatsCollection, // io.atcr.hold.stats (from holds)
8585+ atproto.CaptainCollection, // io.atcr.hold.captain (from holds)
8686+ atproto.CrewCollection, // io.atcr.hold.crew (from holds)
8087 }
81888289 for _, collection := range collections {
···316323 // Stats are stored in hold PDSes, not user PDSes
317324 // 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
318325 return b.processor.ProcessStats(ctx, did, record.Value, false)
326326+ case atproto.CaptainCollection:
327327+ // Captain records are stored in hold PDSes
328328+ // 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
329329+ return b.processor.ProcessCaptain(ctx, did, record.Value)
330330+ case atproto.CrewCollection:
331331+ // Crew records are stored in hold PDSes
332332+ // 'did' here is the hold's DID, rkey is derived from member DID
333333+ // Extract rkey from record URI (at://did/collection/rkey)
334334+ rkey := extractRkeyFromURI(record.URI)
335335+ return b.processor.ProcessCrew(ctx, did, rkey, record.Value)
319336 default:
320337 return fmt.Errorf("unsupported collection: %s", collection)
321338 }
···391408 return nil
392409}
393410411411+// queryCrewRecords queries a hold's crew records and caches them in the database
412412+// This is necessary for localhost/private holds that aren't discoverable via the relay
413413+func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error {
414414+ // Resolve hold DID to URL
415415+ holdURL := atproto.ResolveHoldURL(holdDID)
416416+417417+ // Create client for hold's PDS
418418+ holdClient := atproto.NewClient(holdURL, holdDID, "")
419419+420420+ var cursor string
421421+ recordCount := 0
422422+423423+ // Paginate through all crew records
424424+ for {
425425+ records, nextCursor, err := holdClient.ListRecordsForRepo(ctx, holdDID, atproto.CrewCollection, 100, cursor)
426426+ if err != nil {
427427+ // If no crew records exist, that's okay
428428+ if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "RecordNotFound") {
429429+ slog.Debug("No crew records found for hold", "hold_did", holdDID)
430430+ return nil
431431+ }
432432+ return fmt.Errorf("failed to list crew records: %w", err)
433433+ }
434434+435435+ for _, record := range records {
436436+ rkey := extractRkeyFromURI(record.URI)
437437+ if err := b.processor.ProcessCrew(ctx, holdDID, rkey, record.Value); err != nil {
438438+ slog.Warn("Backfill failed to process crew record", "hold_did", holdDID, "uri", record.URI, "error", err)
439439+ continue
440440+ }
441441+ recordCount++
442442+ }
443443+444444+ if nextCursor == "" {
445445+ break
446446+ }
447447+ cursor = nextCursor
448448+ }
449449+450450+ if recordCount > 0 {
451451+ slog.Info("Backfill cached crew records for hold", "hold_did", holdDID, "count", recordCount)
452452+ }
453453+ return nil
454454+}
455455+394456// reconcileAnnotations ensures annotations come from the newest manifest in each repository
395457// This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations
396458func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error {
···635697636698 return nil
637699}
700700+701701+// extractRkeyFromURI extracts the rkey from an AT-URI
702702+// Format: at://did/collection/rkey
703703+func extractRkeyFromURI(uri string) string {
704704+ // URI format: at://did/collection/rkey
705705+ parts := strings.Split(uri, "/")
706706+ if len(parts) >= 5 {
707707+ return parts[4]
708708+ }
709709+ return ""
710710+}
+78
pkg/appview/jetstream/processor.go
···433433 })
434434}
435435436436+// ProcessCaptain handles captain record events from hold PDSes
437437+// This is called when Jetstream receives a captain create/update/delete event from a hold
438438+// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info
439439+func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordData []byte) error {
440440+ // Unmarshal captain record
441441+ var captainRecord atproto.CaptainRecord
442442+ if err := json.Unmarshal(recordData, &captainRecord); err != nil {
443443+ return fmt.Errorf("failed to unmarshal captain record: %w", err)
444444+ }
445445+446446+ // Convert to db struct and upsert
447447+ record := &db.HoldCaptainRecord{
448448+ HoldDID: holdDID,
449449+ OwnerDID: captainRecord.Owner,
450450+ Public: captainRecord.Public,
451451+ AllowAllCrew: captainRecord.AllowAllCrew,
452452+ DeployedAt: captainRecord.DeployedAt,
453453+ Region: captainRecord.Region,
454454+ UpdatedAt: time.Now(),
455455+ }
456456+457457+ if err := db.UpsertCaptainRecord(p.db, record); err != nil {
458458+ return fmt.Errorf("failed to upsert captain record: %w", err)
459459+ }
460460+461461+ slog.Info("Processed captain record",
462462+ "component", "processor",
463463+ "hold_did", holdDID,
464464+ "owner_did", captainRecord.Owner,
465465+ "public", captainRecord.Public,
466466+ "allow_all_crew", captainRecord.AllowAllCrew)
467467+468468+ return nil
469469+}
470470+471471+// ProcessCrew handles crew record events from hold PDSes
472472+// This is called when Jetstream receives a crew create/update/delete event from a hold
473473+// The holdDID is the DID of the hold PDS (event.DID), and the record contains member info
474474+func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string, recordData []byte) error {
475475+ // Unmarshal crew record
476476+ var crewRecord atproto.CrewRecord
477477+ if err := json.Unmarshal(recordData, &crewRecord); err != nil {
478478+ return fmt.Errorf("failed to unmarshal crew record: %w", err)
479479+ }
480480+481481+ // Marshal permissions to JSON string
482482+ permissionsJSON := ""
483483+ if len(crewRecord.Permissions) > 0 {
484484+ if jsonBytes, err := json.Marshal(crewRecord.Permissions); err == nil {
485485+ permissionsJSON = string(jsonBytes)
486486+ }
487487+ }
488488+489489+ // Convert to db struct and upsert
490490+ member := &db.CrewMember{
491491+ HoldDID: holdDID,
492492+ MemberDID: crewRecord.Member,
493493+ Rkey: rkey,
494494+ Role: crewRecord.Role,
495495+ Permissions: permissionsJSON,
496496+ Tier: crewRecord.Tier,
497497+ AddedAt: crewRecord.AddedAt,
498498+ }
499499+500500+ if err := db.UpsertCrewMember(p.db, member); err != nil {
501501+ return fmt.Errorf("failed to upsert crew member: %w", err)
502502+ }
503503+504504+ slog.Debug("Processed crew record",
505505+ "component", "processor",
506506+ "hold_did", holdDID,
507507+ "member_did", crewRecord.Member,
508508+ "role", crewRecord.Role,
509509+ "permissions", crewRecord.Permissions)
510510+511511+ return nil
512512+}
513513+436514// ProcessAccount handles account status events (deactivation/deletion/etc)
437515// This is called when Jetstream receives an account event indicating status changes.
438516//
+62
pkg/appview/jetstream/worker.go
···326326 case atproto.StatsCollection:
327327 slog.Info("Jetstream processing stats event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
328328 return w.processStats(commit)
329329+ case atproto.CaptainCollection:
330330+ slog.Info("Jetstream processing captain event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
331331+ return w.processCaptain(commit)
332332+ case atproto.CrewCollection:
333333+ slog.Info("Jetstream processing crew event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
334334+ return w.processCrew(commit)
329335 default:
330336 // Ignore other collections
331337 return nil
···512518513519 // Use shared processor - commit.DID is the hold's DID
514520 return w.processor.ProcessStats(context.Background(), commit.DID, recordBytes, false)
521521+}
522522+523523+// processCaptain processes a captain record event from a hold's PDS
524524+func (w *Worker) processCaptain(commit *CommitEvent) error {
525525+ holdDID := commit.DID // The repo DID IS the hold DID
526526+527527+ if commit.Operation == "delete" {
528528+ // Delete captain record - this cascades to crew members
529529+ if err := db.DeleteCaptainRecord(w.db, holdDID); err != nil {
530530+ return fmt.Errorf("failed to delete captain record: %w", err)
531531+ }
532532+ slog.Info("Deleted captain record for hold", "hold_did", holdDID)
533533+ return nil
534534+ }
535535+536536+ // Parse captain record
537537+ if commit.Record == nil {
538538+ return nil
539539+ }
540540+541541+ // Marshal map to bytes for processing
542542+ recordBytes, err := json.Marshal(commit.Record)
543543+ if err != nil {
544544+ return fmt.Errorf("failed to marshal captain record: %w", err)
545545+ }
546546+547547+ // Use shared processor
548548+ return w.processor.ProcessCaptain(context.Background(), holdDID, recordBytes)
549549+}
550550+551551+// processCrew processes a crew record event from a hold's PDS
552552+func (w *Worker) processCrew(commit *CommitEvent) error {
553553+ holdDID := commit.DID // The repo DID IS the hold DID
554554+555555+ if commit.Operation == "delete" {
556556+ // Delete crew member by rkey
557557+ if err := db.DeleteCrewMemberByRkey(w.db, holdDID, commit.RKey); err != nil {
558558+ return fmt.Errorf("failed to delete crew member: %w", err)
559559+ }
560560+ slog.Info("Deleted crew member from hold", "hold_did", holdDID, "rkey", commit.RKey)
561561+ return nil
562562+ }
563563+564564+ // Parse crew record
565565+ if commit.Record == nil {
566566+ return nil
567567+ }
568568+569569+ // Marshal map to bytes for processing
570570+ recordBytes, err := json.Marshal(commit.Record)
571571+ if err != nil {
572572+ return fmt.Errorf("failed to marshal crew record: %w", err)
573573+ }
574574+575575+ // Use shared processor - pass rkey for storage
576576+ return w.processor.ProcessCrew(context.Background(), holdDID, commit.RKey, recordBytes)
515577}
516578517579// processIdentity processes an identity event (handle change)
···5757 // Query: userDid={did}
5858 // Response: {"userDid": "...", "uniqueBlobs": 10, "totalSize": 1073741824}
5959 HoldGetQuota = "/xrpc/io.atcr.hold.getQuota"
6060+6161+ // HoldExportUserData exports all user data from a hold service (GDPR compliance).
6262+ // Method: GET
6363+ // Query: userDid={did}
6464+ // Response: JSON containing all user data stored by the hold
6565+ HoldExportUserData = "/xrpc/io.atcr.hold.exportUserData"
6066)
61676268// Hold service crew management endpoints (io.atcr.hold.*)
+1-2
pkg/atproto/lexicon.go
···580580 AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
581581 EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
582582 DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
583583- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
584584- Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
583583+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
585584}
586585587586// CrewRecord represents a crew member in the hold
···212212213213 return ""
214214}
215215+216216+// ListLayerRecordsForUser returns all layer records uploaded by a specific user
217217+// Used for GDPR data export to return all layers a user has pushed to this hold
218218+func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) ([]*atproto.LayerRecord, error) {
219219+ if p.recordsIndex == nil {
220220+ return nil, fmt.Errorf("records index not available")
221221+ }
222222+223223+ // Get session for reading record data
224224+ session, err := p.carstore.ReadOnlySession(p.uid)
225225+ if err != nil {
226226+ return nil, fmt.Errorf("failed to create session: %w", err)
227227+ }
228228+229229+ head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
230230+ if err != nil {
231231+ return nil, fmt.Errorf("failed to get repo head: %w", err)
232232+ }
233233+234234+ if !head.Defined() {
235235+ // Empty repo - return empty list
236236+ return []*atproto.LayerRecord{}, nil
237237+ }
238238+239239+ repoHandle, err := repo.OpenRepo(ctx, session, head)
240240+ if err != nil {
241241+ return nil, fmt.Errorf("failed to open repo: %w", err)
242242+ }
243243+244244+ var records []*atproto.LayerRecord
245245+246246+ // Iterate all layer records via the index
247247+ cursor := ""
248248+ batchSize := 1000 // Process in batches
249249+250250+ for {
251251+ indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
252252+ if err != nil {
253253+ return nil, fmt.Errorf("failed to list layer records: %w", err)
254254+ }
255255+256256+ for _, rec := range indexRecords {
257257+ // Construct record path and get the record data
258258+ recordPath := rec.Collection + "/" + rec.Rkey
259259+260260+ _, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
261261+ if err != nil {
262262+ // Skip records we can't read
263263+ continue
264264+ }
265265+266266+ // Decode the layer record
267267+ recordValue, err := lexutil.CborDecodeValue(*recBytes)
268268+ if err != nil {
269269+ continue
270270+ }
271271+272272+ layerRecord, ok := recordValue.(*atproto.LayerRecord)
273273+ if !ok {
274274+ continue
275275+ }
276276+277277+ // Filter by userDID
278278+ if layerRecord.UserDID != userDID {
279279+ continue
280280+ }
281281+282282+ records = append(records, layerRecord)
283283+ }
284284+285285+ if nextCursor == "" {
286286+ break
287287+ }
288288+ cursor = nextCursor
289289+ }
290290+291291+ if records == nil {
292292+ records = []*atproto.LayerRecord{}
293293+ }
294294+295295+ return records, nil
296296+}
···216216217217 return stats, nil
218218}
219219+220220+// ListStatsRecordsForUser returns all stats records where the user is the repository owner
221221+// Used for GDPR data export to return all stats for repositories owned by the user
222222+func (p *HoldPDS) ListStatsRecordsForUser(ctx context.Context, userDID string) ([]*atproto.StatsRecord, error) {
223223+ // Get all stats records and filter by ownerDID
224224+ allStats, err := p.ListStats(ctx)
225225+ if err != nil {
226226+ return nil, err
227227+ }
228228+229229+ var userStats []*atproto.StatsRecord
230230+ for _, stat := range allStats {
231231+ if stat.OwnerDID == userDID {
232232+ userStats = append(userStats, stat)
233233+ }
234234+ }
235235+236236+ if userStats == nil {
237237+ userStats = []*atproto.StatsRecord{}
238238+ }
239239+240240+ return userStats, nil
241241+}