Hold discovery in the registry middleware called getRecord on the repository owner's PDS for every request under /v2/: every HEAD, POST, PATCH, PUT and GET. A 10-layer push was 40 or more PDS round trips, and it was the last per-request network call on the push path that had nothing to do with moving bytes. Only two profile fields are used there: the default hold and the auto-remove-untagged flag. The users row already caches the default hold, written by the Jetstream processor on every profile event and prefilled by the backfill, and the auth gate already reads it from there. This makes the row a faithful copy of what the registry needs and switches the middleware to it. The auto-remove flag gets a nullable users column. NULL means the value has never been learned; the processor writes 0 or 1 on every profile event and never NULL. On a request whose row is missing or still NULL, the middleware does one live fetch, uses it, and writes both fields back, including a 0 for a user with no profile at all, so the fallback runs at most once per user. A failed fetch writes nothing and uses the appview default for that request, so a network error is never cached. That single mechanism covers the minutes after a deploy while the startup backfill fills the column, a brand-new user, and a user the backfill has not reached. The processor also stops returning early on an empty default hold, which left a user who removed their custom hold pushing to it forever. Empty is now written through and means the appview default, matching what the auth gate already reads. Tests count PDS requests with a test server: a populated row makes none, a NULL row makes exactly one and then none, a missing profile is cached as known, and a failed fetch degrades without writing. The migration was applied to a fresh database and to one built from the previous schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
14 KiB
Bring Your Own Storage (BYOS)
Overview
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. Users can:
- Deploy their own hold service with embedded PDS
- Control access via crew membership in the hold's PDS
- Keep blob data in their own S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.) while manifests stay in their user PDS
Architecture
┌──────────────────────────────────────────┐
│ ATCR AppView (API) │
│ - Manifests → User's PDS │
│ - Auth & service token management │
│ - Blob routing via XRPC │
│ - Profile management │
└────────────┬─────────────────────────────┘
│
│ Hold discovery (findHoldDIDAndPrefs), read from
│ the local users row, not the PDS:
│ 1. io.atcr.sailor.profile.defaultHold (DID)
│ 2. AppView default hold (server.managed_holds[0])
│
│ Then resolveSuccessor: if the chosen hold's
│ captain record sets a successor DID, apply a
│ single-hop redirect to it (hold migration).
▼
┌──────────────────────────────────────────┐
│ User's PDS │
│ - io.atcr.sailor.profile (hold DID) │
│ - io.atcr.manifest (with holdDid) │
└────────────┬─────────────────────────────┘
│
│ Service token from user's PDS
▼
┌──────────────────────────────────────────┐
│ Hold Service (did:web:hold.example.com) │
│ ├── Embedded PDS │
│ │ ├── Captain record (ownership) │
│ │ └── Crew records (access control) │
│ ├── XRPC multipart upload endpoints │
│ └── Storage driver (S3/Storj/etc.) │
└──────────────────────────────────────────┘
Hold Service Components
Each hold is a full ATProto actor with:
- DID:
did:web:hold.example.com(hold's identity) - Embedded PDS: Stores captain + crew records (shared data)
- Storage backend: S3-compatible (AWS S3, Storj, Minio, UpCloud, etc.)
- XRPC endpoints: Standard ATProto + custom OCI multipart upload
Records in Hold's PDS
Captain record (io.atcr.hold.captain/self):
{
"$type": "io.atcr.hold.captain",
"owner": "did:plc:alice123",
"public": false,
"allowAllCrew": false,
"enableBlueskyPosts": false,
"deployedAt": "2025-10-14T...",
"region": "iad",
"successor": ""
}
region and successor are optional. successor holds the DID of a replacement hold; when set, the AppView applies a single-hop redirect to it during hold discovery (see the Architecture diagram above).
Crew records (io.atcr.hold.crew/{rkey}):
{
"$type": "io.atcr.hold.crew",
"member": "did:plc:bob456",
"role": "captain",
"permissions": ["blob:read", "blob:write"],
"tier": "bosun",
"plankowner": false,
"addedAt": "2025-10-14T..."
}
Authorization is driven by the permissions array (blob:read, blob:write, crew:admin), not the role string. blob:write implicitly grants blob:read (you can't push without being able to pull). tier and plankowner are optional and feed quota limits.
Sailor Profile (User's PDS)
Users set their preferred hold in their sailor profile:
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:hold.example.com",
"createdAt": "2025-10-02T...",
"updatedAt": "2025-10-02T..."
}
Deployment
Configuration
The hold service is configured with Viper: a YAML file is the primary source, and
environment variables override individual fields. Env var names are HOLD_ plus the
YAML path with _ separators (e.g. server.public_url → HOLD_SERVER_PUBLIC_URL).
S3 credentials use the standard AWS names.
Generate a fully commented config and run with it:
./bin/atcr-hold config init config-hold.yaml
# edit config-hold.yaml, then:
./bin/atcr-hold serve --config config-hold.yaml
Key fields (YAML on the left, env override on the right):
server:
public_url: https://hold.example.com # HOLD_SERVER_PUBLIC_URL (REQUIRED)
public: false # HOLD_SERVER_PUBLIC (allow anonymous reads)
registration:
owner_did: did:plc:your-did-here # HOLD_REGISTRATION_OWNER_DID
allow_all_crew: false # HOLD_REGISTRATION_ALLOW_ALL_CREW
database:
path: /var/lib/atcr-hold # HOLD_DATABASE_PATH (carstore + SQLite)
key_path: "" # HOLD_DATABASE_KEY_PATH (defaults to {path}/signing.key)
storage:
bucket: my-blobs # S3_BUCKET (REQUIRED)
region: us-east-1 # AWS_REGION
endpoint: "" # S3_ENDPOINT (for non-AWS providers)
S3 credentials are read from the standard AWS env vars (AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY).
Running Locally
For local development, use Minio as an S3-compatible storage:
# Start Minio (in separate terminal)
docker run -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001"
# Build
go build -o bin/atcr-hold ./cmd/hold
# Run (env overrides shown; a YAML config works too)
export HOLD_SERVER_PUBLIC_URL=http://localhost:8080
export HOLD_REGISTRATION_OWNER_DID=did:plc:your-did-here
export AWS_ACCESS_KEY_ID=minioadmin
export AWS_SECRET_ACCESS_KEY=minioadmin
export S3_BUCKET=test
export S3_ENDPOINT=http://localhost:9000
export HOLD_DATABASE_PATH=/tmp/atcr-hold
./bin/atcr-hold serve
On first run, the hold service creates:
- Captain record in embedded PDS (making you the owner)
- Crew record for owner with all permissions
- DID document at
/.well-known/did.json
Deploy to Fly.io
# Create fly.toml
cat > fly.toml <<EOF
app = "my-atcr-hold"
primary_region = "ord"
[env]
HOLD_SERVER_PUBLIC_URL = "https://my-atcr-hold.fly.dev"
AWS_REGION = "us-east-1"
S3_BUCKET = "my-blobs"
HOLD_SERVER_PUBLIC = "false"
HOLD_REGISTRATION_ALLOW_ALL_CREW = "false"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256
EOF
# Deploy
fly launch
fly deploy
# Set secrets
fly secrets set AWS_ACCESS_KEY_ID=...
fly secrets set AWS_SECRET_ACCESS_KEY=...
fly secrets set HOLD_REGISTRATION_OWNER_DID=did:plc:your-did-here
Request Flow
Push with BYOS
1. Client: docker push atcr.io/alice/myapp:latest
2. AppView resolves alice → did:plc:alice123
3. AppView discovers hold DID:
- Read alice's cached defaultHold from the local users row, which Jetstream
keeps current from her sailor profile. Only a row that has never been
filled costs one live profile fetch, and that fetch is written back.
- Returns: "did:web:alice-storage.fly.dev"
4. AppView gets service token from alice's PDS:
GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:alice-storage.fly.dev
Response: { "token": "eyJ..." }
5. AppView buffers the blob (16MB limit) and verifies the bytes it received
against the digest the client claimed. A mismatch is rejected here, before
anything reaches storage.
5a. Small blob (fits in the buffer, which is every config blob and most layers):
AppView asks for one write capability and PUTs the whole blob to its final
content-addressed key. No multipart session, no temp object, no copy.
GET /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...&method=PUT
Authorization: Bearer {serviceToken}
Response: { "url": "https://s3.../presigned" }
AppView: PUT that URL with Content-Type: application/octet-stream
5b. Large blob (outgrew the buffer): multipart, as below.
6. AppView initiates multipart upload to hold, on the first flush:
POST https://alice-storage.fly.dev/xrpc/io.atcr.hold.initiateUpload
Authorization: Bearer {serviceToken}
Body: { "digest": "sha256:abc..." }
Response: { "uploadId": "xyz" }
7. For each part:
- AppView: POST /xrpc/io.atcr.hold.getPartUploadUrl
- Hold validates service token, checks crew membership
- Hold returns: { "url": "https://s3.../presigned" }
- AppView uploads the part to the S3 presigned URL
8. AppView completes upload:
POST /xrpc/io.atcr.hold.completeUpload
Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] }
9. Manifest stored in alice's PDS:
- holdDid: "did:web:alice-storage.fly.dev"
- holdEndpoint: "https://alice-storage.fly.dev" (backward compat)
Pull with BYOS
1. Client: docker pull atcr.io/alice/myapp:latest
2. AppView fetches manifest from alice's PDS
3. Manifest contains:
- holdDid: "did:web:alice-storage.fly.dev"
4. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123
5. AppView reads the hold DID from the manifest's holdDid field (per request)
6. AppView gets service token from alice's PDS
(validated service tokens are cached ~45s to absorb a burst of blob requests)
7. AppView calls hold XRPC:
GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123
Authorization: Bearer {serviceToken}
Response: { "url": "https://s3.../presigned-download" }
8. AppView redirects client to presigned S3 URL
9. Client downloads directly from S3
Key insight: Pull uses the holdDid stored in the manifest, ensuring blobs are fetched from where they were originally pushed.
Access Control
Read Access
- Public hold (
server.public: true): Anonymous + authenticated users - Private hold (
server.public: false): Authenticated users with crew membership
Write Access
- Hold owner (captain) OR crew members only
- Verified via
io.atcr.hold.crewrecords in hold's embedded PDS - Service token proves user identity (from user's PDS)
Authorization Flow
1. AppView gets service token from user's PDS
2. AppView sends request to hold with service token
3. Hold validates service token (checks it's from user's PDS)
4. Hold extracts user's DID from token
5. Hold checks crew records in its embedded PDS
6. If crew member found → allow, else → deny
Managing Crew Members
Add Crew Member
Use ATProto client to create crew record in hold's PDS:
# Via XRPC (if hold supports it)
POST https://hold.example.com/xrpc/io.atcr.hold.requestCrew
Authorization: Bearer {userOAuthToken}
# Or manually via captain's OAuth to hold's PDS
atproto put-record \
--pds https://hold.example.com \
--collection io.atcr.hold.crew \
--rkey "{memberDID}" \
--value '{
"$type": "io.atcr.hold.crew",
"member": "did:plc:bob456",
"role": "crew",
"permissions": ["blob:read", "blob:write"]
}'
Remove Crew Member
atproto delete-record \
--pds https://hold.example.com \
--collection io.atcr.hold.crew \
--rkey "{memberDID}"
Storage Backends
Hold service requires S3-compatible storage. Supported providers:
- AWS S3 - Amazon Simple Storage Service
- Storj - Decentralized cloud storage (via S3 gateway)
- Minio - High-performance object storage (great for local development)
- UpCloud - European cloud provider
- Azure - Azure Blob Storage (via S3-compatible API)
- GCS - Google Cloud Storage (via S3-compatible API)
Example: Team Hold
# 1. Deploy hold service
export HOLD_SERVER_PUBLIC_URL=https://team-hold.fly.dev
export HOLD_REGISTRATION_OWNER_DID=did:plc:admin
export HOLD_SERVER_PUBLIC=false # Private
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export S3_BUCKET=team-blobs
fly deploy
# 2. Hold auto-creates captain + crew records on first run
# 3. Admin adds team members via hold's PDS (requires OAuth)
# (TODO: Implement crew management UI/CLI)
# 4. Team members set their sailor profile:
atproto put-record \
--collection io.atcr.sailor.profile \
--rkey "self" \
--value '{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:team-hold.fly.dev"
}'
# 5. Team members can now push/pull using team hold
Limitations
Current IAM Challenges
See EMBEDDED_PDS.md for detailed discussion.
Known issues:
- RPC permission format: Service tokens don't work with IP-based DIDs in local dev
- Dynamic hold discovery: AppView can't dynamically OAuth arbitrary holds from sailor profiles
- Manual profile management: No UI for updating sailor profile (must use ATProto client)
Workaround: Use hostname-based DIDs (did:web:hold.example.com) and public holds for now.
Future Improvements
- Crew management UI - Web interface for adding/removing crew members
- Dynamic OAuth - Support for arbitrary BYOS holds without pre-configuration
- Hold migration - Tools for moving blobs between holds
- Storage analytics - Track usage per user/repository
- Distributed cache - Redis for hold DID cache in multi-instance deployments
References
- EMBEDDED_PDS.md - Embedded PDS architecture and IAM details
- ATProto Lexicon Spec
- Distribution Storage Drivers
- S3 Presigned URLs