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

Configure Feed

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

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

+4124 -159
+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 + }