A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1# Hold Discovery
2
3This document describes how AppView discovers available holds and presents them to users for selection.
4
5## TL;DR
6
7**Problem:** Users currently enter hold URLs manually in a text field. They don't know what holds exist or which ones they can access.
8
9**Solution:**
101. Subscribe to Jetstream for `io.atcr.hold.captain` and `io.atcr.hold.crew` collections
112. Cache discovered holds and crew memberships in SQLite
123. Replace the text input with a dropdown showing available holds grouped by access level
13
14**Key Changes:**
15- New table: `hold_crew_members` (hold_did, member_did, rkey, permissions, ...)
16- Jetstream collections: `io.atcr.hold.captain`, `io.atcr.hold.crew`
17- Settings UI: Text input → `<select>` dropdown with optgroups
18- Form field: `hold_endpoint` (URL) → `hold_did` (DID)
19
20**Hold Categories in Dropdown:**
21| Group | Who Can Use |
22|-------|-------------|
23| Your Holds | User is captain (owner) |
24| Crew Member | User has explicit crew record |
25| Open Registration | `allowAllCrew=true` |
26| Public Holds | `public=true` |
27
28## Overview
29
30Users 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:
31
32- Holds the user owns (captain)
33- Holds where the user is a crew member
34- Holds that allow all crew members (open registration)
35- Public holds (anyone can read/write)
36
37## Architecture
38
39### Discovery Sources
40
41Hold discovery leverages the ATProto network infrastructure:
42
43```
44┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
45│ Hold Service │────▶│ Relay │────▶│ Jetstream │
46│ (embedded PDS) │ │ (BGS/bigsky) │ │ │
47└─────────────────┘ └─────────────────┘ └────────┬────────┘
48 │
49 ▼
50 ┌─────────────────┐
51 │ AppView │
52 │ (subscriber) │
53 └────────┬────────┘
54 │
55 ▼
56 ┌─────────────────┐
57 │ SQLite │
58 │ (cache) │
59 └─────────────────┘
60```
61
621. **Hold services** run embedded PDSes that store captain and crew records
632. **Relays** crawl hold PDSes after `request-crawl.sh` is run
643. **Jetstream** streams record events filtered by collection
654. **AppView** subscribes to Jetstream and caches records in SQLite
66
67### Record Types
68
69Two ATProto record collections are relevant for discovery:
70
71#### `io.atcr.hold.captain`
72
73Singleton record (rkey: `self`) in each hold's embedded PDS describing the hold:
74
75```json
76{
77 "$type": "io.atcr.hold.captain",
78 "ownerDid": "did:plc:abc123",
79 "public": false,
80 "allowAllCrew": true,
81 "deployedAt": "2025-01-07T12:00:00Z",
82 "region": "us-east-1",
83 "provider": "fly.io"
84}
85```
86
87| Field | Type | Description |
88|-------|------|-------------|
89| `ownerDid` | string | DID of the hold owner (captain) |
90| `public` | boolean | If true, anyone can read and write blobs |
91| `allowAllCrew` | boolean | If true, any authenticated user can self-register as crew |
92| `deployedAt` | string | ISO 8601 timestamp of deployment |
93| `region` | string | Optional geographic region identifier |
94| `provider` | string | Optional hosting provider name |
95
96#### `io.atcr.hold.crew`
97
98One record per crew member in the hold's embedded PDS:
99
100```json
101{
102 "$type": "io.atcr.hold.crew",
103 "memberDid": "did:plc:xyz789",
104 "role": "contributor",
105 "permissions": ["blob:read", "blob:write"],
106 "tier": "standard",
107 "addedAt": "2025-01-07T12:00:00Z"
108}
109```
110
111| Field | Type | Description |
112|-------|------|-------------|
113| `memberDid` | string | DID of the crew member |
114| `role` | string | Human-readable role name |
115| `permissions` | string[] | Permission grants: `blob:read`, `blob:write`, `crew:admin` |
116| `tier` | string | Optional tier for quota management |
117| `addedAt` | string | ISO 8601 timestamp when added |
118
119**Record key derivation:** Crew records use a deterministic rkey based on the member's DID:
120
121```go
122func CrewRecordKey(memberDID string) string {
123 hash := sha256.Sum256([]byte(memberDID))
124 return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16])
125}
126```
127
128This enables O(1) lookup of a specific member's crew record.
129
130## Data Model
131
132### Database Schema
133
134Add to `pkg/appview/db/schema.sql`:
135
136```sql
137-- Cached hold captain records from Jetstream
138-- Primary discovery source for available holds
139CREATE TABLE IF NOT EXISTS hold_captain_records (
140 did TEXT PRIMARY KEY, -- Hold's DID (did:web:hold01.atcr.io)
141 owner_did TEXT NOT NULL, -- Captain's DID
142 public INTEGER NOT NULL DEFAULT 0, -- 1 if public hold
143 allow_all_crew INTEGER NOT NULL DEFAULT 0, -- 1 if open registration
144 deployed_at TEXT, -- ISO 8601 deployment timestamp
145 region TEXT, -- Geographic region
146 provider TEXT, -- Hosting provider
147 endpoint TEXT, -- Resolved HTTP endpoint (cached)
148 created_at TEXT NOT NULL DEFAULT (datetime('now')),
149 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
150);
151
152CREATE INDEX IF NOT EXISTS idx_hold_captain_owner ON hold_captain_records(owner_did);
153CREATE INDEX IF NOT EXISTS idx_hold_captain_public ON hold_captain_records(public);
154CREATE INDEX IF NOT EXISTS idx_hold_captain_allow_all ON hold_captain_records(allow_all_crew);
155
156-- Cached hold crew memberships from Jetstream
157-- Enables reverse lookup: "which holds is user X a member of?"
158CREATE TABLE IF NOT EXISTS hold_crew_members (
159 hold_did TEXT NOT NULL, -- Hold's DID
160 member_did TEXT NOT NULL, -- Crew member's DID
161 rkey TEXT NOT NULL, -- ATProto record key (for delete handling)
162 role TEXT, -- Human-readable role
163 permissions TEXT, -- JSON array of permissions
164 tier TEXT, -- Optional quota tier
165 added_at TEXT, -- ISO 8601 timestamp
166 created_at TEXT NOT NULL DEFAULT (datetime('now')),
167 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
168 PRIMARY KEY (hold_did, member_did),
169 FOREIGN KEY (hold_did) REFERENCES hold_captain_records(did) ON DELETE CASCADE
170);
171
172CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
173CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
174CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
175```
176
177### Migration
178
179Add to `pkg/appview/db/migrations/`:
180
181```yaml
182# 006_hold_discovery.yaml
183id: 006_hold_discovery
184description: Add hold crew members table for discovery
185up: |
186 CREATE TABLE IF NOT EXISTS hold_crew_members (
187 hold_did TEXT NOT NULL,
188 member_did TEXT NOT NULL,
189 rkey TEXT NOT NULL,
190 role TEXT,
191 permissions TEXT,
192 tier TEXT,
193 added_at TEXT,
194 created_at TEXT NOT NULL DEFAULT (datetime('now')),
195 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
196 PRIMARY KEY (hold_did, member_did),
197 FOREIGN KEY (hold_did) REFERENCES hold_captain_records(did) ON DELETE CASCADE
198 );
199 CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
200 CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
201 CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
202down: |
203 DROP INDEX IF EXISTS idx_hold_crew_rkey;
204 DROP INDEX IF EXISTS idx_hold_crew_hold;
205 DROP INDEX IF EXISTS idx_hold_crew_member;
206 DROP TABLE IF EXISTS hold_crew_members;
207```
208
209## Jetstream Integration
210
211### Subscription Configuration
212
213Update the Jetstream worker to subscribe to hold collections:
214
215```go
216// pkg/appview/jetstream/worker.go
217
218var wantedCollections = []string{
219 "io.atcr.manifest",
220 "io.atcr.tag",
221 "io.atcr.hold.stats",
222 "io.atcr.hold.captain", // NEW: Hold discovery
223 "io.atcr.hold.crew", // NEW: Crew membership discovery
224}
225```
226
227### Event Processing
228
229Add processors for captain and crew records:
230
231```go
232// pkg/appview/jetstream/processor.go
233
234func (p *Processor) ProcessEvent(evt *Event) error {
235 switch evt.Collection {
236 case "io.atcr.manifest":
237 return p.ProcessManifest(evt)
238 case "io.atcr.tag":
239 return p.ProcessTag(evt)
240 case "io.atcr.hold.stats":
241 return p.ProcessStats(evt)
242 case "io.atcr.hold.captain":
243 return p.ProcessCaptain(evt)
244 case "io.atcr.hold.crew":
245 return p.ProcessCrew(evt)
246 default:
247 return nil
248 }
249}
250
251func (p *Processor) ProcessCaptain(evt *Event) error {
252 // The repo DID IS the hold DID (hold's embedded PDS)
253 holdDID := evt.DID
254
255 if evt.Operation == "delete" {
256 return p.db.DeleteCaptainRecord(holdDID)
257 }
258
259 var record atproto.CaptainRecord
260 if err := json.Unmarshal(evt.Record, &record); err != nil {
261 return fmt.Errorf("unmarshal captain record: %w", err)
262 }
263
264 // Resolve hold DID to HTTP endpoint for caching
265 endpoint, err := p.resolver.ResolveHoldURL(holdDID)
266 if err != nil {
267 // Log but don't fail - endpoint can be resolved later
268 log.Warn().Err(err).Str("did", holdDID).Msg("failed to resolve hold endpoint")
269 }
270
271 // Verify this is actually a hold by checking /.well-known/did.json
272 // for #atcr_hold service type
273 if !p.verifyHoldService(holdDID, endpoint) {
274 log.Debug().Str("did", holdDID).Msg("skipping non-hold captain record")
275 return nil
276 }
277
278 return p.db.UpsertCaptainRecord(holdDID, &db.CaptainRecord{
279 DID: holdDID,
280 OwnerDID: record.OwnerDID,
281 Public: record.Public,
282 AllowAllCrew: record.AllowAllCrew,
283 DeployedAt: record.DeployedAt,
284 Region: record.Region,
285 Provider: record.Provider,
286 Endpoint: endpoint,
287 })
288}
289
290func (p *Processor) ProcessCrew(evt *Event) error {
291 // The repo DID IS the hold DID (hold's embedded PDS)
292 holdDID := evt.DID
293
294 if evt.Operation == "delete" {
295 // Need to determine member DID from rkey or record
296 // For delete events, we may not have the record body
297 return p.db.DeleteCrewMemberByRkey(holdDID, evt.Rkey)
298 }
299
300 var record atproto.CrewRecord
301 if err := json.Unmarshal(evt.Record, &record); err != nil {
302 return fmt.Errorf("unmarshal crew record: %w", err)
303 }
304
305 // Verify the hold exists in our captain records
306 // If not, this crew record is for an unknown hold - skip it
307 if _, err := p.db.GetCaptainRecord(holdDID); err != nil {
308 log.Debug().Str("hold", holdDID).Msg("skipping crew record for unknown hold")
309 return nil
310 }
311
312 permissionsJSON, _ := json.Marshal(record.Permissions)
313
314 return p.db.UpsertCrewMember(holdDID, &db.CrewMember{
315 HoldDID: holdDID,
316 MemberDID: record.MemberDID,
317 Role: record.Role,
318 Permissions: string(permissionsJSON),
319 Tier: record.Tier,
320 AddedAt: record.AddedAt,
321 })
322}
323
324func (p *Processor) verifyHoldService(did, endpoint string) bool {
325 // Fetch /.well-known/did.json and check for #atcr_hold service
326 didDoc, err := p.resolver.ResolveDIDDocument(did)
327 if err != nil {
328 return false
329 }
330
331 for _, svc := range didDoc.Service {
332 if svc.ID == did+"#atcr_hold" || svc.Type == "AtcrHold" {
333 return true
334 }
335 }
336
337 return false
338}
339```
340
341### Hold Service Verification
342
343Before caching a captain record, verify the DID document contains the `#atcr_hold` service:
344
345```go
346// pkg/atproto/resolver.go
347
348type DIDDocument struct {
349 ID string `json:"id"`
350 Service []Service `json:"service"`
351 // ... other fields
352}
353
354type Service struct {
355 ID string `json:"id"`
356 Type string `json:"type"`
357 ServiceEndpoint string `json:"serviceEndpoint"`
358}
359
360func (r *Resolver) HasHoldService(did string) (bool, string, error) {
361 doc, err := r.ResolveDIDDocument(did)
362 if err != nil {
363 return false, "", err
364 }
365
366 for _, svc := range doc.Service {
367 // Check for #atcr_hold fragment or AtcrHold type
368 if strings.HasSuffix(svc.ID, "#atcr_hold") || svc.Type == "AtcrHold" {
369 return true, svc.ServiceEndpoint, nil
370 }
371 }
372
373 return false, "", nil
374}
375```
376
377## Backfill Strategy
378
379### Initial Backfill
380
381For holds that existed before AppView started listening to Jetstream, use the existing backfill mechanism:
382
383```go
384// pkg/appview/jetstream/backfill.go
385
386func (b *Backfiller) BackfillHolds(ctx context.Context) error {
387 // List all repos from relay that have io.atcr.hold.captain collection
388 repos, err := b.listReposWithCollection(ctx, "io.atcr.hold.captain")
389 if err != nil {
390 return err
391 }
392
393 for _, repo := range repos {
394 // Fetch captain record
395 captain, err := b.fetchRecord(ctx, repo.DID, "io.atcr.hold.captain", "self")
396 if err != nil {
397 log.Warn().Err(err).Str("did", repo.DID).Msg("failed to fetch captain record")
398 continue
399 }
400
401 // Verify it's a hold service
402 hasService, endpoint, _ := b.resolver.HasHoldService(repo.DID)
403 if !hasService {
404 continue
405 }
406
407 // Upsert captain record
408 if err := b.db.UpsertCaptainRecord(repo.DID, captain); err != nil {
409 log.Warn().Err(err).Str("did", repo.DID).Msg("failed to upsert captain record")
410 continue
411 }
412
413 // Fetch and upsert all crew records for this hold
414 if err := b.backfillCrewRecords(ctx, repo.DID); err != nil {
415 log.Warn().Err(err).Str("did", repo.DID).Msg("failed to backfill crew records")
416 }
417 }
418
419 return nil
420}
421
422func (b *Backfiller) backfillCrewRecords(ctx context.Context, holdDID string) error {
423 // List all records in io.atcr.hold.crew collection
424 records, err := b.listRecords(ctx, holdDID, "io.atcr.hold.crew")
425 if err != nil {
426 return err
427 }
428
429 for _, record := range records {
430 var crew atproto.CrewRecord
431 if err := json.Unmarshal(record.Value, &crew); err != nil {
432 continue
433 }
434
435 permissionsJSON, _ := json.Marshal(crew.Permissions)
436
437 if err := b.db.UpsertCrewMember(holdDID, &db.CrewMember{
438 HoldDID: holdDID,
439 MemberDID: crew.MemberDID,
440 Role: crew.Role,
441 Permissions: string(permissionsJSON),
442 Tier: crew.Tier,
443 AddedAt: crew.AddedAt,
444 }); err != nil {
445 log.Warn().Err(err).Msg("failed to upsert crew member")
446 }
447 }
448
449 return nil
450}
451```
452
453### Listing Repos by Collection
454
455Query the relay for repos that have a specific collection:
456
457```go
458func (b *Backfiller) listReposWithCollection(ctx context.Context, collection string) ([]Repo, error) {
459 // Use com.atproto.sync.listRepos to get all repos
460 // Then filter to those with the target collection
461 //
462 // Note: This is O(n) over all repos on the relay.
463 // For efficiency, could maintain a separate index or use
464 // Jetstream historical replay if available.
465
466 var repos []Repo
467 cursor := ""
468
469 for {
470 resp, err := b.client.SyncListRepos(ctx, cursor, 1000)
471 if err != nil {
472 return nil, err
473 }
474
475 for _, repo := range resp.Repos {
476 // Check if repo has the collection by attempting to list records
477 records, err := b.client.RepoListRecords(ctx, repo.DID, collection, "", 1)
478 if err == nil && len(records.Records) > 0 {
479 repos = append(repos, Repo{DID: repo.DID})
480 }
481 }
482
483 if resp.Cursor == nil || *resp.Cursor == "" {
484 break
485 }
486 cursor = *resp.Cursor
487 }
488
489 return repos, nil
490}
491```
492
493### Bootstrap Configuration
494
495For known holds that may not yet be on relays, support a bootstrap list in configuration:
496
497```bash
498# Environment variable
499ATCR_BOOTSTRAP_HOLDS="did:web:hold01.atcr.io,did:web:hold02.atcr.io"
500```
501
502```go
503func (b *Backfiller) BackfillBootstrapHolds(ctx context.Context, holdDIDs []string) error {
504 for _, did := range holdDIDs {
505 // Verify it's a hold
506 hasService, endpoint, err := b.resolver.HasHoldService(did)
507 if err != nil || !hasService {
508 log.Warn().Str("did", did).Msg("bootstrap hold is not a valid hold service")
509 continue
510 }
511
512 // Fetch captain record directly from hold's PDS
513 captain, err := b.fetchCaptainFromHold(ctx, did, endpoint)
514 if err != nil {
515 log.Warn().Err(err).Str("did", did).Msg("failed to fetch captain from hold")
516 continue
517 }
518
519 if err := b.db.UpsertCaptainRecord(did, captain); err != nil {
520 log.Warn().Err(err).Str("did", did).Msg("failed to upsert bootstrap captain")
521 continue
522 }
523
524 // Also backfill crew records
525 if err := b.backfillCrewFromHold(ctx, did, endpoint); err != nil {
526 log.Warn().Err(err).Str("did", did).Msg("failed to backfill bootstrap crew")
527 }
528 }
529
530 return nil
531}
532
533func (b *Backfiller) fetchCaptainFromHold(ctx context.Context, did, endpoint string) (*db.CaptainRecord, error) {
534 // GET {endpoint}/xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
535 url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=io.atcr.hold.captain&rkey=self",
536 endpoint, did)
537
538 resp, err := http.Get(url)
539 if err != nil {
540 return nil, err
541 }
542 defer resp.Body.Close()
543
544 var result struct {
545 Value atproto.CaptainRecord `json:"value"`
546 }
547 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
548 return nil, err
549 }
550
551 return &db.CaptainRecord{
552 DID: did,
553 OwnerDID: result.Value.OwnerDID,
554 Public: result.Value.Public,
555 AllowAllCrew: result.Value.AllowAllCrew,
556 DeployedAt: result.Value.DeployedAt,
557 Region: result.Value.Region,
558 Provider: result.Value.Provider,
559 Endpoint: endpoint,
560 }, nil
561}
562```
563
564## Database Queries
565
566### Hold Store Functions
567
568Add to `pkg/appview/db/hold_store.go`:
569
570```go
571// CrewMember represents a cached crew membership
572type CrewMember struct {
573 HoldDID string
574 MemberDID string
575 Role string
576 Permissions string // JSON array
577 Tier string
578 AddedAt string
579 CreatedAt string
580 UpdatedAt string
581}
582
583// UpsertCrewMember inserts or updates a crew member record
584func UpsertCrewMember(db *sql.DB, holdDID string, member *CrewMember) error {
585 _, err := db.Exec(`
586 INSERT INTO hold_crew_members (hold_did, member_did, role, permissions, tier, added_at, updated_at)
587 VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
588 ON CONFLICT(hold_did, member_did) DO UPDATE SET
589 role = excluded.role,
590 permissions = excluded.permissions,
591 tier = excluded.tier,
592 added_at = excluded.added_at,
593 updated_at = datetime('now')
594 `, holdDID, member.MemberDID, member.Role, member.Permissions, member.Tier, member.AddedAt)
595 return err
596}
597
598// DeleteCrewMember removes a crew member record
599func DeleteCrewMember(db *sql.DB, holdDID, memberDID string) error {
600 _, err := db.Exec(`
601 DELETE FROM hold_crew_members WHERE hold_did = ? AND member_did = ?
602 `, holdDID, memberDID)
603 return err
604}
605
606// DeleteCrewMemberByRkey removes a crew member by rkey (for delete events)
607func DeleteCrewMemberByRkey(db *sql.DB, holdDID, rkey string) error {
608 // We need to find the member by rkey hash
609 // This is tricky because we store member_did, not rkey
610 // Option 1: Store rkey in the table
611 // Option 2: Iterate and check (slow)
612 // Option 3: Store both member_did and rkey
613
614 // For now, we'll need to add rkey to the schema
615 _, err := db.Exec(`
616 DELETE FROM hold_crew_members WHERE hold_did = ? AND rkey = ?
617 `, holdDID, rkey)
618 return err
619}
620
621// AvailableHold represents a hold available to a user
622type AvailableHold struct {
623 DID string
624 OwnerDID string
625 Public bool
626 AllowAllCrew bool
627 Region string
628 Provider string
629 Endpoint string
630 Membership string // "owner", "crew", "eligible", "public"
631 Permissions []string // nil if not crew
632}
633
634// GetAvailableHolds returns all holds available to a user
635func GetAvailableHolds(db *sql.DB, userDID string) ([]AvailableHold, error) {
636 rows, err := db.Query(`
637 SELECT
638 h.did,
639 h.owner_did,
640 h.public,
641 h.allow_all_crew,
642 h.region,
643 h.provider,
644 h.endpoint,
645 CASE
646 WHEN h.owner_did = ?1 THEN 'owner'
647 WHEN c.member_did IS NOT NULL THEN 'crew'
648 WHEN h.allow_all_crew = 1 THEN 'eligible'
649 WHEN h.public = 1 THEN 'public'
650 ELSE 'none'
651 END as membership,
652 c.permissions
653 FROM hold_captain_records h
654 LEFT JOIN hold_crew_members c
655 ON h.did = c.hold_did AND c.member_did = ?1
656 WHERE h.public = 1
657 OR h.allow_all_crew = 1
658 OR h.owner_did = ?1
659 OR c.member_did IS NOT NULL
660 ORDER BY
661 CASE
662 WHEN h.owner_did = ?1 THEN 0
663 WHEN c.member_did IS NOT NULL THEN 1
664 WHEN h.allow_all_crew = 1 THEN 2
665 ELSE 3
666 END,
667 h.did
668 `, userDID)
669 if err != nil {
670 return nil, err
671 }
672 defer rows.Close()
673
674 var holds []AvailableHold
675 for rows.Next() {
676 var h AvailableHold
677 var permissionsJSON sql.NullString
678
679 err := rows.Scan(
680 &h.DID,
681 &h.OwnerDID,
682 &h.Public,
683 &h.AllowAllCrew,
684 &h.Region,
685 &h.Provider,
686 &h.Endpoint,
687 &h.Membership,
688 &permissionsJSON,
689 )
690 if err != nil {
691 return nil, err
692 }
693
694 if permissionsJSON.Valid {
695 json.Unmarshal([]byte(permissionsJSON.String), &h.Permissions)
696 }
697
698 holds = append(holds, h)
699 }
700
701 return holds, rows.Err()
702}
703
704// GetHoldsOwnedBy returns holds owned by a specific DID
705func GetHoldsOwnedBy(db *sql.DB, ownerDID string) ([]CaptainRecord, error) {
706 rows, err := db.Query(`
707 SELECT did, owner_did, public, allow_all_crew, deployed_at, region, provider, endpoint
708 FROM hold_captain_records
709 WHERE owner_did = ?
710 ORDER BY deployed_at DESC
711 `, ownerDID)
712 if err != nil {
713 return nil, err
714 }
715 defer rows.Close()
716
717 var holds []CaptainRecord
718 for rows.Next() {
719 var h CaptainRecord
720 err := rows.Scan(&h.DID, &h.OwnerDID, &h.Public, &h.AllowAllCrew,
721 &h.DeployedAt, &h.Region, &h.Provider, &h.Endpoint)
722 if err != nil {
723 return nil, err
724 }
725 holds = append(holds, h)
726 }
727
728 return holds, rows.Err()
729}
730
731// GetCrewMemberships returns all holds where a user is a crew member
732func GetCrewMemberships(db *sql.DB, memberDID string) ([]CrewMember, error) {
733 rows, err := db.Query(`
734 SELECT hold_did, member_did, role, permissions, tier, added_at
735 FROM hold_crew_members
736 WHERE member_did = ?
737 ORDER BY added_at DESC
738 `, memberDID)
739 if err != nil {
740 return nil, err
741 }
742 defer rows.Close()
743
744 var memberships []CrewMember
745 for rows.Next() {
746 var m CrewMember
747 err := rows.Scan(&m.HoldDID, &m.MemberDID, &m.Role, &m.Permissions, &m.Tier, &m.AddedAt)
748 if err != nil {
749 return nil, err
750 }
751 memberships = append(memberships, m)
752 }
753
754 return memberships, rows.Err()
755}
756```
757
758## UI Integration
759
760### Current State
761
762The settings page (`pkg/appview/templates/pages/settings.html`) currently has a **text input field** for the default hold:
763
764```html
765<!-- Current implementation (to be replaced) -->
766<section class="settings-section">
767 <h2>Default Hold</h2>
768 <p>Current: <strong id="current-hold">{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p>
769
770 <form hx-post="/api/profile/default-hold" ...>
771 <div class="form-group">
772 <label for="hold-endpoint">Hold Endpoint:</label>
773 <input type="text"
774 id="hold-endpoint"
775 name="hold_endpoint"
776 value="{{ .Profile.DefaultHold }}"
777 placeholder="https://hold.example.com" />
778 <small>Leave empty to use AppView default storage</small>
779 </div>
780 <button type="submit" class="btn-primary">Save</button>
781 </form>
782</section>
783```
784
785**Problems with the current approach:**
786
7871. **Users must know hold URLs** - Requires users to manually find and copy hold endpoint URLs
7882. **No validation** - Users can enter invalid or inaccessible URLs
7893. **No discovery** - Users don't know what holds are available to them
7904. **Poor UX** - Text input is error-prone and unfriendly
7915. **No membership visibility** - Users can't see which holds they're crew on
792
793### Proposed Change: Dropdown with Discovered Holds
794
795Replace the text input with a `<select>` dropdown populated from the hold discovery cache:
796
797```html
798<!-- New implementation -->
799<section class="settings-section">
800 <h2>Default Hold</h2>
801 <p class="help-text">
802 Select where your container images will be stored. Holds are organized by your access level.
803 </p>
804
805 <form hx-post="/api/profile/default-hold"
806 hx-target="#hold-status"
807 hx-swap="innerHTML"
808 id="hold-form">
809
810 <div class="form-group">
811 <label for="default-hold">Storage Hold:</label>
812 <select id="default-hold" name="hold_did" class="form-select">
813 <option value="">AppView Default ({{ .DefaultHoldDisplayName }})</option>
814
815 {{if .OwnedHolds}}
816 <optgroup label="Your Holds">
817 {{range .OwnedHolds}}
818 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
819 {{.DisplayName}}
820 {{if .Region}} ({{.Region}}){{end}}
821 </option>
822 {{end}}
823 </optgroup>
824 {{end}}
825
826 {{if .CrewHolds}}
827 <optgroup label="Crew Member">
828 {{range .CrewHolds}}
829 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
830 {{.DisplayName}}
831 {{if .Region}} ({{.Region}}){{end}}
832 {{if not .HasWritePermission}}[read-only]{{end}}
833 </option>
834 {{end}}
835 </optgroup>
836 {{end}}
837
838 {{if .EligibleHolds}}
839 <optgroup label="Open Registration">
840 {{range .EligibleHolds}}
841 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
842 {{.DisplayName}}
843 {{if .Region}} ({{.Region}}){{end}}
844 </option>
845 {{end}}
846 </optgroup>
847 {{end}}
848
849 {{if .PublicHolds}}
850 <optgroup label="Public Holds">
851 {{range .PublicHolds}}
852 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
853 {{.DisplayName}}
854 {{if .Region}} ({{.Region}}){{end}}
855 </option>
856 {{end}}
857 </optgroup>
858 {{end}}
859 </select>
860 <small>Your images will be stored on the selected hold</small>
861 </div>
862
863 <button type="submit" class="btn-primary">Save</button>
864 </form>
865
866 <div id="hold-status"></div>
867
868 <!-- Hold details panel (shows when hold selected) -->
869 <div id="hold-details" class="hold-details" style="display: none;">
870 <h3>Hold Details</h3>
871 <dl>
872 <dt>DID:</dt>
873 <dd id="hold-did"></dd>
874 <dt>Provider:</dt>
875 <dd id="hold-provider"></dd>
876 <dt>Region:</dt>
877 <dd id="hold-region"></dd>
878 <dt>Your Access:</dt>
879 <dd id="hold-access"></dd>
880 </dl>
881 </div>
882</section>
883```
884
885### Dropdown Option Groups
886
887The dropdown organizes holds into logical groups based on user's relationship:
888
889| Group | Description | Access Level |
890|-------|-------------|--------------|
891| **Your Holds** | Holds where user is the captain (owner) | Full control |
892| **Crew Member** | Holds where user has explicit crew membership | Based on permissions |
893| **Open Registration** | Holds with `allowAllCrew=true` | Can self-register |
894| **Public Holds** | Holds with `public=true` | Anyone can use |
895
896### Visual Indicators
897
898Each option should show relevant context:
899
900```
901┌─ Storage Hold: ─────────────────────────────────────┐
902│ ▼ hold01.atcr.io (us-east) │
903├─────────────────────────────────────────────────────┤
904│ AppView Default (hold01.atcr.io) │
905│ ───────────────────────────────────── │
906│ Your Holds │
907│ my-hold.fly.dev (us-west) │
908│ ───────────────────────────────────── │
909│ Crew Member │
910│ team-hold.company.com (eu-central) │
911│ shared-hold.org (asia-pacific) [read-only] │
912│ ───────────────────────────────────── │
913│ Open Registration │
914│ community-hold.dev (us-east) │
915│ ───────────────────────────────────── │
916│ Public Holds │
917│ public-hold.example.com (global) │
918└─────────────────────────────────────────────────────┘
919```
920
921### Form Submission Change
922
923The form now submits `hold_did` (a DID) instead of `hold_endpoint` (a URL):
924
925**Before:**
926```
927POST /api/profile/default-hold
928Content-Type: application/x-www-form-urlencoded
929
930hold_endpoint=https://hold01.atcr.io
931```
932
933**After:**
934```
935POST /api/profile/default-hold
936Content-Type: application/x-www-form-urlencoded
937
938hold_did=did:web:hold01.atcr.io
939```
940
941The `UpdateDefaultHoldHandler` needs to be updated to accept DIDs:
942
943```go
944// pkg/appview/handlers/settings.go
945
946func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
947 user := middleware.GetUser(r)
948 if user == nil {
949 http.Error(w, "Unauthorized", http.StatusUnauthorized)
950 return
951 }
952
953 // Accept DID (new) or endpoint (legacy/fallback)
954 holdDID := r.FormValue("hold_did")
955 if holdDID == "" {
956 // Fallback for legacy form submissions
957 holdDID = r.FormValue("hold_endpoint")
958 }
959
960 // Validate the hold DID if provided
961 if holdDID != "" {
962 // Check it's in our discovered holds cache
963 captain, err := h.DB.GetCaptainRecord(holdDID)
964 if err != nil {
965 http.Error(w, "Unknown hold: "+holdDID, http.StatusBadRequest)
966 return
967 }
968
969 // Verify user has access to this hold
970 available, err := db.GetAvailableHolds(h.DB, user.DID)
971 if err != nil {
972 http.Error(w, "Failed to check hold access", http.StatusInternalServerError)
973 return
974 }
975
976 hasAccess := false
977 for _, h := range available {
978 if h.DID == holdDID {
979 hasAccess = true
980 break
981 }
982 }
983
984 if !hasAccess {
985 http.Error(w, "You don't have access to this hold", http.StatusForbidden)
986 return
987 }
988 }
989
990 // ... rest of profile update logic
991}
992```
993
994### Settings Handler
995
996Update the settings handler to include available holds:
997
998```go
999// pkg/appview/handlers/settings.go
1000
1001func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) {
1002 ctx := r.Context()
1003 userDID := auth.GetDID(ctx)
1004
1005 // Get user's current profile
1006 profile, err := h.storage.GetProfile(ctx, userDID)
1007 if err != nil {
1008 // Handle error
1009 }
1010
1011 // Get available holds for dropdown
1012 availableHolds, err := db.GetAvailableHolds(h.db, userDID)
1013 if err != nil {
1014 // Handle error
1015 }
1016
1017 data := SettingsPageData{
1018 Profile: profile,
1019 AvailableHolds: availableHolds,
1020 CurrentHoldDID: profile.DefaultHold,
1021 }
1022
1023 h.renderTemplate(w, "settings.html", data)
1024}
1025```
1026
1027### Settings Template
1028
1029```html
1030<!-- pkg/appview/templates/pages/settings.html -->
1031
1032<div class="settings-section">
1033 <h2>Default Hold</h2>
1034 <p class="help-text">
1035 Select where your container images will be stored by default.
1036 </p>
1037
1038 <form method="POST" action="/settings/hold">
1039 <select name="defaultHold" id="defaultHold" class="form-select">
1040 <option value="">-- Select a Hold --</option>
1041
1042 {{if .OwnedHolds}}
1043 <optgroup label="Your Holds">
1044 {{range .OwnedHolds}}
1045 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
1046 {{.DisplayName}} (Owner)
1047 {{if .Region}} - {{.Region}}{{end}}
1048 </option>
1049 {{end}}
1050 </optgroup>
1051 {{end}}
1052
1053 {{if .CrewHolds}}
1054 <optgroup label="Crew Member">
1055 {{range .CrewHolds}}
1056 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
1057 {{.DisplayName}}
1058 {{if .Region}} - {{.Region}}{{end}}
1059 </option>
1060 {{end}}
1061 </optgroup>
1062 {{end}}
1063
1064 {{if .EligibleHolds}}
1065 <optgroup label="Open Registration">
1066 {{range .EligibleHolds}}
1067 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
1068 {{.DisplayName}}
1069 {{if .Region}} - {{.Region}}{{end}}
1070 </option>
1071 {{end}}
1072 </optgroup>
1073 {{end}}
1074
1075 {{if .PublicHolds}}
1076 <optgroup label="Public Holds">
1077 {{range .PublicHolds}}
1078 <option value="{{.DID}}" {{if eq $.CurrentHoldDID .DID}}selected{{end}}>
1079 {{.DisplayName}}
1080 {{if .Region}} - {{.Region}}{{end}}
1081 </option>
1082 {{end}}
1083 </optgroup>
1084 {{end}}
1085 </select>
1086
1087 <button type="submit" class="btn btn-primary">Save</button>
1088 </form>
1089</div>
1090```
1091
1092### Template Data Preparation
1093
1094```go
1095// pkg/appview/handlers/settings.go
1096
1097type SettingsPageData struct {
1098 Profile *atproto.SailorProfile
1099 CurrentHoldDID string
1100 OwnedHolds []HoldDisplay
1101 CrewHolds []HoldDisplay
1102 EligibleHolds []HoldDisplay
1103 PublicHolds []HoldDisplay
1104}
1105
1106type HoldDisplay struct {
1107 DID string
1108 DisplayName string // Derived from DID or endpoint
1109 Region string
1110 Provider string
1111 Permissions []string
1112}
1113
1114func (h *Handler) prepareSettingsData(userDID string, holds []db.AvailableHold, currentHold string) SettingsPageData {
1115 data := SettingsPageData{
1116 CurrentHoldDID: currentHold,
1117 }
1118
1119 for _, hold := range holds {
1120 display := HoldDisplay{
1121 DID: hold.DID,
1122 DisplayName: deriveDisplayName(hold.DID, hold.Endpoint),
1123 Region: hold.Region,
1124 Provider: hold.Provider,
1125 Permissions: hold.Permissions,
1126 }
1127
1128 switch hold.Membership {
1129 case "owner":
1130 data.OwnedHolds = append(data.OwnedHolds, display)
1131 case "crew":
1132 data.CrewHolds = append(data.CrewHolds, display)
1133 case "eligible":
1134 data.EligibleHolds = append(data.EligibleHolds, display)
1135 case "public":
1136 data.PublicHolds = append(data.PublicHolds, display)
1137 }
1138 }
1139
1140 return data
1141}
1142
1143func deriveDisplayName(did, endpoint string) string {
1144 // For did:web, extract the domain
1145 if strings.HasPrefix(did, "did:web:") {
1146 return strings.TrimPrefix(did, "did:web:")
1147 }
1148
1149 // For did:plc, use the endpoint hostname if available
1150 if endpoint != "" {
1151 if u, err := url.Parse(endpoint); err == nil {
1152 return u.Host
1153 }
1154 }
1155
1156 // Fallback to truncated DID
1157 if len(did) > 20 {
1158 return did[:20] + "..."
1159 }
1160 return did
1161}
1162```
1163
1164### CSS Styles
1165
1166Add styles for the hold dropdown and details panel:
1167
1168```css
1169/* pkg/appview/templates/pages/settings.html - add to <style> section */
1170
1171/* Hold Selection Styles */
1172.form-select {
1173 width: 100%;
1174 padding: 0.75rem;
1175 font-size: 1rem;
1176 border: 1px solid var(--border);
1177 border-radius: 4px;
1178 background: var(--bg);
1179 color: var(--fg);
1180 cursor: pointer;
1181}
1182
1183.form-select:focus {
1184 outline: none;
1185 border-color: var(--primary);
1186 box-shadow: 0 0 0 2px var(--primary-bg);
1187}
1188
1189.form-select optgroup {
1190 font-weight: bold;
1191 color: var(--fg-muted);
1192 padding-top: 0.5rem;
1193}
1194
1195.form-select option {
1196 padding: 0.5rem;
1197 font-weight: normal;
1198 color: var(--fg);
1199}
1200
1201/* Hold Details Panel */
1202.hold-details {
1203 margin-top: 1rem;
1204 padding: 1rem;
1205 background: var(--code-bg);
1206 border-radius: 4px;
1207 border: 1px solid var(--border);
1208}
1209
1210.hold-details h3 {
1211 margin-top: 0;
1212 margin-bottom: 0.75rem;
1213 font-size: 0.9rem;
1214 color: var(--fg-muted);
1215 text-transform: uppercase;
1216 letter-spacing: 0.05em;
1217}
1218
1219.hold-details dl {
1220 display: grid;
1221 grid-template-columns: auto 1fr;
1222 gap: 0.5rem 1rem;
1223 margin: 0;
1224}
1225
1226.hold-details dt {
1227 color: var(--fg-muted);
1228 font-weight: 500;
1229}
1230
1231.hold-details dd {
1232 margin: 0;
1233 font-family: monospace;
1234}
1235
1236/* Access Level Badges */
1237.access-badge {
1238 display: inline-block;
1239 padding: 0.125rem 0.5rem;
1240 border-radius: 4px;
1241 font-size: 0.85rem;
1242 font-weight: 500;
1243}
1244
1245.access-owner {
1246 background: #fef3c7;
1247 color: #92400e;
1248}
1249
1250.access-crew {
1251 background: #dcfce7;
1252 color: #166534;
1253}
1254
1255.access-eligible {
1256 background: #e0e7ff;
1257 color: #3730a3;
1258}
1259
1260.access-public {
1261 background: #f3f4f6;
1262 color: #374151;
1263}
1264
1265/* Read-only indicator */
1266.read-only-indicator {
1267 color: var(--warning);
1268 font-size: 0.85rem;
1269 margin-left: 0.25rem;
1270}
1271```
1272
1273### JavaScript Interaction
1274
1275Add JavaScript to show hold details when selection changes:
1276
1277```html
1278<!-- Add to settings.html <script> section -->
1279<script>
1280(function() {
1281 // Hold selection and details display
1282 const holdSelect = document.getElementById('default-hold');
1283 const holdDetails = document.getElementById('hold-details');
1284
1285 // Hold data embedded from server (JSON in data attribute or inline)
1286 const holdData = {{ .HoldDataJSON }};
1287
1288 if (holdSelect) {
1289 holdSelect.addEventListener('change', function() {
1290 const selectedDID = this.value;
1291
1292 if (!selectedDID || !holdData[selectedDID]) {
1293 holdDetails.style.display = 'none';
1294 return;
1295 }
1296
1297 const hold = holdData[selectedDID];
1298
1299 document.getElementById('hold-did').textContent = hold.did;
1300 document.getElementById('hold-provider').textContent = hold.provider || 'Unknown';
1301 document.getElementById('hold-region').textContent = hold.region || 'Global';
1302
1303 // Set access level with badge
1304 const accessEl = document.getElementById('hold-access');
1305 const accessClass = 'access-' + hold.membership;
1306 const accessLabel = {
1307 'owner': 'Owner (Full Control)',
1308 'crew': 'Crew Member',
1309 'eligible': 'Open Registration',
1310 'public': 'Public Access'
1311 }[hold.membership] || hold.membership;
1312
1313 accessEl.innerHTML = `<span class="access-badge ${accessClass}">${accessLabel}</span>`;
1314
1315 // Show permissions for crew members
1316 if (hold.membership === 'crew' && hold.permissions) {
1317 const perms = hold.permissions.join(', ');
1318 accessEl.innerHTML += `<br><small>Permissions: ${perms}</small>`;
1319 }
1320
1321 holdDetails.style.display = 'block';
1322 });
1323
1324 // Trigger on page load if a hold is already selected
1325 if (holdSelect.value) {
1326 holdSelect.dispatchEvent(new Event('change'));
1327 }
1328 }
1329})();
1330</script>
1331```
1332
1333### Server-Side Hold Data
1334
1335The handler needs to serialize hold data for the JavaScript:
1336
1337```go
1338// pkg/appview/handlers/settings.go
1339
1340import "encoding/json"
1341
1342type HoldDataEntry struct {
1343 DID string `json:"did"`
1344 DisplayName string `json:"displayName"`
1345 Provider string `json:"provider"`
1346 Region string `json:"region"`
1347 Membership string `json:"membership"`
1348 Permissions []string `json:"permissions,omitempty"`
1349}
1350
1351func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1352 // ... existing code ...
1353
1354 // Get available holds
1355 availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
1356 if err != nil {
1357 slog.Error("Failed to get available holds", "error", err)
1358 availableHolds = []db.AvailableHold{}
1359 }
1360
1361 // Build hold data map for JavaScript
1362 holdDataMap := make(map[string]HoldDataEntry)
1363 for _, hold := range availableHolds {
1364 holdDataMap[hold.DID] = HoldDataEntry{
1365 DID: hold.DID,
1366 DisplayName: deriveDisplayName(hold.DID, hold.Endpoint),
1367 Provider: hold.Provider,
1368 Region: hold.Region,
1369 Membership: hold.Membership,
1370 Permissions: hold.Permissions,
1371 }
1372 }
1373
1374 holdDataJSON, _ := json.Marshal(holdDataMap)
1375
1376 data := SettingsPageData{
1377 // ... existing fields ...
1378 HoldDataJSON: template.JS(holdDataJSON), // Safe for embedding in <script>
1379 }
1380
1381 // ... render template ...
1382}
1383```
1384
1385### Empty State Handling
1386
1387When no holds are discovered yet, show a helpful message:
1388
1389```html
1390{{if and (not .OwnedHolds) (not .CrewHolds) (not .EligibleHolds) (not .PublicHolds)}}
1391<div class="empty-holds-notice">
1392 <p>
1393 <i data-lucide="info"></i>
1394 No holds discovered yet. Using AppView default storage.
1395 </p>
1396 <p class="help-text">
1397 Holds are discovered automatically via the ATProto network.
1398 If you've deployed your own hold, make sure it has requested a relay crawl.
1399 </p>
1400</div>
1401{{else}}
1402<!-- Show the dropdown -->
1403{{end}}
1404```
1405
1406### Refresh Button
1407
1408Allow users to manually trigger hold refresh:
1409
1410```html
1411<div class="hold-actions">
1412 <button type="button"
1413 class="btn-secondary"
1414 hx-post="/api/holds/refresh"
1415 hx-target="#hold-refresh-status"
1416 hx-swap="innerHTML">
1417 <i data-lucide="refresh-cw"></i> Refresh Holds
1418 </button>
1419 <span id="hold-refresh-status"></span>
1420</div>
1421```
1422
1423```go
1424// pkg/appview/handlers/settings.go
1425
1426func (h *RefreshHoldsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1427 user := middleware.GetUser(r)
1428 if user == nil {
1429 http.Error(w, "Unauthorized", http.StatusUnauthorized)
1430 return
1431 }
1432
1433 // Trigger async refresh of hold cache
1434 go func() {
1435 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1436 defer cancel()
1437
1438 if err := h.Backfiller.RefreshAllHolds(ctx); err != nil {
1439 slog.Error("Failed to refresh holds", "error", err)
1440 }
1441 }()
1442
1443 w.Header().Set("Content-Type", "text/html")
1444 w.Write([]byte(`<span class="success">Refreshing... reload page in a moment</span>`))
1445}
1446```
1447
1448## Cache Invalidation
1449
1450### Real-time Updates via Jetstream
1451
1452Jetstream events automatically update the cache:
1453
1454- **Captain record created/updated**: Upsert to `hold_captain_records`
1455- **Captain record deleted**: Delete from `hold_captain_records` (cascades to crew)
1456- **Crew record created/updated**: Upsert to `hold_crew_members`
1457- **Crew record deleted**: Delete from `hold_crew_members`
1458
1459### Manual Refresh
1460
1461For cases where Jetstream may be delayed or missed events:
1462
1463```go
1464// pkg/appview/handlers/settings.go
1465
1466func (h *Handler) RefreshHoldCache(w http.ResponseWriter, r *http.Request) {
1467 holdDID := r.URL.Query().Get("did")
1468 if holdDID == "" {
1469 http.Error(w, "missing did parameter", http.StatusBadRequest)
1470 return
1471 }
1472
1473 // Verify it's a hold service
1474 hasService, endpoint, err := h.resolver.HasHoldService(holdDID)
1475 if err != nil || !hasService {
1476 http.Error(w, "invalid hold DID", http.StatusBadRequest)
1477 return
1478 }
1479
1480 // Fetch and update captain record
1481 captain, err := h.backfiller.fetchCaptainFromHold(r.Context(), holdDID, endpoint)
1482 if err != nil {
1483 http.Error(w, "failed to fetch captain record", http.StatusInternalServerError)
1484 return
1485 }
1486
1487 if err := h.db.UpsertCaptainRecord(holdDID, captain); err != nil {
1488 http.Error(w, "failed to update cache", http.StatusInternalServerError)
1489 return
1490 }
1491
1492 // Also refresh crew records
1493 if err := h.backfiller.backfillCrewFromHold(r.Context(), holdDID, endpoint); err != nil {
1494 log.Warn().Err(err).Str("did", holdDID).Msg("failed to refresh crew records")
1495 }
1496
1497 http.Redirect(w, r, "/settings", http.StatusSeeOther)
1498}
1499```
1500
1501### TTL-based Refresh
1502
1503Optionally, run periodic refresh of cached records:
1504
1505```go
1506// pkg/appview/jetstream/backfill.go
1507
1508func (b *Backfiller) RefreshStaleHolds(ctx context.Context, maxAge time.Duration) error {
1509 // Find holds not updated recently
1510 rows, err := b.db.Query(`
1511 SELECT did, endpoint FROM hold_captain_records
1512 WHERE updated_at < datetime('now', ?)
1513 `, fmt.Sprintf("-%d seconds", int(maxAge.Seconds())))
1514 if err != nil {
1515 return err
1516 }
1517 defer rows.Close()
1518
1519 for rows.Next() {
1520 var did, endpoint string
1521 if err := rows.Scan(&did, &endpoint); err != nil {
1522 continue
1523 }
1524
1525 // Refresh this hold's data
1526 if err := b.refreshHold(ctx, did, endpoint); err != nil {
1527 log.Warn().Err(err).Str("did", did).Msg("failed to refresh stale hold")
1528 }
1529 }
1530
1531 return rows.Err()
1532}
1533```
1534
1535## Security Considerations
1536
1537### Trust Model
1538
1539- **Captain records are authoritative**: The hold's embedded PDS is the source of truth
1540- **Crew records are authoritative**: Same as captain records
1541- **Cache is for performance**: Always validate against source for sensitive operations
1542- **No user-provided data**: All data comes from Jetstream or direct PDS queries
1543
1544### Access Control
1545
1546- **Read access**: Any authenticated user can view available holds
1547- **Write access**: Only hold owners can modify captain records
1548- **Crew management**: Only hold owners and crew admins can add/remove crew
1549
1550### Data Validation
1551
1552```go
1553func validateCaptainRecord(record *atproto.CaptainRecord) error {
1554 if record.OwnerDID == "" {
1555 return errors.New("owner DID is required")
1556 }
1557 if !strings.HasPrefix(record.OwnerDID, "did:") {
1558 return errors.New("invalid owner DID format")
1559 }
1560 return nil
1561}
1562
1563func validateCrewRecord(record *atproto.CrewRecord) error {
1564 if record.MemberDID == "" {
1565 return errors.New("member DID is required")
1566 }
1567 if !strings.HasPrefix(record.MemberDID, "did:") {
1568 return errors.New("invalid member DID format")
1569 }
1570 for _, perm := range record.Permissions {
1571 if !isValidPermission(perm) {
1572 return fmt.Errorf("invalid permission: %s", perm)
1573 }
1574 }
1575 return nil
1576}
1577
1578func isValidPermission(perm string) bool {
1579 valid := map[string]bool{
1580 "blob:read": true,
1581 "blob:write": true,
1582 "crew:admin": true,
1583 }
1584 return valid[perm]
1585}
1586```
1587
1588## Implementation Checklist
1589
1590### Phase 1: Database Schema
1591
1592- [ ] Add `hold_crew_members` table to `pkg/appview/db/schema.sql`
1593- [ ] Create migration file `pkg/appview/db/migrations/006_hold_discovery.yaml`
1594- [ ] Verify `rkey` column included for delete event handling
1595- [ ] Run migration on dev/staging databases
1596- [ ] Verify foreign key cascade works correctly
1597
1598### Phase 2: Jetstream Integration
1599
1600- [ ] Add `io.atcr.hold.captain` to wanted collections in `pkg/appview/jetstream/worker.go`
1601- [ ] Add `io.atcr.hold.crew` to wanted collections
1602- [ ] Implement `ProcessCaptain` function in `pkg/appview/jetstream/processor.go`
1603- [ ] Implement `ProcessCrew` function
1604- [ ] Add hold service verification (`#atcr_hold` check via DID document)
1605- [ ] Handle delete events for captain records (cascade to crew)
1606- [ ] Handle delete events for crew records (by rkey lookup)
1607- [ ] Test with local hold service connected to local relay
1608
1609### Phase 3: Backfill
1610
1611- [ ] Implement `BackfillHolds` function in `pkg/appview/jetstream/backfill.go`
1612- [ ] Implement `backfillCrewRecords` function
1613- [ ] Implement `listReposWithCollection` helper
1614- [ ] Add `ATCR_BOOTSTRAP_HOLDS` environment variable support
1615- [ ] Implement `BackfillBootstrapHolds` function
1616- [ ] Implement `fetchCaptainFromHold` direct fetch
1617- [ ] Test backfill with production relay
1618- [ ] Add backfill command to CLI (optional)
1619
1620### Phase 4: Database Queries
1621
1622- [ ] Implement `UpsertCrewMember` in `pkg/appview/db/hold_store.go`
1623- [ ] Implement `DeleteCrewMember(holdDID, memberDID)`
1624- [ ] Implement `DeleteCrewMemberByRkey(holdDID, rkey)`
1625- [ ] Implement `GetAvailableHolds(userDID)` with membership categorization
1626- [ ] Implement `GetHoldsOwnedBy(ownerDID)`
1627- [ ] Implement `GetCrewMemberships(memberDID)`
1628- [ ] Add unit tests for all queries
1629
1630### Phase 5: UI Integration - Settings Handler
1631
1632- [ ] Add `DB *sql.DB` field to `SettingsHandler` struct
1633- [ ] Call `db.GetAvailableHolds()` in handler
1634- [ ] Create `SettingsPageData` struct with hold lists
1635- [ ] Implement `prepareSettingsData` helper function
1636- [ ] Implement `deriveDisplayName(did, endpoint)` helper
1637- [ ] Create `HoldDataEntry` struct for JSON serialization
1638- [ ] Serialize hold data to JSON for JavaScript
1639
1640### Phase 6: UI Integration - Template Changes
1641
1642- [ ] Replace text input with `<select>` dropdown in `settings.html`
1643- [ ] Add `<optgroup>` sections: Your Holds, Crew Member, Open Registration, Public
1644- [ ] Add `[read-only]` indicator for crew without write permission
1645- [ ] Add hold details panel (`#hold-details` div)
1646- [ ] Add empty state notice when no holds discovered
1647- [ ] Add "Refresh Holds" button
1648- [ ] Update form to submit `hold_did` instead of `hold_endpoint`
1649
1650### Phase 7: UI Integration - Styles & JavaScript
1651
1652- [ ] Add `.form-select` styles for dropdown
1653- [ ] Add `.hold-details` styles for details panel
1654- [ ] Add `.access-badge` styles (owner, crew, eligible, public)
1655- [ ] Add JavaScript for hold selection change handler
1656- [ ] Show hold details on selection change
1657- [ ] Display permissions for crew members
1658- [ ] Handle initial page load with pre-selected hold
1659
1660### Phase 8: Form Handler Updates
1661
1662- [ ] Update `UpdateDefaultHoldHandler` to accept `hold_did` parameter
1663- [ ] Add fallback for legacy `hold_endpoint` parameter
1664- [ ] Validate hold DID exists in cache
1665- [ ] Verify user has access to selected hold
1666- [ ] Return appropriate error for unknown/inaccessible holds
1667- [ ] Add `RefreshHoldsHandler` for manual refresh button
1668
1669### Phase 9: Testing
1670
1671- [ ] Unit tests for database queries
1672- [ ] Unit tests for Jetstream processors
1673- [ ] Integration test: discover hold via Jetstream
1674- [ ] Integration test: backfill existing holds
1675- [ ] E2E test: settings page displays holds
1676- [ ] E2E test: change default hold via dropdown
1677- [ ] E2E test: verify push uses new default hold
1678
1679### Phase 10: Cache Management & Monitoring
1680
1681- [ ] Implement `RefreshStaleHolds` for TTL-based refresh (optional)
1682- [ ] Add Prometheus metrics for cache operations
1683- [ ] Monitor cache hit/miss rates
1684- [ ] Add logging for discovery events
1685- [ ] Document operational procedures
1686
1687## Future Enhancements
1688
1689### Hold Search
1690
1691Add search/filter capabilities:
1692
1693```sql
1694SELECT * FROM hold_captain_records
1695WHERE region LIKE ?
1696 OR provider LIKE ?
1697ORDER BY ...
1698```
1699
1700### Hold Recommendations
1701
1702Suggest holds based on:
1703- Geographic proximity (region matching)
1704- Provider preference
1705- Existing crew memberships
1706
1707### Hold Statistics
1708
1709Display usage information:
1710- Storage used
1711- Number of images
1712- Number of crew members
1713- Uptime/availability
1714
1715### Hold Comparison
1716
1717Side-by-side comparison of:
1718- Storage limits
1719- Supported features
1720- Geographic regions
1721- Pricing (if applicable)