+1
cmd/appview/serve.go
+1
cmd/appview/serve.go
+1
-1
cmd/hold/main.go
+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
+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
+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
+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
-5
lexicons/io/atcr/hold/captain.json
+393
pkg/appview/db/export.go
+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
+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
®ion,
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
+
®ion,
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
+
®ion,
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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
®ion,
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
-1
pkg/auth/hold_remote_test.go
+18
pkg/hold/config.go
+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
+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
+1
-1
pkg/hold/oci/xrpc_test.go
+2
-1
pkg/hold/pds/captain.go
+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
+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
+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
+1
-1
pkg/hold/pds/layer_test.go
+4
-3
pkg/hold/pds/server.go
+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
+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
+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
+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
+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
+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
+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
+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
+
}