A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

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

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)