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

implement hold discovery dropdown in settings. implement a data privacy export feature

evan.jarrett.net 3409af6c d4b88b51

verified
+1
cmd/appview/serve.go
··· 204 204 HealthChecker: healthChecker, 205 205 ReadmeFetcher: readmeFetcher, 206 206 Templates: uiTemplates, 207 + DefaultHoldDID: defaultHoldDID, 207 208 }) 208 209 } 209 210 }
+1 -1
cmd/hold/main.go
··· 64 64 } 65 65 66 66 // Bootstrap PDS with captain record, hold owner as first crew member, and profile 67 - if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL); err != nil { 67 + if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil { 68 68 slog.Error("Failed to bootstrap PDS", "error", err) 69 69 os.Exit(1) 70 70 }
+304
docs/DIRECT_HOLD_ACCESS.md
··· 1 + # Accessing Hold Data Without AppView 2 + 3 + This document explains how to retrieve your data directly from a hold service without going through the ATCR AppView. This is useful for: 4 + - GDPR data export requests 5 + - Backup and migration 6 + - Debugging and development 7 + - Building alternative clients 8 + 9 + ## Quick Start: App Passwords (Recommended) 10 + 11 + The simplest way to authenticate is using an ATProto app password. This avoids the complexity of OAuth + DPoP. 12 + 13 + ### Step 1: Create an App Password 14 + 15 + 1. Go to your Bluesky settings: https://bsky.app/settings/app-passwords 16 + 2. Create a new app password 17 + 3. Save it securely (you'll only see it once) 18 + 19 + ### Step 2: Get a Session Token 20 + 21 + ```bash 22 + # Replace with your handle and app password 23 + HANDLE="yourhandle.bsky.social" 24 + APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" 25 + 26 + # Create session with your PDS 27 + SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \ 28 + -H "Content-Type: application/json" \ 29 + -d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}") 30 + 31 + # Extract tokens 32 + ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt') 33 + DID=$(echo "$SESSION" | jq -r '.did') 34 + PDS=$(echo "$SESSION" | jq -r '.didDoc.service[0].serviceEndpoint') 35 + 36 + echo "DID: $DID" 37 + echo "PDS: $PDS" 38 + ``` 39 + 40 + ### Step 3: Get a Service Token for the Hold 41 + 42 + ```bash 43 + # The hold DID you want to access (e.g., did:web:hold01.atcr.io) 44 + HOLD_DID="did:web:hold01.atcr.io" 45 + 46 + # Get a service token from your PDS 47 + SERVICE_TOKEN=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \ 48 + -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token') 49 + 50 + echo "Service Token: $SERVICE_TOKEN" 51 + ``` 52 + 53 + ### Step 4: Call Hold Endpoints 54 + 55 + Now you can call any authenticated hold endpoint with the service token: 56 + 57 + ```bash 58 + # Export your data from the hold 59 + curl -s "https://hold01.atcr.io/xrpc/io.atcr.hold.exportUserData" \ 60 + -H "Authorization: Bearer $SERVICE_TOKEN" | jq . 61 + ``` 62 + 63 + ### Complete Script 64 + 65 + Here's a complete script that does all the above: 66 + 67 + ```bash 68 + #!/bin/bash 69 + # export-hold-data.sh - Export your data from an ATCR hold 70 + 71 + set -e 72 + 73 + # Configuration 74 + HANDLE="${1:-yourhandle.bsky.social}" 75 + APP_PASSWORD="${2:-xxxx-xxxx-xxxx-xxxx}" 76 + HOLD_DID="${3:-did:web:hold01.atcr.io}" 77 + 78 + # Default PDS (Bluesky's main PDS) 79 + DEFAULT_PDS="https://bsky.social" 80 + 81 + echo "Authenticating as $HANDLE..." 82 + 83 + # Step 1: Create session 84 + SESSION=$(curl -s -X POST "$DEFAULT_PDS/xrpc/com.atproto.server.createSession" \ 85 + -H "Content-Type: application/json" \ 86 + -d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}") 87 + 88 + # Check for errors 89 + if echo "$SESSION" | jq -e '.error' > /dev/null 2>&1; then 90 + echo "Error: $(echo "$SESSION" | jq -r '.message')" 91 + exit 1 92 + fi 93 + 94 + ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt') 95 + DID=$(echo "$SESSION" | jq -r '.did') 96 + 97 + # Try to get PDS from didDoc, fall back to default 98 + PDS=$(echo "$SESSION" | jq -r '.didDoc.service[] | select(.id == "#atproto_pds") | .serviceEndpoint' 2>/dev/null || echo "$DEFAULT_PDS") 99 + if [ "$PDS" = "null" ] || [ -z "$PDS" ]; then 100 + PDS="$DEFAULT_PDS" 101 + fi 102 + 103 + echo "Authenticated as $DID" 104 + echo "PDS: $PDS" 105 + 106 + # Step 2: Get service token for the hold 107 + echo "Getting service token for $HOLD_DID..." 108 + SERVICE_RESPONSE=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \ 109 + -H "Authorization: Bearer $ACCESS_JWT") 110 + 111 + if echo "$SERVICE_RESPONSE" | jq -e '.error' > /dev/null 2>&1; then 112 + echo "Error getting service token: $(echo "$SERVICE_RESPONSE" | jq -r '.message')" 113 + exit 1 114 + fi 115 + 116 + SERVICE_TOKEN=$(echo "$SERVICE_RESPONSE" | jq -r '.token') 117 + 118 + # Step 3: Resolve hold DID to URL 119 + if [[ "$HOLD_DID" == did:web:* ]]; then 120 + # did:web:example.com -> https://example.com 121 + HOLD_HOST="${HOLD_DID#did:web:}" 122 + HOLD_URL="https://$HOLD_HOST" 123 + else 124 + echo "Error: Only did:web holds are currently supported for direct resolution" 125 + exit 1 126 + fi 127 + 128 + echo "Hold URL: $HOLD_URL" 129 + 130 + # Step 4: Export data 131 + echo "Exporting data from $HOLD_URL..." 132 + curl -s "$HOLD_URL/xrpc/io.atcr.hold.exportUserData" \ 133 + -H "Authorization: Bearer $SERVICE_TOKEN" | jq . 134 + ``` 135 + 136 + Usage: 137 + ```bash 138 + chmod +x export-hold-data.sh 139 + ./export-hold-data.sh yourhandle.bsky.social xxxx-xxxx-xxxx-xxxx did:web:hold01.atcr.io 140 + ``` 141 + 142 + --- 143 + 144 + ## Available Hold Endpoints 145 + 146 + Once you have a service token, you can call these endpoints: 147 + 148 + ### Data Export (GDPR) 149 + ```bash 150 + GET /xrpc/io.atcr.hold.exportUserData 151 + Authorization: Bearer {service_token} 152 + ``` 153 + 154 + Returns all your data stored on that hold: 155 + - Layer records (blobs you've pushed) 156 + - Crew membership status 157 + - Usage statistics 158 + - Whether you're the hold captain 159 + 160 + ### Quota Information 161 + ```bash 162 + GET /xrpc/io.atcr.hold.getQuota?userDid={your_did} 163 + # No auth required - just needs your DID 164 + ``` 165 + 166 + ### Blob Download (if you have read access) 167 + ```bash 168 + GET /xrpc/com.atproto.sync.getBlob?did={owner_did}&cid={blob_digest} 169 + Authorization: Bearer {service_token} 170 + ``` 171 + 172 + Returns a presigned URL to download the blob directly from storage. 173 + 174 + --- 175 + 176 + ## OAuth + DPoP (Advanced) 177 + 178 + App passwords are the simplest option, but OAuth with DPoP is the "proper" way to authenticate in ATProto. However, it's significantly more complex because: 179 + 180 + 1. **DPoP (Demonstrating Proof of Possession)** - Every request requires a cryptographically signed JWT proving you control a specific key 181 + 2. **PAR (Pushed Authorization Requests)** - Authorization parameters are sent server-to-server 182 + 3. **PKCE (Proof Key for Code Exchange)** - Prevents authorization code interception 183 + 184 + ### Why DPoP Makes Curl Impractical 185 + 186 + Each request requires a fresh DPoP proof JWT with: 187 + - Unique `jti` (request ID) 188 + - Current `iat` timestamp 189 + - HTTP method and URL bound to the request 190 + - Server-provided `nonce` 191 + - Signature using your P-256 private key 192 + 193 + Example DPoP proof structure: 194 + ```json 195 + { 196 + "alg": "ES256", 197 + "typ": "dpop+jwt", 198 + "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } 199 + } 200 + { 201 + "htm": "GET", 202 + "htu": "https://bsky.social/xrpc/com.atproto.server.getServiceAuth", 203 + "jti": "550e8400-e29b-41d4-a716-446655440000", 204 + "iat": 1735689100, 205 + "nonce": "server-provided-nonce" 206 + } 207 + ``` 208 + 209 + ### If You Need OAuth 210 + 211 + If you need OAuth (e.g., for a production application), you'll want to use a library: 212 + 213 + **Go:** 214 + ```go 215 + import "github.com/bluesky-social/indigo/atproto/auth/oauth" 216 + ``` 217 + 218 + **TypeScript/JavaScript:** 219 + ```bash 220 + npm install @atproto/oauth-client-node 221 + ``` 222 + 223 + **Python:** 224 + ```bash 225 + pip install atproto 226 + ``` 227 + 228 + These libraries handle all the DPoP complexity for you. 229 + 230 + ### High-Level OAuth Flow 231 + 232 + For documentation purposes, here's what the flow looks like: 233 + 234 + 1. **Resolve identity**: `handle` → `DID` → `PDS endpoint` 235 + 2. **Discover OAuth server**: `GET {pds}/.well-known/oauth-authorization-server` 236 + 3. **Generate DPoP key**: Create P-256 key pair 237 + 4. **PAR request**: Send authorization parameters (with DPoP proof) 238 + 5. **User authorization**: Browser-based login 239 + 6. **Token exchange**: Exchange code for tokens (with DPoP proof) 240 + 7. **Use tokens**: All subsequent requests include DPoP proofs 241 + 242 + Each step after #3 requires generating a fresh DPoP proof JWT, which is why libraries are essential. 243 + 244 + --- 245 + 246 + ## Troubleshooting 247 + 248 + ### "Invalid token" or "Token expired" 249 + 250 + Service tokens are only valid for ~60 seconds. Get a fresh one: 251 + ```bash 252 + SERVICE_TOKEN=$(curl -s "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \ 253 + -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token') 254 + ``` 255 + 256 + ### "Session expired" 257 + 258 + Your access JWT from `createSession` has expired. Create a new session: 259 + ```bash 260 + SESSION=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" ...) 261 + ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt') 262 + ``` 263 + 264 + ### "Audience mismatch" 265 + 266 + The service token is scoped to a specific hold. Make sure `HOLD_DID` matches exactly what's in the `aud` claim of your token. 267 + 268 + ### "Access denied: user is not a crew member" 269 + 270 + You don't have access to this hold. You need to either: 271 + - Be the hold captain (owner) 272 + - Be a crew member with appropriate permissions 273 + 274 + ### Finding Your Hold DID 275 + 276 + Check your sailor profile to find your default hold: 277 + ```bash 278 + curl -s "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=io.atcr.sailor.profile&rkey=self" \ 279 + -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.value.defaultHold' 280 + ``` 281 + 282 + Or check your manifest records for the hold where your images are stored: 283 + ```bash 284 + curl -s "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.manifest&limit=1" \ 285 + -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.records[0].value.holdDid' 286 + ``` 287 + 288 + --- 289 + 290 + ## Security Notes 291 + 292 + - **App passwords** are scoped tokens that can be revoked without changing your main password 293 + - **Service tokens** are short-lived (60 seconds) and scoped to a specific hold 294 + - **Never share** your app password or access tokens 295 + - Service tokens can only be used for the specific hold they were requested for (`aud` claim) 296 + 297 + --- 298 + 299 + ## References 300 + 301 + - [ATProto OAuth Specification](https://atproto.com/specs/oauth) 302 + - [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) 303 + - [Bluesky OAuth Guide](https://docs.bsky.app/docs/advanced-guides/oauth-client) 304 + - [ATCR BYOS Documentation](./BYOS.md)
+1721
docs/HOLD_DISCOVERY.md
··· 1 + # Hold Discovery 2 + 3 + This 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:** 10 + 1. Subscribe to Jetstream for `io.atcr.hold.captain` and `io.atcr.hold.crew` collections 11 + 2. Cache discovered holds and crew memberships in SQLite 12 + 3. 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 + 30 + Users need to select a "default hold" for blob storage. The AppView must discover available holds and determine which ones each user can access. This enables a dropdown in user settings showing: 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 + 41 + Hold 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 + 62 + 1. **Hold services** run embedded PDSes that store captain and crew records 63 + 2. **Relays** crawl hold PDSes after `request-crawl.sh` is run 64 + 3. **Jetstream** streams record events filtered by collection 65 + 4. **AppView** subscribes to Jetstream and caches records in SQLite 66 + 67 + ### Record Types 68 + 69 + Two ATProto record collections are relevant for discovery: 70 + 71 + #### `io.atcr.hold.captain` 72 + 73 + Singleton 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 + 98 + One 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 122 + func CrewRecordKey(memberDID string) string { 123 + hash := sha256.Sum256([]byte(memberDID)) 124 + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]) 125 + } 126 + ``` 127 + 128 + This enables O(1) lookup of a specific member's crew record. 129 + 130 + ## Data Model 131 + 132 + ### Database Schema 133 + 134 + Add to `pkg/appview/db/schema.sql`: 135 + 136 + ```sql 137 + -- Cached hold captain records from Jetstream 138 + -- Primary discovery source for available holds 139 + CREATE 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 + 152 + CREATE INDEX IF NOT EXISTS idx_hold_captain_owner ON hold_captain_records(owner_did); 153 + CREATE INDEX IF NOT EXISTS idx_hold_captain_public ON hold_captain_records(public); 154 + CREATE 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?" 158 + CREATE 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 + 172 + CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did); 173 + CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did); 174 + CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey); 175 + ``` 176 + 177 + ### Migration 178 + 179 + Add to `pkg/appview/db/migrations/`: 180 + 181 + ```yaml 182 + # 006_hold_discovery.yaml 183 + id: 006_hold_discovery 184 + description: Add hold crew members table for discovery 185 + up: | 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); 202 + down: | 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 + 213 + Update the Jetstream worker to subscribe to hold collections: 214 + 215 + ```go 216 + // pkg/appview/jetstream/worker.go 217 + 218 + var 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 + 229 + Add processors for captain and crew records: 230 + 231 + ```go 232 + // pkg/appview/jetstream/processor.go 233 + 234 + func (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 + 251 + func (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 + 290 + func (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 + 324 + func (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 + 343 + Before caching a captain record, verify the DID document contains the `#atcr_hold` service: 344 + 345 + ```go 346 + // pkg/atproto/resolver.go 347 + 348 + type DIDDocument struct { 349 + ID string `json:"id"` 350 + Service []Service `json:"service"` 351 + // ... other fields 352 + } 353 + 354 + type Service struct { 355 + ID string `json:"id"` 356 + Type string `json:"type"` 357 + ServiceEndpoint string `json:"serviceEndpoint"` 358 + } 359 + 360 + func (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 + 381 + For holds that existed before AppView started listening to Jetstream, use the existing backfill mechanism: 382 + 383 + ```go 384 + // pkg/appview/jetstream/backfill.go 385 + 386 + func (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 + 422 + func (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 + 455 + Query the relay for repos that have a specific collection: 456 + 457 + ```go 458 + func (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 + 495 + For known holds that may not yet be on relays, support a bootstrap list in configuration: 496 + 497 + ```bash 498 + # Environment variable 499 + ATCR_BOOTSTRAP_HOLDS="did:web:hold01.atcr.io,did:web:hold02.atcr.io" 500 + ``` 501 + 502 + ```go 503 + func (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 + 533 + func (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 + 568 + Add to `pkg/appview/db/hold_store.go`: 569 + 570 + ```go 571 + // CrewMember represents a cached crew membership 572 + type 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 584 + func 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 599 + func 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) 607 + func 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 622 + type 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 635 + func 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 705 + func 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 732 + func 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 + 762 + The 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 + 787 + 1. **Users must know hold URLs** - Requires users to manually find and copy hold endpoint URLs 788 + 2. **No validation** - Users can enter invalid or inaccessible URLs 789 + 3. **No discovery** - Users don't know what holds are available to them 790 + 4. **Poor UX** - Text input is error-prone and unfriendly 791 + 5. **No membership visibility** - Users can't see which holds they're crew on 792 + 793 + ### Proposed Change: Dropdown with Discovered Holds 794 + 795 + Replace 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 + 887 + The 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 + 898 + Each 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 + 923 + The form now submits `hold_did` (a DID) instead of `hold_endpoint` (a URL): 924 + 925 + **Before:** 926 + ``` 927 + POST /api/profile/default-hold 928 + Content-Type: application/x-www-form-urlencoded 929 + 930 + hold_endpoint=https://hold01.atcr.io 931 + ``` 932 + 933 + **After:** 934 + ``` 935 + POST /api/profile/default-hold 936 + Content-Type: application/x-www-form-urlencoded 937 + 938 + hold_did=did:web:hold01.atcr.io 939 + ``` 940 + 941 + The `UpdateDefaultHoldHandler` needs to be updated to accept DIDs: 942 + 943 + ```go 944 + // pkg/appview/handlers/settings.go 945 + 946 + func (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 + 996 + Update the settings handler to include available holds: 997 + 998 + ```go 999 + // pkg/appview/handlers/settings.go 1000 + 1001 + func (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 + 1097 + type SettingsPageData struct { 1098 + Profile *atproto.SailorProfile 1099 + CurrentHoldDID string 1100 + OwnedHolds []HoldDisplay 1101 + CrewHolds []HoldDisplay 1102 + EligibleHolds []HoldDisplay 1103 + PublicHolds []HoldDisplay 1104 + } 1105 + 1106 + type HoldDisplay struct { 1107 + DID string 1108 + DisplayName string // Derived from DID or endpoint 1109 + Region string 1110 + Provider string 1111 + Permissions []string 1112 + } 1113 + 1114 + func (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 + 1143 + func 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 + 1166 + Add 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 + 1275 + Add 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 + 1335 + The handler needs to serialize hold data for the JavaScript: 1336 + 1337 + ```go 1338 + // pkg/appview/handlers/settings.go 1339 + 1340 + import "encoding/json" 1341 + 1342 + type 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 + 1351 + func (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 + 1387 + When 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 + 1408 + Allow 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 + 1426 + func (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 + 1452 + Jetstream 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 + 1461 + For cases where Jetstream may be delayed or missed events: 1462 + 1463 + ```go 1464 + // pkg/appview/handlers/settings.go 1465 + 1466 + func (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 + 1503 + Optionally, run periodic refresh of cached records: 1504 + 1505 + ```go 1506 + // pkg/appview/jetstream/backfill.go 1507 + 1508 + func (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 1553 + func 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 + 1563 + func 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 + 1578 + func 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 + 1691 + Add search/filter capabilities: 1692 + 1693 + ```sql 1694 + SELECT * FROM hold_captain_records 1695 + WHERE region LIKE ? 1696 + OR provider LIKE ? 1697 + ORDER BY ... 1698 + ``` 1699 + 1700 + ### Hold Recommendations 1701 + 1702 + Suggest holds based on: 1703 + - Geographic proximity (region matching) 1704 + - Provider preference 1705 + - Existing crew memberships 1706 + 1707 + ### Hold Statistics 1708 + 1709 + Display usage information: 1710 + - Storage used 1711 + - Number of images 1712 + - Number of crew members 1713 + - Uptime/availability 1714 + 1715 + ### Hold Comparison 1716 + 1717 + Side-by-side comparison of: 1718 + - Storage limits 1719 + - Supported features 1720 + - Geographic regions 1721 + - Pricing (if applicable)
+26 -1
docs/HOLD_XRPC_ENDPOINTS.md
··· 37 37 | `/xrpc/com.atproto.repo.deleteRecord` | POST | Delete a record | 38 38 | `/xrpc/com.atproto.repo.uploadBlob` | POST | Upload ATProto blob | 39 39 40 - ### DPoP Auth Required 40 + ### Auth Required (Service Token or DPoP) 41 41 42 42 | Endpoint | Method | Description | 43 43 |----------|--------|-------------| 44 44 | `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership | 45 + | `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) | 45 46 46 47 --- 47 48 ··· 60 61 61 62 --- 62 63 64 + ## ATCR Hold-Specific Endpoints (`io.atcr.hold.*`) 65 + 66 + | Endpoint | Method | Auth | Description | 67 + |----------|--------|------|-------------| 68 + | `/xrpc/io.atcr.hold.initiateUpload` | POST | blob:write | Start multipart upload | 69 + | `/xrpc/io.atcr.hold.getPartUploadUrl` | POST | blob:write | Get presigned URL for part | 70 + | `/xrpc/io.atcr.hold.uploadPart` | PUT | blob:write | Direct buffered part upload | 71 + | `/xrpc/io.atcr.hold.completeUpload` | POST | blob:write | Finalize multipart upload | 72 + | `/xrpc/io.atcr.hold.abortUpload` | POST | blob:write | Cancel multipart upload | 73 + | `/xrpc/io.atcr.hold.notifyManifest` | POST | blob:write | Notify manifest push | 74 + | `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership | 75 + | `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export | 76 + | `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info | 77 + 78 + --- 79 + 63 80 ## Standard ATProto Endpoints (excluding io.atcr.hold.*) 64 81 65 82 | Endpoint | ··· 82 99 | /xrpc/app.bsky.actor.getProfiles | 83 100 | /.well-known/did.json | 84 101 | /.well-known/atproto-did | 102 + 103 + --- 104 + 105 + ## See Also 106 + 107 + - [DIRECT_HOLD_ACCESS.md](./DIRECT_HOLD_ACCESS.md) - How to call hold endpoints directly without AppView (app passwords, curl examples) 108 + - [BYOS.md](./BYOS.md) - Bring Your Own Storage architecture 109 + - [OAUTH.md](./OAUTH.md) - OAuth + DPoP authentication details
-5
lexicons/io/atcr/hold/captain.json
··· 36 36 "type": "string", 37 37 "description": "S3 region where blobs are stored", 38 38 "maxLength": 64 39 - }, 40 - "provider": { 41 - "type": "string", 42 - "description": "Deployment provider (e.g., fly.io, aws, etc.)", 43 - "maxLength": 64 44 39 } 45 40 } 46 41 }
+393
pkg/appview/db/export.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "time" 7 + 8 + "atcr.io/pkg/atproto" 9 + ) 10 + 11 + // UserDataExport represents the GDPR-compliant data export for a user 12 + // Contains only data we originate, not cached PDS data 13 + type UserDataExport struct { 14 + ExportedAt time.Time `json:"exported_at"` 15 + ExportVersion string `json:"export_version"` 16 + DID string `json:"did"` 17 + Devices []DeviceExport `json:"devices"` 18 + OAuthSessions []OAuthSessionExport `json:"oauth_sessions"` 19 + UISessions []UISessionExport `json:"ui_sessions"` 20 + HoldMemberships HoldMembershipsExport `json:"hold_memberships"` 21 + KnownHolds KnownHoldsExport `json:"known_holds"` 22 + CachedDataNote CachedDataNote `json:"cached_data_note"` 23 + } 24 + 25 + // DeviceExport is a sanitized device record (no secret hash) 26 + type DeviceExport struct { 27 + ID string `json:"id"` 28 + Name string `json:"name"` 29 + IPAddress string `json:"ip_address"` 30 + Location string `json:"location,omitempty"` 31 + UserAgent string `json:"user_agent"` 32 + CreatedAt time.Time `json:"created_at"` 33 + LastUsed *time.Time `json:"last_used,omitempty"` 34 + } 35 + 36 + // OAuthSessionExport is a sanitized OAuth session record (no tokens) 37 + type OAuthSessionExport struct { 38 + SessionID string `json:"session_id"` 39 + CreatedAt time.Time `json:"created_at"` 40 + UpdatedAt time.Time `json:"updated_at"` 41 + } 42 + 43 + // UISessionExport is a sanitized UI session record 44 + type UISessionExport struct { 45 + ID string `json:"id"` 46 + ExpiresAt time.Time `json:"expires_at"` 47 + CreatedAt time.Time `json:"created_at"` 48 + } 49 + 50 + // HoldMembershipsExport contains hold approval and denial records 51 + type HoldMembershipsExport struct { 52 + Approvals []HoldApprovalExport `json:"approvals"` 53 + Denials []HoldDenialExport `json:"denials"` 54 + } 55 + 56 + // HoldApprovalExport represents a hold crew approval 57 + type HoldApprovalExport struct { 58 + HoldDID string `json:"hold_did"` 59 + ApprovedAt time.Time `json:"approved_at"` 60 + ExpiresAt time.Time `json:"expires_at"` 61 + } 62 + 63 + // HoldDenialExport represents a hold crew denial (rate limiting) 64 + type HoldDenialExport struct { 65 + HoldDID string `json:"hold_did"` 66 + DenialCount int `json:"denial_count"` 67 + NextRetryAt time.Time `json:"next_retry_at"` 68 + LastDeniedAt time.Time `json:"last_denied_at"` 69 + } 70 + 71 + // KnownHoldsExport lists holds where the user has interacted 72 + type KnownHoldsExport struct { 73 + Note string `json:"note"` 74 + Holds []KnownHoldExport `json:"holds"` 75 + } 76 + 77 + // KnownHoldExport represents a hold the user has interacted with 78 + type KnownHoldExport struct { 79 + HoldDID string `json:"hold_did"` 80 + Relationship string `json:"relationship"` // "captain", "crew_member" 81 + FirstSeen time.Time `json:"first_seen"` 82 + ExportEndpoint string `json:"export_endpoint"` 83 + } 84 + 85 + // CachedDataNote explains what cached data exists and how to access it 86 + type CachedDataNote struct { 87 + Message string `json:"message"` 88 + DeletionNotice string `json:"deletion_notice"` 89 + YourPDSCollections []string `json:"your_pds_collections"` 90 + HowToAccess string `json:"how_to_access"` 91 + } 92 + 93 + // ExportUserData gathers all user data for GDPR export 94 + // Only includes data we originate, not cached PDS data 95 + func ExportUserData(db *sql.DB, did string) (*UserDataExport, error) { 96 + export := &UserDataExport{ 97 + ExportedAt: time.Now().UTC(), 98 + ExportVersion: "1.0", 99 + DID: did, 100 + } 101 + 102 + // Get devices (sanitized - no secret hash) 103 + devices, err := getDevicesForExport(db, did) 104 + if err != nil { 105 + return nil, fmt.Errorf("failed to get devices: %w", err) 106 + } 107 + export.Devices = devices 108 + 109 + // Get OAuth sessions (sanitized - no tokens) 110 + oauthSessions, err := getOAuthSessionsForExport(db, did) 111 + if err != nil { 112 + return nil, fmt.Errorf("failed to get OAuth sessions: %w", err) 113 + } 114 + export.OAuthSessions = oauthSessions 115 + 116 + // Get UI sessions 117 + uiSessions, err := getUISessionsForExport(db, did) 118 + if err != nil { 119 + return nil, fmt.Errorf("failed to get UI sessions: %w", err) 120 + } 121 + export.UISessions = uiSessions 122 + 123 + // Get hold memberships (approvals and denials) 124 + memberships, err := getHoldMembershipsForExport(db, did) 125 + if err != nil { 126 + return nil, fmt.Errorf("failed to get hold memberships: %w", err) 127 + } 128 + export.HoldMemberships = memberships 129 + 130 + // Get known holds (where user is captain or crew) 131 + knownHolds, err := getKnownHoldsForExport(db, did) 132 + if err != nil { 133 + return nil, fmt.Errorf("failed to get known holds: %w", err) 134 + } 135 + export.KnownHolds = knownHolds 136 + 137 + // Add cached data note 138 + export.CachedDataNote = CachedDataNote{ 139 + Message: "We cache data from your PDS for performance. This cached data is NOT included in this export as it is under your direct control on your PDS.", 140 + DeletionNotice: "If you delete your account, ALL data including cached data will be permanently removed from our servers.", 141 + YourPDSCollections: []string{ 142 + "io.atcr.manifest - Your container image manifests", 143 + "io.atcr.tag - Your image tags", 144 + "io.atcr.sailor.profile - Your profile preferences", 145 + "io.atcr.sailor.star - Your starred repositories", 146 + "io.atcr.repo.page - Your repository pages (description, avatar)", 147 + }, 148 + HowToAccess: "Use your PDS provider's tools or ATProto client libraries to export this data directly.", 149 + } 150 + 151 + return export, nil 152 + } 153 + 154 + // getDevicesForExport retrieves sanitized device records 155 + func getDevicesForExport(db *sql.DB, did string) ([]DeviceExport, error) { 156 + rows, err := db.Query(` 157 + SELECT id, name, ip_address, location, user_agent, created_at, last_used 158 + FROM devices 159 + WHERE did = ? 160 + ORDER BY created_at DESC 161 + `, did) 162 + if err != nil { 163 + return nil, err 164 + } 165 + defer rows.Close() 166 + 167 + var devices []DeviceExport 168 + for rows.Next() { 169 + var d DeviceExport 170 + var lastUsed sql.NullTime 171 + var location sql.NullString 172 + 173 + err := rows.Scan(&d.ID, &d.Name, &d.IPAddress, &location, &d.UserAgent, &d.CreatedAt, &lastUsed) 174 + if err != nil { 175 + return nil, err 176 + } 177 + 178 + if lastUsed.Valid { 179 + d.LastUsed = &lastUsed.Time 180 + } 181 + if location.Valid { 182 + d.Location = location.String 183 + } 184 + 185 + devices = append(devices, d) 186 + } 187 + 188 + if devices == nil { 189 + devices = []DeviceExport{} 190 + } 191 + 192 + return devices, rows.Err() 193 + } 194 + 195 + // getOAuthSessionsForExport retrieves sanitized OAuth session records 196 + func getOAuthSessionsForExport(db *sql.DB, did string) ([]OAuthSessionExport, error) { 197 + rows, err := db.Query(` 198 + SELECT session_id, created_at, updated_at 199 + FROM oauth_sessions 200 + WHERE account_did = ? 201 + ORDER BY created_at DESC 202 + `, did) 203 + if err != nil { 204 + return nil, err 205 + } 206 + defer rows.Close() 207 + 208 + var sessions []OAuthSessionExport 209 + for rows.Next() { 210 + var s OAuthSessionExport 211 + err := rows.Scan(&s.SessionID, &s.CreatedAt, &s.UpdatedAt) 212 + if err != nil { 213 + return nil, err 214 + } 215 + sessions = append(sessions, s) 216 + } 217 + 218 + if sessions == nil { 219 + sessions = []OAuthSessionExport{} 220 + } 221 + 222 + return sessions, rows.Err() 223 + } 224 + 225 + // getUISessionsForExport retrieves sanitized UI session records 226 + func getUISessionsForExport(db *sql.DB, did string) ([]UISessionExport, error) { 227 + rows, err := db.Query(` 228 + SELECT id, expires_at, created_at 229 + FROM ui_sessions 230 + WHERE did = ? 231 + ORDER BY created_at DESC 232 + `, did) 233 + if err != nil { 234 + return nil, err 235 + } 236 + defer rows.Close() 237 + 238 + var sessions []UISessionExport 239 + for rows.Next() { 240 + var s UISessionExport 241 + err := rows.Scan(&s.ID, &s.ExpiresAt, &s.CreatedAt) 242 + if err != nil { 243 + return nil, err 244 + } 245 + sessions = append(sessions, s) 246 + } 247 + 248 + if sessions == nil { 249 + sessions = []UISessionExport{} 250 + } 251 + 252 + return sessions, rows.Err() 253 + } 254 + 255 + // getHoldMembershipsForExport retrieves hold approval and denial records 256 + func getHoldMembershipsForExport(db *sql.DB, did string) (HoldMembershipsExport, error) { 257 + memberships := HoldMembershipsExport{ 258 + Approvals: []HoldApprovalExport{}, 259 + Denials: []HoldDenialExport{}, 260 + } 261 + 262 + // Get approvals 263 + approvalRows, err := db.Query(` 264 + SELECT hold_did, approved_at, expires_at 265 + FROM hold_crew_approvals 266 + WHERE user_did = ? 267 + ORDER BY approved_at DESC 268 + `, did) 269 + if err != nil { 270 + return memberships, err 271 + } 272 + defer approvalRows.Close() 273 + 274 + for approvalRows.Next() { 275 + var a HoldApprovalExport 276 + err := approvalRows.Scan(&a.HoldDID, &a.ApprovedAt, &a.ExpiresAt) 277 + if err != nil { 278 + return memberships, err 279 + } 280 + memberships.Approvals = append(memberships.Approvals, a) 281 + } 282 + if err := approvalRows.Err(); err != nil { 283 + return memberships, err 284 + } 285 + 286 + // Get denials 287 + denialRows, err := db.Query(` 288 + SELECT hold_did, denial_count, next_retry_at, last_denied_at 289 + FROM hold_crew_denials 290 + WHERE user_did = ? 291 + ORDER BY last_denied_at DESC 292 + `, did) 293 + if err != nil { 294 + return memberships, err 295 + } 296 + defer denialRows.Close() 297 + 298 + for denialRows.Next() { 299 + var d HoldDenialExport 300 + err := denialRows.Scan(&d.HoldDID, &d.DenialCount, &d.NextRetryAt, &d.LastDeniedAt) 301 + if err != nil { 302 + return memberships, err 303 + } 304 + memberships.Denials = append(memberships.Denials, d) 305 + } 306 + 307 + return memberships, denialRows.Err() 308 + } 309 + 310 + // getKnownHoldsForExport retrieves holds where user is captain or crew member 311 + func getKnownHoldsForExport(db *sql.DB, did string) (KnownHoldsExport, error) { 312 + known := KnownHoldsExport{ 313 + Note: "Hold services where you have interacted. Each hold stores its own records about you. Contact each hold directly to export that data.", 314 + Holds: []KnownHoldExport{}, 315 + } 316 + 317 + // Get holds where user is captain 318 + captainRows, err := db.Query(` 319 + SELECT hold_did, updated_at 320 + FROM hold_captain_records 321 + WHERE owner_did = ? 322 + ORDER BY updated_at DESC 323 + `, did) 324 + if err != nil { 325 + return known, err 326 + } 327 + defer captainRows.Close() 328 + 329 + for captainRows.Next() { 330 + var holdDID string 331 + var updatedAt time.Time 332 + err := captainRows.Scan(&holdDID, &updatedAt) 333 + if err != nil { 334 + return known, err 335 + } 336 + known.Holds = append(known.Holds, KnownHoldExport{ 337 + HoldDID: holdDID, 338 + Relationship: "captain", 339 + FirstSeen: updatedAt, 340 + ExportEndpoint: resolveHoldExportEndpoint(holdDID), 341 + }) 342 + } 343 + if err := captainRows.Err(); err != nil { 344 + return known, err 345 + } 346 + 347 + // Get holds where user is crew member 348 + crewRows, err := db.Query(` 349 + SELECT hold_did, created_at 350 + FROM hold_crew_members 351 + WHERE member_did = ? 352 + ORDER BY created_at DESC 353 + `, did) 354 + if err != nil { 355 + return known, err 356 + } 357 + defer crewRows.Close() 358 + 359 + for crewRows.Next() { 360 + var holdDID string 361 + var createdAt time.Time 362 + err := crewRows.Scan(&holdDID, &createdAt) 363 + if err != nil { 364 + return known, err 365 + } 366 + 367 + // Check if already added as captain 368 + alreadyAdded := false 369 + for _, h := range known.Holds { 370 + if h.HoldDID == holdDID { 371 + alreadyAdded = true 372 + break 373 + } 374 + } 375 + 376 + if !alreadyAdded { 377 + known.Holds = append(known.Holds, KnownHoldExport{ 378 + HoldDID: holdDID, 379 + Relationship: "crew_member", 380 + FirstSeen: createdAt, 381 + ExportEndpoint: resolveHoldExportEndpoint(holdDID), 382 + }) 383 + } 384 + } 385 + 386 + return known, crewRows.Err() 387 + } 388 + 389 + // resolveHoldExportEndpoint converts a hold DID to its export endpoint URL 390 + // Uses the shared ResolveHoldURL for did:web resolution 391 + func resolveHoldExportEndpoint(holdDID string) string { 392 + return atproto.ResolveHoldURL(holdDID) + atproto.HoldExportUserData 393 + }
+272 -11
pkg/appview/db/hold_store.go
··· 14 14 AllowAllCrew bool `json:"allowAllCrew"` 15 15 DeployedAt string `json:"deployedAt"` 16 16 Region string `json:"region"` 17 - Provider string `json:"provider"` 18 17 UpdatedAt time.Time `json:"-"` // Set manually, not from JSON 19 18 } 20 19 ··· 23 22 func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) { 24 23 query := ` 25 24 SELECT hold_did, owner_did, public, allow_all_crew, 26 - deployed_at, region, provider, updated_at 25 + deployed_at, region, updated_at 27 26 FROM hold_captain_records 28 27 WHERE hold_did = ? 29 28 ` 30 29 31 30 var record HoldCaptainRecord 32 - var deployedAt, region, provider sql.NullString 31 + var deployedAt, region sql.NullString 33 32 34 33 err := db.QueryRow(query, holdDID).Scan( 35 34 &record.HoldDID, ··· 38 37 &record.AllowAllCrew, 39 38 &deployedAt, 40 39 &region, 41 - &provider, 42 40 &record.UpdatedAt, 43 41 ) 44 42 ··· 56 54 } 57 55 if region.Valid { 58 56 record.Region = region.String 59 - } 60 - if provider.Valid { 61 - record.Provider = provider.String 62 57 } 63 58 64 59 return &record, nil ··· 69 64 query := ` 70 65 INSERT INTO hold_captain_records ( 71 66 hold_did, owner_did, public, allow_all_crew, 72 - deployed_at, region, provider, updated_at 73 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 67 + deployed_at, region, updated_at 68 + ) VALUES (?, ?, ?, ?, ?, ?, ?) 74 69 ON CONFLICT(hold_did) DO UPDATE SET 75 70 owner_did = excluded.owner_did, 76 71 public = excluded.public, 77 72 allow_all_crew = excluded.allow_all_crew, 78 73 deployed_at = excluded.deployed_at, 79 74 region = excluded.region, 80 - provider = excluded.provider, 81 75 updated_at = excluded.updated_at 82 76 ` 83 77 ··· 88 82 record.AllowAllCrew, 89 83 nullString(record.DeployedAt), 90 84 nullString(record.Region), 91 - nullString(record.Provider), 92 85 record.UpdatedAt, 93 86 ) 94 87 ··· 136 129 } 137 130 return sql.NullString{String: s, Valid: true} 138 131 } 132 + 133 + // GetCaptainRecordsForOwner retrieves all captain records where the user is the owner 134 + // Used for GDPR export to find all holds owned by a user 135 + func GetCaptainRecordsForOwner(db *sql.DB, ownerDID string) ([]*HoldCaptainRecord, error) { 136 + query := ` 137 + SELECT hold_did, owner_did, public, allow_all_crew, 138 + deployed_at, region, updated_at 139 + FROM hold_captain_records 140 + WHERE owner_did = ? 141 + ORDER BY updated_at DESC 142 + ` 143 + 144 + rows, err := db.Query(query, ownerDID) 145 + if err != nil { 146 + return nil, fmt.Errorf("failed to query captain records for owner: %w", err) 147 + } 148 + defer rows.Close() 149 + 150 + var records []*HoldCaptainRecord 151 + for rows.Next() { 152 + var record HoldCaptainRecord 153 + var deployedAt, region sql.NullString 154 + 155 + err := rows.Scan( 156 + &record.HoldDID, 157 + &record.OwnerDID, 158 + &record.Public, 159 + &record.AllowAllCrew, 160 + &deployedAt, 161 + &region, 162 + &record.UpdatedAt, 163 + ) 164 + if err != nil { 165 + return nil, fmt.Errorf("failed to scan captain record: %w", err) 166 + } 167 + 168 + if deployedAt.Valid { 169 + record.DeployedAt = deployedAt.String 170 + } 171 + if region.Valid { 172 + record.Region = region.String 173 + } 174 + 175 + records = append(records, &record) 176 + } 177 + 178 + if err := rows.Err(); err != nil { 179 + return nil, fmt.Errorf("error iterating captain records: %w", err) 180 + } 181 + 182 + if records == nil { 183 + records = []*HoldCaptainRecord{} 184 + } 185 + 186 + return records, nil 187 + } 188 + 189 + // DeleteCaptainRecord removes a captain record from the cache 190 + func DeleteCaptainRecord(db *sql.DB, holdDID string) error { 191 + // Note: hold_crew_members doesn't have CASCADE, so delete crew first 192 + _, err := db.Exec(`DELETE FROM hold_crew_members WHERE hold_did = ?`, holdDID) 193 + if err != nil { 194 + return fmt.Errorf("failed to delete crew members for hold: %w", err) 195 + } 196 + 197 + _, err = db.Exec(`DELETE FROM hold_captain_records WHERE hold_did = ?`, holdDID) 198 + if err != nil { 199 + return fmt.Errorf("failed to delete captain record: %w", err) 200 + } 201 + return nil 202 + } 203 + 204 + // CrewMember represents a cached crew membership from Jetstream 205 + type CrewMember struct { 206 + HoldDID string 207 + MemberDID string 208 + Rkey string 209 + Role string 210 + Permissions string // JSON array 211 + Tier string 212 + AddedAt string 213 + CreatedAt time.Time 214 + UpdatedAt time.Time 215 + } 216 + 217 + // UpsertCrewMember inserts or updates a crew member record 218 + func UpsertCrewMember(db *sql.DB, member *CrewMember) error { 219 + query := ` 220 + INSERT INTO hold_crew_members ( 221 + hold_did, member_did, rkey, role, permissions, tier, added_at, updated_at 222 + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 223 + ON CONFLICT(hold_did, member_did) DO UPDATE SET 224 + rkey = excluded.rkey, 225 + role = excluded.role, 226 + permissions = excluded.permissions, 227 + tier = excluded.tier, 228 + added_at = excluded.added_at, 229 + updated_at = CURRENT_TIMESTAMP 230 + ` 231 + 232 + _, err := db.Exec(query, 233 + member.HoldDID, 234 + member.MemberDID, 235 + member.Rkey, 236 + nullString(member.Role), 237 + nullString(member.Permissions), 238 + nullString(member.Tier), 239 + nullString(member.AddedAt), 240 + ) 241 + 242 + if err != nil { 243 + return fmt.Errorf("failed to upsert crew member: %w", err) 244 + } 245 + return nil 246 + } 247 + 248 + // DeleteCrewMemberByRkey removes a crew member by rkey (for delete events from Jetstream) 249 + func DeleteCrewMemberByRkey(db *sql.DB, holdDID, rkey string) error { 250 + _, err := db.Exec(`DELETE FROM hold_crew_members WHERE hold_did = ? AND rkey = ?`, holdDID, rkey) 251 + if err != nil { 252 + return fmt.Errorf("failed to delete crew member by rkey: %w", err) 253 + } 254 + return nil 255 + } 256 + 257 + // AvailableHold represents a hold available to a user, with membership info 258 + type AvailableHold struct { 259 + HoldDID string 260 + OwnerDID string 261 + Public bool 262 + AllowAllCrew bool 263 + Region string 264 + Membership string // "owner", "crew", "eligible", "public" 265 + Permissions string // JSON array (only for crew) 266 + } 267 + 268 + // GetAvailableHolds returns all holds available to a user, grouped by membership type 269 + // Results are ordered: owner first, then crew, then eligible, then public 270 + func GetAvailableHolds(db *sql.DB, userDID string) ([]AvailableHold, error) { 271 + query := ` 272 + SELECT 273 + h.hold_did, 274 + h.owner_did, 275 + h.public, 276 + h.allow_all_crew, 277 + h.region, 278 + CASE 279 + WHEN h.owner_did = ?1 THEN 'owner' 280 + WHEN c.member_did IS NOT NULL THEN 'crew' 281 + WHEN h.allow_all_crew = 1 THEN 'eligible' 282 + WHEN h.public = 1 THEN 'public' 283 + ELSE 'none' 284 + END as membership, 285 + c.permissions 286 + FROM hold_captain_records h 287 + LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1 288 + WHERE h.public = 1 289 + OR h.allow_all_crew = 1 290 + OR h.owner_did = ?1 291 + OR c.member_did IS NOT NULL 292 + ORDER BY 293 + CASE 294 + WHEN h.owner_did = ?1 THEN 0 295 + WHEN c.member_did IS NOT NULL THEN 1 296 + WHEN h.allow_all_crew = 1 THEN 2 297 + ELSE 3 298 + END, 299 + h.hold_did 300 + ` 301 + 302 + rows, err := db.Query(query, userDID) 303 + if err != nil { 304 + return nil, fmt.Errorf("failed to query available holds: %w", err) 305 + } 306 + defer rows.Close() 307 + 308 + var holds []AvailableHold 309 + for rows.Next() { 310 + var hold AvailableHold 311 + var region, permissions sql.NullString 312 + 313 + err := rows.Scan( 314 + &hold.HoldDID, 315 + &hold.OwnerDID, 316 + &hold.Public, 317 + &hold.AllowAllCrew, 318 + &region, 319 + &hold.Membership, 320 + &permissions, 321 + ) 322 + if err != nil { 323 + return nil, fmt.Errorf("failed to scan available hold: %w", err) 324 + } 325 + 326 + if region.Valid { 327 + hold.Region = region.String 328 + } 329 + if permissions.Valid { 330 + hold.Permissions = permissions.String 331 + } 332 + 333 + holds = append(holds, hold) 334 + } 335 + 336 + if err := rows.Err(); err != nil { 337 + return nil, fmt.Errorf("error iterating available holds: %w", err) 338 + } 339 + 340 + return holds, nil 341 + } 342 + 343 + // GetCrewMemberships returns all holds where a user is a crew member 344 + func GetCrewMemberships(db *sql.DB, memberDID string) ([]CrewMember, error) { 345 + query := ` 346 + SELECT hold_did, member_did, rkey, role, permissions, tier, added_at, created_at, updated_at 347 + FROM hold_crew_members 348 + WHERE member_did = ? 349 + ORDER BY added_at DESC 350 + ` 351 + 352 + rows, err := db.Query(query, memberDID) 353 + if err != nil { 354 + return nil, fmt.Errorf("failed to query crew memberships: %w", err) 355 + } 356 + defer rows.Close() 357 + 358 + var memberships []CrewMember 359 + for rows.Next() { 360 + var m CrewMember 361 + var role, permissions, tier, addedAt sql.NullString 362 + 363 + err := rows.Scan( 364 + &m.HoldDID, 365 + &m.MemberDID, 366 + &m.Rkey, 367 + &role, 368 + &permissions, 369 + &tier, 370 + &addedAt, 371 + &m.CreatedAt, 372 + &m.UpdatedAt, 373 + ) 374 + if err != nil { 375 + return nil, fmt.Errorf("failed to scan crew membership: %w", err) 376 + } 377 + 378 + if role.Valid { 379 + m.Role = role.String 380 + } 381 + if permissions.Valid { 382 + m.Permissions = permissions.String 383 + } 384 + if tier.Valid { 385 + m.Tier = tier.String 386 + } 387 + if addedAt.Valid { 388 + m.AddedAt = addedAt.String 389 + } 390 + 391 + memberships = append(memberships, m) 392 + } 393 + 394 + if err := rows.Err(); err != nil { 395 + return nil, fmt.Errorf("error iterating crew memberships: %w", err) 396 + } 397 + 398 + return memberships, nil 399 + }
-14
pkg/appview/db/hold_store_test.go
··· 103 103 AllowAllCrew: false, 104 104 DeployedAt: "2025-01-15", 105 105 Region: "us-west-2", 106 - Provider: "aws", 107 106 UpdatedAt: time.Now(), 108 107 } 109 108 ··· 159 158 if record.Region != testRecord.Region { 160 159 t.Errorf("Region = %v, want %v", record.Region, testRecord.Region) 161 160 } 162 - if record.Provider != testRecord.Provider { 163 - t.Errorf("Provider = %v, want %v", record.Provider, testRecord.Provider) 164 - } 165 161 } else { 166 162 if record != nil { 167 163 t.Errorf("Expected nil, got record: %+v", record) ··· 183 179 AllowAllCrew: true, 184 180 DeployedAt: "", // Empty - should be NULL 185 181 Region: "", // Empty - should be NULL 186 - Provider: "", // Empty - should be NULL 187 182 UpdatedAt: time.Now(), 188 183 } 189 184 ··· 207 202 if record.Region != "" { 208 203 t.Errorf("Region = %v, want empty string", record.Region) 209 204 } 210 - if record.Provider != "" { 211 - t.Errorf("Provider = %v, want empty string", record.Provider) 212 - } 213 205 } 214 206 215 207 // TestUpsertCaptainRecord_Insert tests inserting new records ··· 223 215 AllowAllCrew: true, 224 216 DeployedAt: "2025-02-01", 225 217 Region: "eu-west-1", 226 - Provider: "gcp", 227 218 UpdatedAt: time.Now(), 228 219 } 229 220 ··· 262 253 AllowAllCrew: false, 263 254 DeployedAt: "2025-01-01", 264 255 Region: "us-east-1", 265 - Provider: "aws", 266 256 UpdatedAt: time.Now().Add(-1 * time.Hour), 267 257 } 268 258 ··· 279 269 AllowAllCrew: true, // Changed allow all crew 280 270 DeployedAt: "2025-03-01", // Changed date 281 271 Region: "ap-south-1", // Changed region 282 - Provider: "azure", // Changed provider 283 272 UpdatedAt: time.Now(), 284 273 } 285 274 ··· 312 301 } 313 302 if retrieved.Region != updatedRecord.Region { 314 303 t.Errorf("Region = %v, want %v", retrieved.Region, updatedRecord.Region) 315 - } 316 - if retrieved.Provider != updatedRecord.Provider { 317 - t.Errorf("Provider = %v, want %v", retrieved.Provider, updatedRecord.Provider) 318 304 } 319 305 320 306 // Verify there's still only one record in the database
+19
pkg/appview/db/migrations/0008_add_hold_crew_members.yaml
··· 1 + description: Add hold_crew_members table for cached crew memberships from Jetstream 2 + query: | 3 + -- Cached hold crew memberships from Jetstream 4 + -- Enables reverse lookup: "which holds is user X a member of?" 5 + CREATE TABLE IF NOT EXISTS hold_crew_members ( 6 + hold_did TEXT NOT NULL, 7 + member_did TEXT NOT NULL, 8 + rkey TEXT NOT NULL, 9 + role TEXT, 10 + permissions TEXT, -- JSON array 11 + tier TEXT, 12 + added_at TEXT, 13 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 14 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 15 + PRIMARY KEY (hold_did, member_did) 16 + ); 17 + CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did); 18 + CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did); 19 + CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
+18 -1
pkg/appview/db/schema.sql
··· 183 183 allow_all_crew BOOLEAN NOT NULL, 184 184 deployed_at TEXT, 185 185 region TEXT, 186 - provider TEXT, 187 186 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 188 187 ); 189 188 CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); ··· 206 205 PRIMARY KEY(hold_did, user_did) 207 206 ); 208 207 CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at); 208 + 209 + -- Cached hold crew memberships from Jetstream 210 + -- Enables reverse lookup: "which holds is user X a member of?" 211 + CREATE TABLE IF NOT EXISTS hold_crew_members ( 212 + hold_did TEXT NOT NULL, 213 + member_did TEXT NOT NULL, 214 + rkey TEXT NOT NULL, 215 + role TEXT, 216 + permissions TEXT, -- JSON array 217 + tier TEXT, 218 + added_at TEXT, 219 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 220 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 221 + PRIMARY KEY (hold_did, member_did) 222 + ); 223 + CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did); 224 + CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did); 225 + CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey); 209 226 210 227 CREATE TABLE IF NOT EXISTS repo_pages ( 211 228 did TEXT NOT NULL,
+230
pkg/appview/handlers/export.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "sync" 12 + "time" 13 + 14 + "atcr.io/pkg/appview/db" 15 + "atcr.io/pkg/appview/middleware" 16 + "atcr.io/pkg/atproto" 17 + "atcr.io/pkg/auth" 18 + "atcr.io/pkg/auth/oauth" 19 + ) 20 + 21 + // HoldExportResult represents the result of fetching export from a hold 22 + type HoldExportResult struct { 23 + HoldDID string `json:"hold_did"` 24 + Endpoint string `json:"endpoint"` 25 + Status string `json:"status"` // "success", "failed", "offline" 26 + Error string `json:"error,omitempty"` 27 + Data json.RawMessage `json:"data,omitempty"` // Raw JSON from hold 28 + } 29 + 30 + // FullUserDataExport represents the complete GDPR export including hold data 31 + type FullUserDataExport struct { 32 + AppViewData *db.UserDataExport `json:"appview_data"` 33 + HoldExports []HoldExportResult `json:"hold_exports"` 34 + } 35 + 36 + // ExportUserDataHandler handles GDPR data export requests 37 + type ExportUserDataHandler struct { 38 + DB *sql.DB 39 + Refresher *oauth.Refresher 40 + } 41 + 42 + func (h *ExportUserDataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 43 + // Get authenticated user from middleware 44 + user := middleware.GetUser(r) 45 + if user == nil { 46 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 47 + return 48 + } 49 + 50 + slog.Info("Processing data export request", "component", "export", "did", user.DID) 51 + 52 + // Export all user data from database 53 + appViewData, err := db.ExportUserData(h.DB, user.DID) 54 + if err != nil { 55 + slog.Error("Failed to export user data", "component", "export", "did", user.DID, "error", err) 56 + http.Error(w, "Failed to export data", http.StatusInternalServerError) 57 + return 58 + } 59 + 60 + // Get all holds where user is a member (from cached crew memberships) 61 + holdExports := h.fetchHoldExports(r.Context(), user) 62 + 63 + // Build full export 64 + fullExport := FullUserDataExport{ 65 + AppViewData: appViewData, 66 + HoldExports: holdExports, 67 + } 68 + 69 + // Set headers for file download 70 + filename := fmt.Sprintf("atcr-data-export-%s.json", time.Now().Format("2006-01-02")) 71 + w.Header().Set("Content-Type", "application/json") 72 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 73 + 74 + // Write JSON with indentation for readability 75 + encoder := json.NewEncoder(w) 76 + encoder.SetIndent("", " ") 77 + if err := encoder.Encode(fullExport); err != nil { 78 + slog.Error("Failed to encode export data", "component", "export", "did", user.DID, "error", err) 79 + // Can't send error response at this point, headers already sent 80 + return 81 + } 82 + 83 + slog.Info("Data export completed successfully", 84 + "component", "export", 85 + "did", user.DID, 86 + "hold_count", len(holdExports)) 87 + } 88 + 89 + // fetchHoldExports fetches export data from all holds where user is a member 90 + func (h *ExportUserDataHandler) fetchHoldExports(ctx context.Context, user *db.User) []HoldExportResult { 91 + var results []HoldExportResult 92 + 93 + // Get crew memberships from database 94 + memberships, err := db.GetCrewMemberships(h.DB, user.DID) 95 + if err != nil { 96 + slog.Warn("Failed to get crew memberships for export", 97 + "component", "export", 98 + "did", user.DID, 99 + "error", err) 100 + return results 101 + } 102 + 103 + if len(memberships) == 0 { 104 + return results 105 + } 106 + 107 + // Collect unique hold DIDs 108 + holdDIDs := make(map[string]bool) 109 + for _, m := range memberships { 110 + holdDIDs[m.HoldDID] = true 111 + } 112 + 113 + // Also check captain records (holds owned by user) 114 + if h.DB != nil { 115 + captainHolds, err := db.GetCaptainRecordsForOwner(h.DB, user.DID) 116 + if err == nil { 117 + for _, hold := range captainHolds { 118 + holdDIDs[hold.HoldDID] = true 119 + } 120 + } 121 + } 122 + 123 + // Fetch from each hold concurrently with timeout 124 + var wg sync.WaitGroup 125 + resultChan := make(chan HoldExportResult, len(holdDIDs)) 126 + 127 + for holdDID := range holdDIDs { 128 + wg.Add(1) 129 + go func(holdDID string) { 130 + defer wg.Done() 131 + result := h.fetchSingleHoldExport(ctx, user, holdDID) 132 + resultChan <- result 133 + }(holdDID) 134 + } 135 + 136 + // Wait for all goroutines to complete 137 + wg.Wait() 138 + close(resultChan) 139 + 140 + // Collect results 141 + for result := range resultChan { 142 + results = append(results, result) 143 + } 144 + 145 + return results 146 + } 147 + 148 + // fetchSingleHoldExport fetches export data from a single hold 149 + func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string) HoldExportResult { 150 + // Resolve hold DID to URL 151 + holdURL := atproto.ResolveHoldURL(holdDID) 152 + endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData" 153 + 154 + result := HoldExportResult{ 155 + HoldDID: holdDID, 156 + Endpoint: endpoint, 157 + Status: "failed", 158 + } 159 + 160 + // Check if we have OAuth refresher (needed for service tokens) 161 + if h.Refresher == nil { 162 + result.Error = "OAuth not configured - cannot authenticate to hold" 163 + return result 164 + } 165 + 166 + // Create context with timeout (5 seconds per hold) 167 + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 168 + defer cancel() 169 + 170 + // Get service token from user's PDS 171 + serviceToken, err := auth.GetOrFetchServiceToken(timeoutCtx, h.Refresher, user.DID, holdDID, user.PDSEndpoint) 172 + if err != nil { 173 + slog.Warn("Failed to get service token for hold export", 174 + "component", "export", 175 + "hold_did", holdDID, 176 + "user_did", user.DID, 177 + "error", err) 178 + result.Error = fmt.Sprintf("Failed to authenticate: %v", err) 179 + return result 180 + } 181 + 182 + // Create request 183 + req, err := http.NewRequestWithContext(timeoutCtx, "GET", endpoint, nil) 184 + if err != nil { 185 + result.Error = fmt.Sprintf("Failed to create request: %v", err) 186 + return result 187 + } 188 + 189 + // Set auth header 190 + req.Header.Set("Authorization", "Bearer "+serviceToken) 191 + 192 + // Make request 193 + resp, err := http.DefaultClient.Do(req) 194 + if err != nil { 195 + slog.Warn("Hold export request failed", 196 + "component", "export", 197 + "hold_did", holdDID, 198 + "endpoint", endpoint, 199 + "error", err) 200 + result.Status = "offline" 201 + result.Error = fmt.Sprintf("Could not contact hold. Please request export directly at: %s", endpoint) 202 + return result 203 + } 204 + defer resp.Body.Close() 205 + 206 + // Check response status 207 + if resp.StatusCode != http.StatusOK { 208 + body, _ := io.ReadAll(resp.Body) 209 + result.Error = fmt.Sprintf("Hold returned status %d: %s", resp.StatusCode, string(body)) 210 + return result 211 + } 212 + 213 + // Read response body 214 + body, err := io.ReadAll(resp.Body) 215 + if err != nil { 216 + result.Error = fmt.Sprintf("Failed to read response: %v", err) 217 + return result 218 + } 219 + 220 + // Store raw JSON data 221 + result.Status = "success" 222 + result.Data = json.RawMessage(body) 223 + 224 + slog.Debug("Successfully fetched hold export", 225 + "component", "export", 226 + "hold_did", holdDID, 227 + "user_did", user.DID) 228 + 229 + return result 230 + }
+153 -7
pkg/appview/handlers/settings.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "database/sql" 5 + "encoding/json" 4 6 "html/template" 5 7 "log/slog" 6 8 "net/http" 9 + "net/url" 10 + "strings" 7 11 "time" 8 12 13 + "atcr.io/pkg/appview/db" 9 14 "atcr.io/pkg/appview/middleware" 10 15 "atcr.io/pkg/appview/storage" 11 16 "atcr.io/pkg/atproto" 12 17 "atcr.io/pkg/auth/oauth" 13 18 ) 14 19 20 + // HoldDisplay represents a hold for display in the UI 21 + type HoldDisplay struct { 22 + DID string `json:"did"` 23 + DisplayName string `json:"displayName"` 24 + Region string `json:"region"` 25 + Membership string `json:"membership"` 26 + Permissions []string `json:"permissions,omitempty"` 27 + } 28 + 15 29 // SettingsHandler handles the settings page 16 30 type SettingsHandler struct { 17 - Templates *template.Template 18 - Refresher *oauth.Refresher 19 - RegistryURL string 31 + Templates *template.Template 32 + Refresher *oauth.Refresher 33 + RegistryURL string 34 + DB *sql.DB 35 + DefaultHoldDID string 20 36 } 21 37 22 38 func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 47 63 48 64 slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold) 49 65 66 + // Get available holds for dropdown 67 + var ownedHolds, crewHolds, eligibleHolds, publicHolds []HoldDisplay 68 + holdDataMap := make(map[string]HoldDisplay) 69 + 70 + if h.DB != nil { 71 + availableHolds, err := db.GetAvailableHolds(h.DB, user.DID) 72 + if err != nil { 73 + slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err) 74 + } else { 75 + // Group holds by membership type 76 + for _, hold := range availableHolds { 77 + display := HoldDisplay{ 78 + DID: hold.HoldDID, 79 + DisplayName: deriveDisplayName(hold.HoldDID), 80 + Region: hold.Region, 81 + Membership: hold.Membership, 82 + } 83 + 84 + // Parse permissions JSON if present 85 + if hold.Permissions != "" { 86 + json.Unmarshal([]byte(hold.Permissions), &display.Permissions) 87 + } 88 + 89 + // Add to data map for JavaScript 90 + holdDataMap[hold.HoldDID] = display 91 + 92 + // Group by membership type 93 + switch hold.Membership { 94 + case "owner": 95 + ownedHolds = append(ownedHolds, display) 96 + case "crew": 97 + crewHolds = append(crewHolds, display) 98 + case "eligible": 99 + eligibleHolds = append(eligibleHolds, display) 100 + case "public": 101 + publicHolds = append(publicHolds, display) 102 + } 103 + } 104 + } 105 + } 106 + 107 + // Serialize hold data for JavaScript 108 + holdDataJSON, _ := json.Marshal(holdDataMap) 109 + 110 + // Check if current hold needs to be shown separately (not in discovered holds) 111 + _, currentHoldDiscovered := holdDataMap[profile.DefaultHold] 112 + showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered 113 + 114 + // Look up AppView default hold details from database 115 + appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID) 116 + var appViewDefaultRegion string 117 + if h.DefaultHoldDID != "" && h.DB != nil { 118 + if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil { 119 + appViewDefaultRegion = captain.Region 120 + } 121 + } 122 + 50 123 data := struct { 51 124 PageData 52 125 Profile struct { ··· 55 128 PDSEndpoint string 56 129 DefaultHold string 57 130 } 131 + CurrentHoldDID string 132 + CurrentHoldDisplay string 133 + ShowCurrentHold bool 134 + AppViewDefaultHoldDisplay string 135 + AppViewDefaultRegion string 136 + OwnedHolds []HoldDisplay 137 + CrewHolds []HoldDisplay 138 + EligibleHolds []HoldDisplay 139 + PublicHolds []HoldDisplay 140 + HoldDataJSON template.JS 58 141 }{ 59 - PageData: NewPageData(r, h.RegistryURL), 142 + PageData: NewPageData(r, h.RegistryURL), 143 + CurrentHoldDID: profile.DefaultHold, 144 + CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold), 145 + ShowCurrentHold: showCurrentHold, 146 + AppViewDefaultHoldDisplay: appViewDefaultDisplay, 147 + AppViewDefaultRegion: appViewDefaultRegion, 148 + OwnedHolds: ownedHolds, 149 + CrewHolds: crewHolds, 150 + EligibleHolds: eligibleHolds, 151 + PublicHolds: publicHolds, 152 + HoldDataJSON: template.JS(holdDataJSON), 60 153 } 61 154 62 155 data.Profile.Handle = user.Handle ··· 70 163 } 71 164 } 72 165 166 + // deriveDisplayName derives a human-readable name from a hold DID 167 + func deriveDisplayName(did string) string { 168 + // For did:web, extract the domain 169 + if strings.HasPrefix(did, "did:web:") { 170 + domain := strings.TrimPrefix(did, "did:web:") 171 + // URL-decode the domain (did:web encodes : as %3A) 172 + decoded, err := url.QueryUnescape(domain) 173 + if err == nil { 174 + return decoded 175 + } 176 + return domain 177 + } 178 + 179 + // For did:plc, truncate for display 180 + if len(did) > 24 { 181 + return did[:24] + "..." 182 + } 183 + return did 184 + } 185 + 73 186 // UpdateDefaultHoldHandler handles updating the default hold 74 187 type UpdateDefaultHoldHandler struct { 75 188 Refresher *oauth.Refresher 76 189 Templates *template.Template 190 + DB *sql.DB 77 191 } 78 192 79 193 func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 83 197 return 84 198 } 85 199 86 - holdEndpoint := r.FormValue("hold_endpoint") 200 + // Accept hold_did (new dropdown) or hold_endpoint (legacy text input) 201 + holdDID := r.FormValue("hold_did") 202 + if holdDID == "" { 203 + holdDID = r.FormValue("hold_endpoint") 204 + } 205 + 206 + // Validate hold DID if provided and database is available 207 + if holdDID != "" && h.DB != nil { 208 + // Check if user has access to this hold 209 + availableHolds, err := db.GetAvailableHolds(h.DB, user.DID) 210 + if err != nil { 211 + slog.Warn("Failed to validate hold access", "component", "settings", "did", user.DID, "error", err) 212 + // Don't block - fall through to allow the update 213 + } else { 214 + hasAccess := false 215 + for _, hold := range availableHolds { 216 + if hold.HoldDID == holdDID { 217 + hasAccess = true 218 + break 219 + } 220 + } 221 + 222 + if !hasAccess { 223 + w.Header().Set("Content-Type", "text/html") 224 + h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 225 + "Class": "error", 226 + "Icon": "alert-circle", 227 + "Message": "You don't have access to this hold", 228 + }) 229 + return 230 + } 231 + } 232 + } 87 233 88 234 // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) 89 235 client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) ··· 92 238 profile, err := storage.GetProfile(r.Context(), client) 93 239 if err != nil || profile == nil { 94 240 // Profile doesn't exist, create new one 95 - profile = atproto.NewSailorProfileRecord(holdEndpoint) 241 + profile = atproto.NewSailorProfileRecord(holdDID) 96 242 } else { 97 243 // Update existing profile 98 - profile.DefaultHold = holdEndpoint 244 + profile.DefaultHold = holdDID 99 245 profile.UpdatedAt = time.Now() 100 246 } 101 247
+75 -2
pkg/appview/jetstream/backfill.go
··· 61 61 func (b *BackfillWorker) Start(ctx context.Context) error { 62 62 slog.Info("Backfill: Starting sync-based backfill...") 63 63 64 - // First, query and cache the default hold's captain record 64 + // First, query and cache the default hold's captain and crew records 65 + // This is necessary for localhost/private holds not discoverable via relay 65 66 if b.defaultHoldDID != "" { 66 - slog.Info("Backfill querying default hold captain record", "hold_did", b.defaultHoldDID) 67 + slog.Info("Backfill querying default hold records", "hold_did", b.defaultHoldDID) 67 68 if err := b.queryCaptainRecord(ctx, b.defaultHoldDID); err != nil { 68 69 slog.Warn("Backfill failed to query default hold captain record", "error", err) 69 70 // Don't fail the whole backfill - just warn 70 71 } 72 + if err := b.queryCrewRecords(ctx, b.defaultHoldDID); err != nil { 73 + slog.Warn("Backfill failed to query default hold crew records", "error", err) 74 + // Don't fail the whole backfill - just warn 75 + } 71 76 } 72 77 73 78 collections := []string{ ··· 77 82 atproto.SailorProfileCollection, // io.atcr.sailor.profile 78 83 atproto.RepoPageCollection, // io.atcr.repo.page 79 84 atproto.StatsCollection, // io.atcr.hold.stats (from holds) 85 + atproto.CaptainCollection, // io.atcr.hold.captain (from holds) 86 + atproto.CrewCollection, // io.atcr.hold.crew (from holds) 80 87 } 81 88 82 89 for _, collection := range collections { ··· 316 323 // Stats are stored in hold PDSes, not user PDSes 317 324 // 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io) 318 325 return b.processor.ProcessStats(ctx, did, record.Value, false) 326 + case atproto.CaptainCollection: 327 + // Captain records are stored in hold PDSes 328 + // 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io) 329 + return b.processor.ProcessCaptain(ctx, did, record.Value) 330 + case atproto.CrewCollection: 331 + // Crew records are stored in hold PDSes 332 + // 'did' here is the hold's DID, rkey is derived from member DID 333 + // Extract rkey from record URI (at://did/collection/rkey) 334 + rkey := extractRkeyFromURI(record.URI) 335 + return b.processor.ProcessCrew(ctx, did, rkey, record.Value) 319 336 default: 320 337 return fmt.Errorf("unsupported collection: %s", collection) 321 338 } ··· 391 408 return nil 392 409 } 393 410 411 + // queryCrewRecords queries a hold's crew records and caches them in the database 412 + // This is necessary for localhost/private holds that aren't discoverable via the relay 413 + func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error { 414 + // Resolve hold DID to URL 415 + holdURL := atproto.ResolveHoldURL(holdDID) 416 + 417 + // Create client for hold's PDS 418 + holdClient := atproto.NewClient(holdURL, holdDID, "") 419 + 420 + var cursor string 421 + recordCount := 0 422 + 423 + // Paginate through all crew records 424 + for { 425 + records, nextCursor, err := holdClient.ListRecordsForRepo(ctx, holdDID, atproto.CrewCollection, 100, cursor) 426 + if err != nil { 427 + // If no crew records exist, that's okay 428 + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "RecordNotFound") { 429 + slog.Debug("No crew records found for hold", "hold_did", holdDID) 430 + return nil 431 + } 432 + return fmt.Errorf("failed to list crew records: %w", err) 433 + } 434 + 435 + for _, record := range records { 436 + rkey := extractRkeyFromURI(record.URI) 437 + if err := b.processor.ProcessCrew(ctx, holdDID, rkey, record.Value); err != nil { 438 + slog.Warn("Backfill failed to process crew record", "hold_did", holdDID, "uri", record.URI, "error", err) 439 + continue 440 + } 441 + recordCount++ 442 + } 443 + 444 + if nextCursor == "" { 445 + break 446 + } 447 + cursor = nextCursor 448 + } 449 + 450 + if recordCount > 0 { 451 + slog.Info("Backfill cached crew records for hold", "hold_did", holdDID, "count", recordCount) 452 + } 453 + return nil 454 + } 455 + 394 456 // reconcileAnnotations ensures annotations come from the newest manifest in each repository 395 457 // This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations 396 458 func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error { ··· 635 697 636 698 return nil 637 699 } 700 + 701 + // extractRkeyFromURI extracts the rkey from an AT-URI 702 + // Format: at://did/collection/rkey 703 + func extractRkeyFromURI(uri string) string { 704 + // URI format: at://did/collection/rkey 705 + parts := strings.Split(uri, "/") 706 + if len(parts) >= 5 { 707 + return parts[4] 708 + } 709 + return "" 710 + }
+78
pkg/appview/jetstream/processor.go
··· 433 433 }) 434 434 } 435 435 436 + // ProcessCaptain handles captain record events from hold PDSes 437 + // This is called when Jetstream receives a captain create/update/delete event from a hold 438 + // The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info 439 + func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordData []byte) error { 440 + // Unmarshal captain record 441 + var captainRecord atproto.CaptainRecord 442 + if err := json.Unmarshal(recordData, &captainRecord); err != nil { 443 + return fmt.Errorf("failed to unmarshal captain record: %w", err) 444 + } 445 + 446 + // Convert to db struct and upsert 447 + record := &db.HoldCaptainRecord{ 448 + HoldDID: holdDID, 449 + OwnerDID: captainRecord.Owner, 450 + Public: captainRecord.Public, 451 + AllowAllCrew: captainRecord.AllowAllCrew, 452 + DeployedAt: captainRecord.DeployedAt, 453 + Region: captainRecord.Region, 454 + UpdatedAt: time.Now(), 455 + } 456 + 457 + if err := db.UpsertCaptainRecord(p.db, record); err != nil { 458 + return fmt.Errorf("failed to upsert captain record: %w", err) 459 + } 460 + 461 + slog.Info("Processed captain record", 462 + "component", "processor", 463 + "hold_did", holdDID, 464 + "owner_did", captainRecord.Owner, 465 + "public", captainRecord.Public, 466 + "allow_all_crew", captainRecord.AllowAllCrew) 467 + 468 + return nil 469 + } 470 + 471 + // ProcessCrew handles crew record events from hold PDSes 472 + // This is called when Jetstream receives a crew create/update/delete event from a hold 473 + // The holdDID is the DID of the hold PDS (event.DID), and the record contains member info 474 + func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string, recordData []byte) error { 475 + // Unmarshal crew record 476 + var crewRecord atproto.CrewRecord 477 + if err := json.Unmarshal(recordData, &crewRecord); err != nil { 478 + return fmt.Errorf("failed to unmarshal crew record: %w", err) 479 + } 480 + 481 + // Marshal permissions to JSON string 482 + permissionsJSON := "" 483 + if len(crewRecord.Permissions) > 0 { 484 + if jsonBytes, err := json.Marshal(crewRecord.Permissions); err == nil { 485 + permissionsJSON = string(jsonBytes) 486 + } 487 + } 488 + 489 + // Convert to db struct and upsert 490 + member := &db.CrewMember{ 491 + HoldDID: holdDID, 492 + MemberDID: crewRecord.Member, 493 + Rkey: rkey, 494 + Role: crewRecord.Role, 495 + Permissions: permissionsJSON, 496 + Tier: crewRecord.Tier, 497 + AddedAt: crewRecord.AddedAt, 498 + } 499 + 500 + if err := db.UpsertCrewMember(p.db, member); err != nil { 501 + return fmt.Errorf("failed to upsert crew member: %w", err) 502 + } 503 + 504 + slog.Debug("Processed crew record", 505 + "component", "processor", 506 + "hold_did", holdDID, 507 + "member_did", crewRecord.Member, 508 + "role", crewRecord.Role, 509 + "permissions", crewRecord.Permissions) 510 + 511 + return nil 512 + } 513 + 436 514 // ProcessAccount handles account status events (deactivation/deletion/etc) 437 515 // This is called when Jetstream receives an account event indicating status changes. 438 516 //
+62
pkg/appview/jetstream/worker.go
··· 326 326 case atproto.StatsCollection: 327 327 slog.Info("Jetstream processing stats event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 328 328 return w.processStats(commit) 329 + case atproto.CaptainCollection: 330 + slog.Info("Jetstream processing captain event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 331 + return w.processCaptain(commit) 332 + case atproto.CrewCollection: 333 + slog.Info("Jetstream processing crew event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 334 + return w.processCrew(commit) 329 335 default: 330 336 // Ignore other collections 331 337 return nil ··· 512 518 513 519 // Use shared processor - commit.DID is the hold's DID 514 520 return w.processor.ProcessStats(context.Background(), commit.DID, recordBytes, false) 521 + } 522 + 523 + // processCaptain processes a captain record event from a hold's PDS 524 + func (w *Worker) processCaptain(commit *CommitEvent) error { 525 + holdDID := commit.DID // The repo DID IS the hold DID 526 + 527 + if commit.Operation == "delete" { 528 + // Delete captain record - this cascades to crew members 529 + if err := db.DeleteCaptainRecord(w.db, holdDID); err != nil { 530 + return fmt.Errorf("failed to delete captain record: %w", err) 531 + } 532 + slog.Info("Deleted captain record for hold", "hold_did", holdDID) 533 + return nil 534 + } 535 + 536 + // Parse captain record 537 + if commit.Record == nil { 538 + return nil 539 + } 540 + 541 + // Marshal map to bytes for processing 542 + recordBytes, err := json.Marshal(commit.Record) 543 + if err != nil { 544 + return fmt.Errorf("failed to marshal captain record: %w", err) 545 + } 546 + 547 + // Use shared processor 548 + return w.processor.ProcessCaptain(context.Background(), holdDID, recordBytes) 549 + } 550 + 551 + // processCrew processes a crew record event from a hold's PDS 552 + func (w *Worker) processCrew(commit *CommitEvent) error { 553 + holdDID := commit.DID // The repo DID IS the hold DID 554 + 555 + if commit.Operation == "delete" { 556 + // Delete crew member by rkey 557 + if err := db.DeleteCrewMemberByRkey(w.db, holdDID, commit.RKey); err != nil { 558 + return fmt.Errorf("failed to delete crew member: %w", err) 559 + } 560 + slog.Info("Deleted crew member from hold", "hold_did", holdDID, "rkey", commit.RKey) 561 + return nil 562 + } 563 + 564 + // Parse crew record 565 + if commit.Record == nil { 566 + return nil 567 + } 568 + 569 + // Marshal map to bytes for processing 570 + recordBytes, err := json.Marshal(commit.Record) 571 + if err != nil { 572 + return fmt.Errorf("failed to marshal crew record: %w", err) 573 + } 574 + 575 + // Use shared processor - pass rkey for storage 576 + return w.processor.ProcessCrew(context.Background(), holdDID, commit.RKey, recordBytes) 515 577 } 516 578 517 579 // processIdentity processes an identity event (handle change)
+13 -3
pkg/appview/routes/routes.go
··· 29 29 HealthChecker *holdhealth.Checker 30 30 ReadmeFetcher *readme.Fetcher 31 31 Templates *template.Template 32 + DefaultHoldDID string 32 33 } 33 34 34 35 // RegisterUIRoutes registers all web UI and API routes on the provided router ··· 185 186 r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database)) 186 187 187 188 r.Get("/settings", (&uihandlers.SettingsHandler{ 188 - Templates: deps.Templates, 189 - Refresher: deps.Refresher, 190 - RegistryURL: registryURL, 189 + Templates: deps.Templates, 190 + Refresher: deps.Refresher, 191 + RegistryURL: registryURL, 192 + DB: deps.Database, 193 + DefaultHoldDID: deps.DefaultHoldDID, 191 194 }).ServeHTTP) 192 195 193 196 r.Get("/api/storage", (&uihandlers.StorageHandler{ ··· 198 201 r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{ 199 202 Refresher: deps.Refresher, 200 203 Templates: deps.Templates, 204 + DB: deps.Database, 201 205 }).ServeHTTP) 202 206 203 207 r.Delete("/api/images/{repository}/tags/{tag}", (&uihandlers.DeleteTagHandler{ ··· 235 239 r.Delete("/api/devices/{id}", (&uihandlers.RevokeDeviceHandler{ 236 240 Store: deps.DeviceStore, 237 241 SessionStore: deps.SessionStore, 242 + }).ServeHTTP) 243 + 244 + // GDPR data export 245 + r.Get("/api/export-data", (&uihandlers.ExportUserDataHandler{ 246 + DB: deps.Database, 247 + Refresher: deps.Refresher, 238 248 }).ServeHTTP) 239 249 }) 240 250
+278 -19
pkg/appview/templates/pages/settings.html
··· 39 39 </section> 40 40 41 41 <!-- Default Hold Section --> 42 - <section class="settings-section"> 42 + <section class="settings-section hold-section"> 43 43 <h2>Default Hold</h2> 44 - <p>Current: <strong id="current-hold">{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p> 44 + <p class="help-text">Select where your container images will be stored.</p> 45 45 46 46 <form hx-post="/api/profile/default-hold" 47 47 hx-target="#hold-status" ··· 49 49 id="hold-form"> 50 50 51 51 <div class="form-group"> 52 - <label for="hold-endpoint">Hold Endpoint:</label> 53 - <input type="text" 54 - id="hold-endpoint" 55 - name="hold_endpoint" 56 - value="{{ .Profile.DefaultHold }}" 57 - placeholder="https://hold.example.com" /> 58 - <small>Leave empty to use AppView default storage</small> 52 + <label for="default-hold">Storage Hold:</label> 53 + <div class="select-wrapper"> 54 + <select id="default-hold" name="hold_did" class="form-select"> 55 + <option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option> 56 + 57 + {{ if .ShowCurrentHold }} 58 + <option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option> 59 + {{ end }} 60 + 61 + {{ if .OwnedHolds }} 62 + <optgroup label="Your Holds"> 63 + {{ range .OwnedHolds }} 64 + <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 65 + {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 66 + </option> 67 + {{ end }} 68 + </optgroup> 69 + {{ end }} 70 + 71 + {{ if .CrewHolds }} 72 + <optgroup label="Crew Member"> 73 + {{ range .CrewHolds }} 74 + <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 75 + {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 76 + </option> 77 + {{ end }} 78 + </optgroup> 79 + {{ end }} 80 + 81 + {{ if .EligibleHolds }} 82 + <optgroup label="Open Registration"> 83 + {{ range .EligibleHolds }} 84 + <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 85 + {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 86 + </option> 87 + {{ end }} 88 + </optgroup> 89 + {{ end }} 90 + 91 + {{ if .PublicHolds }} 92 + <optgroup label="Public Holds"> 93 + {{ range .PublicHolds }} 94 + <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 95 + {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 96 + </option> 97 + {{ end }} 98 + </optgroup> 99 + {{ end }} 100 + </select> 101 + <i data-lucide="chevron-down" class="select-icon"></i> 102 + </div> 103 + <small>Your images will be stored on the selected hold</small> 59 104 </div> 60 105 61 106 <button type="submit" class="btn-primary">Save</button> 62 107 </form> 63 108 64 109 <div id="hold-status"></div> 110 + 111 + <!-- Hold details panel (shows when hold selected) --> 112 + <div id="hold-details" class="hold-details" style="display: none;"> 113 + <h3>Hold Details</h3> 114 + <dl> 115 + <dt>DID:</dt> 116 + <dd id="hold-did"></dd> 117 + <dt>Region:</dt> 118 + <dd id="hold-region"></dd> 119 + <dt>Your Access:</dt> 120 + <dd id="hold-access"></dd> 121 + </dl> 122 + </div> 123 + 65 124 </section> 66 125 67 126 <!-- Authorized Devices Section --> ··· 114 173 </table> 115 174 </div> 116 175 </section> 176 + 177 + <!-- Data Privacy Section --> 178 + <section class="settings-section privacy-section"> 179 + <h2>Data Privacy</h2> 180 + <p>Download a copy of all data we store about you.</p> 181 + 182 + <div class="privacy-actions"> 183 + <a href="/api/export-data" class="btn-secondary" download> 184 + <i data-lucide="download"></i> 185 + Export All My Data 186 + </a> 187 + </div> 188 + 189 + <p class="privacy-note"> 190 + <small> 191 + This includes your authorized devices, sessions, and hold memberships. 192 + Data stored on your PDS is already under your control. 193 + See our <a href="/privacy">Privacy Policy</a> for details. 194 + </small> 195 + </p> 196 + </section> 117 197 </div> 118 198 </main> 119 199 120 200 <script> 121 - // Default Hold Update - Dynamic display update 201 + // Hold data from server (for details panel) 202 + const holdData = {{ .HoldDataJSON }}; 203 + 204 + // Hold Selection and Details Display 122 205 document.addEventListener('DOMContentLoaded', function() { 206 + const holdSelect = document.getElementById('default-hold'); 207 + const holdDetails = document.getElementById('hold-details'); 123 208 const holdForm = document.getElementById('hold-form'); 124 209 125 - holdForm.addEventListener('htmx:afterSwap', function(event) { 126 - // Check if the response contains success indicator 127 - if (event.detail.xhr.status === 200) { 128 - const holdInput = document.getElementById('hold-endpoint'); 129 - const currentHoldDisplay = document.getElementById('current-hold'); 130 - const newValue = holdInput.value.trim(); 210 + if (holdSelect) { 211 + holdSelect.addEventListener('change', function() { 212 + const selectedDID = this.value; 131 213 132 - // Update the current hold display 133 - currentHoldDisplay.textContent = newValue || 'Not set'; 214 + if (!selectedDID || !holdData[selectedDID]) { 215 + holdDetails.style.display = 'none'; 216 + return; 217 + } 218 + 219 + const hold = holdData[selectedDID]; 220 + 221 + document.getElementById('hold-did').textContent = hold.did; 222 + document.getElementById('hold-region').textContent = hold.region || 'Unknown'; 223 + 224 + // Set access level with badge 225 + const accessEl = document.getElementById('hold-access'); 226 + const accessLabel = { 227 + 'owner': 'Owner (Full Control)', 228 + 'crew': 'Crew Member', 229 + 'eligible': 'Open Registration', 230 + 'public': 'Public Access' 231 + }[hold.membership] || hold.membership; 232 + 233 + const accessClass = 'access-' + hold.membership; 234 + accessEl.innerHTML = '<span class="access-badge ' + accessClass + '">' + accessLabel + '</span>'; 235 + 236 + // Show permissions for crew members 237 + if (hold.membership === 'crew' && hold.permissions && hold.permissions.length > 0) { 238 + accessEl.innerHTML += '<br><small>Permissions: ' + hold.permissions.join(', ') + '</small>'; 239 + } 240 + 241 + holdDetails.style.display = 'block'; 242 + }); 243 + 244 + // Trigger on page load if a hold is already selected 245 + if (holdSelect.value) { 246 + holdSelect.dispatchEvent(new Event('change')); 134 247 } 135 - }); 248 + } 249 + 250 + // HTMX success handler 251 + if (holdForm) { 252 + holdForm.addEventListener('htmx:afterSwap', function(event) { 253 + if (event.detail.xhr.status === 200) { 254 + // Reinitialize Lucide icons if any were added 255 + if (typeof lucide !== 'undefined') { 256 + lucide.createIcons(); 257 + } 258 + } 259 + }); 260 + } 136 261 }); 137 262 138 263 // Device Management JavaScript ··· 398 523 } 399 524 .devices-list { 400 525 margin-top: 2rem; 526 + } 527 + 528 + /* Hold Selection Styles */ 529 + .hold-section .select-wrapper { 530 + position: relative; 531 + display: block; 532 + } 533 + .hold-section .form-select { 534 + width: 100%; 535 + padding: 0.75rem 2.5rem 0.75rem 0.75rem; 536 + font-size: 1rem; 537 + border: 1px solid var(--border); 538 + border-radius: 4px; 539 + background: var(--bg); 540 + color: var(--fg); 541 + cursor: pointer; 542 + appearance: none; 543 + -webkit-appearance: none; 544 + -moz-appearance: none; 545 + } 546 + .hold-section .select-icon { 547 + position: absolute; 548 + right: 0.75rem; 549 + top: 50%; 550 + transform: translateY(-50%); 551 + width: 1.25rem; 552 + height: 1.25rem; 553 + color: var(--fg-muted); 554 + pointer-events: none; 555 + } 556 + .hold-section .form-select:focus { 557 + outline: none; 558 + border-color: var(--primary); 559 + box-shadow: 0 0 0 2px var(--primary-bg, rgba(59, 130, 246, 0.1)); 560 + } 561 + .hold-section .form-select:focus + .select-icon { 562 + color: var(--primary); 563 + } 564 + .hold-section .form-select optgroup { 565 + font-weight: bold; 566 + color: var(--fg-muted); 567 + padding-top: 0.5rem; 568 + } 569 + .hold-section .form-select option { 570 + padding: 0.5rem; 571 + font-weight: normal; 572 + color: var(--fg); 573 + } 574 + 575 + /* Hold Details Panel */ 576 + .hold-details { 577 + margin-top: 1rem; 578 + padding: 1rem; 579 + background: var(--code-bg); 580 + border-radius: 4px; 581 + border: 1px solid var(--border); 582 + } 583 + .hold-details h3 { 584 + margin-top: 0; 585 + margin-bottom: 0.75rem; 586 + font-size: 0.9rem; 587 + color: var(--fg-muted); 588 + text-transform: uppercase; 589 + letter-spacing: 0.05em; 590 + } 591 + .hold-details dl { 592 + display: grid; 593 + grid-template-columns: auto 1fr; 594 + gap: 0.5rem 1rem; 595 + margin: 0; 596 + } 597 + .hold-details dt { 598 + color: var(--fg-muted); 599 + font-weight: 500; 600 + } 601 + .hold-details dd { 602 + margin: 0; 603 + font-family: monospace; 604 + word-break: break-all; 605 + } 606 + 607 + /* Access Level Badges */ 608 + .access-badge { 609 + display: inline-block; 610 + padding: 0.125rem 0.5rem; 611 + border-radius: 4px; 612 + font-size: 0.85rem; 613 + font-weight: 500; 614 + } 615 + .access-owner { 616 + background: #fef3c7; 617 + color: #92400e; 618 + } 619 + .access-crew { 620 + background: #dcfce7; 621 + color: #166534; 622 + } 623 + .access-eligible { 624 + background: #e0e7ff; 625 + color: #3730a3; 626 + } 627 + .access-public { 628 + background: #f3f4f6; 629 + color: #374151; 630 + } 631 + 632 + /* Privacy Section Styles */ 633 + .privacy-section .privacy-actions { 634 + margin: 1rem 0; 635 + } 636 + .privacy-section .btn-secondary { 637 + display: inline-flex; 638 + align-items: center; 639 + gap: 0.5rem; 640 + padding: 0.75rem 1.5rem; 641 + background: var(--code-bg); 642 + color: var(--fg); 643 + border: 1px solid var(--border); 644 + border-radius: 4px; 645 + text-decoration: none; 646 + font-weight: 500; 647 + transition: background 0.2s, border-color 0.2s; 648 + } 649 + .privacy-section .btn-secondary:hover { 650 + background: var(--border); 651 + border-color: var(--fg-muted); 652 + } 653 + .privacy-section .privacy-note { 654 + color: var(--fg-muted); 655 + margin-top: 1rem; 656 + } 657 + .privacy-section .privacy-note a { 658 + color: var(--primary); 659 + text-decoration: underline; 401 660 } 402 661 </style> 403 662 </body>
+1 -42
pkg/atproto/cbor_gen.go
··· 342 342 } 343 343 344 344 cw := cbg.NewCborWriter(w) 345 - fieldCount := 8 345 + fieldCount := 7 346 346 347 347 if t.Region == "" { 348 - fieldCount-- 349 - } 350 - 351 - if t.Provider == "" { 352 348 fieldCount-- 353 349 } 354 350 ··· 440 436 return err 441 437 } 442 438 if _, err := cw.WriteString(string(t.Region)); err != nil { 443 - return err 444 - } 445 - } 446 - 447 - // t.Provider (string) (string) 448 - if t.Provider != "" { 449 - 450 - if len("provider") > 8192 { 451 - return xerrors.Errorf("Value in field \"provider\" was too long") 452 - } 453 - 454 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil { 455 - return err 456 - } 457 - if _, err := cw.WriteString(string("provider")); err != nil { 458 - return err 459 - } 460 - 461 - if len(t.Provider) > 8192 { 462 - return xerrors.Errorf("Value in field t.Provider was too long") 463 - } 464 - 465 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil { 466 - return err 467 - } 468 - if _, err := cw.WriteString(string(t.Provider)); err != nil { 469 439 return err 470 440 } 471 441 } ··· 618 588 } 619 589 620 590 t.Region = string(sval) 621 - } 622 - // t.Provider (string) (string) 623 - case "provider": 624 - 625 - { 626 - sval, err := cbg.ReadStringWithMax(cr, 8192) 627 - if err != nil { 628 - return err 629 - } 630 - 631 - t.Provider = string(sval) 632 591 } 633 592 // t.DeployedAt (string) (string) 634 593 case "deployedAt":
+6
pkg/atproto/endpoints.go
··· 57 57 // Query: userDid={did} 58 58 // Response: {"userDid": "...", "uniqueBlobs": 10, "totalSize": 1073741824} 59 59 HoldGetQuota = "/xrpc/io.atcr.hold.getQuota" 60 + 61 + // HoldExportUserData exports all user data from a hold service (GDPR compliance). 62 + // Method: GET 63 + // Query: userDid={did} 64 + // Response: JSON containing all user data stored by the hold 65 + HoldExportUserData = "/xrpc/io.atcr.hold.exportUserData" 60 66 ) 61 67 62 68 // Hold service crew management endpoints (io.atcr.hold.*)
+1 -2
pkg/atproto/lexicon.go
··· 580 580 AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 581 581 EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 582 582 DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 583 - Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) 584 - Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) 583 + Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional) 585 584 } 586 585 587 586 // CrewRecord represents a crew member in the hold
+4 -4
pkg/auth/hold_local_test.go
··· 43 43 if err != nil { 44 44 panic(err) 45 45 } 46 - err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "") 46 + err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "", "") 47 47 if err != nil { 48 48 panic(err) 49 49 } ··· 54 54 if err != nil { 55 55 panic(err) 56 56 } 57 - err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "") 57 + err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "", "") 58 58 if err != nil { 59 59 panic(err) 60 60 } ··· 65 65 if err != nil { 66 66 panic(err) 67 67 } 68 - err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "") 68 + err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "", "") 69 69 if err != nil { 70 70 panic(err) 71 71 } ··· 93 93 94 94 // Bootstrap with owner if provided 95 95 if ownerDID != "" { 96 - err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "") 96 + err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "") 97 97 if err != nil { 98 98 t.Fatalf("Failed to bootstrap HoldPDS: %v", err) 99 99 }
+4 -12
pkg/auth/hold_remote.go
··· 144 144 // getCachedCaptainRecord retrieves a captain record from database cache 145 145 func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainRecordWithMeta, error) { 146 146 query := ` 147 - SELECT owner_did, public, allow_all_crew, deployed_at, region, provider, updated_at 147 + SELECT owner_did, public, allow_all_crew, deployed_at, region, updated_at 148 148 FROM hold_captain_records 149 149 WHERE hold_did = ? 150 150 ` 151 151 152 152 var record atproto.CaptainRecord 153 - var deployedAt, region, provider sql.NullString 153 + var deployedAt, region sql.NullString 154 154 var updatedAt time.Time 155 155 156 156 err := a.db.QueryRow(query, holdDID).Scan( ··· 159 159 &record.AllowAllCrew, 160 160 &deployedAt, 161 161 &region, 162 - &provider, 163 162 &updatedAt, 164 163 ) 165 164 ··· 177 176 } 178 177 if region.Valid { 179 178 record.Region = region.String 180 - } 181 - if provider.Valid { 182 - record.Provider = provider.String 183 179 } 184 180 185 181 return &captainRecordWithMeta{ ··· 193 189 query := ` 194 190 INSERT INTO hold_captain_records ( 195 191 hold_did, owner_did, public, allow_all_crew, 196 - deployed_at, region, provider, updated_at 197 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 192 + deployed_at, region, updated_at 193 + ) VALUES (?, ?, ?, ?, ?, ?, ?) 198 194 ON CONFLICT(hold_did) DO UPDATE SET 199 195 owner_did = excluded.owner_did, 200 196 public = excluded.public, 201 197 allow_all_crew = excluded.allow_all_crew, 202 198 deployed_at = excluded.deployed_at, 203 199 region = excluded.region, 204 - provider = excluded.provider, 205 200 updated_at = excluded.updated_at 206 201 ` 207 202 ··· 212 207 record.AllowAllCrew, 213 208 nullString(record.DeployedAt), 214 209 nullString(record.Region), 215 - nullString(record.Provider), 216 210 time.Now(), 217 211 ) 218 212 ··· 256 250 AllowAllCrew bool `json:"allowAllCrew"` 257 251 DeployedAt string `json:"deployedAt"` 258 252 Region string `json:"region,omitempty"` 259 - Provider string `json:"provider,omitempty"` 260 253 } `json:"value"` 261 254 } 262 255 ··· 272 265 AllowAllCrew: xrpcResp.Value.AllowAllCrew, 273 266 DeployedAt: xrpcResp.Value.DeployedAt, 274 267 Region: xrpcResp.Value.Region, 275 - Provider: xrpcResp.Value.Provider, 276 268 } 277 269 278 270 return record, nil
-1
pkg/auth/hold_remote_test.go
··· 129 129 AllowAllCrew: false, 130 130 DeployedAt: "2025-10-28T00:00:00Z", 131 131 Region: "us-east-1", 132 - Provider: "fly.io", 133 132 } 134 133 135 134 err := remote.setCachedCaptainRecord(holdDID, captainRecord)
+18
pkg/hold/config.go
··· 7 7 8 8 import ( 9 9 "bytes" 10 + "context" 10 11 "encoding/json" 11 12 "fmt" 13 + "log/slog" 12 14 "net/http" 13 15 "net/url" 14 16 "os" ··· 54 56 // If true, creates posts when users push images 55 57 // Synced to captain record's enableBlueskyPosts field on startup 56 58 EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"` 59 + 60 + // Region is the deployment region, auto-detected from cloud metadata or S3 config 61 + Region string `yaml:"region"` 57 62 } 58 63 59 64 // StorageConfig wraps distribution's storage configuration ··· 148 153 // Admin panel configuration 149 154 cfg.Admin.Enabled = os.Getenv("HOLD_ADMIN_ENABLED") == "true" 150 155 156 + // Detect region from cloud metadata or S3 config 157 + if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil { 158 + cfg.Registration.Region = meta.Region 159 + slog.Info("Detected cloud metadata", "region", meta.Region) 160 + } else { 161 + // Fall back to S3 region 162 + if storageType == "s3" { 163 + cfg.Registration.Region = getEnvOrDefault("AWS_REGION", "us-east-1") 164 + slog.Info("Using S3 region", "region", cfg.Registration.Region) 165 + } 166 + } 167 + 151 168 return cfg, nil 152 169 } 153 170 ··· 199 216 } 200 217 return defaultValue 201 218 } 219 + 202 220 203 221 // RequestCrawl sends a crawl request to the ATProto relay for the given hostname. 204 222 // This makes the hold's PDS discoverable by the relay network.
+65
pkg/hold/metadata.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "time" 9 + ) 10 + 11 + // CloudMetadata contains region info from cloud metadata service 12 + type CloudMetadata struct { 13 + Region string 14 + } 15 + 16 + // DetectCloudMetadata queries the instance metadata service (169.254.169.254) 17 + // Currently supports UpCloud. Others can be added via PR. 18 + func DetectCloudMetadata(ctx context.Context) (*CloudMetadata, error) { 19 + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) 20 + defer cancel() 21 + 22 + // Try UpCloud metadata format 23 + if meta, err := detectUpCloud(ctx); err == nil { 24 + return meta, nil 25 + } 26 + 27 + // Add other providers here (AWS, GCP, Azure, DigitalOcean, etc.) 28 + // Contributors welcome! 29 + 30 + return nil, nil // No metadata available 31 + } 32 + 33 + // detectUpCloud queries UpCloud's metadata service 34 + func detectUpCloud(ctx context.Context) (*CloudMetadata, error) { 35 + req, err := http.NewRequestWithContext(ctx, "GET", "http://169.254.169.254/metadata/v1.json", nil) 36 + if err != nil { 37 + return nil, err 38 + } 39 + 40 + resp, err := http.DefaultClient.Do(req) 41 + if err != nil { 42 + return nil, err 43 + } 44 + defer resp.Body.Close() 45 + 46 + if resp.StatusCode != 200 { 47 + return nil, fmt.Errorf("metadata returned %d", resp.StatusCode) 48 + } 49 + 50 + var data struct { 51 + CloudName string `json:"cloud_name"` 52 + Region string `json:"region"` 53 + } 54 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 55 + return nil, err 56 + } 57 + 58 + if data.CloudName != "upcloud" { 59 + return nil, fmt.Errorf("not upcloud: %s", data.CloudName) 60 + } 61 + 62 + return &CloudMetadata{ 63 + Region: data.Region, 64 + }, nil 65 + }
+1 -1
pkg/hold/oci/xrpc_test.go
··· 111 111 r, w, _ := os.Pipe() 112 112 os.Stdout = w 113 113 114 - err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "") 114 + err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 115 115 116 116 // Restore stdout 117 117 w.Close()
+2 -1
pkg/hold/pds/captain.go
··· 17 17 18 18 // CreateCaptainRecord creates the captain record for the hold (first-time only). 19 19 // This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify. 20 - func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) { 20 + func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool, region string) (cid.Cid, error) { 21 21 captainRecord := &atproto.CaptainRecord{ 22 22 Type: atproto.CaptainCollection, 23 23 Owner: ownerDID, ··· 25 25 AllowAllCrew: allowAllCrew, 26 26 EnableBlueskyPosts: enableBlueskyPosts, 27 27 DeployedAt: time.Now().Format(time.RFC3339), 28 + Region: region, 28 29 } 29 30 30 31 // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
+4 -9
pkg/hold/pds/captain_test.go
··· 55 55 r, w, _ := os.Pipe() 56 56 os.Stdout = w 57 57 58 - err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "") 58 + err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "") 59 59 60 60 w.Close() 61 61 os.Stdout = oldStdout ··· 114 114 defer pds.Close() 115 115 116 116 // Create captain record 117 - recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts) 117 + recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts, "") 118 118 if err != nil { 119 119 t.Fatalf("CreateCaptainRecord failed: %v", err) 120 120 } ··· 164 164 ownerDID := "did:plc:alice123" 165 165 166 166 // Create captain record 167 - createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false) 167 + createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "") 168 168 if err != nil { 169 169 t.Fatalf("CreateCaptainRecord failed: %v", err) 170 170 } ··· 221 221 ownerDID := "did:plc:alice123" 222 222 223 223 // Create initial captain record (public=false, allowAllCrew=false, enableBlueskyPosts=false) 224 - _, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false) 224 + _, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false, "") 225 225 if err != nil { 226 226 t.Fatalf("CreateCaptainRecord failed: %v", err) 227 227 } ··· 343 343 AllowAllCrew: true, 344 344 DeployedAt: "2025-10-16T12:00:00Z", 345 345 Region: "us-west-2", 346 - Provider: "fly.io", 347 346 }, 348 347 }, 349 348 { ··· 355 354 AllowAllCrew: true, 356 355 DeployedAt: "2025-10-16T12:00:00Z", 357 356 Region: "", 358 - Provider: "", 359 357 }, 360 358 }, 361 359 } ··· 399 397 } 400 398 if decoded.Region != tt.record.Region { 401 399 t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region) 402 - } 403 - if decoded.Provider != tt.record.Provider { 404 - t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider) 405 400 } 406 401 }) 407 402 }
+82
pkg/hold/pds/layer.go
··· 212 212 213 213 return "" 214 214 } 215 + 216 + // ListLayerRecordsForUser returns all layer records uploaded by a specific user 217 + // Used for GDPR data export to return all layers a user has pushed to this hold 218 + func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) ([]*atproto.LayerRecord, error) { 219 + if p.recordsIndex == nil { 220 + return nil, fmt.Errorf("records index not available") 221 + } 222 + 223 + // Get session for reading record data 224 + session, err := p.carstore.ReadOnlySession(p.uid) 225 + if err != nil { 226 + return nil, fmt.Errorf("failed to create session: %w", err) 227 + } 228 + 229 + head, err := p.carstore.GetUserRepoHead(ctx, p.uid) 230 + if err != nil { 231 + return nil, fmt.Errorf("failed to get repo head: %w", err) 232 + } 233 + 234 + if !head.Defined() { 235 + // Empty repo - return empty list 236 + return []*atproto.LayerRecord{}, nil 237 + } 238 + 239 + repoHandle, err := repo.OpenRepo(ctx, session, head) 240 + if err != nil { 241 + return nil, fmt.Errorf("failed to open repo: %w", err) 242 + } 243 + 244 + var records []*atproto.LayerRecord 245 + 246 + // Iterate all layer records via the index 247 + cursor := "" 248 + batchSize := 1000 // Process in batches 249 + 250 + for { 251 + indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true) 252 + if err != nil { 253 + return nil, fmt.Errorf("failed to list layer records: %w", err) 254 + } 255 + 256 + for _, rec := range indexRecords { 257 + // Construct record path and get the record data 258 + recordPath := rec.Collection + "/" + rec.Rkey 259 + 260 + _, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath) 261 + if err != nil { 262 + // Skip records we can't read 263 + continue 264 + } 265 + 266 + // Decode the layer record 267 + recordValue, err := lexutil.CborDecodeValue(*recBytes) 268 + if err != nil { 269 + continue 270 + } 271 + 272 + layerRecord, ok := recordValue.(*atproto.LayerRecord) 273 + if !ok { 274 + continue 275 + } 276 + 277 + // Filter by userDID 278 + if layerRecord.UserDID != userDID { 279 + continue 280 + } 281 + 282 + records = append(records, layerRecord) 283 + } 284 + 285 + if nextCursor == "" { 286 + break 287 + } 288 + cursor = nextCursor 289 + } 290 + 291 + if records == nil { 292 + records = []*atproto.LayerRecord{} 293 + } 294 + 295 + return records, nil 296 + }
+1 -1
pkg/hold/pds/layer_test.go
··· 308 308 } 309 309 310 310 // Bootstrap with owner 311 - if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { 311 + if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { 312 312 t.Fatalf("Failed to bootstrap PDS: %v", err) 313 313 } 314 314
+4 -3
pkg/hold/pds/server.go
··· 153 153 } 154 154 155 155 // Bootstrap initializes the hold with the captain record, owner as first crew member, and profile 156 - func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL string) error { 156 + func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error { 157 157 if ownerDID == "" { 158 158 return nil 159 159 } ··· 185 185 } 186 186 187 187 // Create captain record (hold ownership and settings) 188 - _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts) 188 + _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region) 189 189 if err != nil { 190 190 return fmt.Errorf("failed to create captain record: %w", err) 191 191 } ··· 193 193 slog.Info("Created captain record", 194 194 "public", public, 195 195 "allowAllCrew", allowAllCrew, 196 - "enableBlueskyPosts", p.enableBlueskyPosts) 196 + "enableBlueskyPosts", p.enableBlueskyPosts, 197 + "region", region) 197 198 198 199 // Add hold owner as first crew member with admin role 199 200 _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
+13 -13
pkg/hold/pds/server_test.go
··· 69 69 70 70 // Bootstrap with a captain record 71 71 ownerDID := "did:plc:owner123" 72 - if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { 72 + if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { 73 73 t.Fatalf("Bootstrap failed: %v", err) 74 74 } 75 75 ··· 129 129 publicAccess := true 130 130 allowAllCrew := false 131 131 132 - err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "") 132 + err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "") 133 133 if err != nil { 134 134 t.Fatalf("Bootstrap failed: %v", err) 135 135 } ··· 204 204 ownerDID := "did:plc:alice123" 205 205 206 206 // First bootstrap 207 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 207 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 208 208 if err != nil { 209 209 t.Fatalf("First bootstrap failed: %v", err) 210 210 } ··· 223 223 crewCount1 := len(crew1) 224 224 225 225 // Second bootstrap (should be idempotent - skip creation) 226 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 226 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 227 227 if err != nil { 228 228 t.Fatalf("Second bootstrap failed: %v", err) 229 229 } ··· 268 268 defer pds.Close() 269 269 270 270 // Bootstrap with empty owner DID (should be no-op) 271 - err = pds.Bootstrap(ctx, nil, "", true, false, "") 271 + err = pds.Bootstrap(ctx, nil, "", true, false, "", "") 272 272 if err != nil { 273 273 t.Fatalf("Bootstrap with empty owner should not error: %v", err) 274 274 } ··· 302 302 303 303 // Bootstrap to create captain record 304 304 ownerDID := "did:plc:alice123" 305 - if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { 305 + if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { 306 306 t.Fatalf("Bootstrap failed: %v", err) 307 307 } 308 308 ··· 355 355 publicAccess := true 356 356 allowAllCrew := false 357 357 358 - err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "") 358 + err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "") 359 359 if err != nil { 360 360 t.Fatalf("Bootstrap failed with did:web owner: %v", err) 361 361 } ··· 414 414 415 415 // Bootstrap with did:plc owner 416 416 plcOwner := "did:plc:alice123" 417 - err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "") 417 + err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "") 418 418 if err != nil { 419 419 t.Fatalf("Bootstrap failed: %v", err) 420 420 } ··· 509 509 } 510 510 511 511 // Bootstrap should create captain record 512 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 512 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 513 513 if err != nil { 514 514 t.Fatalf("Bootstrap failed: %v", err) 515 515 } ··· 559 559 560 560 // Create captain record WITHOUT crew (unusual state) 561 561 ownerDID := "did:plc:alice123" 562 - _, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false) 562 + _, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "") 563 563 if err != nil { 564 564 t.Fatalf("CreateCaptainRecord failed: %v", err) 565 565 } ··· 584 584 585 585 // Bootstrap should be idempotent but notice missing crew 586 586 // Currently Bootstrap skips if captain exists, so crew won't be added 587 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 587 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 588 588 if err != nil { 589 589 t.Fatalf("Bootstrap failed: %v", err) 590 590 } ··· 856 856 857 857 // Bootstrap to create some records in MST (captain + crew) 858 858 ownerDID := "did:plc:testowner" 859 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 859 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 860 860 if err != nil { 861 861 t.Fatalf("Bootstrap failed: %v", err) 862 862 } ··· 921 921 defer pds.Close() 922 922 923 923 // Bootstrap to create records 924 - err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "") 924 + err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "") 925 925 if err != nil { 926 926 t.Fatalf("Bootstrap failed: %v", err) 927 927 }
+23
pkg/hold/pds/stats.go
··· 216 216 217 217 return stats, nil 218 218 } 219 + 220 + // ListStatsRecordsForUser returns all stats records where the user is the repository owner 221 + // Used for GDPR data export to return all stats for repositories owned by the user 222 + func (p *HoldPDS) ListStatsRecordsForUser(ctx context.Context, userDID string) ([]*atproto.StatsRecord, error) { 223 + // Get all stats records and filter by ownerDID 224 + allStats, err := p.ListStats(ctx) 225 + if err != nil { 226 + return nil, err 227 + } 228 + 229 + var userStats []*atproto.StatsRecord 230 + for _, stat := range allStats { 231 + if stat.OwnerDID == userDID { 232 + userStats = append(userStats, stat) 233 + } 234 + } 235 + 236 + if userStats == nil { 237 + userStats = []*atproto.StatsRecord{} 238 + } 239 + 240 + return userStats, nil 241 + }
+1 -1
pkg/hold/pds/status_test.go
··· 277 277 278 278 // Bootstrap once 279 279 ownerDID := "did:plc:testowner123" 280 - err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "") 280 + err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "") 281 281 if err != nil { 282 282 panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err)) 283 283 }
+138
pkg/hold/pds/xrpc.go
··· 195 195 r.Group(func(r chi.Router) { 196 196 r.Use(h.requireAuth) 197 197 r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew) 198 + // GDPR data export endpoint (TODO: implement) 199 + r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData) 198 200 }) 199 201 200 202 // Public quota endpoint (no auth - quota is per-user, just needs userDid param) ··· 1492 1494 1493 1495 render.JSON(w, r, stats) 1494 1496 } 1497 + 1498 + // HoldUserDataExport represents the GDPR data export from a hold service 1499 + type HoldUserDataExport struct { 1500 + ExportedAt time.Time `json:"exported_at"` 1501 + HoldDID string `json:"hold_did"` 1502 + UserDID string `json:"user_did"` 1503 + IsCaptain bool `json:"is_captain"` 1504 + CrewRecord *CrewExport `json:"crew_record,omitempty"` 1505 + LayerRecords []LayerExport `json:"layer_records"` 1506 + StatsRecords []StatsExport `json:"stats_records"` 1507 + } 1508 + 1509 + // CrewExport represents a sanitized crew record for export 1510 + type CrewExport struct { 1511 + Role string `json:"role"` 1512 + Permissions []string `json:"permissions"` 1513 + Tier string `json:"tier,omitempty"` 1514 + AddedAt string `json:"added_at"` 1515 + } 1516 + 1517 + // LayerExport represents a layer record for export 1518 + type LayerExport struct { 1519 + Digest string `json:"digest"` 1520 + Size int64 `json:"size"` 1521 + MediaType string `json:"media_type"` 1522 + Manifest string `json:"manifest"` 1523 + CreatedAt string `json:"created_at"` 1524 + } 1525 + 1526 + // StatsExport represents a stats record for export 1527 + type StatsExport struct { 1528 + Repository string `json:"repository"` 1529 + PullCount int64 `json:"pull_count"` 1530 + PushCount int64 `json:"push_count"` 1531 + LastPull string `json:"last_pull,omitempty"` 1532 + LastPush string `json:"last_push,omitempty"` 1533 + UpdatedAt string `json:"updated_at"` 1534 + } 1535 + 1536 + // HandleExportUserData handles GDPR data export requests for a specific user. 1537 + // This endpoint returns all records stored on this hold's PDS that reference 1538 + // the authenticated user's DID. 1539 + // 1540 + // Returns: 1541 + // - io.atcr.hold.layer records where userDid matches 1542 + // - io.atcr.hold.crew record for the DID (if exists) 1543 + // - io.atcr.hold.stats records where ownerDid matches 1544 + // - Whether the user is the hold captain 1545 + // 1546 + // Authentication: Requires valid service token from user's PDS 1547 + func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Request) { 1548 + // Get authenticated user from context 1549 + user := getUserFromContext(r) 1550 + if user == nil { 1551 + http.Error(w, "authentication required", http.StatusUnauthorized) 1552 + return 1553 + } 1554 + 1555 + slog.Info("GDPR data export requested", 1556 + "requester_did", user.DID, 1557 + "hold_did", h.pds.DID()) 1558 + 1559 + export := HoldUserDataExport{ 1560 + ExportedAt: time.Now().UTC(), 1561 + HoldDID: h.pds.DID(), 1562 + UserDID: user.DID, 1563 + LayerRecords: []LayerExport{}, 1564 + StatsRecords: []StatsExport{}, 1565 + } 1566 + 1567 + // Check if user is captain 1568 + _, captain, err := h.pds.GetCaptainRecord(r.Context()) 1569 + if err == nil && captain != nil && captain.Owner == user.DID { 1570 + export.IsCaptain = true 1571 + } 1572 + 1573 + // Get crew record for user 1574 + _, crewRecord, err := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 1575 + if err == nil && crewRecord != nil { 1576 + export.CrewRecord = &CrewExport{ 1577 + Role: crewRecord.Role, 1578 + Permissions: crewRecord.Permissions, 1579 + Tier: crewRecord.Tier, 1580 + AddedAt: crewRecord.AddedAt, 1581 + } 1582 + } 1583 + 1584 + // Get layer records for user 1585 + layerRecords, err := h.pds.ListLayerRecordsForUser(r.Context(), user.DID) 1586 + if err != nil { 1587 + slog.Warn("Failed to get layer records for export", 1588 + "user_did", user.DID, 1589 + "error", err) 1590 + // Continue with empty list - don't fail entire export 1591 + } else { 1592 + for _, layer := range layerRecords { 1593 + export.LayerRecords = append(export.LayerRecords, LayerExport{ 1594 + Digest: layer.Digest, 1595 + Size: layer.Size, 1596 + MediaType: layer.MediaType, 1597 + Manifest: layer.Manifest, 1598 + CreatedAt: layer.CreatedAt, 1599 + }) 1600 + } 1601 + } 1602 + 1603 + // Get stats records for user 1604 + statsRecords, err := h.pds.ListStatsRecordsForUser(r.Context(), user.DID) 1605 + if err != nil { 1606 + slog.Warn("Failed to get stats records for export", 1607 + "user_did", user.DID, 1608 + "error", err) 1609 + // Continue with empty list - don't fail entire export 1610 + } else { 1611 + for _, stat := range statsRecords { 1612 + export.StatsRecords = append(export.StatsRecords, StatsExport{ 1613 + Repository: stat.Repository, 1614 + PullCount: stat.PullCount, 1615 + PushCount: stat.PushCount, 1616 + LastPull: stat.LastPull, 1617 + LastPush: stat.LastPush, 1618 + UpdatedAt: stat.UpdatedAt, 1619 + }) 1620 + } 1621 + } 1622 + 1623 + slog.Info("GDPR data export completed", 1624 + "user_did", user.DID, 1625 + "hold_did", h.pds.DID(), 1626 + "is_captain", export.IsCaptain, 1627 + "has_crew_record", export.CrewRecord != nil, 1628 + "layer_count", len(export.LayerRecords), 1629 + "stats_count", len(export.StatsRecords)) 1630 + 1631 + render.JSON(w, r, export) 1632 + }
+4 -4
pkg/hold/pds/xrpc_test.go
··· 58 58 r, w, _ := os.Pipe() 59 59 os.Stdout = w 60 60 61 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 61 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 62 62 63 63 // Restore stdout 64 64 w.Close() ··· 116 116 r, w, _ := os.Pipe() 117 117 os.Stdout = w 118 118 119 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 119 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 120 120 121 121 // Restore stdout 122 122 w.Close() ··· 1986 1986 r, w, _ := os.Pipe() 1987 1987 os.Stdout = w 1988 1988 1989 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 1989 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 1990 1990 1991 1991 // Restore stdout 1992 1992 w.Close() ··· 2429 2429 2430 2430 // Clean up - recreate captain record if it was deleted 2431 2431 if w.Code == http.StatusOK { 2432 - handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "") 2432 + handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "") 2433 2433 } 2434 2434 } 2435 2435
+53 -1
pkg/logging/logger.go
··· 7 7 package logging 8 8 9 9 import ( 10 + "fmt" 10 11 "io" 11 12 "log/slog" 12 13 "os" ··· 56 57 levelVar.Set(logLevel) 57 58 58 59 opts := &slog.HandlerOptions{ 59 - Level: levelVar, 60 + Level: levelVar, 61 + AddSource: true, 62 + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { 63 + if a.Key == slog.SourceKey { 64 + if src, ok := a.Value.Any().(*slog.Source); ok { 65 + a.Value = slog.StringValue(shortenSource(src.File, src.Line)) 66 + } 67 + } 68 + return a 69 + }, 60 70 } 61 71 62 72 handler := slog.NewTextHandler(os.Stdout, opts) ··· 125 135 "from", "DEBUG", 126 136 "to", levelToString(originalLevel), 127 137 "trigger", "auto-revert") 138 + } 139 + 140 + // shortenSource shortens file paths for cleaner log output. 141 + // - Our code (atcr.io/): shows pkg/appview/jetstream/processor.go:73 142 + // - Library code (/pkg/mod/): shows indigo/atproto/identity/handle.go:225 143 + // - Other: shows last 3 path components 144 + func shortenSource(file string, line int) string { 145 + // Our code: strip everything up to and including atcr.io/ 146 + if idx := strings.Index(file, "atcr.io/"); idx != -1 { 147 + return fmt.Sprintf("%s:%d", file[idx+8:], line) // 8 = len("atcr.io/") 148 + } 149 + 150 + // Library code in go mod cache: extract module name + relative path 151 + // Example: /go/pkg/mod/github.com/bluesky-social/indigo@v0.0.0-.../atproto/identity/handle.go 152 + // becomes: indigo/atproto/identity/handle.go:225 153 + if idx := strings.Index(file, "/pkg/mod/"); idx != -1 { 154 + modPath := file[idx+9:] // 9 = len("/pkg/mod/") 155 + if atIdx := strings.Index(modPath, "@"); atIdx != -1 { 156 + // Get module path before @ 157 + modFullPath := modPath[:atIdx] 158 + parts := strings.Split(modFullPath, "/") 159 + 160 + // Get module name - skip version suffix like "v3" if present 161 + modName := parts[len(parts)-1] 162 + if len(parts) >= 2 && len(modName) >= 2 && modName[0] == 'v' && modName[1] >= '0' && modName[1] <= '9' { 163 + modName = parts[len(parts)-2] 164 + } 165 + 166 + // Get path after version 167 + afterAt := modPath[atIdx+1:] 168 + if slashIdx := strings.Index(afterAt, "/"); slashIdx != -1 { 169 + return fmt.Sprintf("%s%s:%d", modName, afterAt[slashIdx:], line) 170 + } 171 + } 172 + } 173 + 174 + // Fallback: show last 3 path components 175 + parts := strings.Split(file, "/") 176 + if len(parts) > 3 { 177 + parts = parts[len(parts)-3:] 178 + } 179 + return fmt.Sprintf("%s:%d", strings.Join(parts, "/"), line) 128 180 } 129 181 130 182 func levelToString(l slog.Level) string {
+55
pkg/logging/logger_test.go
··· 395 395 396 396 // cleanup() will restore the original logger when defer runs 397 397 } 398 + 399 + func TestShortenSource(t *testing.T) { 400 + tests := []struct { 401 + name string 402 + file string 403 + line int 404 + expected string 405 + }{ 406 + { 407 + name: "our code", 408 + file: "/app/atcr.io/pkg/appview/jetstream/processor.go", 409 + line: 73, 410 + expected: "pkg/appview/jetstream/processor.go:73", 411 + }, 412 + { 413 + name: "indigo library", 414 + file: "/go/pkg/mod/github.com/bluesky-social/indigo@v0.0.0-20251218205144-034a2c019e64/atproto/identity/handle.go", 415 + line: 225, 416 + expected: "indigo/atproto/identity/handle.go:225", 417 + }, 418 + { 419 + name: "distribution with v3 suffix", 420 + file: "/go/pkg/mod/github.com/distribution/distribution/v3@v3.0.0-rc.3/registry/storage/driver.go", 421 + line: 123, 422 + expected: "distribution/registry/storage/driver.go:123", 423 + }, 424 + { 425 + name: "chi router", 426 + file: "/go/pkg/mod/github.com/go-chi/chi/v5@v5.0.10/mux.go", 427 + line: 42, 428 + expected: "chi/mux.go:42", 429 + }, 430 + { 431 + name: "simple module without version suffix", 432 + file: "/go/pkg/mod/github.com/ipfs/go-cid@v0.4.1/cid.go", 433 + line: 99, 434 + expected: "go-cid/cid.go:99", 435 + }, 436 + { 437 + name: "fallback - unknown path", 438 + file: "/some/random/path/to/file.go", 439 + line: 10, 440 + expected: "path/to/file.go:10", 441 + }, 442 + } 443 + 444 + for _, tt := range tests { 445 + t.Run(tt.name, func(t *testing.T) { 446 + result := shortenSource(tt.file, tt.line) 447 + if result != tt.expected { 448 + t.Errorf("shortenSource(%q, %d) = %q, want %q", tt.file, tt.line, result, tt.expected) 449 + } 450 + }) 451 + } 452 + }