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

Configure Feed

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

consider embedded pds for holds

+669 -16
+25 -16
CLAUDE.md
··· 214 214 **Authentication Flow:** 215 215 ``` 216 216 1. User configures Docker to use the credential helper (adds to config.json) 217 - 2. On first docker push/pull, helper generates ECDSA P-256 DPoP key 218 - 3. Resolve handle → DID → PDS endpoint 219 - 4. Discover OAuth server metadata from PDS 220 - 5. PAR request with DPoP header → get request_uri 221 - 6. Open browser for user authorization 222 - 7. Exchange code for token with DPoP proof 223 - 8. Save: access token, refresh token, DPoP key, DID, handle 217 + 2. On first docker push/pull, Docker calls credential helper 218 + 3. Credential helper opens browser → AppView OAuth page 219 + 4. AppView handles OAuth flow: 220 + - Resolves handle → DID → PDS endpoint 221 + - Discovers OAuth server metadata from PDS 222 + - PAR request with DPoP header → get request_uri 223 + - User authorizes in browser 224 + - AppView exchanges code for OAuth token with DPoP proof 225 + - AppView stores: OAuth token, refresh token, DPoP key, DID, handle 226 + 5. AppView shows device approval page: "Can [device] push to your account?" 227 + 6. User approves device 228 + 7. AppView issues registry JWT with validated DID 229 + 8. AppView returns JSON token to credential helper (via callback or browser display) 230 + 9. Credential helper saves registry JWT locally 231 + 10. Helper returns registry JWT to Docker 224 232 225 233 Later (subsequent docker push): 226 - 9. Docker calls credential helper 227 - 10. Helper loads token, refreshes if needed 228 - 11. Helper calls /auth/exchange with OAuth token + handle 229 - 12. AppView validates token via PDS getSession 230 - 13. AppView ensures sailor profile exists (creates with defaultHold if first login) 231 - 14. AppView issues registry JWT with validated DID 232 - 15. Helper returns JWT to Docker 234 + 11. Docker calls credential helper 235 + 12. Helper returns cached registry JWT (or re-authenticates if expired) 233 236 ``` 237 + 238 + **Key distinction:** The credential helper never manages OAuth tokens or DPoP keys directly. AppView owns the OAuth session and issues registry JWTs to the credential helper. This means AppView has access to user OAuth tokens and DPoP keys, which it needs for: 239 + - Writing manifests to user's PDS 240 + - Validating user sessions 241 + - Delegating access to hold services 234 242 235 243 **Security:** 236 244 - Tokens validated against authoritative source (user's PDS) ··· 479 487 - `HOLD_OWNER` - DID for auto-registration (optional) 480 488 481 489 **Credential Helper**: 482 - - Token storage: `~/.atcr/oauth-token.json` 483 - - Contains: access token, refresh token, DPoP key (PEM), DID, handle 490 + - Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store) 491 + - Contains: Registry JWT issued by AppView (NOT OAuth tokens) 492 + - OAuth session managed entirely by AppView 484 493 485 494 ### Development Notes 486 495
+644
docs/EMBEDDED_PDS.md
··· 1 + # Embedded PDS Architecture for Hold Services 2 + 3 + This document explores the evolution of ATCR's hold service architecture toward becoming an embedded ATProto PDS (Personal Data Server). 4 + 5 + ## Motivation 6 + 7 + ### Comparison to Other ATProto Projects 8 + 9 + Several ATProto projects face similar challenges with large data storage: 10 + 11 + | Project | Large Data | Metadata | Current Solution | 12 + |---------|-----------|----------|------------------| 13 + | **tangled.org** | Git objects | Issues, PRs, comments | External knot storage | 14 + | **stream.place** | Video segments | Stream info, chat | Embedded "static PDS" | 15 + | **ATCR** | Container blobs | Manifests, comments, builds | External hold service | 16 + 17 + **Common problem:** Large binary data can't realistically live in user PDSs, but interaction metadata gets fragmented across different users' PDSs. 18 + 19 + **Emerging pattern:** Application-specific storage services with embedded minimal PDS implementations. 20 + 21 + ### The Fragmentation Problem 22 + 23 + #### Tangled.org Example 24 + ``` 25 + user/myproject repository 26 + ├── Git data → Knot (external storage) 27 + ├── Issues → Created by @alice → Lives in alice's PDS 28 + ├── PRs → Created by @bob → Lives in bob's PDS 29 + └── Comments → Created by @charlie → Lives in charlie's PDS 30 + ``` 31 + 32 + **Problems:** 33 + - Repo owner can't export all issues/PRs easily 34 + - No single source of truth for repo metadata 35 + - Interaction history fragmented across PDSs 36 + - Can't encrypt repo data while maintaining collaboration 37 + 38 + #### ATCR's Similar Challenge 39 + ``` 40 + atcr.io/alice/myapp 41 + ├── Manifests → alice's PDS 42 + ├── Blobs → Hold service (external) 43 + └── Future: Comments, builds, attestations → Where? 44 + ``` 45 + 46 + ### Stream.place's Approach 47 + 48 + Stream.place built a **minimal "static PDS"** embedded in their application with just the XRPC endpoints they need: 49 + - `com.atproto.repo.describeRepo` 50 + - `com.atproto.sync.subscribeRepos` 51 + - Minimal read methods 52 + 53 + **Why:** Avoid rate-limiting Bluesky's infrastructure with video segments while staying ATProto-native. 54 + 55 + ## Current Hold Service Architecture 56 + 57 + The current hold service is intentionally minimal: 58 + 59 + ``` 60 + Hold Service = 61 + - OAuth token validation (call user's PDS) 62 + - Generate presigned S3 URLs 63 + - Return HTTP redirects 64 + - Optional crew membership checks 65 + ``` 66 + 67 + **Endpoints:** 68 + - `POST /get-presigned-url` → S3 download URL 69 + - `POST /put-presigned-url` → S3 upload URL 70 + - `GET /blobs/{digest}` → Proxy fallback 71 + - `PUT /blobs/{digest}` → Proxy fallback 72 + - `GET /health` → Health check 73 + 74 + **Resource footprint:** 75 + - Single Go binary (~20MB) 76 + - No database (stateless) 77 + - No PDS (validates against user's PDS) 78 + - Minimal memory/CPU (just signing URLs) 79 + - S3 does all the heavy lifting 80 + 81 + This is already **as cheap as possible** for what it does - just an OAuth validation + URL signing service. 82 + 83 + ## Why Not Force Blobs into User PDSs? 84 + 85 + ### Size Considerations 86 + 87 + **PDS blob limits:** Default ~50MB (Bluesky may be lower) 88 + 89 + **Container layer sizes:** 90 + - Alpine base: ~5MB ✓ 91 + - Config blobs: ~1-5KB ✓ 92 + - Small Go binaries: 10-30MB ✓ 93 + - Node.js base: 100-200MB ✗ 94 + - Python base: 50-100MB ✗ 95 + - ML models: 500MB - 10GB ✗ 96 + - Large datasets: huge ✗ 97 + 98 + **Reality:** Many/most layers exceed 50MB. A split-brain approach would be the norm, not the exception. 99 + 100 + ### Split-Brain Complexity 101 + 102 + ```go 103 + func (s *SplitBlobStore) Create(ctx context.Context, options ...) { 104 + // Challenges: 105 + // 1. Monolithic uploads: Size known upfront ✓ 106 + // 2. Chunked uploads: Size unknown until complete ✗ 107 + // 3. Resumable uploads: State management across PDS/hold ✗ 108 + // 4. Mount/cross-repo: Which backend to check? ✗ 109 + } 110 + ``` 111 + 112 + Detection works for simple cases but breaks down with: 113 + - Multipart/chunked uploads (no size until complete) 114 + - Resumable uploads (stateful across boundaries) 115 + - Cross-repository blob mounts (which backend?) 116 + 117 + ### Pragmatic Decision 118 + 119 + **Accept the trade-off:** 120 + - Blobs in holds (practical for large data) 121 + - Manifests in user's PDS (ownership of metadata) 122 + - Focus on making holds easy to deploy and migrate 123 + 124 + Users still own the **important part** - the manifest is the source of truth for what the image is. 125 + 126 + ## Embedded PDS Vision 127 + 128 + ### Key Insight: Hold is the PDS 129 + 130 + Because blobs are **content-addressed** and **deduplicated globally**, there isn't a singular owner of blob data. Multiple images share the same base layer blobs. 131 + 132 + **Therefore:** The **hold itself** is the PDS (with identity `did:web:hold1.example.com`), not individual image repositories. 133 + 134 + ### Proposed Architecture 135 + 136 + ``` 137 + Hold Service = Minimal PDS (did:web:hold1.example.com) 138 + ├── Standard ATProto blob endpoints: 139 + │ ├── com.atproto.sync.uploadBlob 140 + │ ├── com.atproto.sync.getBlob 141 + │ └── Blob storage → S3 (like normal PDS) 142 + ├── Custom XRPC methods: 143 + │ ├── io.atcr.hold.delegateAccess (IAM) 144 + │ ├── io.atcr.hold.getUploadUrl (optimization) 145 + │ ├── io.atcr.hold.getDownloadUrl (optimization) 146 + │ ├── io.atcr.hold.exportImage (data portability) 147 + │ └── io.atcr.hold.getStats (metadata) 148 + └── Records (hold's own PDS): 149 + ├── io.atcr.hold.crew (crew membership) 150 + └── io.atcr.hold.config (hold configuration) 151 + ``` 152 + 153 + ### Benefits 154 + 155 + 1. **ATProto-native**: Uses standard XRPC, not custom REST API 156 + 2. **Discoverable**: Hold's DID document advertises capabilities 157 + 3. **Portable**: Users can export images via XRPC 158 + 4. **Standardized**: Blob operations use ATProto conventions 159 + 5. **Future-proof**: Can add more XRPC methods as needed 160 + 6. **Interoperable**: Works with ATProto tooling 161 + 162 + ## Implementation Details 163 + 164 + ### 1. SHA256 to CID Mapping 165 + 166 + ATProto uses CIDs (Content Identifiers) for blobs, while OCI uses SHA256 digests. However, CIDs support SHA256 as the hash function. 167 + 168 + **Key insight:** We can construct CIDs directly from SHA256 digests with no additional storage needed! 169 + 170 + ```go 171 + // pkg/hold/cid.go 172 + func DigestToCID(digest string) (cid.Cid, error) { 173 + // sha256:abc123... → raw bytes 174 + hash := parseDigest(digest) 175 + 176 + // Construct CIDv1 with sha256 codec 177 + return cid.NewCidV1( 178 + cid.Raw, // codec 179 + multihash.SHA2_256, // hash function 180 + hash, // hash bytes 181 + ) 182 + } 183 + 184 + func CIDToDigest(c cid.Cid) string { 185 + // Decode multihash → sha256:abc... 186 + mh := c.Hash() 187 + return fmt.Sprintf("sha256:%x", mh) 188 + } 189 + ``` 190 + 191 + **Mapping:** 192 + ``` 193 + OCI digest: sha256:abc123... 194 + ATProto CID: bafybei... (CIDv1 with sha256, base32 encoded) 195 + Storage path: s3://bucket/blobs/sha256/ab/abc123... 196 + ``` 197 + 198 + Blobs stay in distribution's layout, we just compute CID on-the-fly. **No mapping records needed.** 199 + 200 + ### 2. Storage: Distribution Layout with PDS Interface 201 + 202 + The hold's blob storage uses distribution's driver directly - no encoding or transformation: 203 + 204 + ```go 205 + type HoldBlobStore struct { 206 + storageDriver storagedriver.StorageDriver // S3, filesystem, etc 207 + } 208 + 209 + // Implements ATProto blob interface 210 + func (h *HoldBlobStore) UploadBlob(ctx context.Context, data io.Reader) (cid.Cid, error) { 211 + // 1. Compute sha256 while reading 212 + digest, size := computeDigest(data) 213 + 214 + // 2. Store at distribution's path: blobs/sha256/ab/abc123... 215 + path := h.blobPath(digest) 216 + h.storageDriver.PutContent(ctx, path, data) 217 + 218 + // 3. Return CID (computed from sha256) 219 + return DigestToCID(digest), nil 220 + } 221 + 222 + func (h *HoldBlobStore) GetBlob(ctx context.Context, c cid.Cid) (io.Reader, error) { 223 + // 1. Convert CID → sha256 digest 224 + digest := CIDToDigest(c) 225 + 226 + // 2. Fetch from distribution's path 227 + path := h.blobPath(digest) 228 + return h.storageDriver.Reader(ctx, path, 0) 229 + } 230 + ``` 231 + 232 + Storage continues to use distribution's existing S3 layout. The PDS interface is just a wrapper. 233 + 234 + ### 3. Authentication & IAM 235 + 236 + **Challenge:** ATProto operations are authenticated AS the account owner. For hold operations, we need actions to be performed AS the hold (not individual users), but authorized BY crew members. 237 + 238 + **Important context:** AppView manages the user's OAuth session. When users authenticate via the credential helper, they actually authenticate through AppView's web interface. AppView obtains and stores the user's OAuth token and DPoP key. The credential helper only receives a registry JWT. 239 + 240 + **Proposed: DPoP Proof Delegation (Standard ATProto Federation)** 241 + 242 + ``` 243 + 1. User authenticates via AppView (OAuth flow) 244 + - AppView obtains: OAuth token, refresh token, DPoP key, DID 245 + - AppView stores these in its token storage 246 + - Credential helper receives: Registry JWT only 247 + 248 + 2. When AppView needs blob access, it calls hold: 249 + POST /xrpc/io.atcr.hold.delegateAccess 250 + Headers: Authorization: DPoP <user-oauth-token> 251 + DPoP: <proof-signed-with-user-dpop-key> 252 + Body: { 253 + "userDid": "did:plc:alice123", 254 + "purpose": "blob-upload", 255 + "duration": 900 256 + } 257 + 258 + 3. Hold validates (standard ATProto token validation): 259 + - Verify DPoP proof signature matches token's bound key 260 + - Call user's PDS: com.atproto.server.getSession (validates token) 261 + - Extract user's DID from validated session 262 + - Check user's DID in hold's crew records 263 + - If authorized, issue temporary token for blob operations 264 + 265 + 4. AppView uses delegated token for blob operations: 266 + POST /xrpc/com.atproto.sync.uploadBlob 267 + Headers: Authorization: DPoP <hold-token> 268 + DPoP: <proof> 269 + ``` 270 + 271 + **This is standard ATProto federation** - services pass OAuth tokens with DPoP proofs between each other. Hold independently validates tokens against the user's PDS, so there's no trust relationship required. 272 + 273 + **Crew records stored in hold's PDS:** 274 + ```json 275 + { 276 + "$type": "io.atcr.hold.crew", 277 + "member": "did:plc:alice123", 278 + "role": "admin", 279 + "permissions": ["blob:read", "blob:write", "crew:manage"], 280 + "addedAt": "2025-10-14T..." 281 + } 282 + ``` 283 + 284 + **Security considerations:** 285 + - User's OAuth token is exposed to hold during delegation 286 + - However, hold independently validates it (can't be forged) 287 + - Tokens are short-lived (15min typical) 288 + - Hold only accepts tokens for crew members 289 + - Hold validates DPoP binding (requires private key) 290 + - Standard ATProto security model 291 + 292 + ### 4. Presigned URLs for Optimized Egress 293 + 294 + While standard ATProto blob endpoints work, direct S3 access is more efficient. Hold can expose custom XRPC methods: 295 + 296 + ```go 297 + // io.atcr.hold.getUploadUrl - Get presigned upload URL 298 + type GetUploadUrlRequest struct { 299 + Digest string // sha256:abc... 300 + Size int64 301 + } 302 + 303 + type GetUploadUrlResponse struct { 304 + UploadURL string // Presigned S3 URL 305 + ExpiresAt time.Time 306 + } 307 + 308 + // io.atcr.hold.getDownloadUrl - Get presigned download URL 309 + type GetDownloadUrlRequest struct { 310 + Digest string 311 + } 312 + 313 + type GetDownloadUrlResponse struct { 314 + DownloadURL string // Presigned S3 URL 315 + ExpiresAt time.Time 316 + } 317 + ``` 318 + 319 + **AppView uses optimized path:** 320 + ```go 321 + func (a *ATProtoBlobStore) ServeBlob(ctx, w, r, dgst) error { 322 + // Try optimized presigned URL endpoint 323 + resp, err := a.client.GetDownloadUrl(ctx, dgst) 324 + if err == nil { 325 + // Redirect directly to S3 326 + http.Redirect(w, r, resp.DownloadURL, http.StatusTemporaryRedirect) 327 + return nil 328 + } 329 + 330 + // Fallback: Standard ATProto blob endpoint (proxied) 331 + reader, _ := a.client.GetBlob(ctx, holdDID, cid) 332 + io.Copy(w, reader) 333 + } 334 + ``` 335 + 336 + **Best of both worlds:** Standard ATProto interface + S3 optimization for bandwidth efficiency. 337 + 338 + ### 5. Image Export for Portability 339 + 340 + Custom XRPC method enables users to export entire images: 341 + 342 + ```go 343 + // io.atcr.hold.exportImage - Export all blobs for an image 344 + type ExportImageRequest struct { 345 + Manifest *oci.Manifest // User provides manifest 346 + } 347 + 348 + type ExportImageResponse struct { 349 + ArchiveURL string // Presigned S3 URL to tar.gz 350 + ExpiresAt time.Time 351 + } 352 + 353 + // Implementation: 354 + // 1. Extract all blob digests from manifest (config + layers) 355 + // 2. Create tar.gz with all blobs 356 + // 3. Upload to S3 temp location 357 + // 4. Return presigned download URL (15min expiry) 358 + ``` 359 + 360 + Users can request all blobs for their images and migrate to different holds. 361 + 362 + ## Changes Required 363 + 364 + ### AppView Changes 365 + 366 + **Current:** 367 + ```go 368 + type ProxyBlobStore struct { 369 + holdURL string // HTTP endpoint 370 + } 371 + 372 + func (p *ProxyBlobStore) ServeBlob(...) { 373 + // POST /put-presigned-url 374 + // Return redirect 375 + } 376 + ``` 377 + 378 + **New:** 379 + ```go 380 + type ATProtoBlobStore struct { 381 + holdDID string // did:web:hold1.example.com 382 + holdURL string // Resolved from DID document 383 + client *atproto.Client // XRPC client 384 + delegatedToken string // From io.atcr.hold.delegateAccess 385 + } 386 + 387 + func (a *ATProtoBlobStore) ServeBlob(ctx, w, r, dgst) error { 388 + // Try optimized: io.atcr.hold.getDownloadUrl 389 + // Fallback: com.atproto.sync.getBlob 390 + } 391 + ``` 392 + 393 + ### Hold Service Changes 394 + 395 + Transform from simple HTTP server to minimal PDS: 396 + 397 + ```go 398 + // cmd/hold/main.go 399 + func main() { 400 + // Storage driver (unchanged) 401 + storageDriver := buildStorageDriver() 402 + 403 + // NEW: Embedded PDS 404 + pds := hold.NewEmbeddedPDS(hold.Config{ 405 + DID: "did:web:hold1.example.com", 406 + BlobStore: storageDriver, 407 + Collections: []string{ 408 + "io.atcr.hold.crew", 409 + "io.atcr.hold.config", 410 + }, 411 + }) 412 + 413 + // Serve XRPC endpoints 414 + mux.Handle("/xrpc/", pds.Handler()) 415 + 416 + // Legacy endpoints (optional for backwards compat) 417 + // mux.Handle("/get-presigned-url", legacyHandler) 418 + } 419 + ``` 420 + 421 + ## Open Questions 422 + 423 + ### 1. Docker Hub Size Limits 424 + 425 + **Research findings:** Docker Hub has soft limits around 10-20GB per layer, with practical issues beyond that. No hard-coded enforcement. 426 + 427 + **For ATCR:** Hold services can theoretically support larger blobs if S3 and network infrastructure allows. May want configurable limits to prevent abuse. 428 + 429 + ### 2. Token Delegation Security Model 430 + 431 + **Recommended approach:** DPoP proof delegation (standard ATProto federation pattern) 432 + 433 + Open questions: 434 + - How long should delegated tokens last? (15min like presigned URLs?) 435 + - Should delegation be per-operation or session-based? 436 + - Do we need audit logs for delegated operations? 437 + - Can AppView cache delegated tokens across requests? 438 + - Should we implement token refresh for long-running operations? 439 + 440 + ### 3. Migration Path 441 + 442 + - Do we support both HTTP and XRPC APIs during transition? 443 + - How do existing manifests with `holdEndpoint: "https://..."` migrate to `holdDid: "did:web:..."`? 444 + - Can AppView auto-detect if hold supports XRPC vs legacy? 445 + 446 + ### 4. PDS Implementation Scope 447 + 448 + **Minimal endpoints needed:** 449 + - `com.atproto.sync.uploadBlob` 450 + - `com.atproto.sync.getBlob` 451 + - `com.atproto.repo.describeRepo` (discovery) 452 + - Custom XRPC methods (delegation, presigned URLs, export) 453 + 454 + **Not needed:** 455 + - `com.atproto.repo.*` (no user repos) 456 + - `com.atproto.server.*` (no user sessions) 457 + - Most sync/admin endpoints 458 + 459 + Can we build a reusable "static PDS" library for apps like ATCR, tangled.org, stream.place? 460 + 461 + ### 5. Crew Management 462 + 463 + - How are crew members added/removed? 464 + - UI in AppView? CLI tool? Direct XRPC calls? 465 + - Can crew members delegate to other crew members? 466 + - Role hierarchy (owner > admin > member)? 467 + 468 + ### 6. Hold Discovery & Registration 469 + 470 + **Current:** Hold registers by creating records in owner's PDS 471 + **New:** Hold is its own identity - how does AppView discover available holds? 472 + 473 + Possibilities: 474 + - Holds publish to feeds 475 + - AppView maintains directory 476 + - DIDs are manually configured 477 + - ATProto directory service 478 + 479 + ### 7. Multi-Tenancy 480 + 481 + Could one hold PDS serve multiple "logical holds" for different organizations? 482 + 483 + ``` 484 + did:web:hold-provider.com/org1 485 + did:web:hold-provider.com/org2 486 + ``` 487 + 488 + Or should each hold be a separate deployment? 489 + 490 + ### 8. Blob Deduplication 491 + 492 + Current behavior: Global deduplication (same layer shared across all images). 493 + 494 + With embedded PDS: 495 + - Does dedup stay global across all crew/users? 496 + - Or is it per-hold (isolated storage)? 497 + - How do we track blob references for garbage collection? 498 + 499 + ### 9. Cost Model 500 + 501 + - Who pays for S3 storage/egress? 502 + - Hold operator? Image owner? Per-pull? 503 + - How to implement metering/billing via XRPC? 504 + 505 + ### 10. Disaster Recovery 506 + 507 + - How to backup hold's PDS (crew records, config)? 508 + - Can holds replicate to other holds? 509 + - Image export handles blobs - what about metadata? 510 + 511 + ## Implementation Plan 512 + 513 + ### Phase 1: Basic PDS with Carstore (Current) 514 + 515 + **Decision: Use indigo's carstore with SQLite backend** 516 + 517 + ```go 518 + import ( 519 + "github.com/bluesky-social/indigo/carstore" 520 + "github.com/bluesky-social/indigo/repo" 521 + ) 522 + 523 + type HoldPDS struct { 524 + did string 525 + carstore carstore.CarStore 526 + repo *repo.Repo 527 + } 528 + 529 + func NewHoldPDS(did, dbPath string) (*HoldPDS, error) { 530 + // Create SQLite-backed carstore 531 + sqlStore, err := carstore.NewSqliteStore(dbPath) 532 + cs := sqlStore.CarStore() 533 + 534 + // Get or create repo 535 + head, err := cs.GetUserRepoHead(ctx, did) 536 + var r *repo.Repo 537 + if err == carstore.ErrRepoNotFound { 538 + r, err = repo.NewRepo(ctx, did, cs.Blockstore()) 539 + } else { 540 + r, err = repo.OpenRepo(ctx, cs.Blockstore(), head) 541 + } 542 + 543 + return &HoldPDS{did: did, carstore: cs, repo: r}, nil 544 + } 545 + ``` 546 + 547 + **Storage:** 548 + - Single file: `/var/lib/atcr-hold/hold.db` (SQLite) 549 + - Contains MST nodes, records, commits in carstore tables 550 + - Proper indigo repo/MST implementation (production-tested) 551 + 552 + **Why SQLite carstore:** 553 + - ✅ Single file persistence (like appview's SQLite) 554 + - ✅ Official indigo storage backend 555 + - ✅ No custom blockstore implementation needed 556 + - ✅ Handles compaction/cleanup automatically 557 + - ✅ Migration path to Postgres/Scylla if needed 558 + - ✅ Easy to replicate (Litestream, LiteFS, rsync) 559 + 560 + **Scale considerations:** 561 + - SQLite carstore marked "experimental" but suitable for single-hold use 562 + - MST designed for massive scale (O(log n) operations) 563 + - 1000 crew records = ~1-2MB database (trivial) 564 + - Bluesky PDSs use carstore for millions of records 565 + - If needed: migrate to Postgres-backed carstore (same API) 566 + 567 + ### Crew Management: Individual Records 568 + 569 + **Decision: Individual crew record per user (remove wildcard logic)** 570 + 571 + ```json 572 + // io.atcr.hold.crew/{rkey} 573 + { 574 + "$type": "io.atcr.hold.crew", 575 + "member": "did:plc:alice123", 576 + "role": "admin", // or "member" 577 + "permissions": ["blob:read", "blob:write"], 578 + "addedAt": "2025-10-14T..." 579 + } 580 + 581 + // io.atcr.hold.config/policy 582 + { 583 + "$type": "io.atcr.hold.config", 584 + "access": "public", // or "allowlist" 585 + "allowAny": true, // public: allow any authenticated user 586 + "requireAuth": true, // require authentication (no anonymous) 587 + "maxUsers": 1000 // optional limit 588 + } 589 + ``` 590 + 591 + **Authorization logic:** 592 + ```go 593 + func (p *HoldPDS) CheckAccess(ctx context.Context, userDID string) (bool, error) { 594 + policy := p.GetPolicy(ctx) 595 + 596 + if policy.Access == "public" && policy.AllowAny { 597 + // Public hold - any authenticated ATCR user allowed 598 + // No individual crew record needed 599 + return true, nil 600 + } 601 + 602 + if policy.Access == "allowlist" { 603 + // Check explicit crew membership 604 + _, err := p.GetCrewMember(ctx, userDID) 605 + return err == nil, nil 606 + } 607 + 608 + return false, nil 609 + } 610 + ``` 611 + 612 + **Benefits of individual records:** 613 + - Auditability (track who has access) 614 + - Per-user permissions (admin vs member) 615 + - Explicit revocation capabilities 616 + - Analytics (usage tracking) 617 + - Rate limiting (per-user quotas) 618 + - subscribeRepos events on crew changes 619 + 620 + **Use cases:** 621 + - **Public community hold:** `access: "public", allowAny: true` - no crew records needed 622 + - **Private team hold:** `access: "allowlist"` - explicit crew membership 623 + - **Hybrid:** Public access + explicit admin crew records for elevated permissions 624 + 625 + ### Next Steps 626 + 627 + 1. **Add indigo dependencies** - carstore, repo, MST 628 + 2. **Implement HoldPDS with carstore** - Create pkg/hold/pds 629 + 3. **Add crew management** - CRUD operations for crew records 630 + 4. **Implement standard PDS endpoints** - describeServer, describeRepo, getRecord, listRecords 631 + 5. **Add DID document** - did:web identity generation 632 + 6. **Custom XRPC methods** - getUploadUrl, getDownloadUrl (presigned URLs) 633 + 7. **Wire up in cmd/hold** - Serve XRPC alongside existing HTTP 634 + 8. **Test basic operations** - Add/list crew, policy checks 635 + 9. **Design delegation/IAM** - Token exchange for authenticated operations 636 + 10. **Implement AppView XRPC client** - Support PDS-based holds 637 + 638 + ## References 639 + 640 + - **Stream.place embedded PDS:** https://streamplace.leaflet.pub/3lut7mgni5s2k/l-quote/6_318-6_554#6 641 + - **ATProto OAuth spec:** https://atproto.com/specs/oauth 642 + - **ATProto XRPC spec:** https://atproto.com/specs/xrpc 643 + - **CID spec:** https://github.com/multiformats/cid 644 + - **OCI Distribution Spec:** https://github.com/opencontainers/distribution-spec