A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1# Accessing Hold Data Without AppView
2
3This 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
11The 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
151. Go to your Bluesky settings: https://bsky.app/settings/app-passwords
162. Create a new app password
173. 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
23HANDLE="yourhandle.bsky.social"
24APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
25
26# Create session with your PDS
27SESSION=$(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
32ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
33DID=$(echo "$SESSION" | jq -r '.did')
34PDS=$(echo "$SESSION" | jq -r '.didDoc.service[0].serviceEndpoint')
35
36echo "DID: $DID"
37echo "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)
44HOLD_DID="did:web:hold01.atcr.io"
45
46# Get a service token from your PDS
47SERVICE_TOKEN=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
48 -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')
49
50echo "Service Token: $SERVICE_TOKEN"
51```
52
53### Step 4: Call Hold Endpoints
54
55Now you can call any authenticated hold endpoint with the service token:
56
57```bash
58# Export your data from the hold
59curl -s "https://hold01.atcr.io/xrpc/io.atcr.hold.exportUserData" \
60 -H "Authorization: Bearer $SERVICE_TOKEN" | jq .
61```
62
63### Complete Script
64
65Here'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
71set -e
72
73# Configuration
74HANDLE="${1:-yourhandle.bsky.social}"
75APP_PASSWORD="${2:-xxxx-xxxx-xxxx-xxxx}"
76HOLD_DID="${3:-did:web:hold01.atcr.io}"
77
78# Default PDS (Bluesky's main PDS)
79DEFAULT_PDS="https://bsky.social"
80
81echo "Authenticating as $HANDLE..."
82
83# Step 1: Create session
84SESSION=$(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
89if echo "$SESSION" | jq -e '.error' > /dev/null 2>&1; then
90 echo "Error: $(echo "$SESSION" | jq -r '.message')"
91 exit 1
92fi
93
94ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
95DID=$(echo "$SESSION" | jq -r '.did')
96
97# Try to get PDS from didDoc, fall back to default
98PDS=$(echo "$SESSION" | jq -r '.didDoc.service[] | select(.id == "#atproto_pds") | .serviceEndpoint' 2>/dev/null || echo "$DEFAULT_PDS")
99if [ "$PDS" = "null" ] || [ -z "$PDS" ]; then
100 PDS="$DEFAULT_PDS"
101fi
102
103echo "Authenticated as $DID"
104echo "PDS: $PDS"
105
106# Step 2: Get service token for the hold
107echo "Getting service token for $HOLD_DID..."
108SERVICE_RESPONSE=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
109 -H "Authorization: Bearer $ACCESS_JWT")
110
111if 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
114fi
115
116SERVICE_TOKEN=$(echo "$SERVICE_RESPONSE" | jq -r '.token')
117
118# Step 3: Resolve hold DID to URL
119if [[ "$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"
123else
124 echo "Error: Only did:web holds are currently supported for direct resolution"
125 exit 1
126fi
127
128echo "Hold URL: $HOLD_URL"
129
130# Step 4: Export data
131echo "Exporting data from $HOLD_URL..."
132curl -s "$HOLD_URL/xrpc/io.atcr.hold.exportUserData" \
133 -H "Authorization: Bearer $SERVICE_TOKEN" | jq .
134```
135
136Usage:
137```bash
138chmod +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
146Once you have a service token, you can call these endpoints:
147
148### Data Export (GDPR)
149```bash
150GET /xrpc/io.atcr.hold.exportUserData
151Authorization: Bearer {service_token}
152```
153
154Returns 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
162GET /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
168GET /xrpc/com.atproto.sync.getBlob?did={owner_did}&cid={blob_digest}
169Authorization: Bearer {service_token}
170```
171
172Returns a presigned URL to download the blob directly from storage.
173
174---
175
176## OAuth + DPoP (Advanced)
177
178App 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
1801. **DPoP (Demonstrating Proof of Possession)** - Every request requires a cryptographically signed JWT proving you control a specific key
1812. **PAR (Pushed Authorization Requests)** - Authorization parameters are sent server-to-server
1823. **PKCE (Proof Key for Code Exchange)** - Prevents authorization code interception
183
184### Why DPoP Makes Curl Impractical
185
186Each 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
193Example 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
211If you need OAuth (e.g., for a production application), you'll want to use a library:
212
213**Go:**
214```go
215import "github.com/bluesky-social/indigo/atproto/auth/oauth"
216```
217
218**TypeScript/JavaScript:**
219```bash
220npm install @atproto/oauth-client-node
221```
222
223**Python:**
224```bash
225pip install atproto
226```
227
228These libraries handle all the DPoP complexity for you.
229
230### High-Level OAuth Flow
231
232For documentation purposes, here's what the flow looks like:
233
2341. **Resolve identity**: `handle` → `DID` → `PDS endpoint`
2352. **Discover OAuth server**: `GET {pds}/.well-known/oauth-authorization-server`
2363. **Generate DPoP key**: Create P-256 key pair
2374. **PAR request**: Send authorization parameters (with DPoP proof)
2385. **User authorization**: Browser-based login
2396. **Token exchange**: Exchange code for tokens (with DPoP proof)
2407. **Use tokens**: All subsequent requests include DPoP proofs
241
242Each 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
250Service tokens are only valid for ~60 seconds. Get a fresh one:
251```bash
252SERVICE_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
258Your access JWT from `createSession` has expired. Create a new session:
259```bash
260SESSION=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" ...)
261ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
262```
263
264### "Audience mismatch"
265
266The 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
270You 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
276Check your sailor profile to find your default hold:
277```bash
278curl -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
282Or check your manifest records for the hold where your images are stored:
283```bash
284curl -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)