Files
at-container-registry/docs/DIRECT_HOLD_ACCESS.md

8.8 KiB

Accessing Hold Data Without AppView

This document explains how to retrieve your data directly from a hold service without going through the ATCR AppView. This is useful for:

  • GDPR data export requests
  • Backup and migration
  • Debugging and development
  • Building alternative clients

The simplest way to authenticate is using an ATProto app password. This avoids the complexity of OAuth + DPoP.

Step 1: Create an App Password

  1. Go to your Bluesky settings: https://bsky.app/settings/app-passwords
  2. Create a new app password
  3. Save it securely (you'll only see it once)

Step 2: Get a Session Token

# Replace with your handle and app password
HANDLE="yourhandle.bsky.social"
APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"

# Create session with your PDS
SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
  -H "Content-Type: application/json" \
  -d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}")

# Extract tokens
ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
DID=$(echo "$SESSION" | jq -r '.did')
PDS=$(echo "$SESSION" | jq -r '.didDoc.service[0].serviceEndpoint')

echo "DID: $DID"
echo "PDS: $PDS"

Step 3: Get a Service Token for the Hold

# The hold DID you want to access (e.g., did:web:hold01.atcr.io)
HOLD_DID="did:web:hold01.atcr.io"

# Get a service token from your PDS
SERVICE_TOKEN=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
  -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')

echo "Service Token: $SERVICE_TOKEN"

Step 4: Call Hold Endpoints

Now you can call any authenticated hold endpoint with the service token:

# Export your data from the hold
curl -s "https://hold01.atcr.io/xrpc/io.atcr.hold.exportUserData" \
  -H "Authorization: Bearer $SERVICE_TOKEN" | jq .

Complete Script

Here's a complete script that does all the above:

#!/bin/bash
# export-hold-data.sh - Export your data from an ATCR hold

set -e

# Configuration
HANDLE="${1:-yourhandle.bsky.social}"
APP_PASSWORD="${2:-xxxx-xxxx-xxxx-xxxx}"
HOLD_DID="${3:-did:web:hold01.atcr.io}"

# Default PDS (Bluesky's main PDS)
DEFAULT_PDS="https://bsky.social"

echo "Authenticating as $HANDLE..."

# Step 1: Create session
SESSION=$(curl -s -X POST "$DEFAULT_PDS/xrpc/com.atproto.server.createSession" \
  -H "Content-Type: application/json" \
  -d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}")

# Check for errors
if echo "$SESSION" | jq -e '.error' > /dev/null 2>&1; then
  echo "Error: $(echo "$SESSION" | jq -r '.message')"
  exit 1
fi

ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
DID=$(echo "$SESSION" | jq -r '.did')

# Try to get PDS from didDoc, fall back to default
PDS=$(echo "$SESSION" | jq -r '.didDoc.service[] | select(.id == "#atproto_pds") | .serviceEndpoint' 2>/dev/null || echo "$DEFAULT_PDS")
if [ "$PDS" = "null" ] || [ -z "$PDS" ]; then
  PDS="$DEFAULT_PDS"
fi

echo "Authenticated as $DID"
echo "PDS: $PDS"

# Step 2: Get service token for the hold
echo "Getting service token for $HOLD_DID..."
SERVICE_RESPONSE=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
  -H "Authorization: Bearer $ACCESS_JWT")

if echo "$SERVICE_RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
  echo "Error getting service token: $(echo "$SERVICE_RESPONSE" | jq -r '.message')"
  exit 1
fi

SERVICE_TOKEN=$(echo "$SERVICE_RESPONSE" | jq -r '.token')

# Step 3: Resolve hold DID to URL
if [[ "$HOLD_DID" == did:web:* ]]; then
  # did:web:example.com -> https://example.com
  HOLD_HOST="${HOLD_DID#did:web:}"
  HOLD_URL="https://$HOLD_HOST"
else
  echo "Error: Only did:web holds are currently supported for direct resolution"
  exit 1
fi

echo "Hold URL: $HOLD_URL"

# Step 4: Export data
echo "Exporting data from $HOLD_URL..."
curl -s "$HOLD_URL/xrpc/io.atcr.hold.exportUserData" \
  -H "Authorization: Bearer $SERVICE_TOKEN" | jq .

Usage:

chmod +x export-hold-data.sh
./export-hold-data.sh yourhandle.bsky.social xxxx-xxxx-xxxx-xxxx did:web:hold01.atcr.io

Available Hold Endpoints

Once you have a service token, you can call these endpoints:

Data Export (GDPR)

GET /xrpc/io.atcr.hold.exportUserData
Authorization: Bearer {service_token}

Returns all your data stored on that hold:

  • Layer records (blobs you've pushed)
  • Crew membership status
  • Usage statistics
  • Whether you're the hold captain

Quota Information

GET /xrpc/io.atcr.hold.getQuota?userDid={your_did}
# No auth required - just needs your DID

Blob Download (if you have read access)

GET /xrpc/com.atproto.sync.getBlob?did={owner_did}&cid={blob_digest}
Authorization: Bearer {service_token}

Returns a presigned URL to download the blob directly from storage.


OAuth + DPoP (Advanced)

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:

  1. DPoP (Demonstrating Proof of Possession) - Every request requires a cryptographically signed JWT proving you control a specific key
  2. PAR (Pushed Authorization Requests) - Authorization parameters are sent server-to-server
  3. PKCE (Proof Key for Code Exchange) - Prevents authorization code interception

Why DPoP Makes Curl Impractical

Each request requires a fresh DPoP proof JWT with:

  • Unique jti (request ID)
  • Current iat timestamp
  • HTTP method and URL bound to the request
  • Server-provided nonce
  • Signature using your P-256 private key

Example DPoP proof structure:

{
  "alg": "ES256",
  "typ": "dpop+jwt",
  "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
{
  "htm": "GET",
  "htu": "https://bsky.social/xrpc/com.atproto.server.getServiceAuth",
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "iat": 1735689100,
  "nonce": "server-provided-nonce"
}

If You Need OAuth

If you need OAuth (e.g., for a production application), you'll want to use a library:

Go:

import "github.com/bluesky-social/indigo/atproto/auth/oauth"

TypeScript/JavaScript:

npm install @atproto/oauth-client-node

Python:

pip install atproto

These libraries handle all the DPoP complexity for you.

High-Level OAuth Flow

For documentation purposes, here's what the flow looks like:

  1. Resolve identity: handleDIDPDS endpoint
  2. Discover OAuth server: GET {pds}/.well-known/oauth-authorization-server
  3. Generate DPoP key: Create P-256 key pair
  4. PAR request: Send authorization parameters (with DPoP proof)
  5. User authorization: Browser-based login
  6. Token exchange: Exchange code for tokens (with DPoP proof)
  7. Use tokens: All subsequent requests include DPoP proofs

Each step after #3 requires generating a fresh DPoP proof JWT, which is why libraries are essential.


Troubleshooting

"Invalid token" or "Token expired"

Service tokens are only valid for ~60 seconds. Get a fresh one:

SERVICE_TOKEN=$(curl -s "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
  -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')

"Session expired"

Your access JWT from createSession has expired. Create a new session:

SESSION=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" ...)
ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')

"Audience mismatch"

The service token is scoped to a specific hold. Make sure HOLD_DID matches exactly what's in the aud claim of your token.

"Access denied: user is not a crew member"

You don't have access to this hold. You need to either:

  • Be the hold captain (owner)
  • Be a crew member with appropriate permissions

Finding Your Hold DID

Check your sailor profile to find your default hold:

curl -s "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=io.atcr.sailor.profile&rkey=self" \
  -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.value.defaultHold'

Or check your manifest records for the hold where your images are stored:

curl -s "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.manifest&limit=1" \
  -H "Authorization: Bearer $ACCESS_JWT" | jq -r '.records[0].value.holdDid'

Security Notes

  • App passwords are scoped tokens that can be revoked without changing your main password
  • Service tokens are short-lived (60 seconds) and scoped to a specific hold
  • Never share your app password or access tokens
  • Service tokens can only be used for the specific hold they were requested for (aud claim)

References