An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.
at refactor/relay-dry-extraction 1200 lines 70 kB view raw view rendered
1**Provisioning API** 2 3Design Specification 4 5Desktop PDS Relay Layer 6 7Version 0.3 --- Mobile-First Reconciliation 8 9March 2026 10 11*v0.3 Changes — Mobile-First Reconciliation + Endpoint Consolidation* 12 13*Reconciled with mobile architecture spec v1.2 (canonical) and migration spec v0.1.* 14*All endpoints tagged with milestone phase.* 15 16*FIX Ed25519 → P-256/secp256k1 throughout (ATProto requirement)* 17*FIX DID ceremony: client sends key material, relay constructs DID doc* 18*FIX Tier model: Free/Pro/Business + BYO as deployment model* 19*NEW POST /v1/accounts/mobile — combined mobile account creation* 20*NEW 9 endpoints from mobile spec (relay keys, device mgmt, signing)* 21*NEW 8 endpoints from migration spec (transfer, recovery, Shamir)* 22*NEW Blob endpoints (uploadBlob, getBlob, listBlobs)* 23*NEW Milestone tags on all endpoint groups* 24 25**CONFIDENTIAL** 26 271\. Overview 28 29This document specifies the provisioning API for the Desktop PDS relay layer. The API orchestrates five flows: account creation, device binding, DID ceremony, handle/domain setup, and account exit. Two client types consume the API: the web dashboard (account lifecycle, billing) and the Tauri desktop app (device binding, runtime operations). 30 31All endpoints are served over HTTPS at the relay's base URL. The API follows REST conventions with JSON request/response bodies. Authentication uses Bearer tokens with three scopes: account, session, and device. 32 331.1 Base URL 34 35> https://relay.{service-domain}/v1 36 371.2 Identity Model 38 39The relay supports two DID methods. The choice is made during the setup wizard and cannot be changed after the DID ceremony completes. 40 41 ------------ ------------- ----------------------------------- --------------------------------------------------------------------------------------------- 42 **Method** **Default** **Requirement** **Exit Story** 43 did:plc Yes Available to all tiers User signs a PLC operation to repoint service endpoint. Clean exit, zero ongoing liability. 44 did:web No Custom domain required (Pro tier) User controls the domain, repoints DNS at new PDS. Zero ongoing liability. 45 ------------ ------------- ----------------------------------- --------------------------------------------------------------------------------------------- 46 47> ***Design Decision:** did:web is only available to users who bring their own domain. Subdomain-based did:web is not offered because it creates exit liability --- the relay would be obligated to host the DID document indefinitely after the user leaves.* 48 491.3 Authentication Model 50 51The API uses three token types, each scoped to a specific client and lifetime: 52 53 ---------------- --------------------- ---------------------------- -------------------------------------------- 54 **Token** **Issued To** **Lifetime** **Scope** 55 session\_token Web dashboard 24 hours (renewable) Account management, billing, handle config 56 device\_token Tauri app Long-lived (until revoked) Relay connection, DID ops, sync 57 claim\_code Web → Tauri handoff 15 minutes (single-use) Device registration only 58 ---------------- --------------------- ---------------------------- -------------------------------------------- 59 60All authenticated requests must include the token in the Authorization header: 61 62> Authorization: Bearer {token} 63> 64> ***Design Decision:** All users authenticate through the web dashboard, including self-hosted relay operators. There is no static\_token bypass. One auth path means one security model to audit.* 65 661.4 Rate Limiting 67 68All endpoints are rate-limited per token. Free-tier accounts have stricter limits. When a limit is hit, the API returns 429 Too Many Requests with a Retry-After header. 69 70 ------------- ------------ ----------- --------------- 71 **Tier** **Writes** **Reads** **Burst** 72 Free 30/min 120/min 5 concurrent 73 Pro 120/min 600/min 20 concurrent 74 Business 300/min 1200/min 50 concurrent 75 ------------- ------------ ----------- --------------- 76 77Note: BYO relay operators configure their own rate limits. BYO is a deployment model, not a subscription tier. See §6.3 for BYO relay configuration. 78 791.5 Error Envelope 80 81All error responses use a consistent JSON envelope: 82 83> { 84> 85> \"error\": { 86> 87> \"code\": \"ACCOUNT\_EXISTS\", 88> 89> \"message\": \"An account with this email already exists.\", 90> 91> \"details\": { \... } // optional, endpoint-specific 92> 93> } 94> 95> } 96 972\. Account Lifecycle 98 99Account endpoints are consumed by the web dashboard. They handle signup, authentication, and account management. Accounts start on the free tier with usage caps enforced at the relay level. 100 101**POST /v1/accounts** [v0.1] 102 103Create a new account. Returns session credentials and a one-time claim code for device binding. 104 105**Request Body** 106 107 --------------- ---------- -------------- ----------------------- 108 **Field** **Type** **Required** **Description** 109 email string yes User's email address 110 password string yes Minimum 12 characters 111 display\_name string no Optional display name 112 --------------- ---------- -------------- ----------------------- 113 114**Response (200 OK)** 115 116 ---------------- ---------- -------------------------------------------- 117 **Field** **Type** **Description** 118 account\_id string UUID v7 account identifier 119 session\_token string JWT, 24-hour expiry 120 claim\_code string 6-character alphanumeric, 15-minute expiry 121 tier string Always \"free\" on creation 122 ---------------- ---------- -------------------------------------------- 123 124**Error Responses** 125 126 ------------ ----------------- --------------------------------------- 127 **Status** **Code** **Description** 128 409 ACCOUNT\_EXISTS Email already registered 129 422 WEAK\_PASSWORD Password doesn't meet requirements 130 429 RATE\_LIMITED Too many signup attempts from this IP 131 ------------ ----------------- --------------------------------------- 132 133> ***Note:** The claim\_code is displayed in the web dashboard for the user to paste into their Tauri app. It is single-use and expires after 15 minutes. A new one can be generated via POST /v1/accounts/claim-codes.* 134 135**POST /v1/accounts/mobile** [v0.1] 136 137Combined account creation for mobile clients. Creates the account, binds the device, generates the relay signing key, and initiates the DID ceremony in one request. Replaces the multi-step web dashboard flow for iOS users. 138 139**Request Body** 140 141 ----------------------- ---------- -------------- ------------------------------------------------------- 142 **Field** **Type** **Required** **Description** 143 email string yes User's email address 144 password string yes Minimum 12 characters 145 display_name string no Optional display name 146 device_public_key string yes P-256 public key from Secure Enclave 147 device_name string no e.g. "iPhone 15 Pro" 148 rotation_pub_key string yes P-256 rotation key (stays on device) 149 handle string no Desired handle (subdomain assigned if omitted) 150 did_method string no "did:plc" (default) or "did:web" 151 ----------------------- ---------- -------------- ------------------------------------------------------- 152 153**Response (200 OK)** 154 155 ----------------------- ---------- ------------------------------------------------------- 156 **Field** **Type** **Description** 157 account_id string UUID v7 account identifier 158 device_id string UUID v7 device identifier 159 device_token string Long-lived opaque token 160 session_token string JWT, 24-hour expiry 161 did string Fully qualified DID string 162 did_document object The constructed DID document 163 handle string Assigned handle 164 relay_endpoint object Relay Endpoint Object (see §3.2) 165 relay_signing_key string Key ID of the relay's signing key 166 tier string Always "free" on creation 167 shamir_share_1 string Encrypted share for iCloud Keychain storage 168 shamir_share_3_options array Available storage methods for Share 3 169 ----------------------- ---------- ------------------------------------------------------- 170 171**Error Responses** 172 173 ------------ ----------------------- ------------------------------------------------------- 174 **Status** **Code** **Description** 175 409 ACCOUNT_EXISTS Email already registered 176 422 WEAK_PASSWORD Password doesn't meet requirements 177 422 INVALID_KEY Public key is malformed or unsupported curve 178 409 HANDLE_TAKEN Requested handle is already in use 179 429 RATE_LIMITED Too many signup attempts 180 ------------ ----------------------- ------------------------------------------------------- 181 182Note: This endpoint performs Shamir share generation as part of account creation. Share 1 is returned in the response for the client to store in iCloud Keychain. Share 2 is escrowed at the relay. Share 3 handling depends on user choice (communicated in a follow-up call or during onboarding flow). 183 184**POST /v1/accounts/sessions** [v0.1] 185 186Authenticate and obtain a session token. Supports email/password and refresh token flows. 187 188**Request Body** 189 190 ---------------- ---------- -------------- ---------------------------------------- 191 **Field** **Type** **Required** **Description** 192 email string yes\* Required for password auth 193 password string yes\* Required for password auth 194 refresh\_token string yes\* Alternative: renew an existing session 195 ---------------- ---------- -------------- ---------------------------------------- 196 197**Response (200 OK)** 198 199 ---------------- ---------- ----------------------------- 200 **Field** **Type** **Description** 201 session\_token string JWT, 24-hour expiry 202 refresh\_token string Opaque token, 30-day expiry 203 account\_id string UUID v7 204 ---------------- ---------- ----------------------------- 205 206**Error Responses** 207 208 ------------ ---------------------- --------------------------- 209 **Status** **Code** **Description** 210 401 INVALID\_CREDENTIALS Email/password mismatch 211 401 TOKEN\_EXPIRED Refresh token has expired 212 423 ACCOUNT\_LOCKED Too many failed attempts 213 ------------ ---------------------- --------------------------- 214 215**POST /v1/accounts/claim-codes** [v0.1] 216 217Generate a new device claim code. Invalidates any previously active claim code for this account. 218 219**Response (200 OK)** 220 221 ------------- ---------- -------------------------------------------- 222 **Field** **Type** **Description** 223 claim\_code string 6-character alphanumeric, 15-minute expiry 224 expires\_at string ISO 8601 timestamp 225 ------------- ---------- -------------------------------------------- 226 227**Error Responses** 228 229 ------------ --------------- ---------------------------------- 230 **Status** **Code** **Description** 231 401 UNAUTHORIZED Invalid or missing session token 232 429 RATE\_LIMITED Max 5 claim codes per hour 233 ------------ --------------- ---------------------------------- 234 235> ***Note:** Requires session\_token authentication.* 236 237**GET /v1/accounts/:id/usage** [v0.1] 238 239Returns current usage metrics for the account. Consumed by both the web dashboard (billing page) and the Tauri app (status bar indicator). 240 241**Response (200 OK)** 242 243 ------------------ ---------- ------------------------------------------------------- 244 **Field** **Type** **Description** 245 tier string Current tier: \"free\" \| \"pro\" \| \"self\_hosted\" 246 period\_start string ISO 8601, start of current billing period 247 storage\_bytes integer Repo storage consumed 248 storage\_limit integer Tier limit in bytes 249 bandwidth\_bytes integer Relay bandwidth this period 250 bandwidth\_limit integer Tier limit in bytes 251 requests\_count integer XRPC requests proxied this period 252 requests\_limit integer Tier limit 253 ------------------ ---------- ------------------------------------------------------- 254 255**Error Responses** 256 257 ------------ -------------- -------------------------------- 258 **Status** **Code** **Description** 259 401 UNAUTHORIZED Invalid token 260 403 FORBIDDEN Token doesn't own this account 261 ------------ -------------- -------------------------------- 262 263> ***Note:** Both session\_token and device\_token can access this endpoint, but only for their own account.* 264 2653\. Device Registration 266 267Device endpoints are consumed by the Tauri app. The claim code handoff binds a specific device to an account, and the device\_token becomes the app's long-lived credential for all relay interactions. 268 2693.1 Multi-Device Model 270 271Pro accounts support up to 5 devices. To maintain a linear commit chain on the ATProto repo (required for federation), the relay enforces a primary-device model with lease-based write ownership. 272 273- **Primary device:** Holds the write lease. Can commit to the repo, push to the relay, and trigger federation events. 274 275- **Secondary devices:** Read-only replicas that sync from the relay. They see the full repo but cannot commit. A secondary can request promotion to primary. 276 277- **Lease transfer:** Explicit via the web dashboard or Tauri app. The current primary relinquishes the lease, the requesting device acquires it. If the primary is offline for longer than the lease TTL (configurable, default 24 hours), the lease expires and any device can claim it. 278 279> ***Design Decision:** Primary-device with lease was chosen over last-write-wins or conflict queues. LWW risks lost writes on rebase; conflict queues require users to resolve Merkle tree forks. Primary-device sidesteps the problem entirely and matches how most people use desktop apps.* 280 281**POST /v1/devices** [v0.1] 282 283Register a device by redeeming a claim code. The Tauri app generates a keypair locally and sends the public key. The relay binds the device and returns a device token. The first device registered to an account automatically receives the primary write lease. 284 285**Request Body** 286 287 --------------------- ---------- -------------- -------------------------------------------- 288 **Field** **Type** **Required** **Description** 289 claim\_code string yes 6-character code from web dashboard 290 device\_public\_key string yes P-256 (secp256r1) public key, base64url-encoded 291 device\_name string no Human-readable label, e.g. \"MacBook Pro\" 292 os string no Operating system identifier 293 app\_version string no Tauri app version string 294 --------------------- ---------- -------------- -------------------------------------------- 295 296**Response (200 OK)** 297 298 ----------------- ---------- ------------------------------------------- 299 **Field** **Type** **Description** 300 device\_id string UUID v7 device identifier 301 device\_token string Long-lived opaque token 302 account\_id string Bound account UUID 303 is\_primary boolean Whether this device holds the write lease 304 relay\_endpoint object See Relay Endpoint Object below 305 ----------------- ---------- ------------------------------------------- 306 307**Error Responses** 308 309 ------------ ---------------- --------------------------------------------------- 310 **Status** **Code** **Description** 311 400 INVALID\_CLAIM Claim code is invalid, expired, or already used 312 409 DEVICE\_LIMIT Account has reached maximum device count for tier 313 422 INVALID\_KEY Public key is malformed or unsupported curve 314 ------------ ---------------- --------------------------------------------------- 315 316> ***Note:** The device private key never leaves the Tauri app. The relay stores only the public key for challenge-response verification during reconnection.* 317 3183.2 Relay Endpoint Object 319 320Returned by device registration and the relay info endpoint: 321 322 ---------------- ---------- -------------------------------------------- 323 **Field** **Type** **Description** 324 host string Relay hostname 325 port integer Relay port (typically 443) 326 iroh\_node\_id string Iroh node identifier for direct connection 327 region string Relay region code, e.g. \"us-east-1\" 328 protocol string Connection protocol: \"iroh\" \| \"wss\" 329 ---------------- ---------- -------------------------------------------- 330 331**GET /v1/devices/:id/relay** [v0.1] 332 333Retrieve the assigned relay endpoint for a device. Used by the Tauri app on startup to discover where to connect. 334 335**Response (200 OK)** 336 337 ----------------- ---------- --------------------------------------------------------- 338 **Field** **Type** **Description** 339 relay\_endpoint object Relay Endpoint Object (see 3.2) 340 status string Relay status: \"online\" \| \"degraded\" \| \"offline\" 341 buffer\_depth integer Messages buffered while device was offline 342 ----------------- ---------- --------------------------------------------------------- 343 344**Error Responses** 345 346 ------------ -------------------- ---------------------------------------- 347 **Status** **Code** **Description** 348 401 UNAUTHORIZED Invalid device token 349 404 DEVICE\_NOT\_FOUND Device ID doesn't exist or was revoked 350 ------------ -------------------- ---------------------------------------- 351 352**POST /v1/devices/:id/lease** [v1.0] 353 354Request or release the primary write lease for a device. 355 356**Request Body** 357 358 ----------- ---------- -------------- ---------------------------- 359 **Field** **Type** **Required** **Description** 360 action string yes \"acquire\" or \"release\" 361 ----------- ---------- -------------- ---------------------------- 362 363**Response (200 OK)** 364 365 -------------------- ---------- --------------------------------------------------------------- 366 **Field** **Type** **Description** 367 is\_primary boolean Whether this device now holds the lease 368 lease\_expires\_at string ISO 8601, when the lease auto-expires if not renewed 369 previous\_primary string Device ID of the previous primary (null if lease was expired) 370 -------------------- ---------- --------------------------------------------------------------- 371 372**Error Responses** 373 374 ------------ ---------------------- --------------------------------------------------------------------------------- 375 **Status** **Code** **Description** 376 409 LEASE\_HELD Another device holds an active lease. Must wait for expiry or explicit release. 377 403 SINGLE\_DEVICE\_TIER Free tier accounts have only one device; lease management is not applicable. 378 ------------ ---------------------- --------------------------------------------------------------------------------- 379 380> ***Note:** The Tauri app should silently renew the lease by calling this endpoint periodically (recommended: every 6 hours). If the primary device is offline beyond the lease TTL (default 24h), any other device can acquire the lease.* 381 382**DELETE /v1/devices/:id** [v1.0] 383 384Revoke a device. Invalidates its device\_token and disconnects it from the relay. If the revoked device was primary, the lease is released. Buffered messages are held for 72 hours before purging. 385 386**Response (200 OK)** 387 388 ------------------- ---------- ----------------------------------------------------------- 389 **Field** **Type** **Description** 390 revoked\_at string ISO 8601 timestamp 391 buffer\_purge\_at string ISO 8601, when buffered messages will be deleted 392 lease\_released boolean Whether the primary lease was released by this revocation 393 ------------------- ---------- ----------------------------------------------------------- 394 395**Error Responses** 396 397 ------------ -------------- ------------------------------- 398 **Status** **Code** **Description** 399 401 UNAUTHORIZED Invalid token 400 403 FORBIDDEN Token doesn't own this device 401 ------------ -------------- ------------------------------- 402 403> ***Note:** Requires session\_token (web dashboard). Device tokens cannot self-revoke.* 404 4054\. DID Ceremony 406 407The DID ceremony binds a decentralized identifier to the user's device and relay. The Tauri app generates the DID document locally (the private key never leaves the device) and submits it to the relay for registration. The DID is the user's identity --- the follow graph, repo, and all social connections reference it. There is no such thing as migrating from one DID to another; that would be creating a new account. 408 4094.1 PLC Mirror 410 411The relay operates a PLC directory mirror to improve resolution speed and provide resilience against upstream outages. The mirror starts as read-only (caching and serving existing PLC documents) and may graduate to a read-write authority in a future version. 412 413 ----------- ---------------------- ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 414 **Phase** **Mode** **Behavior** 415 v1.0 Read-only cache Mirrors the PLC directory. Serves cached DID documents for faster resolution. Falls back to upstream plc.directory if cache misses. Provides resilience if upstream is temporarily unavailable. 416 Future Read-write authority Can accept and validate new PLC operations independently. Participates in the PLC directory network as a peer. 417 ----------- ---------------------- ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 418 419> ***Note:** New DID operations (creation, rotation) are submitted to plc.directory via the relay as a proxy in v1.0. The mirror is purely for read-side performance and resilience.* 420 421**POST /v1/dids** [v0.1] 422 423Initiate a DID ceremony. The client submits key material (public keys only — private keys never leave the device). The relay constructs the DID document, including verification methods from submitted keys, service endpoint pointing to the relay, and handle (atproto_handle alias). 424 425For did:plc, the relay constructs and signs the genesis operation, then submits it to plc.directory. For did:web, the relay begins serving /.well-known/did.json through the user's custom domain. 426 427The relay holds the signing key and uses it for all commit signing. The rotation key stays on the device (Secure Enclave on iOS, Keychain on macOS) and is only needed for DID recovery/rotation. 428 429**Request Body** 430 431 ----------------------- ---------- -------------- ------------------------------------------------------- 432 **Field** **Type** **Required** **Description** 433 signing_pub_key string yes P-256 public key for signing, base64url-encoded 434 rotation_pub_key string yes P-256 public key for DID rotation, base64url-encoded 435 method string no "did:plc" (default) or "did:web" (requires custom domain) 436 handle string no Desired handle (if not already set via POST /v1/handles) 437 recovery_keys array no Additional rotation keys for did:plc recovery 438 ----------------------- ---------- -------------- ------------------------------------------------------- 439 440**Response (200 OK)** 441 442 ----------------------- ---------- ------------------------------------------------------- 443 **Field** **Type** **Description** 444 did string Fully qualified DID string 445 did_document object W3C DID Document 446 relay_signing_key_id string Identifier of the relay-generated signing key 447 ----------------------- ---------- ------------------------------------------------------- 448 449**Error Responses** 450 451 ------------ -------------------------------- ------------------------------------------------------- 452 **Status** **Code** **Description** 453 409 ACCOUNT_NOT_FOUND Account does not exist 454 422 INVALID_KEY_FORMAT Public key is malformed or not on supported curve (P-256/secp256k1) 455 422 UNSUPPORTED_DID_METHOD Requested method is not supported or missing required fields 456 429 RATE_LIMITED Too many DID ceremonies initiated 457 ------------ -------------------------------- ------------------------------------------------------- 458 459> ***Note:** For did:plc, the relay submits the signed genesis operation to plc.directory and status will be "pending_propagation" until confirmed. For did:web, the relay begins serving /.well-known/did.json through the user's custom domain.* 460 461**GET /v1/dids/:did** [v0.1] 462 463Retrieve the current DID document and resolution status. For did:plc, resolution checks the PLC mirror first, then falls back to upstream. 464 465**Response (200 OK)** 466 467 ------------------- ---------- ----------------------------------------------------------- 468 **Field** **Type** **Description** 469 did string Fully qualified DID string 470 did\_document object Current DID document 471 method string \"did:plc\" \| \"did:web\" 472 status string \"active\" \| \"pending\_propagation\" \| \"deactivated\" 473 resolution\_url string Public resolution URL 474 last\_rotated\_at string ISO 8601, last key rotation timestamp 475 mirror\_age\_ms integer For did:plc: staleness of the PLC mirror cache entry 476 ------------------- ---------- ----------------------------------------------------------- 477 478**Error Responses** 479 480 ------------ ----------------- --------------------------------- 481 **Status** **Code** **Description** 482 404 DID\_NOT\_FOUND DID doesn't exist in this relay 483 ------------ ----------------- --------------------------------- 484 485**POST /v1/dids/:did/rotate** [v1.0] 486 487Rotate the signing key for a DID. The Tauri app generates a new keypair, signs the rotation operation with the current key, and submits it. Critical for key compromise recovery. 488 489**Request Body** 490 491 ------------------ ---------- -------------- -------------------------------------------------------- 492 **Field** **Type** **Required** **Description** 493 new\_public\_key string yes New P-256 (secp256r1) public key, base64url-encoded 494 rotation\_proof string yes Signed proof from the current key authorizing rotation 495 ------------------ ---------- -------------- -------------------------------------------------------- 496 497**Response (200 OK)** 498 499 --------------------- ---------- ---------------------------------------- 500 **Field** **Type** **Description** 501 did\_document object Updated DID document with new key 502 previous\_key\_hash string Hash of the rotated-out key for audit 503 status string \"active\" \| \"pending\_propagation\" 504 --------------------- ---------- ---------------------------------------- 505 506**Error Responses** 507 508 ------------ ------------------------ ------------------------------------- 509 **Status** **Code** **Description** 510 400 INVALID\_PROOF Rotation proof signature is invalid 511 409 ROTATION\_IN\_PROGRESS A rotation is already pending 512 ------------ ------------------------ ------------------------------------- 513 514> ***Note:** Key rotation also updates the device's registered public key at the relay. For did:plc, the signed rotation operation is submitted to plc.directory. The old key remains valid for challenge-response for a 24-hour grace period to handle in-flight requests.* 515 5165\. Handle & Domain Management 517 518Handle endpoints manage the user's ATProto handle (e.g., alice.yourservice.net or alice.com). Free-tier users get a subdomain on the service domain. Pro users can bring their own domain. The relay automates DNS record provisioning via Cloudflare or Route53. 519 520> ***Note:** Custom domains serve double duty: they are required for both custom handles AND did:web identity. When a Pro user verifies a custom domain, both the handle and the did:web option become available in the setup wizard.* 521 522**POST /v1/handles** [v0.1] 523 524Request a handle. For subdomain handles, the relay provisions DNS immediately. For custom domains, the relay returns required DNS records for the user to configure. 525 526**Request Body** 527 528 ----------- ---------- -------------- -------------------------------------------------------------------------- 529 **Field** **Type** **Required** **Description** 530 handle string yes Desired handle (e.g., \"alice\" for subdomain, \"alice.com\" for custom) 531 type string no \"subdomain\" (default) or \"custom\" 532 ----------- ---------- -------------- -------------------------------------------------------------------------- 533 534**Response (200 OK)** 535 536 --------------------- ---------- ------------------------------------------------------------- 537 **Field** **Type** **Description** 538 handle string Full handle (e.g., \"alice.relay.example.net\") 539 type string \"subdomain\" \| \"custom\" 540 status string \"active\" \| \"pending\_dns\" \| \"pending\_verification\" 541 dns\_records array Required DNS records (for custom domains) 542 verification\_token string TXT record value for domain ownership proof 543 didweb\_eligible boolean Whether this domain unlocks did:web as a DID method option 544 --------------------- ---------- ------------------------------------------------------------- 545 546**Error Responses** 547 548 ------------ ------------------ --------------------------------------------------- 549 **Status** **Code** **Description** 550 409 HANDLE\_TAKEN Handle is already in use 551 403 TIER\_RESTRICTED Custom domains require Pro tier 552 422 INVALID\_HANDLE Handle contains invalid characters or is reserved 553 ------------ ------------------ --------------------------------------------------- 554 5555.1 DNS Records Object 556 557For custom domains, the response includes an array of DNS records the user must create: 558 559 -------------- ---------- ------------------------------------------------ 560 **Field** **Type** **Description** 561 record\_type string DNS record type: \"CNAME\" \| \"TXT\" \| \"A\" 562 name string Record name (e.g., \"\_atproto.alice.com\") 563 value string Record value 564 ttl integer Recommended TTL in seconds 565 -------------- ---------- ------------------------------------------------ 566 567**GET /v1/handles/:handle/status** [v0.1] 568 569Poll DNS propagation and verification status for a handle. The Tauri app and web dashboard poll this endpoint after handle creation. 570 571**Response (200 OK)** 572 573 ----------------- ---------- --------------------------------------------------------------------------- 574 **Field** **Type** **Description** 575 handle string The handle being checked 576 status string \"active\" \| \"pending\_dns\" \| \"pending\_verification\" \| \"failed\" 577 dns\_checks array Per-record check results with pass/fail and last\_checked timestamps 578 verified\_at string ISO 8601, null until verified 579 failure\_reason string Present only if status is \"failed\" 580 ----------------- ---------- --------------------------------------------------------------------------- 581 582**Error Responses** 583 584 ------------ -------------------- --------------------------------------- 585 **Status** **Code** **Description** 586 404 HANDLE\_NOT\_FOUND Handle doesn't exist for this account 587 ------------ -------------------- --------------------------------------- 588 589> ***Note:** For subdomain handles, status transitions directly to \"active\". For custom domains, expect 5--30 minutes for DNS propagation. The relay checks every 60 seconds.* 590 591**DELETE /v1/handles/:handle** [v0.1] 592 593Release a handle. For subdomain handles, the DNS record is removed immediately. For custom domains, the relay stops serving resolution but does not modify the user's DNS. 594 595**Response (200 OK)** 596 597 ----------------- ---------- --------------------------------------------------------------- 598 **Field** **Type** **Description** 599 released\_at string ISO 8601 timestamp 600 cooldown\_until string ISO 8601, handle is reserved for 30 days to prevent squatting 601 ----------------- ---------- --------------------------------------------------------------- 602 603**Error Responses** 604 605 ------------ -------------- ------------------------------- 606 **Status** **Code** **Description** 607 401 UNAUTHORIZED Invalid token 608 403 FORBIDDEN Token doesn't own this handle 609 ------------ -------------- ------------------------------- 610 6116\. Setup Wizard Flow 612 613The Tauri app includes a first-run setup wizard that guides the user through the complete provisioning flow in a single sitting. The wizard orchestrates the API calls described in sections 2--5 into a linear, non-technical experience. 614 6156.1 Wizard Steps 616 6171. **Enter claim code** --- User copies the 6-character code from the web dashboard. Tauri generates a P-256 keypair and calls POST /v1/devices. 618 6192. **Choose a handle** --- Wizard offers a subdomain handle immediately. If the account has a verified custom domain (Pro tier), the custom domain option also appears. Calls POST /v1/handles. 620 6213. **DID ceremony** --- Wizard pre-selects did:plc. If the user selected a custom domain handle in step 2, a toggle appears offering did:web as an alternative. Wizard prompts for optional recovery key setup (Shamir shares). Calls POST /v1/dids. 622 6234. **Wait for propagation** --- Wizard polls GET /v1/dids/:did and GET /v1/handles/:handle/status until both are active. Shows a progress indicator with estimated time. 624 6255. **Federation handshake** --- Relay calls requestCrawl to the BGS. Wizard confirms the PDS is discoverable on the ATProto network. Displays a \"You're live\" confirmation. 626 6276.2 Failure Recovery 628 629Each wizard step is independently retryable. If the Tauri app loses connection or is closed mid-wizard, it resumes from the last completed step on next launch. The wizard state is persisted locally. 630 631 ---------------------------- ------------------------------------------------------------------------ 632 **Failure Point** **Recovery** 633 Claim code expired Generate new code via web dashboard; wizard restarts at step 1 634 Device registration failed Retry with a new claim code 635 Handle taken Wizard prompts for an alternative handle 636 DID propagation timeout Wizard continues polling; user can skip and check later 637 DNS verification timeout Wizard shows manual DNS instructions; polling continues in background 638 Relay unreachable Tauri retries with exponential backoff; wizard shows connection status 639 ---------------------------- ------------------------------------------------------------------------ 640 6416.3 BYO Relay Configuration 642 643Self-hosted users who run their own relay can point the Tauri app at their relay before entering the setup wizard. The wizard writes a config file that the app reads on subsequent launches. 644 6456.3.1 Config File 646 647 --------------- ---------------------------- 648 **Platform** **Path** 649 macOS / Linux \~/.config/pds/relay.toml 650 Windows %APPDATA%\\pds\\relay.toml 651 --------------- ---------------------------- 652 653Minimal config shape: 654 655> \[relay\] 656> 657> url = \"https://my-relay.example.com\" 658> 659> iroh\_node\_id = \"abc123\...\" \# optional, discovered via API 660> 661> \# Auth method is always claim\_code (web dashboard flow) 662 663If the config file exists when the Tauri app launches for the first time, the wizard uses the configured relay URL instead of the default managed service. All subsequent wizard steps (claim code, device binding, DID ceremony) proceed identically. 664 6657\. Exit Ceremony 666 667The exit ceremony allows a user to leave the relay and take their identity and data with them. The DID is the user's identity --- what moves is the PDS hosting, not the identity itself. The follow graph, followers, and all social connections remain intact because the DID never changes. 668 669> ***Design Decision:** The exit story is a sovereignty requirement, not an afterthought. If users can't leave cleanly, the product's sovereignty promise is hollow.* 670 6717.1 Exit Flow 672 6736. **Export repo** --- User downloads a full CAR file of their ATProto repo via the export endpoint. This is the portable data package. 674 6757. **Prepare new PDS** --- User imports the CAR file into their new PDS host (outside the scope of this API, but the export format follows the ATProto spec for com.atproto.sync.getRepo). 676 6778. **Repoint DID** --- For did:plc: user signs a PLC operation updating the service endpoint to their new PDS. The relay submits this to plc.directory. For did:web: user updates their domain's DNS / .well-known/did.json to point to the new PDS. No relay involvement needed. 678 6799. **Grace period** --- The relay continues serving the repo and forwarding requests for 30 days after the DID repoints. This gives the network time to update resolution caches and ensures no dropped interactions during transition. 680 68110. **Account teardown** --- After the grace period (or immediately if the user confirms), the relay purges the repo data, revokes all device tokens, and marks the account as closed. 682 683**GET /v1/export/repo** [v1.0] 684 685Export the full ATProto repo as a CAR (Content Addressable aRchive) file. This is a potentially large download --- the relay streams the response. 686 687**Response (200 OK)** 688 689 --------------------- ---------- -------------------------------------------------- 690 **Field** **Type** **Description** 691 Content-Type header application/vnd.ipld.car 692 Content-Disposition header attachment; filename=\"{did}.car\" 693 X-Repo-Rev header The repo revision (commit CID) at time of export 694 --------------------- ---------- -------------------------------------------------- 695 696**Error Responses** 697 698 ------------ ---------------------- ---------------------------------------------------- 699 **Status** **Code** **Description** 700 401 UNAUTHORIZED Invalid token 701 503 EXPORT\_IN\_PROGRESS Another export is already running for this account 702 ------------ ---------------------- ---------------------------------------------------- 703 704> ***Note:** Requires device\_token from the primary device, or session\_token. The response is streamed --- clients should handle large payloads. Recommended: pipe to disk rather than buffering in memory.* 705 706**POST /v1/dids/:did/migrate** [v1.0] 707 708Construct and submit a signed DID operation that repoints the service endpoint to a new PDS. For did:plc, this submits the operation to plc.directory. For did:web, this is a no-op (the user controls their own DNS). 709 710**Request Body** 711 712 ------------------------ ---------- -------------- ------------------------------------------------------------ 713 **Field** **Type** **Required** **Description** 714 new\_service\_endpoint string yes URL of the new PDS (e.g., \"https://pds.alice.com\") 715 signing\_proof string yes Proof signed by the device's key authorizing the migration 716 ------------------------ ---------- -------------- ------------------------------------------------------------ 717 718**Response (200 OK)** 719 720 ------------------------ ---------- ----------------------------------------------------- 721 **Field** **Type** **Description** 722 did string The DID that was migrated 723 new\_service\_endpoint string The endpoint now in the DID document 724 operation\_id string PLC operation ID (for did:plc) 725 status string \"pending\_propagation\" \| \"active\" 726 grace\_period\_ends string ISO 8601, when the relay stops serving the old repo 727 ------------------------ ---------- ----------------------------------------------------- 728 729**Error Responses** 730 731 ------------ ------------------------- ------------------------------------------------------------------------- 732 **Status** **Code** **Description** 733 400 INVALID\_PROOF Signing proof is invalid 734 400 INVALID\_ENDPOINT New service endpoint is unreachable or malformed 735 409 MIGRATION\_IN\_PROGRESS A migration is already pending for this DID 736 422 DIDWEB\_SELF\_SERVICE did:web migration is handled via user's own DNS; no relay action needed 737 ------------ ------------------------- ------------------------------------------------------------------------- 738 739> ***Note:** The relay validates that the new service endpoint is reachable and responds to basic ATProto XRPC calls before submitting the PLC operation. This prevents accidental lockout from typos.* 740 741**DELETE /v1/accounts/:id** [v1.0] 742 743Initiate account teardown. By default, enters a 30-day grace period during which the relay continues serving the repo. The user can force immediate deletion by passing force=true. 744 745**Request Body** 746 747 -------------- ---------- -------------- ---------------------------------------------------------------------------- 748 **Field** **Type** **Required** **Description** 749 force boolean no Skip grace period and delete immediately (default: false) 750 confirmation string yes Must be the string \"DELETE {account\_id}\" to prevent accidental deletion 751 -------------- ---------- -------------- ---------------------------------------------------------------------------- 752 753**Response (200 OK)** 754 755 --------------------- ---------- -------------------------------------------------------------- 756 **Field** **Type** **Description** 757 status string \"grace\_period\" \| \"deleted\" 758 grace\_period\_ends string ISO 8601, when data will be purged (null if force=true) 759 devices\_revoked integer Number of device tokens invalidated 760 data\_purge\_at string ISO 8601, when repo and account data are permanently deleted 761 --------------------- ---------- -------------------------------------------------------------- 762 763**Error Responses** 764 765 ------------ ----------------------- ---------------------------------------------------- 766 **Status** **Code** **Description** 767 401 UNAUTHORIZED Invalid session token 768 400 INVALID\_CONFIRMATION Confirmation string doesn't match 769 409 ACTIVE\_MIGRATION Cannot delete while a DID migration is in progress 770 ------------ ----------------------- ---------------------------------------------------- 771 772> ***Note:** Requires session\_token. During the grace period, the account is read-only: the relay serves existing repo data and DID resolution but rejects new commits. The user can cancel the deletion during this period via POST /v1/accounts/:id/restore.* 773 774**POST /v1/accounts/:id/restore** [v1.0] 775 776Cancel a pending account deletion during the grace period. Restores full write access and re-activates device tokens. 777 778**Response (200 OK)** 779 780 ------------------- ---------- ------------------------------------- 781 **Field** **Type** **Description** 782 status string \"active\" 783 devices\_restored integer Number of device tokens reactivated 784 ------------------- ---------- ------------------------------------- 785 786**Error Responses** 787 788 ------------ ------------------------ ----------------------------------------------------- 789 **Status** **Code** **Description** 790 401 UNAUTHORIZED Invalid session token 791 404 NOT\_IN\_GRACE\_PERIOD Account is not currently in a deletion grace period 792 410 ALREADY\_DELETED Grace period has expired; data has been purged 793 ------------ ------------------------ ----------------------------------------------------- 794 7958\. Free Tier Enforcement 796 797Usage is tracked at the relay level and enforced per-account. When a cap is reached, the relay returns 429 on write operations but continues serving reads (eventually consistent). This ensures the PDS remains visible on the network even when the account is over quota. 798 799 ----------------------- --------------- --------------- ----------------------------------------- 800 **Resource** **Free Tier** **Pro Tier** **Enforcement** 801 Repo storage 500 MB 50 GB Reject commits over limit 802 Relay bandwidth 2 GB/month 100 GB/month Throttle to 128 kbps over limit 803 XRPC proxied requests 10,000/month 500,000/month 429 on writes, reads continue 804 Devices per account 1 5 Reject new device registrations 805 Custom domains 0 3 Reject handle creation with type=custom 806 ----------------------- --------------- --------------- ----------------------------------------- 807 808Approaching-limit warnings are surfaced through the usage endpoint (GET /v1/accounts/:id/usage), via a custom X-Usage-Warning response header when utilization exceeds 80%, and through the Iroh channel as push notifications to the Tauri app. 809 810Critical account events (usage at 90%, device revoked, DID rotation) trigger email notifications to the account's registered email address. This does not require a user-facing event API. 811 8129\. Security Considerations 813 8149.1 Token Security 815 816- Session tokens are JWTs signed with RS256. The relay's public key is published at /.well-known/jwks.json. 817 818- Device tokens are opaque (server-side lookup), not JWTs, to allow instant revocation without token refresh lag. 819 820- Claim codes are cryptographically random, 6-character alphanumeric (36⁶ ≈ 2.2 billion possibilities), rate-limited to 5 attempts per code. 821 8229.2 Key Management 823 824- Device private keys are generated and stored in the OS keychain (macOS Keychain, Windows Credential Manager) via Tauri's secure storage API. 825 826- The relay never sees, transmits, or stores private keys. All cryptographic proofs are generated device-side. 827 828- Key rotation invalidates the previous key after a 24-hour grace period. Rotation events are logged immutably. 829 830- Recovery keys (Shamir shares) are configured during the DID ceremony step of the setup wizard. Loss of all signing keys without recovery shares means the DID is permanently orphaned. 831 8329.3 Transport 833 834- All API traffic is TLS 1.3 only. The relay does not support TLS 1.2 fallback. 835 836- Device-to-relay data sync uses Iroh's encrypted transport (QUIC-based, end-to-end encrypted). 837 838- CORS is restricted to the service's web dashboard origin. The Tauri app uses direct HTTPS, not browser fetch. 839 84010\. Internal Observability 841 842The relay exposes internal metrics for operating the SaaS product. These endpoints are not public-facing --- they are served on a separate internal port (default: 9090) accessible only from the ops network. 843 844> ***Design Decision:** User-facing event streams (webhooks, SSE) are deferred. Device-side notifications use the existing Iroh channel. Email handles critical account events. A public event API will be added if self-hosted operators request it.* 845 84610.1 Infrastructure Metrics 847 848Prometheus-compatible metrics endpoint for standard monitoring infrastructure (Grafana, Datadog, PagerDuty). 849 850> GET :9090/metrics 851 852 ----------------------------------- ----------- ------------------------------------------------ 853 **Metric** **Type** **Description** 854 relay\_active\_connections gauge Currently connected Iroh tunnels 855 relay\_request\_duration\_seconds histogram XRPC request latency distribution 856 relay\_error\_total counter Errors by type and status code 857 relay\_buffer\_depth gauge Messages buffered per device (offline devices) 858 relay\_bandwidth\_bytes\_total counter Bytes proxied, labeled by account tier 859 plc\_mirror\_resolution\_ms histogram DID resolution latency from PLC mirror 860 plc\_mirror\_cache\_hit\_ratio gauge PLC mirror cache hit rate 861 iroh\_tunnel\_health gauge Per-tunnel health score (0--1) 862 ----------------------------------- ----------- ------------------------------------------------ 863 86410.2 Business Metrics 865 866Higher-level metrics for product health monitoring. Served as JSON on the internal port. 867 868> GET :9090/internal/stats 869 870 ------------------------------------- ------------------------------------------------------------- 871 **Metric** **Description** 872 accounts\_by\_tier Count of accounts per tier (free/pro/business) 873 storage\_utilization\_p50\_p95\_p99 Storage consumption distribution across accounts 874 federation\_health requestCrawl success rate, BGS ingestion lag 875 did\_resolution\_latency P50/P95 DID resolution time via PLC mirror vs upstream 876 active\_devices\_24h Devices that connected in the last 24 hours 877 stale\_devices\_72h Devices that haven't connected in 72+ hours (churn signal) 878 exit\_ceremonies\_in\_progress Active DID migrations and account deletions in grace period 879 ------------------------------------- ------------------------------------------------------------- 880 881> ***Note:** Business metrics power internal dashboards and customer support tooling. They are not exposed to users. The /internal/stats endpoint requires a separate ops-scoped token and is never routed through the public load balancer.* 882 883## 11. Relay Key Management [v0.1] 884 885The relay holds the ATProto signing key. These endpoints manage the relay's key lifecycle. 886 887**POST /v1/relay/keys** [v0.1] 888 889Generate a new relay signing key. Called during account creation or key rotation. The relay generates the key internally — the private key is never exposed. 890 891**Response (200 OK)** 892 893 ----------------------- ---------- ------------------------------------------------------- 894 **Field** **Type** **Description** 895 key_id string Key identifier 896 public_key string P-256 public key, base64url-encoded 897 algorithm string "ES256" (P-256) or "ES256K" (secp256k1) 898 created_at string ISO 8601 899 ----------------------- ---------- ------------------------------------------------------- 900 901**DELETE /v1/relay/keys/:keyId** [v1.0] 902 903Revoke a relay signing key. Triggers DID rotation to update the signing key in the DID document. 904 905**Response (200 OK)** 906 907 ----------------------- ---------- ------------------------------------------------------- 908 **Field** **Type** **Description** 909 revoked_at string ISO 8601 910 rotation_status string "pending" | "complete" 911 ----------------------- ---------- ------------------------------------------------------- 912 913**POST /v1/relay/commits/sign** [v0.2] 914 915Sign an unsigned commit constructed by the desktop PDS. Desktop-enrolled mode only. 916 917**Request Body** 918 919 ----------------------- ---------- -------------- ------------------------------------------------------- 920 **Field** **Type** **Required** **Description** 921 unsigned_commit bytes yes CAR-encoded unsigned commit 922 repo_did string yes DID of the repo 923 ----------------------- ---------- -------------- ------------------------------------------------------- 924 925**Response (200 OK)** 926 927 ----------------------- ---------- ------------------------------------------------------- 928 **Field** **Type** **Description** 929 signed_commit bytes CAR-encoded signed commit 930 commit_cid string CID of the signed commit 931 ----------------------- ---------- ------------------------------------------------------- 932 933**GET /v1/relay/repo/snapshot** [v0.2] 934 935Full repo export as CAR file. Used by desktop during initial sync after enrollment. 936 937**Response:** streaming CAR file (same format as com.atproto.sync.getRepo) 938 939**GET /v1/relay/mode** [v0.2] 940 941Current relay operating mode for this account. 942 943**Response (200 OK)** 944 945 ----------------------- ---------- ------------------------------------------------------- 946 **Field** **Type** **Description** 947 mode string "mobile-only" | "desktop-enrolled" | "desktop-offline" 948 primary_device string Device ID of repo host (null in mobile-only) 949 signing_key_id string Active signing key identifier 950 ----------------------- ---------- ------------------------------------------------------- 951 952## 12. Device Management [v0.2] 953 954Extended device operations for the mobile app. These supplement the existing device registration endpoints in Section 3. 955 956**POST /v1/devices/:id/pair** [v0.2] 957 958Initiate device pairing via QR code. The phone generates a pairing session, the desktop scans the QR code containing the session details. 959 960**Request Body** 961 962 ----------------------- ---------- -------------- ------------------------------------------------------- 963 **Field** **Type** **Required** **Description** 964 pairing_code string yes Code from QR scan 965 device_type string yes "desktop" | "mobile" 966 ----------------------- ---------- -------------- ------------------------------------------------------- 967 968**Response (200 OK)** 969 970 ----------------------- ---------- ------------------------------------------------------- 971 **Field** **Type** **Description** 972 paired_at string ISO 8601 973 device_id string The paired device's ID 974 pairing_status string "paired" | "pending_promotion" 975 ----------------------- ---------- ------------------------------------------------------- 976 977**POST /v1/devices/:id/promote** [v0.2] 978 979Promote a paired desktop to repo host. Transitions the relay from mobile-only to desktop-enrolled mode. The relay transfers the repo to the desktop via Iroh. 980 981**Response (200 OK)** 982 983 ----------------------- ---------- ------------------------------------------------------- 984 **Field** **Type** **Description** 985 promoted_at string ISO 8601 986 mode string "desktop-enrolled" 987 repo_transfer string "in_progress" | "complete" 988 ----------------------- ---------- ------------------------------------------------------- 989 990**GET /v1/devices/:id/status** [v0.2] 991 992Device health and connectivity status. 993 994**Response (200 OK)** 995 996 ----------------------- ---------- ------------------------------------------------------- 997 **Field** **Type** **Description** 998 device_id string Device identifier 999 status string "online" | "offline" | "degraded" 1000 last_seen string ISO 8601 1001 is_primary boolean Whether this device hosts the repo 1002 mode string Current lifecycle phase 1003 ----------------------- ---------- ------------------------------------------------------- 1004 1005**DELETE /v1/devices/:id** [v0.2] 1006 1007De-enroll a device. Already exists in Section 3. This note confirms mobile app can also call it (not just web dashboard). 1008 1009Note: Update Section 3 to allow device_token auth (not just session_token) for mobile-initiated device removal. 1010 1011## 13. Data Transfer [v0.1] 1012 1013Planned device swap (e.g., upgrading phones). Uses Iroh for direct peer-to-peer transfer with a 6-digit verification code. 1014 1015**POST /v1/transfer/initiate** [v0.1] 1016 1017Generate a transfer session. Returns a 6-digit code for the new device to enter. 1018 1019**Response (200 OK)** 1020 1021 ----------------------- ---------- ------------------------------------------------------- 1022 **Field** **Type** **Description** 1023 transfer_id string Transfer session identifier 1024 code string 6-digit verification code 1025 expires_at string ISO 8601 (15 minutes) 1026 iroh_ticket string Iroh connection ticket for direct transfer 1027 ----------------------- ---------- ------------------------------------------------------- 1028 1029**POST /v1/transfer/accept** [v0.1] 1030 1031New device submits the transfer code to join the session. 1032 1033**Request Body** 1034 1035 ----------------------- ---------- -------------- ------------------------------------------------------- 1036 **Field** **Type** **Required** **Description** 1037 code string yes 6-digit code from old device 1038 device_public_key string yes P-256 public key of new device 1039 ----------------------- ---------- -------------- ------------------------------------------------------- 1040 1041**Response (200 OK)** 1042 1043 ----------------------- ---------- ------------------------------------------------------- 1044 **Field** **Type** **Description** 1045 transfer_id string Transfer session ID 1046 status string "accepted" | "transferring" 1047 ----------------------- ---------- ------------------------------------------------------- 1048 1049**POST /v1/transfer/complete** [v0.1] 1050 1051Finalize the transfer. Old device's token is revoked, new device receives a fresh device_token. 1052 1053**Response (200 OK)** 1054 1055 ----------------------- ---------- ------------------------------------------------------- 1056 **Field** **Type** **Description** 1057 new_device_id string New device's identifier 1058 device_token string New device's long-lived token 1059 old_device_revoked boolean Confirmation old token is dead 1060 ----------------------- ---------- ------------------------------------------------------- 1061 1062## 14. Recovery [v1.0] 1063 1064Unplanned device loss recovery via Shamir share reconstruction. 1065 1066**POST /v1/recovery/initiate** [v1.0] 1067 1068Begin a recovery ceremony. User must present 2 of 3 Shamir shares to reconstruct the rotation key. 1069 1070**Request Body** 1071 1072 ----------------------- ---------- -------------- ------------------------------------------------------- 1073 **Field** **Type** **Required** **Description** 1074 email string yes Account email for verification 1075 share_1 string yes First Shamir share (e.g., from iCloud) 1076 share_source_1 string yes "icloud" | "relay" | "device" | "paper" 1077 ----------------------- ---------- -------------- ------------------------------------------------------- 1078 1079**Response (200 OK)** 1080 1081 ----------------------- ---------- ------------------------------------------------------- 1082 **Field** **Type** **Description** 1083 recovery_id string Recovery session identifier 1084 shares_needed integer Number of additional shares required 1085 status string "awaiting_shares" | "ready_to_verify" 1086 ----------------------- ---------- ------------------------------------------------------- 1087 1088**POST /v1/recovery/verify-key** [v1.0] 1089 1090Submit reconstructed key material to prove DID ownership. 1091 1092**Request Body** 1093 1094 ----------------------- ---------- -------------- ------------------------------------------------------- 1095 **Field** **Type** **Required** **Description** 1096 recovery_id string yes Recovery session ID 1097 share_2 string yes Second Shamir share 1098 share_source_2 string yes Source of the second share 1099 ----------------------- ---------- -------------- ------------------------------------------------------- 1100 1101**Response (200 OK)** 1102 1103 ----------------------- ---------- ------------------------------------------------------- 1104 **Field** **Type** **Description** 1105 status string "verified" | "failed" 1106 rotation_key string Reconstructed rotation public key (for verification) 1107 ----------------------- ---------- ------------------------------------------------------- 1108 1109**GET /v1/recovery/restore** [v1.0] 1110 1111Stream the repo and blobs from the relay to the new device after successful key verification. 1112 1113**Response:** streaming CAR file + blob manifest 1114 1115**PUT /v1/keys/shares/:id** [v1.0] 1116 1117Update the relay-held Shamir share (Share 2). Used after key rotation to re-split with new shares. 1118 1119**Request Body** 1120 1121 ----------------------- ---------- -------------- ------------------------------------------------------- 1122 **Field** **Type** **Required** **Description** 1123 encrypted_share string yes New encrypted share data 1124 ----------------------- ---------- -------------- ------------------------------------------------------- 1125 1126**Response (200 OK)** 1127 1128 ----------------------- ---------- ------------------------------------------------------- 1129 **Field** **Type** **Description** 1130 updated_at string ISO 8601 1131 ----------------------- ---------- ------------------------------------------------------- 1132 1133**GET /v1/keys/rotation-log** [v1.0] 1134 1135Immutable audit log of all Shamir share rotations and recovery attempts. 1136 1137**Response (200 OK)** 1138 1139 ----------------------- ---------- ------------------------------------------------------- 1140 **Field** **Type** **Description** 1141 entries array List of rotation/recovery events with timestamps 1142 ----------------------- ---------- ------------------------------------------------------- 1143 1144## 15. Blob Management [v0.1] 1145 1146Blob endpoints follow the ATProto spec. See blob-handling-spec.md for storage architecture and lifecycle details. 1147 1148**POST /v1/blobs/upload** [v0.1] 1149 1150Alias for com.atproto.repo.uploadBlob. Accepts multipart upload, returns CID reference. Subject to per-account storage quotas. 1151 1152Note: This is the same endpoint as the XRPC uploadBlob — listed here for completeness. The provisioning API does not add a separate blob upload path. 1153 1154**GET /v1/accounts/:id/storage** [v0.1] 1155 1156Blob storage usage for an account. Extends the existing usage endpoint with blob-specific metrics. 1157 1158**Response (200 OK)** 1159 1160 ----------------------- ---------- ------------------------------------------------------- 1161 **Field** **Type** **Description** 1162 blob_count integer Total blobs stored 1163 blob_bytes integer Total blob storage consumed 1164 blob_limit integer Tier storage limit for blobs 1165 largest_blob integer Size of largest blob (bytes) 1166 ----------------------- ---------- ------------------------------------------------------- 1167 1168 1169Appendix A: Status Codes Reference 1170 1171 ---------- --------------------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- 1172 **Code** **Constants** **Context** 1173 400 INVALID\_CLAIM, INVALID\_DOCUMENT, INVALID\_PROOF, INVALID\_ENDPOINT, INVALID\_CONFIRMATION Malformed request or failed validation 1174 401 UNAUTHORIZED, INVALID\_CREDENTIALS, TOKEN\_EXPIRED Authentication failure 1175 403 FORBIDDEN, TIER\_RESTRICTED, DIDWEB\_REQUIRES\_DOMAIN, SINGLE\_DEVICE\_TIER Insufficient permissions or tier 1176 404 NOT\_FOUND, DEVICE\_NOT\_FOUND, DID\_NOT\_FOUND, HANDLE\_NOT\_FOUND, NOT\_IN\_GRACE\_PERIOD Resource doesn't exist 1177 409 ACCOUNT\_EXISTS, DEVICE\_LIMIT, DID\_EXISTS, HANDLE\_TAKEN, ROTATION\_IN\_PROGRESS, LEASE\_HELD, MIGRATION\_IN\_PROGRESS, ACTIVE\_MIGRATION Conflict with existing state 1178 410 ALREADY\_DELETED Resource permanently removed 1179 422 WEAK\_PASSWORD, INVALID\_KEY, INVALID\_HANDLE, KEY\_MISMATCH, DIDWEB\_SELF\_SERVICE Semantic validation failure 1180 423 ACCOUNT\_LOCKED Temporarily locked due to abuse 1181 429 RATE\_LIMITED Rate or usage cap exceeded 1182 503 EXPORT\_IN\_PROGRESS Temporary unavailability 1183 ---------- --------------------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- 1184 1185Appendix B: Design Decisions Log 1186 1187This appendix collects all design decisions made during the specification process, with rationale for future reference. 1188 1189 ------------------------------------------------ -------------------------------------------------------------------------------------------------------------------------------------------------------------- 1190 **Decision** **Rationale** 1191 did:plc is the default DID method Decouples identity from relay domain. Clean exit path: user signs a PLC operation to repoint service endpoint. No ongoing liability for the relay operator. 1192 did:web requires a user-owned custom domain Eliminates exit liability. If did:web were offered on service subdomains, the relay would be obligated to host DID documents indefinitely after users leave. 1193 Primary-device write lease for multi-device ATProto repos require a linear commit chain. LWW risks lost writes; conflict queues have poor UX. Primary-device matches actual desktop usage patterns. 1194 Single auth path (web dashboard for all users) One security model to audit. Self-hosted static\_token bypass can be added later if operators request it. 1195 No public event API in v1.0 Iroh channel handles device notifications. Email handles critical alerts. A webhook/SSE surface adds complexity without clear demand. 1196 PLC mirror starts read-only Reduces operational risk. Read-write authority requires consensus participation with the PLC network --- deferred until the product matures. 1197 Setup wizard handles DID ceremony Users get a single \"setup complete\" moment. Splitting device binding and DID ceremony into separate sessions creates drop-off risk. 1198 30-day grace period on account deletion Prevents accidental data loss. The relay continues serving the repo during transition, ensuring zero dropped interactions for the user's followers. 1199 Relay constructs DID document Client sends raw key material, not a pre-built DID document. Ensures relay controls document structure, service endpoints, and signing key binding. Client only needs public keys — no DID assembly logic required. Simplifies mobile clients significantly. 1200 ------------------------------------------------ --------------------------------------------------------------------------------------------------------------------------------------------------------------