{"openapi":"3.1.0","info":{"title":"SDK API","description":"Generated from the axum handlers — route and schema are registered in one call via `routes!()`, so routes using that macro appear in this document by construction. Routes mounted by other means (plain `.route()`, macro expansion, or from outside this crate) are not automatically covered; an AST guard catches directly-written `.route()` calls but has known limits.\n\nCovers messages, rooms, catalog, push, token minting, auth self-mint (challenge/verify), presence (typing/read/presence), pubkey registry, MLS (RFC 9420) Delivery Service, and attachments (presign/upload/download).","license":{"name":"AGPL-3.0-or-later"},"version":"0.1.0"},"paths":{"/api/sdk/attachments/presign":{"post":{"tags":["attachments"],"summary":"Mint a presigned upload URL for an attachment","description":"Validates the declared MIME type, byte size, and SHA-256 hash, inserts a pending row into sdk_attachments, and returns an attachment_id plus a presigned upload URL. The upload URL carries an HMAC token (?t=) that authorises the subsequent PUT — the PUT is NOT behind Bearer JWT auth. The SHA-256 is verified server-side after the upload stream completes, before the file is committed to disk.","operationId":"attachment_presign","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignBody"}}},"required":true},"responses":{"200":{"description":"Presigned upload URL minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignResp"}}}},"422":{"description":"Invalid mime_type, byte_size, sha256, or app_id"},"500":{"description":"Database insert error"},"503":{"description":"Attachment subkey unavailable (SDK_MASTER_KEY not set)"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/attachments/{id}":{"get":{"tags":["attachments"],"summary":"Download an attachment (Bearer JWT)","description":"Streams the attachment bytes from disk. Authorised by a Bearer SDK JWT — the path every existing consumer uses, including the published @oxpulse/chat-widget. Scoped to the JWT's app_id (WHERE app_id = $1 AND attachment_id = $2). Supports Range requests (206 Partial Content) for video seeking. Video/image types are served inline; other non-media types get Content-Disposition: attachment. X-Content-Type-Options: nosniff is always set.","operationId":"attachment_get","parameters":[{"name":"id","in":"path","description":"Attachment identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Full attachment bytes streamed","content":{"application/octet-stream":{}}},"206":{"description":"Partial content (Range request satisfied)"},"401":{"description":"Bearer JWT missing, invalid, or expired"},"404":{"description":"Attachment not found (unknown id, wrong tenant, or file missing on disk)"},"416":{"description":"Range Not Satisfiable"},"500":{"description":"Database lookup or response build error"}},"security":[{"bearerAuth":[]}]},"put":{"tags":["attachments"],"summary":"Upload attachment bytes to a presigned URL","description":"Receives the raw attachment body and streams it to disk. Authenticated by the HMAC token (?t=) from the presign response — NOT a Bearer JWT. The server verifies the SHA-256 hash after the stream completes and before the file is committed. Replay-protected: a second PUT to the same attachment_id returns 409.","operationId":"attachment_put","parameters":[{"name":"id","in":"path","description":"Attachment identifier","required":true,"schema":{"type":"string"}},{"name":"t","in":"query","description":"HMAC upload token from the presign response","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Upload verified and committed"},"401":{"description":"HMAC token missing, invalid, or expired"},"409":{"description":"Attachment already uploaded (replay guard)"},"422":{"description":"Content-Length mismatch or SHA-256 mismatch or body exceeds declared size"},"500":{"description":"Disk I/O or database error"}}}},"/api/sdk/attachments/{id}/dl":{"get":{"tags":["attachments"],"summary":"Download an attachment (presigned HMAC token)","description":"Streams the attachment bytes from disk. Authorised by an HMAC download token (?t=) from the presign-get endpoint — NOT a Bearer JWT, so a <video src> element can fetch it directly. Scoped to the token's app_id. Supports Range requests (206 Partial Content) for video seeking. Video/image types are served inline (no Content-Disposition: attachment); other non-media types still get attachment disposition. X-Content-Type-Options: nosniff is always set.","operationId":"attachment_get_presigned","parameters":[{"name":"id","in":"path","description":"Attachment identifier","required":true,"schema":{"type":"string"}},{"name":"t","in":"query","description":"HMAC download token from presign-get","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Full attachment bytes streamed","content":{"application/octet-stream":{}}},"206":{"description":"Partial content (Range request satisfied)"},"401":{"description":"HMAC download token missing, invalid, or expired"},"404":{"description":"Attachment not found (unknown id, wrong tenant, or file missing on disk)"},"416":{"description":"Range Not Satisfiable"},"500":{"description":"Database lookup or response build error"}}}},"/api/sdk/attachments/{id}/presign-get":{"post":{"tags":["attachments"],"summary":"Mint a presigned download URL for an attachment","description":"Issues a time-limited presigned GET URL (?t= HMAC token) for inline playback (e.g. <video src>). The download token uses a distinct HKDF label from the upload token, so it cannot be replayed as a PUT. Scoped to the caller's app_id — a presigned read for tenant A cannot read tenant B's bytes.","operationId":"attachment_presign_get","parameters":[{"name":"id","in":"path","description":"Attachment identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Presigned download URL minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignGetResp"}}}},"404":{"description":"Attachment not found (unknown id or wrong tenant)"},"503":{"description":"Download subkey unavailable (SDK_MASTER_KEY not set)"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/auth/anon-read-mint":{"post":{"tags":["auth"],"summary":"Anonymous read token mint","description":"Issues a 300-second read-only JWT scoped to chat:read + chat:subscribe for a single room. Public and unauthenticated — no header credential. Fail-CLOSED: room must be open + anon_read=true. Disabled (503) when OXPULSE_CHAT_ANON_TOKEN_KEY env var is absent.","operationId":"sdkAuthAnonReadMint","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnonReadMintRequest"}}},"required":true},"responses":{"200":{"description":"Token minted","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"no-store"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnonReadMintResponse"}}}},"400":{"description":"Missing or invalid JSON body"},"403":{"description":"Room not found, not open, or anon_read=false (no info leak)"},"503":{"description":"Endpoint disabled (env var unset), DB error, or sign error"}},"security":[{}]}},"/api/sdk/auth/challenge":{"post":{"tags":["auth"],"summary":"Request a self-mint challenge nonce","description":"First step of the Ed25519 self-mint flow: a client that holds an Ed25519 key pair proves key possession to obtain a short-lived SDK JWT without a server-side secret. The server issues a single-use nonce bound to the caller's public key; the client signs it in the verify step.","operationId":"auth_challenge","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChallengeRequest"}}},"required":true},"responses":{"200":{"description":"Challenge issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChallengeResponse"}}}},"400":{"description":"Missing or invalid pubkey_b64u"},"503":{"description":"Nonce store unavailable (Redis down)"}}}},"/api/sdk/auth/group-grant-mint":{"post":{"tags":["auth"],"summary":"Partner grant JWT exchange","description":"Verifies a partner-issued Ed25519 grant JWT from the request body and issues a per-room SDK JWT. No header credential — the grant JWT is the proof. Gated on CHAT_V7_UNIFIED=true|1. Grant jtis are replay-protected via the shared nonce store.","operationId":"sdkAuthGroupGrantMint","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantMintRequest"}}},"required":true},"responses":{"200":{"description":"Token minted","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"no-store"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupGrantMintResponse"}}}},"400":{"description":"Malformed request or missing/invalid required claims (name, room, avatar_url)"},"401":{"description":"JWT verification failed — bad alg, bad signature, expired, replayed jti, exp too long, wrong issuer, unknown kid"},"403":{"description":"sdk_apps row found but crypto_mode != plaintext"},"500":{"description":"JWT signing failure"},"503":{"description":"No DB pool, sdk_apps row missing, nonce store unavailable, or room provision error"}},"security":[{}]}},"/api/sdk/auth/group-mint":{"post":{"tags":["auth"],"summary":"Group-chat token mint (nonce proof)","description":"Mints a group-chat JWT by verifying an Ed25519 nonce proof. The client reuses a challenge nonce from POST /api/sdk/auth/challenge and signs it with a domain-separated prefix. No header credential — the proof is in the body. Gated on CHAT_V7_UNIFIED=true|1.","operationId":"sdkAuthGroupMint","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupMintRequest"}}},"required":true},"responses":{"200":{"description":"Token minted","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"no-store"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupMintResponse"}}}},"400":{"description":"Malformed request — invalid pubkey, nonce, or signature format"},"401":{"description":"Nonce expired/unknown or signature mismatch"},"403":{"description":"sdk_apps row found but crypto_mode != plaintext"},"500":{"description":"JWT signing failure"},"503":{"description":"No DB pool or sdk_apps row missing (Phase 0 not provisioned)"}},"security":[{}]}},"/api/sdk/auth/verify":{"post":{"tags":["auth"],"summary":"Exchange a signed challenge nonce for an SDK JWT","description":"Second step of the Ed25519 self-mint flow: the client signs the server-issued nonce with its private key and submits the signature. The server verifies the signature, burns the nonce (single-use), and mints a short-lived SDK JWT carrying messages, rooms, presence, push, and call scopes. This is how embedded clients (kiosk, CLI, partner bot) obtain a token without a server-side HMAC secret.","operationId":"auth_verify","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyRequest"}}},"required":true},"responses":{"200":{"description":"Token minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyResponse"}}}},"400":{"description":"Invalid pubkey, nonce, or signature encoding"},"401":{"description":"Nonce unknown/expired or signature mismatch"},"500":{"description":"JWT signing error"},"503":{"description":"Nonce store unavailable (Redis down)"}}}},"/api/sdk/catalog/products":{"get":{"tags":["catalog"],"summary":"GET /api/sdk/catalog/products — list own products (newest first, paginated).","description":"#2439: keyset pagination via an opaque `cursor` query param. When `cursor`\nis absent, the first page is returned (backward compatible). The response\ncarries `has_more` and, when `has_more`, a `next_cursor` the client passes\nback as `?cursor=...` to fetch the next page. #2457: `has_more` is exact\n(a `limit + 1` sentinel row), so an exact-multiple final page — a FULL page\nthat is nonetheless the last — reports `has_more == false` and no\n`next_cursor`, not a spurious cursor whose fetch returns nothing.\nGET /api/sdk/catalog/products — list own products (newest first, paginated).","operationId":"list_products_handler","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","description":"#2439: opaque keyset cursor for pagination. Pass the `next_cursor` from\nthe previous `CatalogProductListDTO` response to fetch the next page.\nWhen absent, the first page (newest products) is returned — backward\ncompatible with pre-pagination callers.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Paginated product list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogProductListDTO"}}}},"400":{"description":"Invalid cursor"},"403":{"description":"Scope denied (catalog:read:*)"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"post":{"tags":["catalog"],"summary":"POST /api/sdk/catalog/products — create a product.","operationId":"create_product_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductRequest"}}},"required":true},"responses":{"200":{"description":"Product upserted (idempotent re-POST)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogProductDTO"}}}},"201":{"description":"Product created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogProductDTO"}}}},"400":{"description":"Invalid product_ref or product_meta"},"403":{"description":"Scope denied (catalog:write:*)"},"413":{"description":"product_meta exceeds 8 KiB"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/catalog/products/{product_ref}":{"get":{"tags":["catalog"],"summary":"GET /api/sdk/catalog/products/:product_ref — get a single product.","operationId":"get_product_handler","parameters":[{"name":"product_ref","in":"path","description":"Product identifier (SKU/UUID)","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Product found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogProductDTO"}}}},"403":{"description":"Scope denied (catalog:read:*)"},"404":{"description":"Product not found"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"delete":{"tags":["catalog"],"summary":"DELETE /api/sdk/catalog/products/:product_ref — soft-delete (archive).","description":"Returns 204 No Content on success. `archived_at` is NOT surfaced in the\nresponse body — the 204 status is the wire contract existing clients depend\non, and `archived_at` is available via a subsequent GET (which returns 404\nfor archived rows) or the product row's `archived_at` field on list/get\nbefore archival. Changing 204 → 200+body would be a breaking wire-contract\nchange for no functional gain (#2440).\nDELETE /api/sdk/catalog/products/:product_ref — soft-delete (archive).","operationId":"archive_product_handler","parameters":[{"name":"product_ref","in":"path","description":"Product identifier (SKU/UUID)","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Product archived"},"403":{"description":"Scope denied (catalog:write:*)"},"404":{"description":"Product not found"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"patch":{"tags":["catalog"],"summary":"PATCH /api/sdk/catalog/products/:product_ref — update product_meta.","operationId":"update_product_handler","parameters":[{"name":"product_ref","in":"path","description":"Product identifier (SKU/UUID)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProductRequest"}}},"required":true},"responses":{"200":{"description":"Product updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogProductDTO"}}}},"400":{"description":"Invalid product_meta"},"403":{"description":"Scope denied (catalog:write:*)"},"404":{"description":"Product not found"},"413":{"description":"product_meta exceeds 8 KiB"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/health":{"get":{"tags":["health"],"summary":"SDK health probe (auth-gated)","description":"Returns the verified app_id + user_id from the JWT claims. Behind sdk_auth_middleware — reaching this handler proves the bearer token is valid. No separate scope required.","operationId":"sdkHealth","responses":{"200":{"description":"Token valid — app_id + user_id echoed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkHealthResponse"}}}},"401":{"description":"Missing or invalid bearer token"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/keys/publish":{"post":{"tags":["mls"],"summary":"POST /api/sdk/keys/publish — publish a KeyPackage to the directory.","description":"The server stores the KeyPackage per-user. Regular KeyPackages are\nconsumed (deleted) when used in a Commit; the last-resort KeyPackage is\nnever deleted. The server enforces max 10 regular + 1 last-resort per user.","operationId":"publish_key_package_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishKeyPackageRequest"}}},"required":true},"responses":{"201":{"description":"KeyPackage published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishKeyPackageResponse"}}}},"400":{"description":"Invalid base64 or size"},"403":{"description":"Auth denied"},"409":{"description":"Last-resort already exists, or quota exceeded"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/keys/{uid}":{"get":{"tags":["mls"],"summary":"GET /api/sdk/keys/:uid — fetch all unpublished KeyPackages for a user.","description":"Returns KeyPackages oldest-first. The caller (group creator) fetches\nthese to create Add proposals. After a Commit consumes a KeyPackage,\nthe caller DELETEs it from the directory.","operationId":"get_key_packages_handler","parameters":[{"name":"uid","in":"path","description":"User identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"KeyPackages list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackagesListResponse"}}}},"403":{"description":"Auth denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"delete":{"tags":["mls"],"summary":"DELETE /api/sdk/keys/:uid — consume (delete) a KeyPackage.","description":"Called by the client after consuming a KeyPackage in a Commit. The\nlast-resort KeyPackage is never deleted — it is marked as consumed\ninstead (so it can be re-used in future Add proposals when all regular\nKeyPackages are exhausted).","operationId":"delete_key_package_handler","parameters":[{"name":"uid","in":"path","description":"User identifier","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"KeyPackage consumed"},"400":{"description":"Invalid base64"},"403":{"description":"Auth denied"},"404":{"description":"KeyPackage not found"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/mesh-channel-token":{"post":{"tags":["mesh"],"summary":"BLE mesh channel token mint (internal — scheduled to move off /api/sdk/)","description":"Derives a channel_id from (geohash, day_utc) via BLAKE3 and issues a 3600-second JWT for the mesh room. Public and unauthenticated. Disabled (503) when OXPULSE_CHAT_MESH_TOKEN_KEY is absent. Internal BLE mesh transport — should eventually move off /api/sdk/.","operationId":"sdkMeshChannelToken","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeshTokenRequest"}}},"required":true},"responses":{"200":{"description":"Token minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeshTokenResponse"}}}},"400":{"description":"Missing/invalid JSON body, invalid geohash or day_utc"},"500":{"description":"Internal error (JWT signing)"},"503":{"description":"Endpoint disabled (OXPULSE_CHAT_MESH_TOKEN_KEY unset)"}},"security":[{}]}},"/api/sdk/messages":{"get":{"tags":["messages"],"summary":"GET /api/sdk/messages — dispatches to product_ref search OR room list\nbased on query params. SRP: routing only; auth/business logic lives in the\nsub-handlers. Each sub-handler owns its own scope and membership policy.","operationId":"list_handler","responses":{"200":{"description":"Paginated message list or product_ref search results"},"400":{"description":"Missing room_id"},"403":{"description":"Scope or membership denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"post":{"tags":["messages"],"operationId":"append_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppendRequest"}}},"required":true},"responses":{"201":{"description":"Message appended","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppendResponse"}}}},"400":{"description":"Payload validation or base64 decode failure"},"403":{"description":"Scope or membership denied"},"413":{"description":"Sealed frame exceeds MAX_FRAME_BYTES"},"429":{"description":"Rate limit exceeded (sealed-DM floor, intro, or open-room)"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/messages/batch":{"post":{"tags":["messages"],"summary":"W7-P1 T7: POST /api/sdk/messages/batch","description":"Accepts a JSON array of `BatchAppendItem`s and inserts them in a single\nPostgres transaction (all-or-nothing). Intended for the burner→persistent\nupgrade path: the client ships its entire DC backlog in one request.\n\nAuth: same `chat:write:<room_id>` scope check as `append_handler`.\nMembership: same W5 gate, checked once — which is sound only because every\nitem must name the same room. That is enforced below (#2765), not assumed.\n\nReturns 200 with the stored rows in insertion order, or an appropriate\nerror code:\n- 400 — empty batch, items spanning more than one room, or bad base64 in sealed_b64\n- 403 — scope or membership denied\n- 413 — any item's sealed frame exceeds `MAX_FRAME_BYTES`\n- 500 — DB error","operationId":"batch_append_handler","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BatchAppendItem"}}}},"required":true},"responses":{"200":{"description":"Batch inserted (all-or-nothing)"},"400":{"description":"Empty batch, items spanning more than one room, or bad base64"},"403":{"description":"Scope or membership denied"},"413":{"description":"Any item sealed frame exceeds MAX_FRAME_BYTES"},"429":{"description":"Rate limit exceeded"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/messages/subscribe":{"get":{"tags":["messages"],"summary":"GET /api/sdk/messages/subscribe?ticket=<uuid>&after_seq=<n>","description":"Authenticates via the short-lived single-use ticket issued by\nPOST /api/sdk/messages/subscribe-ticket.  This avoids passing a long-lived\nBearer token in a URL query parameter (RFC 6750 §5.3 violation).","operationId":"subscribe","responses":{"200":{"description":"SSE stream of message, mutation, and transient events"},"401":{"description":"Invalid, expired, or already-consumed ticket"},"403":{"description":"Scope or membership denied"},"500":{"description":"DB error during membership re-check"},"503":{"description":"Server shutting down — concurrency limiter closed"}},"security":[{"subscribeTicket":[]}]}},"/api/sdk/messages/subscribe-ticket":{"post":{"tags":["messages"],"summary":"POST /api/sdk/messages/subscribe-ticket","description":"Issues a short-lived, single-use ticket that the client exchanges for an\nSSE stream.  This endpoint uses standard Bearer auth (sdk_auth_middleware)\nso the JWT is passed in the Authorization header, not a query param.\n\nThe client then opens EventSource with `?ticket=<uuid>` — the ticket is\nconsumed on first SSE accept, preventing replay.","operationId":"subscribe_ticket_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscribeTicketRequest"}}},"required":true},"responses":{"200":{"description":"Ticket issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscribeTicketResponse"}}}},"403":{"description":"Scope or membership denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/messages/{room_id}":{"delete":{"tags":["messages"],"operationId":"delete_room_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Room message log deleted"},"403":{"description":"Scope or moderator denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/messages/{room_id}/{msg_id}":{"delete":{"tags":["messages"],"summary":"DELETE /api/sdk/messages/:room_id/:msg_id","description":"Soft-delete a message — only the original sender may delete.\nScope: `chat:write:<room_id>`. RLS by `sender_uid = claims.user_id`.\nClears `sealed`, sets `deleted_at`. Notifies SSE subscribers.","operationId":"delete_message_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Message soft-deleted"},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope or author denied"},"404":{"description":"Message not found"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"patch":{"tags":["messages"],"summary":"PATCH /api/sdk/messages/:room_id/:msg_id","description":"Edit a message — only the original sender may edit.\nScope: `chat:write:<room_id>`. RLS by `sender_uid = claims.user_id`.\nBumps `edit_count`, sets `edited_at`. Notifies SSE subscribers.","operationId":"patch_message_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchMessageRequest"}}},"required":true},"responses":{"200":{"description":"Message edited"},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope denied"},"404":{"description":"Message not found"},"413":{"description":"Sealed frame exceeds MAX_FRAME_BYTES"},"422":{"description":"Bad base64 in sealed_b64"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/messages/{room_id}/{msg_id}/reactions":{"get":{"tags":["messages"],"summary":"GET /api/sdk/messages/:room_id/:msg_id/reactions","description":"Return aggregated reaction counts + user lists for a message.\nScope: `chat:read:<room_id>`.","operationId":"list_reactions_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Reaction counts and user lists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReactionsResponse"}}}},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"post":{"tags":["messages"],"summary":"POST /api/sdk/messages/:room_id/:msg_id/reactions","description":"Add a reaction. Idempotent per (user_id, emoji). FK enforces the message exists.\nScope: `chat:write:<room_id>`.","operationId":"post_reaction_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostReactionRequest"}}},"required":true},"responses":{"200":{"description":"Reaction added (idempotent)"},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope denied"},"404":{"description":"Message not found"},"422":{"description":"Invalid reaction string"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/messages/{room_id}/{msg_id}/reactions/{reaction}":{"delete":{"tags":["messages"],"summary":"DELETE /api/sdk/messages/:room_id/:msg_id/reactions/:reaction","description":"Remove own reaction. No-op (204) if the reaction did not exist.\nScope: `chat:write:<room_id>`. RLS: only own reaction is deleted.","operationId":"delete_reaction_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"reaction","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Reaction removed (idempotent)"},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope denied"},"422":{"description":"Invalid reaction string"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/push/subscribe":{"post":{"tags":["push"],"summary":"POST /api/sdk/push/subscribe — register a Web Push subscription.","description":"Scope: `push:write:*`. Anti-impersonation: `req.user_id` must equal the\nJWT `sub` claim.","operationId":"subscribe_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscribeRequest"}}},"required":true},"responses":{"200":{"description":"Subscription registered (idempotent upsert)"},"400":{"description":"Invalid base64url p256dh or auth key"},"403":{"description":"Scope denied or user_id mismatch"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/push/unsubscribe":{"delete":{"tags":["push"],"summary":"DELETE /api/sdk/push/unsubscribe — remove a Web Push subscription.","description":"Scope: `push:write:*`.","operationId":"unsubscribe_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnsubscribeRequest"}}},"required":true},"responses":{"204":{"description":"Subscription removed"},"403":{"description":"Scope denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/push/vapid-public-key":{"get":{"tags":["push"],"summary":"GET /api/sdk/push/vapid-public-key — unauthenticated; returns VAPID public key.","operationId":"vapid_key_handler","responses":{"200":{"description":"VAPID public key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VapidKeyResponse"}}}}}}},"/api/sdk/rooms":{"get":{"tags":["rooms"],"summary":"GET /api/sdk/rooms?limit=N&offset=M&include_archived=true","description":"Lists rooms in the caller's app where the caller is an active member.\nOrdered by created_at DESC. Default limit 50, max 200.\n\nScope: `rooms:read:*`.","operationId":"list_rooms_handler","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"offset","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"200":{"description":"List of caller's active rooms","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListRoomsResponse"}}}},"403":{"description":"Scope denied (rooms:read:*)"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"post":{"tags":["rooms"],"summary":"POST /api/sdk/rooms — create a room with optional initial member list.","description":"Scope: `rooms:write:*`.\nIdempotent: if room_id already exists, returns the existing room.\nThe caller is added as owner automatically if not present in initial_members.","operationId":"create_room_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoomRequest"}}},"required":true},"responses":{"201":{"description":"Room created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoomResponse"}}}},"400":{"description":"Invalid visibility or metadata"},"403":{"description":"Scope denied (rooms:write:*)"},"409":{"description":"Room already exists with different data"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}":{"get":{"tags":["rooms"],"summary":"GET /api/sdk/rooms/:room_id — room metadata + member list.","description":"Scope: `rooms:read:*`. Caller must be an active member.","operationId":"get_room_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Room metadata + member list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoomResponse"}}}},"403":{"description":"Scope denied or not a member"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"patch":{"tags":["rooms"],"summary":"PATCH /api/sdk/rooms/:room_id — update title/metadata. Owner-only.","description":"Scope: `rooms:write:*`.","operationId":"update_room_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRoomRequest"}}},"required":true},"responses":{"200":{"description":"Room updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoomResponse"}}}},"400":{"description":"Invalid visibility or metadata"},"403":{"description":"Scope denied or caller is not owner"},"404":{"description":"Room not found"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/call/decline":{"post":{"tags":["voip"],"summary":"VoIP call decline relay","description":"Relays a call-decline notification to the other room member via the transient SSE fanout so the caller's ring UI transitions to DECLINED. Auth-gated: the verified JWT must carry `call:invite:*` (reused — no separate decline scope). Co-membership gate: the decliner must be an active member. Returns 204 No Content on success.","operationId":"sdkVoipCallDecline","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoipCallDeclineRequest"}}},"required":true},"responses":{"204":{"description":"Decline relayed via transient SSE"},"403":{"description":"Scope denied or decliner is not an active member"},"422":{"description":"call_uuid empty after sanitization"},"500":{"description":"Transient SSE publish failed (relay_error)"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/call/invite":{"post":{"tags":["voip"],"summary":"VoIP call invite (APNs push)","description":"Triggers an APNs push notification to the callee when they are backgrounded. Auth-gated: the verified JWT must carry `call:invite:*`. Co-membership double gate: both caller and callee must be active members of the room. Returns 202 (not 200) — the push is fire-and-forget.","operationId":"sdkVoipCallInvite","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoipCallInviteRequest"}}},"required":true},"responses":{"202":{"description":"Accepted — push dispatched, suppressed (callee has fresh presence), or no token on file","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoipCallInviteResponse"}}}},"403":{"description":"Scope denied or co-membership gate failed (identical body — no leak)"},"422":{"description":"Payload invalid — empty caller_name after sanitization or callee_user_id out of range"},"429":{"description":"Per-(app, caller, callee) rate limit exceeded"},"503":{"description":"Push gateway not configured (APNS_* env vars absent)"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/join":{"post":{"tags":["rooms"],"summary":"POST /api/sdk/rooms/:room_id/join — join a group room via share-link alias.","description":"Scope: `rooms:write:*`. The alias IS the capability — caller proves they\nhold a valid (non-expired, room-kind=group-chat) link for THIS room.\nThe server self-adds ctx.user_id to sdk_room_members as role='member'.\n\nReturns 200 with joined=false when the caller is already a member (idempotent).\nReturns 403 when the alias is missing, expired, or for a different room.\n\nCRITICAL: this handler bypasses the normal `is_active_member` pre-check\n(caller is not a member yet). The alias-validation query is the capability gate.","operationId":"group_join_by_link_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JoinByLinkRequest"}}},"required":true},"responses":{"200":{"description":"Joined or already a member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JoinByLinkResponse"}}}},"403":{"description":"Scope denied or alias invalid/expired"},"404":{"description":"Alias not found"},"500":{"description":"DB error"},"503":{"description":"Alias lookup DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/members":{"post":{"tags":["rooms"],"summary":"POST /api/sdk/rooms/:room_id/members — add one or many members.","description":"Scope: `rooms:write:*`. Caller must be an active member.\n\nAccepts two body shapes (back-compat):\n- `{\"user_id\": \"...\", \"role\": \"...\"}` — single add, returns 201 + MemberResponse\n- `{\"user_ids\": [\"...\", ...], \"role\": \"...\"}` — bulk add, returns 200 + BulkAddMemberResponse\n\nThe bulk path avoids the per-IP rate limit cap by consolidating N membership\nwrites into one HTTP call (industry pattern: Stream/Sendbird/GetStream).\nMax 500 user_ids per call; empty list or >500 → 400.","operationId":"add_member_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMemberBody"}}},"required":true},"responses":{"200":{"description":"Members added (bulk)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkAddMemberResponse"}}}},"201":{"description":"Member added (single)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberResponse"}}}},"400":{"description":"Empty bulk list or >500 user_ids"},"403":{"description":"Scope denied or not authorized to manage members"},"409":{"description":"Member is banned"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/members/{user_id}":{"delete":{"tags":["rooms"],"summary":"DELETE /api/sdk/rooms/:room_id/members/:user_id — soft-remove a member.","description":"Scope: `rooms:write:*`. Caller must be an active member.\nCannot remove the last owner: returns 409.","operationId":"remove_member_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}},{"name":"user_id","in":"path","description":"User to remove","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Member removed"},"403":{"description":"Scope denied or not authorized"},"404":{"description":"Room or member not found"},"409":{"description":"Cannot remove the last owner"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/mls-messages":{"post":{"tags":["mls"],"summary":"POST /api/sdk/rooms/:room_id/mls-messages — relay an MLS protocol message.","description":"Stores the message in `sdk_mls_protocol_messages` (TTL 1 h) and fans out\nvia PG NOTIFY `sdk_mls_protocol`. SSE subscribers wake on NOTIFY and\nfetch the full message from the DB.\n\nOnly 'proposal' and 'commit' messages go through this path. Application\nmessages use the existing `sdk_message_log` as opaque `sealed_b64`.","operationId":"relay_mls_message_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RelayMlsMessageRequest"}}},"required":true},"responses":{"201":{"description":"Message relayed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RelayMlsMessageResponse"}}}},"400":{"description":"Invalid base64, size, or message_type"},"403":{"description":"Auth denied or not a room member"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/mls-messages/{message_id}":{"get":{"tags":["mls"],"summary":"GET /api/sdk/rooms/:room_id/mls-messages/:message_id — fetch a single\nprotocol message by UUID.","description":"Called by the client after receiving an `event: mls-protocol` SSE event\nwith a `message_id`. The server returns the full MLS message bytes.\n\nAuthz: the caller must be an active member of the room (same check as\n`relay_mls_message_handler`). The message is NOT deleted on fetch — the\n1-hour TTL sweep handles cleanup, and a client may fetch the same message\nmultiple times during reconnect recovery.","operationId":"get_mls_message_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}},{"name":"message_id","in":"path","description":"Protocol message UUID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Message fetched","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMlsMessageResponse"}}}},"400":{"description":"Malformed message_id UUID"},"403":{"description":"Auth denied or not a room member"},"404":{"description":"Message not found or expired"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/mls-welcome":{"get":{"tags":["mls"],"summary":"GET /api/sdk/rooms/:room_id/mls-welcome — fetch pending Welcomes\n(non-destructive).","description":"Called by the client after receiving an `event: mls-welcome` SSE event\nwith a `room_id`. Returns all pending Welcomes for the authenticated user\nin this room, ordered by `created_at` ascending, each carrying a\n`welcome_id` the client uses to ack the row after processing.\n\n**Fetch → process → ack (NOT consume-on-fetch):** the GET is a plain read\n— the rows stay in the queue. The client processes each Welcome via\n`MLSGroupManager.processWelcome(roomId, welcomeBytes)`, then DELETEs it by\n`welcome_id` (`ack_mls_welcome_handler`). The server never deletes an\nunacked Welcome; the SSE flush keeps notifying until the client acks, so a\nclient that crashed mid-processing recovers on reconnect instead of\nsilently losing the invite. A client that never acks gets re-notified\nuntil the 7-day TTL sweep reaps the row — acking is mandatory, not\noptional. The earlier consume-on-fetch design (`DELETE ... RETURNING`)\ndestroyed the row before the client had processed it; a dropped response,\na TLS reset mid-body, or a crash lost the invite permanently, and the SSE\nflush selected the very queue the DELETE emptied so the invitee was never\nnotified again.\n\nAuthz: the query is scoped to `target_uid = ctx.user_id` — a user can\nonly fetch Welcomes addressed to them. Room membership is NOT required:\na Welcome may be for a room the user hasn't joined yet (they're being\ninvited). The `sdk_auth_middleware` already verified the SDK JWT.","operationId":"get_mls_welcome_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Welcomes fetched (non-destructive)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMlsWelcomeResponse"}}}},"403":{"description":"Auth denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"post":{"tags":["mls"],"summary":"POST /api/sdk/rooms/:room_id/mls-welcome — queue a Welcome for an offline member.","description":"Called by the group creator after a Commit that adds a member. The\nWelcome is queued in `sdk_mls_welcome_queue` (TTL 7 days) and flushed\non the target user's SSE connect.","operationId":"queue_welcome_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueWelcomeRequest"}}},"required":true},"responses":{"201":{"description":"Welcome queued"},"400":{"description":"Invalid base64 or size"},"403":{"description":"Auth denied or not a room member"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/mls-welcome/{welcome_id}":{"delete":{"tags":["mls"],"summary":"DELETE /api/sdk/rooms/:room_id/mls-welcome/:welcome_id — ack a processed\nWelcome.","description":"Called by the client AFTER it has successfully processed a Welcome fetched\nfrom `GET /api/sdk/rooms/:room_id/mls-welcome`. Deletes exactly the named\nrow from `sdk_mls_welcome_queue`. The server never deletes an unacked\nWelcome; the SSE flush keeps notifying until the client acks, so a client\nthat crashed mid-processing recovers on reconnect. A client that never\nacks gets re-notified until the 7-day TTL sweep reaps the row — acking is\nmandatory, not optional.\n\nAuthz: the DELETE is scoped to `target_uid = ctx.user_id` — a user can\nonly ack Welcomes addressed to them. A `welcome_id` that does not exist\nOR belongs to another user returns the same `404`; a separate `403` would\nturn the endpoint into an existence oracle for other users' invites.","operationId":"ack_mls_welcome_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}},{"name":"welcome_id","in":"path","description":"Welcome row UUID to ack","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Welcome acked (row deleted)"},"400":{"description":"Malformed welcome_id UUID"},"404":{"description":"Welcome not found (or belongs to another user)"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/pins":{"get":{"tags":["messages"],"summary":"GET /api/sdk/rooms/:room_id/pins","description":"List pinned messages in a room, ordered by pinned_at descending.\nScope: `chat:read:<room_id>`.","operationId":"list_pins_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pinned messages","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PinnedMessageDTO"}}}}},"403":{"description":"Scope denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/pins/{msg_id}":{"post":{"tags":["messages"],"summary":"POST /api/sdk/rooms/:room_id/pins/:msg_id","description":"Pin a message.\nScope: `chat:write:<room_id>`. Idempotent.\n\nAuthorization is flag-gated (P2b / ADR-SDK-001): with\n`SDK_PIN_MODERATOR_ONLY` off (default) any active room writer may pin;\nwith it on only an owner/moderator may pin (see [`pin_moderator_gate`]).","operationId":"pin_message_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Message pinned (idempotent)"},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope or moderator denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]},"delete":{"tags":["messages"],"summary":"DELETE /api/sdk/rooms/:room_id/pins/:msg_id","description":"Unpin a message.\nScope: `chat:write:<room_id>`. No-op if not pinned.\n\nAuthorization is flag-gated (P2b / ADR-SDK-001): with\n`SDK_PIN_MODERATOR_ONLY` off (default) any active room writer may unpin;\nwith it on only an owner/moderator may unpin (see [`pin_moderator_gate`]).","operationId":"unpin_message_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"msg_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Message unpinned (idempotent)"},"400":{"description":"Bad msg_id UUID"},"403":{"description":"Scope or moderator denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/presence":{"get":{"tags":["presence"],"summary":"Get a snapshot of active presence in a room","description":"Returns all non-stale presence heartbeats for the room (rows fresher than SDK_PRESENCE_FRESHNESS_SECS, default 120s), ordered by last_seen_at descending. Offline users are excluded between sweep cycles so the snapshot never shows stale entries.","operationId":"presence_snapshot","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Presence snapshot","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PresenceEntry"}}}}},"403":{"description":"Caller is not an active member of the room"},"500":{"description":"Membership check or SELECT DB error"}},"security":[{"bearerAuth":[]}]},"post":{"tags":["presence"],"summary":"Heartbeat presence in a room","description":"Upserts the caller's last-seen-at timestamp into the presence heartbeats table and broadcasts a transient presence event to SSE subscribers. The body is intentionally empty — presence is the act of calling. A background sweep removes stale rows older than SDK_PRESENCE_STALE_SECS (default 3600s).","operationId":"presence_upsert","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresenceRequest"}}},"required":true},"responses":{"204":{"description":"Heartbeat recorded"},"403":{"description":"Caller is not an active member of the room"},"500":{"description":"Membership check or UPSERT DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/read":{"post":{"tags":["presence"],"summary":"Mark messages read up to a sequence number","description":"Updates the caller's last_read_seq for the room using MAX semantics (monotonic — cannot go backwards) and broadcasts a read_receipt transient event so other SSE subscribers learn the caller's read position immediately. The seq query parameter is the inclusive upper bound the caller has read up to.","operationId":"read_receipt","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}},{"name":"seq","in":"query","description":"Sequence number read up to (inclusive)","required":false,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":"Read position updated"},"400":{"description":"Missing seq query parameter"},"403":{"description":"Caller is not an active member of the room"},"500":{"description":"Membership check or UPDATE DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/shortlink":{"post":{"tags":["rooms"],"summary":"POST /api/sdk/rooms/:room_id/shortlink — mint a group-chat share link.","description":"Scope: `rooms:write:*`. Caller must be an active member.\nInserts alias into `short_aliases` with `room_kind='group-chat'`.","operationId":"group_shortlink_mint_handler","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintGroupLinkRequest"}}},"required":true},"responses":{"200":{"description":"Share link minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintGroupLinkResponse"}}}},"403":{"description":"Scope denied or not authorized to mint link"},"429":{"description":"Max links per room exceeded"},"500":{"description":"DB error"},"503":{"description":"DB insert error or alias space exhausted"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/threads/{root_msg_id}":{"get":{"tags":["messages"],"operationId":"get_thread_handler","parameters":[{"name":"room_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"root_msg_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Thread messages","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MessageDTO"}}}}},"400":{"description":"Bad root_msg_id UUID"},"403":{"description":"Scope or membership denied"},"500":{"description":"DB error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/rooms/{room_id}/typing":{"post":{"tags":["presence"],"summary":"Broadcast a typing indicator","description":"Fire-and-forget transient event: tells other SSE subscribers in the room that the caller is typing. Not persisted — zero rows inserted. Active-membership-gated; the per-user typing rate limiter (burst 5, refill 1 per 2s) applies before the membership check.","operationId":"typing_indicator","parameters":[{"name":"room_id","in":"path","description":"Room identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TypingRequest"}}},"required":true},"responses":{"204":{"description":"Typing indicator broadcast"},"403":{"description":"Caller is not an active member of the room"},"429":{"description":"Per-user typing rate limit exceeded"},"500":{"description":"Membership check DB error or fanout publish failure"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/roster":{"get":{"tags":["roster"],"summary":"Room roster snapshot","description":"Ephemeral epid→display_name + avatar + role maps for room members. Auth-gated: the verified JWT must carry `chat:read:<room_id>`.","operationId":"sdkRosterRead","parameters":[{"name":"room_id","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Roster maps","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"no-store"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RosterResponse"}}}},"400":{"description":"Missing or empty room_id query param"},"403":{"description":"JWT scope does not authorize this room"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/tokens":{"post":{"tags":["auth"],"summary":"POST /api/sdk/tokens — mint an EdDSA SDK JWT from server-side HMAC credentials.","description":"Three headers must all be present: `X-SDK-Key-Id`, `X-SDK-Secret`,\n`X-SDK-Signature` (hex HMAC-SHA256 over the raw request body).\nModelled as three `apiKey` security schemes ANDed — all three or nothing.","operationId":"mint_handler","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintRequest"}}},"required":true},"responses":{"200":{"description":"Token minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintResponse"}}}},"400":{"description":"Missing header, malformed body, or invalid TTL"},"401":{"description":"Unknown key, wrong secret, or invalid signature"},"500":{"description":"JWT signing or internal error"}},"security":[{"sdkKeyId":[],"sdkSecret":[],"sdkSignature":[]}]}},"/api/sdk/users/me/pubkeys":{"get":{"tags":["keys"],"summary":"List the caller's active device pubkeys","description":"Returns all non-revoked pubkeys for the authenticated user, ordered by creation time. Used by a client to discover which of its devices are currently registered.","operationId":"get_my_pubkeys","responses":{"200":{"description":"Pubkey list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PubkeysListResponse"}}}},"500":{"description":"Database error"}},"security":[{"bearerAuth":[]}]},"put":{"tags":["keys"],"summary":"Register or rotate the caller's device keypair","description":"Upserts the caller's Ed25519 + X25519 public keys with a self-signature proving the Ed25519 private key controls the X25519 key. Idempotent on (app_id, user_id, device_id) — re-PUT rotates the key and clears revoked_at. Rate-limited to 1 rotation/hour and 24/day per device.","operationId":"put_my_pubkey","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutPubkeyRequest"}}},"required":true},"responses":{"200":{"description":"Keypair registered/rotated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutPubkeyResponse"}}}},"400":{"description":"Invalid base64url, wrong field length, or self_sig verification failed"},"429":{"description":"Rotation rate limit exceeded (1/hour, 24/day per device)"},"500":{"description":"Database error"}},"security":[{"bearerAuth":[]}]},"delete":{"tags":["keys"],"summary":"Revoke a device pubkey","description":"Soft-revokes the caller's pubkey for the given device_id by setting revoked_at. Idempotent — revoking an already-revoked or non-existent device returns 200 with no error.","operationId":"delete_my_pubkey","parameters":[{"name":"device_id","in":"query","description":"Device identifier to revoke","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pubkey revoked (or already revoked / not found)"},"500":{"description":"Database error"}},"security":[{"bearerAuth":[]}]}},"/api/sdk/users/{user_id}/pubkeys":{"get":{"tags":["keys"],"summary":"Fetch a peer's pubkeys (shared-room gated)","description":"Returns the target user's active pubkeys, but only if the caller and target share an active room. Returns 404 whenever the result is empty — whether the user does not exist, the caller has no shared room, or the user has no pubkeys — so an attacker probing user_ids cannot distinguish the cases. Clients MUST verify the self_sig binding before using x25519_pub to encrypt.","operationId":"get_other_pubkeys","parameters":[{"name":"user_id","in":"path","description":"Target user identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Peer pubkey list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PubkeysListResponse"}}}},"404":{"description":"No pubkeys found (no shared room, user absent, or no keys)"},"500":{"description":"Database error"}},"security":[{"bearerAuth":[]}]}}},"components":{"schemas":{"AddMemberBody":{"oneOf":[{"$ref":"#/components/schemas/BulkAddMemberRequest"},{"$ref":"#/components/schemas/AddMemberRequest"}],"description":"Untagged union: accept either `{\"user_id\":\"...\"}` or `{\"user_ids\":[...]}`.\nSerde tries each variant in declaration order; `user_ids` is checked first\nso a body that contains BOTH fields is treated as bulk."},"AddMemberRequest":{"type":"object","description":"Single-user add_member request (back-compat wire shape).","required":["user_id"],"properties":{"role":{"type":"string"},"user_id":{"type":"string"}}},"AnonReadMintRequest":{"type":"object","description":"Request body for POST /api/sdk/auth/anon-read-mint.","required":["app_id","room_id"],"properties":{"app_id":{"type":"string","description":"The `app_id` of the SDK app that owns the room."},"room_id":{"type":"string","description":"The `room_id` to grant read access to."}}},"AnonReadMintResponse":{"type":"object","description":"Successful mint response.","required":["token","expires_at","user_id"],"properties":{"expires_at":{"type":"integer","format":"int64"},"token":{"type":"string"},"user_id":{"type":"string","description":"Ephemeral user_id embedded in the token (`anon_<uuid>`)."}}},"AppendRequest":{"type":"object","required":["room_id","msg_id"],"properties":{"attachments":{"description":"#2904: non-sensitive attachment URLs (tenant-hosted, URL pass-through).\nJSONB array of attachment objects, each carrying at least a `url` and\n`kind`. Server-visible so the origin can be validated against the\ntenant's `sdk_attachment_origins` allowlist at send time. E2EE\ninvariant preserved — the URL is a public pointer, not sealed content."},"msg_id":{"type":"string","format":"uuid"},"product_meta":{"description":"W9-P1: non-sensitive marketplace metadata (title, price, currency,\nimageUrl, productUrl). E2EE-relaxed by design — already public on the\nmarketplace listing page. Stored as JSONB so widget/UI can render the\nproduct card on history reload without requiring sender to embed\nproductMeta inside the sealedBody envelope. Capped at MAX_META_BYTES."},"product_ref":{"type":["string","null"],"description":"W9: marketplace product UUID or SKU. Non-sensitive — indexed in the clear.\nEnables GET /api/sdk/messages?product_ref=X for product conversation search."},"room_id":{"type":"string"},"sealed_b64":{"type":["string","null"],"description":"Sealed (AEAD ciphertext) as standard base64. Server never decodes content.\nOptional for product-card messages: when absent the server stores an empty\nsealed frame (one sentinel zero-byte). This preserves the E2EE-optional\ndesign — the sealed field carries optional private context (buyer notes,\nnegotiation) and may be omitted."},"thread_root_msg_id":{"type":["string","null"],"format":"uuid","description":"W7: optional thread root message UUID. Non-sensitive pointer.\nWhen present, this message is a reply in the named thread."}}},"AppendResponse":{"type":"object","required":["seq","msg_id"],"properties":{"attachments":{"description":"#2904: echoes back attachments so clients get round-trip confirmation."},"msg_id":{"type":"string","format":"uuid"},"product_meta":{"description":"W9-P1: echoes back product_meta so clients get round-trip confirmation."},"product_ref":{"type":["string","null"],"description":"W9: echoes back product_ref so clients get wire-contract confirmation."},"seq":{"type":"integer","format":"int64"},"thread_root_msg_id":{"type":["string","null"],"format":"uuid","description":"W7: echoes back thread_root_msg_id so clients get wire-contract confirmation."}}},"BatchAppendItem":{"type":"object","description":"W7-P1 T7: one item in a batch-append request.\nMirrors `AppendRequest` but without the wire-codec wrapper — the batch\nendpoint accepts a plain JSON array (`Vec<BatchAppendItem>`).","required":["room_id","msg_id"],"properties":{"attachments":{},"msg_id":{"type":"string","format":"uuid"},"product_meta":{},"product_ref":{"type":["string","null"]},"room_id":{"type":"string"},"sealed_b64":{"type":["string","null"]},"thread_root_msg_id":{"type":["string","null"],"format":"uuid"}}},"BulkAddMemberRequest":{"type":"object","description":"Bulk add_member request — adds up to 500 users in one call.","required":["user_ids"],"properties":{"role":{"type":"string"},"user_ids":{"type":"array","items":{"type":"string"}}}},"BulkAddMemberResponse":{"type":"object","description":"Response for bulk add_member.","required":["added","updated"],"properties":{"added":{"type":"array","items":{"type":"string"},"description":"user_ids that were newly inserted (active=TRUE, row did not exist before)."},"updated":{"type":"array","items":{"type":"string"},"description":"user_ids for which an existing row was re-activated or role-updated."}}},"CatalogProductDTO":{"type":"object","required":["product_ref","product_meta","created_at","updated_at"],"properties":{"archived_at":{"type":["string","null"]},"created_at":{"type":"string"},"product_meta":{},"product_ref":{"type":"string"},"updated_at":{"type":"string"}}},"CatalogProductListDTO":{"type":"object","required":["products","has_more"],"properties":{"has_more":{"type":"boolean","description":"#2457: `true` when a further page exists (a sentinel row beyond `limit`\nwas found). Exact — unlike a `len == limit` guess, an exact-multiple\nfinal page reports `false`. Mirrors the messages list envelope."},"next_cursor":{"type":["string","null"],"description":"#2439: opaque keyset cursor for the next page. Pass this as `?cursor=`\non the next request to continue paging. `None` when this is the last\npage (`has_more == false`). Additive field — pre-pagination\ncallers that don't read it are unaffected."},"products":{"type":"array","items":{"$ref":"#/components/schemas/CatalogProductDTO"}}}},"ChallengeRequest":{"type":"object","properties":{"pubkey_b64u":{"type":["string","null"],"description":"URL_SAFE_NO_PAD base64 encoding of a 32-byte Ed25519 public key (43 chars).\nOptional at the serde level so we can return 400 (not 422) when absent."}}},"ChallengeResponse":{"type":"object","required":["nonce","expires_at","pubkey_b64u"],"properties":{"expires_at":{"type":"integer","format":"int64","minimum":0},"nonce":{"type":"string"},"pubkey_b64u":{"type":"string"}}},"CreateProductRequest":{"type":"object","required":["product_ref","product_meta"],"properties":{"product_meta":{"description":"Non-sensitive marketplace metadata: {title, price, currency, imageUrl,\nproductUrl}. Validated server-side for required fields."},"product_ref":{"type":"string"}}},"CreateRoomRequest":{"type":"object","properties":{"crypto_mode":{"type":["string","null"],"description":"Per-room crypto_mode override. None = inherit app-level default.\n\"mls\" = MLS (RFC 9420) forward-secrecy group messaging.\nWhen \"mls\", all initial_members MUST have published KeyPackages."},"initial_members":{"type":"array","items":{"$ref":"#/components/schemas/InitialMember"},"description":"Initial member list: [{user_id, role}]. Caller is added as owner\nautomatically if not present."},"metadata":{},"product_ref":{"type":["string","null"]},"room_id":{"type":["string","null"]},"title":{"type":["string","null"]},"visibility":{"type":["string","null"],"description":"Room visibility: \"member\" (default) or \"open\"."}}},"GetMlsMessageResponse":{"type":"object","description":"Response for `GET /api/sdk/rooms/:room_id/mls-messages/:message_id`.","required":["message_b64","message_type","epoch","sender_uid"],"properties":{"epoch":{"type":"integer","format":"int64","description":"MLS epoch the message belongs to."},"message_b64":{"type":"string","description":"Standard base64 of the MLS protocol message."},"message_type":{"type":"string","description":"'proposal' or 'commit'."},"sender_uid":{"type":"string","description":"Sender user UID."}}},"GetMlsWelcomeResponse":{"type":"object","description":"Response for `GET /api/sdk/rooms/:room_id/mls-welcome`.\n\nReturns all pending Welcomes for the authenticated user in this room,\nordered by `created_at` ascending. The fetch is **non-destructive**: the\nrows stay in the queue until the client acks them via\n`DELETE /api/sdk/rooms/:room_id/mls-welcome/:welcome_id` after processing.\nSee the handler doc for the full fetch → process → ack contract.","required":["welcomes"],"properties":{"welcomes":{"type":"array","items":{"$ref":"#/components/schemas/MlsWelcomeEntry"}}}},"GrantMintRequest":{"type":"object","description":"Body for POST /api/sdk/auth/group-grant-mint.\nThe `grant` field is a compact JWT string.","required":["grant"],"properties":{"grant":{"type":"string"}}},"GroupGrantMintResponse":{"type":"object","description":"Response body on success.","required":["token","expires_at"],"properties":{"expires_at":{"type":"integer","format":"int64"},"token":{"type":"string"}}},"GroupMintRequest":{"type":"object","description":"Body for POST /api/sdk/auth/group-mint.\n\nIdentical to `VerifyRequest` in `auth_self.rs` — the client reuses the\nnonce it obtained from `POST /api/sdk/auth/challenge`.","required":["pubkey_b64u","nonce","signature_b64u"],"properties":{"nonce":{"type":"string","description":"Nonce issued by `POST /api/sdk/auth/challenge` (43 chars base64url)."},"pubkey_b64u":{"type":"string","description":"URL_SAFE_NO_PAD base64 of the device's 32-byte Ed25519 public key (43 chars)."},"signature_b64u":{"type":"string","description":"Ed25519 signature over `\"oxpulse-group-mint-v1:{nonce}\"` in base64url.\nDistinct from the self-mint prefix (`sdk-self-mint:`) to prevent\ncontext-substitution across mint routes (SEC-CR-102)."}}},"GroupMintResponse":{"type":"object","description":"Response body on success.","required":["token","expires_at"],"properties":{"expires_at":{"type":"integer","format":"int64"},"token":{"type":"string"}}},"InitialMember":{"type":"object","required":["user_id"],"properties":{"role":{"type":"string"},"user_id":{"type":"string"}}},"JoinByLinkRequest":{"type":"object","required":["alias"],"properties":{"alias":{"type":"string","description":"The short-link alias that was shared with this joiner."}}},"JoinByLinkResponse":{"type":"object","required":["room_id","user_id","role","joined"],"properties":{"joined":{"type":"boolean","description":"Whether this was a fresh join (true) or the user was already a member (false)."},"role":{"type":"string"},"room_id":{"type":"string"},"user_id":{"type":"string"}}},"KeyPackageEntry":{"type":"object","required":["key_package_id","key_package_b64","signature_b64","is_last_resort","created_at"],"properties":{"created_at":{"type":"string"},"is_last_resort":{"type":"boolean"},"key_package_b64":{"type":"string","description":"Standard base64 of the MLS KeyPackage."},"key_package_id":{"type":"string","format":"uuid"},"signature_b64":{"type":"string","description":"Standard base64 of the signature."}}},"KeyPackagesListResponse":{"type":"object","required":["key_packages"],"properties":{"key_packages":{"type":"array","items":{"$ref":"#/components/schemas/KeyPackageEntry"}}}},"ListRoomsResponse":{"type":"object","required":["rooms","limit","offset","has_more"],"properties":{"has_more":{"type":"boolean"},"limit":{"type":"integer","format":"int64"},"offset":{"type":"integer","format":"int64"},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/RoomListItem"}}}},"MemberResponse":{"type":"object","required":["app_id","room_id","user_id","role","joined_at","last_read_seq","active"],"properties":{"active":{"type":"boolean"},"app_id":{"type":"string"},"joined_at":{"type":"string"},"last_read_seq":{"type":"integer","format":"int64"},"role":{"type":"string"},"room_id":{"type":"string"},"user_id":{"type":"string"}}},"MeshTokenRequest":{"type":"object","description":"Request body for POST /api/sdk/mesh-channel-token.\n\n`geohash`: exactly 4 characters from the geohash alphabet (0-9bcdefghjkmnpqrstuvwxyz).\n`day_utc`: date string \"YYYY-MM-DD\", must be within today ±1 UTC day.","required":["geohash","day_utc"],"properties":{"day_utc":{"type":"string"},"geohash":{"type":"string"}}},"MeshTokenResponse":{"type":"object","description":"Token issue response.","required":["token","channel_id","expires_at","user_id","room_id"],"properties":{"channel_id":{"type":"string"},"expires_at":{"type":"integer","format":"int64"},"room_id":{"type":"string","description":"The room_id this token is scoped to (`mesh-<channel_id>`)."},"token":{"type":"string"},"user_id":{"type":"string","description":"Ephemeral user_id embedded in the token (`anon_<uuid>`)."}}},"MessageDTO":{"type":"object","description":"Wire format for REST reads (GET /api/sdk/messages and GET /api/sdk/rooms/:room_id/threads/:root).\n\nReturned in bulk as a JSON array. Consumers may page through the log by\nadvancing `after_seq` on successive requests.","required":["seq","msg_id","sender_uid","sealed_b64","created_at","edit_count"],"properties":{"attachments":{"description":"#2904: non-sensitive attachment URLs (tenant-hosted, URL pass-through)."},"created_at":{"type":"string"},"deleted_at":{"type":["string","null"],"description":"W2: timestamp of soft-delete (RFC 3339), or null if not deleted."},"edit_count":{"type":"integer","format":"int32","description":"W2: number of times the message has been edited."},"edited_at":{"type":["string","null"],"description":"W2: timestamp of the last edit (RFC 3339), or null if never edited."},"msg_id":{"type":"string","format":"uuid"},"product_meta":{"description":"W9-P1: non-sensitive marketplace metadata for product card rendering."},"product_ref":{"type":["string","null"],"description":"W9: marketplace product ref, or null for non-product messages."},"sealed_b64":{"type":"string","description":"Sealed ciphertext as standard base64."},"sender_uid":{"type":"string"},"seq":{"type":"integer","format":"int64"},"thread_root_msg_id":{"type":["string","null"],"format":"uuid","description":"W7: UUID of the thread root, or null for top-level messages."}}},"MintGroupLinkRequest":{"type":"object","properties":{"ttl_seconds":{"type":["integer","null"],"format":"int64","description":"Optional TTL seconds; clamped to [60, 604800]."}}},"MintGroupLinkResponse":{"type":"object","required":["alias","room_id","expires_at","url"],"properties":{"alias":{"type":"string"},"expires_at":{"type":"string","format":"date-time"},"room_id":{"type":"string"},"url":{"type":"string","description":"Full relative URL for share (e.g. \"/s/xA3kP\")."}}},"MintRequest":{"type":"object","required":["user_id"],"properties":{"scopes":{"type":"array","items":{"type":"string"}},"ttl_secs":{"type":["integer","null"],"format":"int64"},"user_id":{"type":"string"}}},"MintResponse":{"type":"object","required":["token","expires_at"],"properties":{"expires_at":{"type":"integer","format":"int64"},"token":{"type":"string"}}},"MlsWelcomeEntry":{"type":"object","required":["welcome_id","welcome_b64"],"properties":{"welcome_b64":{"type":"string","description":"Standard base64 of the MLS Welcome message."},"welcome_id":{"type":"string","description":"Stable row handle the client uses to ack the Welcome after processing\n(the UUID, hyphenated)."}}},"PatchMessageRequest":{"type":"object","required":["sealed_b64"],"properties":{"sealed_b64":{"type":"string","description":"New sealed ciphertext as standard base64. Server never decodes content."}}},"PinnedMessageDTO":{"type":"object","description":"Wire DTO for a pinned message (GET /api/sdk/rooms/:room_id/pins).","required":["app_id","room_id","msg_id","pinned_by","pinned_at"],"properties":{"app_id":{"type":"string"},"msg_id":{"type":"string"},"pinned_at":{"type":"string"},"pinned_by":{"type":"string"},"room_id":{"type":"string"}}},"PostReactionRequest":{"type":"object","description":"POST /api/sdk/messages/:room_id/:msg_id/reactions body.","required":["reaction"],"properties":{"reaction":{"type":"string"}}},"PresenceEntry":{"type":"object","description":"Wire shape for GET /presence entries.\nWire-contract: user_id + last_seen_at (ISO 8601).","required":["user_id","last_seen_at"],"properties":{"last_seen_at":{"type":"string"},"user_id":{"type":"string"}}},"PresenceRequest":{"type":"object","description":"Body for POST /presence — intentionally empty; presence is the act of calling."},"PresignBody":{"type":"object","required":["mime_type","byte_size","sha256"],"properties":{"byte_size":{"type":"integer","format":"int64"},"mime_type":{"type":"string"},"sha256":{"type":"string"},"thumbhash_b64":{"type":["string","null"]}}},"PresignGetResp":{"type":"object","description":"Response from `POST /api/sdk/attachments/{id}/presign-get` (#2903).\n\nCarries a presigned download URL whose `?t=` token authorises a\nBearer-free `GET /api/sdk/attachments/{id}/dl` — the path a `<video src>`\nelement can actually fetch. The token is signed with the download\nsubkey (distinct HKDF label), so it cannot be replayed as a PUT.","required":["download_url"],"properties":{"download_url":{"type":"string"}}},"PresignResp":{"type":"object","required":["attachment_id","upload_url"],"properties":{"attachment_id":{"type":"string"},"upload_url":{"type":"string"}}},"PubkeyEntry":{"type":"object","required":["device_id","ed25519_pub","x25519_pub","self_sig","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"device_id":{"type":"string"},"ed25519_pub":{"type":"string","description":"URL_SAFE_NO_PAD base64url of 32-byte Ed25519 public key."},"self_sig":{"type":"string","description":"URL_SAFE_NO_PAD base64url of 64-byte Ed25519 self-signature.\nCovers: \"oxp/pkbind/v1\" || x25519_pub (raw bytes).\nClients MUST verify this binding before using x25519_pub to encrypt."},"x25519_pub":{"type":"string","description":"URL_SAFE_NO_PAD base64url of 32-byte X25519 public key."}}},"PubkeysListResponse":{"type":"object","required":["pubkeys"],"properties":{"pubkeys":{"type":"array","items":{"$ref":"#/components/schemas/PubkeyEntry"}}}},"PublishKeyPackageRequest":{"type":"object","required":["key_package_b64"],"properties":{"is_last_resort":{"type":"boolean","description":"Marks this as the last-resort KeyPackage (never consumed).\nAt most one last-resort KeyPackage per user."},"key_package_b64":{"type":"string","description":"Standard base64 of the MLS KeyPackage (TLS-serialized)."},"signature_b64":{"type":["string","null"],"description":"Standard base64 of the Ed25519 signature over the KeyPackage.\nThe signature pubkey is embedded in the KeyPackage's LeafNode.\n\nOPTIONAL: the published @oxpulse/chat-sdk (3.5.0) does not send this\nfield — the KeyPackage's TLS serialization already carries the\nsignature in its LeafNode, so a separate field is redundant for a DS\nthat does not verify signatures server-side (this server does not).\nOlder clients that do send it are unaffected (the value is stored)."}}},"PublishKeyPackageResponse":{"type":"object","required":["key_package_id"],"properties":{"key_package_id":{"type":"string","format":"uuid"}}},"PutPubkeyRequest":{"type":"object","required":["device_id","ed25519_pub","x25519_pub","self_sig"],"properties":{"device_id":{"type":"string"},"ed25519_pub":{"type":"string","description":"URL_SAFE_NO_PAD base64url of 32-byte Ed25519 public key."},"self_sig":{"type":"string","description":"URL_SAFE_NO_PAD base64url of 64-byte Ed25519 signature.\nCovers: \"oxp/pkbind/v1\" || x25519_pub (raw bytes)."},"x25519_pub":{"type":"string","description":"URL_SAFE_NO_PAD base64url of 32-byte X25519 public key."}}},"PutPubkeyResponse":{"type":"object","required":["created_at"],"properties":{"created_at":{"type":"string","format":"date-time"}}},"QueueWelcomeRequest":{"type":"object","required":["target_uid","welcome_b64"],"properties":{"target_uid":{"type":"string","description":"Target user UID (the invitee)."},"welcome_b64":{"type":"string","description":"Standard base64 of the MLS Welcome message."}}},"ReactionsResponse":{"type":"object","description":"GET /api/sdk/messages/:room_id/:msg_id/reactions response.","required":["counts","users","truncated"],"properties":{"counts":{"type":"object","additionalProperties":{"type":"integer","format":"int64","minimum":0},"propertyNames":{"type":"string"}},"truncated":{"type":"boolean","description":"True when at least one emoji's user list was truncated.\nExact counts are always preserved in `counts`."},"users":{"type":"object","description":"Per-emoji user lists, capped at `MAX_USERS_PER_REACTION` (100) entries.","additionalProperties":{"type":"array","items":{"type":"string"}},"propertyNames":{"type":"string"}}}},"RelayMlsMessageRequest":{"type":"object","required":["message_b64","message_type","epoch"],"properties":{"epoch":{"type":"integer","format":"int64","description":"MLS epoch the message belongs to."},"message_b64":{"type":"string","description":"Standard base64 of the MLS protocol message (proposal or commit)."},"message_type":{"type":"string","description":"'proposal' or 'commit' (NOT 'application' — those use sdk_message_log)."}}},"RelayMlsMessageResponse":{"type":"object","required":["message_id"],"properties":{"message_id":{"type":"string","format":"uuid"}}},"RoomListItem":{"type":"object","required":["app_id","room_id","created_by","created_at","metadata"],"properties":{"app_id":{"type":"string"},"archived_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string"},"metadata":{},"product_ref":{"type":["string","null"]},"room_id":{"type":"string"},"title":{"type":["string","null"]}}},"RoomResponse":{"type":"object","required":["app_id","room_id","created_by","created_at","metadata","visibility","members"],"properties":{"app_id":{"type":"string"},"archived_at":{"type":["string","null"]},"created_at":{"type":"string"},"created_by":{"type":"string"},"crypto_mode":{"type":["string","null"],"description":"Per-room crypto_mode override. null = inherit app-level default."},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}},"metadata":{},"mls_group_id_b64":{"type":["string","null"],"description":"MLS group identifier (standard base64). Present only when crypto_mode = \"mls\"."},"product_ref":{"type":["string","null"]},"room_id":{"type":"string"},"title":{"type":["string","null"]},"visibility":{"type":"string"}}},"RosterResponse":{"type":"object","description":"Response body for GET /api/sdk/roster.","required":["roster","avatars","roles"],"properties":{"avatars":{"type":"object","description":"epid → avatar_url map. ADDITIVE (T18-avatar): only members whose\nverified grant carried an `avatar_url` claim appear here. Empty when no\nmember has an avatar. Older clients that read only `roster` ignore it.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"roles":{"type":"object","description":"epid → role map. ADDITIVE (P3): only members with a privileged\n(`moderator`/`owner`) role appear here — the default `member` role is\nsparse, mirroring `avatars`. Empty when no member has a privileged\nrole. Older clients that read only `roster`/`avatars` ignore it.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"roster":{"type":"object","description":"epid → display_name map. Empty when Redis is absent or no members\nhave minted a JWT for this room yet.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}}}},"SdkHealthResponse":{"type":"object","description":"Response body for GET /api/sdk/health.","required":["app_id","user_id"],"properties":{"app_id":{"type":"string"},"user_id":{"type":"string"}}},"SubscribeKeys":{"type":"object","required":["p256dh","auth"],"properties":{"auth":{"type":"string","description":"VAPID auth secret as base64url (16 bytes)."},"p256dh":{"type":"string","description":"ECDH public key as base64url (p256dh, typically 65 bytes uncompressed)."}}},"SubscribeRequest":{"type":"object","required":["user_id","endpoint","keys"],"properties":{"endpoint":{"type":"string"},"keys":{"$ref":"#/components/schemas/SubscribeKeys"},"user_id":{"type":"string"}}},"SubscribeTicketRequest":{"type":"object","description":"Query params for POST /api/sdk/messages/subscribe-ticket.\nThe room_id is bundled into the ticket so the SSE handler can scope it.","required":["room_id"],"properties":{"after_seq":{"type":"integer","format":"int64"},"room_id":{"type":"string"}}},"SubscribeTicketResponse":{"type":"object","required":["ticket","expires_at"],"properties":{"expires_at":{"type":"string"},"ticket":{"type":"string"}}},"TypingRequest":{"type":"object","description":"Optional body for POST /typing. `ttl_secs` hints to subscribers how long\nto display the indicator (purely advisory; server does not enforce it).","properties":{"ttl_secs":{"type":["integer","null"],"format":"int32","minimum":0}}},"UnsubscribeRequest":{"type":"object","required":["endpoint"],"properties":{"endpoint":{"type":"string"}}},"UpdateProductRequest":{"type":"object","required":["product_meta"],"properties":{"product_meta":{}}},"UpdateRoomRequest":{"type":"object","properties":{"archived":{"type":["boolean","null"]},"metadata":{},"title":{"type":["string","null"]}}},"VapidKeyResponse":{"type":"object","required":["public_key"],"properties":{"public_key":{"type":"string"}}},"VerifyRequest":{"type":"object","required":["pubkey_b64u","nonce","signature_b64u"],"properties":{"nonce":{"type":"string"},"pubkey_b64u":{"type":"string"},"signature_b64u":{"type":"string"}}},"VerifyResponse":{"type":"object","required":["token","expires_at"],"properties":{"expires_at":{"type":"integer","format":"int64"},"token":{"type":"string"}}},"VoipCallDeclineRequest":{"type":"object","description":"Request body for `POST /api/sdk/rooms/{room_id}/call/decline`.","required":["call_uuid"],"properties":{"call_uuid":{"type":"string","description":"The `call_uuid` from the 202 response of `/call/invite`.\nUsed by the caller to correlate the decline to the in-flight ring."}}},"VoipCallInviteRequest":{"type":"object","description":"Request body for `POST /api/sdk/rooms/{room_id}/call/invite`.\n\n`room_id` is extracted from the URL path; `app_id` + `caller_user_id`\ncome from the authenticated `SDKContext`.","required":["callee_user_id","caller_name","has_video"],"properties":{"callee_user_id":{"type":"string","description":"The callee's user_id within the same app. Must be an active member of\n`room_id`. Length: 1..=256 bytes."},"caller_name":{"type":"string","description":"Display name shown on the CallKit lock-screen card. Server strips C0/C1\ncontrol characters and truncates to 64 chars. Must be non-empty after\nsanitization."},"has_video":{"type":"boolean","description":"Whether this is a video call (drives the CallKit camera icon)."}}},"VoipCallInviteResponse":{"type":"object","description":"202 Accepted response body for the dispatch path.","required":["dispatched"],"properties":{"call_uuid":{"type":["string","null"],"description":"UUID v4 string identifying this call transaction.\nPresent only when `dispatched = true`."},"dispatched":{"type":"boolean","description":"Whether an APNs fan-out was spawned."},"reason":{"type":["string","null"],"description":"Non-dispatch reason. Present when `dispatched = false` and\n`suppressed` is absent."},"suppressed":{"type":["boolean","null"],"description":"Present and `true` when the callee had fresh presence (push suppressed)."}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"EdDSA user JWT minted by your backend. Default TTL 1h, max 24h. Carries `scopes`; the required scope differs per route family and the wildcard is asymmetric — `rooms:write:*` grants all rooms, but `chat:write:<room_id>` is per-room and `chat:write:*` is NOT accepted."},"sdkKeyId":{"type":"apiKey","in":"header","name":"X-SDK-Key-Id","description":"Server-side key id. Never ship to a browser."},"sdkSecret":{"type":"apiKey","in":"header","name":"X-SDK-Secret","description":"Raw server-side secret. Never ship to a browser."},"sdkSignature":{"type":"apiKey","in":"header","name":"X-SDK-Signature","description":"Hex HMAC-SHA256 over the RAW request body. Sign the exact bytes you send — signing a re-serialization (different key order or whitespace) yields 401 InvalidSignature, which is indistinguishable from a wrong secret."},"subscribeTicket":{"type":"apiKey","in":"query","name":"ticket","description":"Single-use SSE ticket from POST /api/sdk/messages/subscribe-ticket. Consumed on first accept. Used instead of a Bearer header because browser EventSource cannot send custom headers."}}},"tags":[{"name":"auth","description":"Token minting and handshake"},{"name":"messages","description":"Send, list, edit, react, pin, subscribe"},{"name":"rooms","description":"Room lifecycle and membership — app-wide scoped, server-side only"},{"name":"push","description":"Web-push subscription management"},{"name":"catalog","description":"Product catalog attached to rooms"},{"name":"presence","description":"Typing indicators, presence heartbeats, read receipts"},{"name":"keys","description":"Device pubkey registry — E2EE key distribution"},{"name":"attachments","description":"File attachment presign, upload, download"},{"name":"roster","description":"Room roster snapshots"},{"name":"health","description":"Health probes"},{"name":"voip","description":"VoIP call invite/decline"}]}