{"openapi":"3.1.0","info":{"title":"bitSimp API","summary":"Anonymous P2P Bitcoin content marketplace — Lightning & Nostr APIs","description":"bitSimp is a Bitcoin-only anonymous peer-to-peer content marketplace. Creators post content behind a price wall denominated in satoshis; buyers pay with the Lightning Network.\n\nThis document describes the public HTTP API of bitsimp.com.\n\n# Authentication\n\nbitSimp is **passwordless**. Authentication is performed by proving ownership of a secp256k1 key pair, using one of two methods:\n\n1. **LNURL-auth (Lightning)** — ECDSA over secp256k1, DER-encoded signatures, 33-byte compressed public keys. See `/api/auth/lightning/lnurl-auth/*`.\n2. **NIP-07 (Nostr)** — BIP-340 Schnorr signatures over secp256k1 (NIP-01/NIP-42), 32-byte x-only public keys. See `/api/auth/nostr/nip-07/*`.\n\nBoth methods share the same challenge flow:\n\n1. `GET .../challenge` — obtain a fresh random 64-char hex challenge (5-minute TTL, single-use).\n2. Sign the challenge with your private key (LNURL: ECDSA DER; Nostr: Schnorr event with the challenge in `content` or a `[\"challenge\", …]` tag).\n3. `POST .../login` — one call that verifies the signature, upserts your account, and **sets an HttpOnly session cookie** (`[__Secure-]authjs.session-token`) in the response.\n\nAlternatively use the two-phase flow: `POST .../verify` then exchange the resulting `k1` through NextAuth sign-in (`/api/auth/callback/lightning`). For headless agents, the published `lnurl-auth` plugin/skill (https://github.com/dyegolara/lnurl-auth-agents) is the reference client for signing the `k1` — install it from npm (`lnurl-auth`), ClawHub (`@dyegolara/lnurl-auth`), or skills.sh (`dyegolara/lnurl-auth-agents --skill lnurl-auth`), with hand-rolled ECDSA as the fallback.\n\n# NIP-98 request authentication\n\nA fixed subset of the API also accepts a signed NIP-98 request (`Authorization: Nostr <base64(JSON kind-27235 event)>`) as per-request authentication: it acts as the existing Account that owns the signing npub for exactly that one request and is never a login — it creates no Account, issues no session cookie and cannot be reused. The `nostr` security scheme below is listed as an alternative on those operations; when the header is present it decides, and an invalid header fails with 401 instead of falling back to a session cookie.\n\nTwo paths for headless LNURL-auth clients:\n\n- **Plugin path** (recommended): strip the `lightning:` URI scheme from the `lnurl` field returned by `/challenge`, pass the bare `LNURL1...` string to the plugin with `--dry-run --json`, then extract `sig` and `key` from the `callbackUrl` query parameters. POSTing `{ pubkey: key, challenge, sig }` to `/login` sets the session cookie.\n- **Raw ECDSA path** (fallback): sign the hex `challenge` field directly (ECDSA DER, secp256k1) and POST `{ pubkey, challenge, sig }` to `/login`. Do **not** bech32-decode the `lnurl` string — that indirection is only needed for interactive wallet QR flows.\n\nNote: a `success: true` from `GET .../validate` only verifies the signature and stages the pubkey for the interactive browser flow — it does **not** create a session. Only `/login` (or the NextAuth sign-in) sets the session cookie.\n\n# Linking sign-in methods\n\nAn authenticated Account can attach the sign-in method it is missing — Lightning or Nostr — and unlink one, through the `/api/auth/link/*` endpoints (ADR 0020). An Account holds at most one Lightning key and one npub, and either attached key signs in to the same Account. Linking is additive and never merges Accounts: a key that already belongs to another Account is rejected with 409. Unlink is guarded so an Account always keeps at least one sign-in method, and neither link nor unlink creates, rotates, or invalidates the session (the identity is always the Account ID).\n\n# Content lifecycle\n\nPosts are never hard-deleted through the public API. They are archived (`is_archived=true`) and can be unarchived later by their owner. Archiving preserves purchase/comment/transaction history.\n\n# Sessions\n\nAuthenticated endpoints read the session cookie `authjs.session-token` (prefixed `__Secure-` on HTTPS). Sessions last 30 days. The user identity is the **account id** (`session.user.name`), returned as `id` in responses.\n\n# Payments\n\nAll monetary amounts are integer **satoshis**. Purchases/tips/deposits are Lightning invoices created via OpenNode; see the authenticated API flows described in this document.\n\n# Errors\n\nMost endpoints return `{ \"error\": string }` (or `{ \"success\": false, \"error\": string }` for auth endpoints) with an appropriate 4xx/5xx status. Error messages are intentionally generic.\n","termsOfService":"https://bitsimp.com/about","contact":{"name":"bitSimp Support","url":"https://bitsimp.com/about"},"version":"1.1.0","license":{"name":"bitSimp","url":"https://bitsimp.com/about"}},"servers":[{"url":"https://bitsimp.com","description":"Production"},{"url":"http://localhost:3000","description":"Local development"}],"security":[],"paths":{"/api/auth/lightning/lnurl-auth/challenge":{"get":{"tags":["Authentication — LNURL-auth (Lightning)"],"summary":"Issue an LNURL-auth challenge","description":"Generates a fresh random 32-byte hex k1 challenge and its corresponding LNURL string (`lightning:LNURL1...`, for QR-code scanning with any LNURL-auth compatible wallet). The challenge is storable in Redis for 5 minutes and is single-use.\n\nThe LNURL embeds the callback `GET /api/auth/lightning/lnurl-auth/validate?k1={k1}&tag=login`, which wallets call after the user approves the login.\n\nHeadless clients have two paths:\n\n- **Plugin path** (recommended): strip the `lightning:` URI scheme from the `lnurl` field and pass the bare `LNURL1...` string to the `lnurl-auth` plugin with `--dry-run --json`. Extract `sig` and `key` from the `callbackUrl` query parameters in the output, then POST `{ pubkey: key, challenge, sig }` to `/api/auth/lightning/lnurl-auth/login`.\n- **Raw ECDSA path** (fallback): sign the hex `challenge` field directly (ECDSA DER, secp256k1) and POST `{ pubkey, challenge, sig }` to `/login`. Do **not** bech32-decode the `lnurl` string — that indirection is only needed for interactive wallet QR flows (and decoding adds a checksum-failure risk that does not apply to the hex field).","operationId":"lnurlAuthChallenge","responses":{"200":{"description":"Challenge issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LnurlChallengeResponse"}}}}}}},"/api/auth/lightning/lnurl-auth/validate":{"get":{"tags":["Authentication — LNURL-auth (Lightning)"],"summary":"LNURL-auth wallet callback (LUD-04)","description":"Wallet-facing callback per LUD-04. Lightning wallets call this URL (embedded in the LNURL) after the user approves the login request, presenting the k1, the ECDSA DER signature and the compressed public key.\n\nOn success the pubkey is stored in Redis under `k1:{k1}` (10-minute TTL) so the browser polling loop can complete the NextAuth sign-in. Unlike `/verify`, this endpoint does not require a pre-issued Redis challenge — the k1 travels inside the LNURL itself.\n\n**This endpoint does NOT create a session** and sets no cookie: a `success: true` response only means the signature is valid and the pubkey is staged for the browser sign-in flow. Headless/agent clients must call `POST /api/auth/lightning/lnurl-auth/login` (or the two-phase `/verify` + NextAuth sign-in) to obtain a session cookie.","operationId":"lnurlAuthValidate","parameters":[{"name":"k1","in":"query","required":true,"description":"The 64-char hex k1 challenge (message that was signed).","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}},{"name":"key","in":"query","required":true,"description":"33-byte compressed secp256k1 public key as 66-char hex.","schema":{"type":"string","example":"03…"}},{"name":"sig","in":"query","required":true,"description":"DER-encoded ECDSA signature over the raw 32-byte k1, as hex.","schema":{"type":"string"}},{"name":"tag","in":"query","required":false,"description":"Fixed value `login`; required by LNURL-auth protocols.","schema":{"type":"string","enum":["login"]}}],"responses":{"200":{"description":"Signature valid — pubkey stored for sign-in","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LnurlValidateResponse"}}}},"400":{"description":"Missing/invalid params or signature verification failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/lightning/lnurl-auth/verify":{"post":{"tags":["Authentication — LNURL-auth (Lightning)"],"summary":"Verify an LNURL-auth signature (two-phase flow)","description":"API-facing equivalent of a wallet calling `/validate`: verify a local ECDSA DER signature over the challenge without a wallet. Requires a challenge previously issued by `/challenge`.\n\nOn success the challenge is consumed (deleted) and the pubkey is stored under `k1:{challenge}` (10-minute TTL), ready for the NextAuth sign-in (`POST /api/auth/callback/lightning`).","operationId":"lnurlAuthVerify","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LnurlVerifyRequest"}}}},"responses":{"200":{"description":"Signature verified — use `k1` to complete NextAuth sign-in","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifySuccess"}}}},"400":{"description":"Missing params, unknown/expired challenge, or bad signature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/lightning/lnurl-auth/login":{"post":{"tags":["Authentication — LNURL-auth (Lightning)"],"summary":"LNURL-auth single-call login (headless / LLM agents)","description":"Single HTTP call for headless clients (CLI tools, scripts, LLM agents): verifies the ECDSA signature, upserts the account row (creates it on first login), and sets an encrypted HttpOnly session cookie in the response. Include the cookie in all subsequent requests. The published `lnurl-auth` plugin/skill (https://github.com/dyegolara/lnurl-auth-agents) is the reference client for signing the `k1` for headless agents — hand-rolled ECDSA is the fallback.\n\nRequest body: `{ pubkey, challenge, sig }` — `pubkey` is the 33-byte compressed key; `challenge` must have been issued by `/challenge`; `sig` is the DER-encoded ECDSA signature over the raw 32-byte challenge.","operationId":"lnurlAuthLogin","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LnurlLoginRequest"}}}},"responses":{"200":{"description":"Authenticated — session cookie set","headers":{"Set-Cookie":{"description":"`[__Secure-]authjs.session-token=<encrypted JWT>; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginSuccess"}}}},"400":{"description":"Missing params, unknown/expired challenge, or bad signature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"500":{"description":"database failure or missing NEXTAUTH_SECRET","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/nostr/nip-07/challenge":{"get":{"tags":["Authentication — NIP-07 (Nostr)"],"summary":"Issue a NIP-07 auth challenge","description":"Generates a fresh random 32-byte hex challenge for NIP-07 authentication and stores it in Redis for 5 minutes (single-use). The client embeds this challenge into a Nostr event (`content` or a `[\"challenge\", …]` tag) and signs it with the browser extension or a local Nostr key.","operationId":"nostrNip07Challenge","responses":{"200":{"description":"Challenge issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NostrChallengeResponse"}}}}}}},"/api/auth/nostr/nip-07/verify":{"post":{"tags":["Authentication — NIP-07 (Nostr)"],"summary":"Verify a signed Nostr event (NIP-01/NIP-42)","description":"Verifies a signed Nostr event (event ID = SHA-256 of serialized fields, BIP-340 Schnorr signature, and the challenge present in `content` or tags). On success the challenge is consumed and the event `pubkey` is stored under `k1:{challenge}` (10-minute TTL), ready for NextAuth sign-in.","operationId":"nostrNip07Verify","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NostrVerifyRequest"}}}},"responses":{"200":{"description":"Event verified — use `k1` to complete NextAuth sign-in","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifySuccess"}}}},"400":{"description":"Missing params, unknown/expired challenge, or invalid event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/nostr/nip-07/login":{"post":{"tags":["Authentication — NIP-07 (Nostr)"],"summary":"NIP-07 single-call login (headless / LLM agents)","description":"Single HTTP call for headless clients: verifies the Nostr event, checks `event.pubkey` matches the submitted `pubkey`, upserts the account row, and sets an encrypted HttpOnly session cookie in the response. Include the cookie in all subsequent requests.","operationId":"nostrNip07Login","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NostrLoginRequest"}}}},"responses":{"200":{"description":"Authenticated — session cookie set","headers":{"Set-Cookie":{"description":"`[__Secure-]authjs.session-token=<encrypted JWT>; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginSuccess"}}}},"400":{"description":"Missing params, unknown/expired challenge, or invalid event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"500":{"description":"database failure or missing NEXTAUTH_SECRET","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/link":{"delete":{"tags":["Authentication — Sign-in method linking"],"summary":"Unlink a sign-in method from the authenticated Account","description":"Removes an attached sign-in method (ADR 0020). Request body: `{ \"method\": \"lightning\" | \"nostr\" }`.\n\nAn Account must always keep at least one sign-in method: attempting to remove the last one is rejected with 409, as is removing a method that is not attached. The session is untouched (identity is the Account ID, ADR 0004) — unlinking the key that created the current session keeps it valid — and no cookie is set.","operationId":"unlinkSignInMethod","security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlinkSignInMethodRequest"}}}},"responses":{"200":{"description":"Method unlinked — session unchanged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkSuccess"}}}},"400":{"description":"Missing or unknown method","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"401":{"description":"No valid session cookie","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"409":{"description":"The method is not linked, or it is the Account's last sign-in method","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"500":{"description":"database failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/link/nostr/challenge":{"get":{"tags":["Authentication — Sign-in method linking"],"summary":"Issue a link challenge for a Nostr key","description":"Session-authenticated counterpart of the login challenge. The challenge is stored bound to the calling Account, single-use, with a 5-minute TTL — deliberate parity with the login challenge's TTL. Because linking challenges live in a separate namespace and are Account-scoped, a login challenge can never be replayed as a link challenge, and only the Account that minted the challenge can complete it.","operationId":"linkNostrChallenge","security":[{"sessionCookie":[]}],"responses":{"200":{"description":"Link challenge issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkChallengeResponse"}}}},"401":{"description":"No valid session cookie","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/link/nostr/complete":{"post":{"tags":["Authentication — Sign-in method linking"],"summary":"Link a Nostr key to the authenticated Account","description":"Attaches the npub to the session's Account — additive only (ADR 0020): the Account must not already have a Nostr method, and replacing an attached method is out of scope.\n\nThe client signs the link challenge with the new key. The signed event content should name the target Account (\"Link this key to bitsimp account <alias | npub | Account ID>\") so NIP-07 extensions display the intent before signing, and must contain the challenge (in `content` or a `[\"challenge\", …]` tag). The naming is display-only — the server enforces the challenge's presence, never the statement text.\n\nOn success the key and its link timestamp are written; the session is untouched (identity is the Account ID, ADR 0004) and no cookie is set — linking is not a signup. Accounts are never merged: a 409 is returned when the method is already attached or the key already belongs to another Account.","operationId":"linkNostrComplete","security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkNostrCompleteRequest"}}}},"responses":{"200":{"description":"Key linked — session unchanged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkSuccess"}}}},"400":{"description":"Missing params, unknown/expired challenge, or invalid event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"401":{"description":"No valid session cookie","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"409":{"description":"Method already linked, or the key belongs to another Account (merging is not supported)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"500":{"description":"database failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/link/lightning/challenge":{"get":{"tags":["Authentication — Sign-in method linking"],"summary":"Issue a link challenge and LNURL for a Lightning key","description":"Session-authenticated counterpart of the login challenge. Returns a fresh k1 stored bound to the calling Account (`link-challenge:{accountId}:{k1}`, single-use, 5-minute TTL — deliberate parity with the login challenge's TTL) plus its LNURL (`lightning:LNURL1...`), whose callback is link-specific and carries the Account id in its path so the wallet can reach the right challenge without a session. A login challenge can never be replayed as a link challenge (the login verify/login endpoints likewise reject link challenges; only the LUD-04 wallet callback is deliberately challenge-less).","operationId":"linkLightningChallenge","security":[{"sessionCookie":[]}],"responses":{"200":{"description":"Link challenge and LNURL issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkLightningChallengeResponse"}}}},"401":{"description":"No valid session cookie","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/link/lightning/validate/{accountId}":{"get":{"tags":["Authentication — Sign-in method linking"],"summary":"Lightning link wallet callback (LUD-04)","description":"Wallet-facing callback embedded in the link LNURL. The wallet presents `k1`, the ECDSA DER signature and the compressed public key; the server verifies the signature over the raw challenge and **stages** the canonical key under the Account-bound link challenge (`link-challenge:{accountId}:{k1}`, 10-minute TTL) so the session-side flow can complete.\n\n**This endpoint does NOT create a session and does NOT mutate the Account** — the link is written only when the authenticated session completes it (`/api/auth/link/lightning/complete`).","operationId":"linkLightningValidate","parameters":[{"name":"accountId","in":"path","required":true,"description":"The uuid v4 Account id that minted the link challenge.","schema":{"type":"string","format":"uuid"}},{"name":"k1","in":"query","required":true,"description":"The 64-char hex link challenge that was signed.","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}},{"name":"key","in":"query","required":true,"description":"33-byte compressed secp256k1 public key as 66-char hex.","schema":{"type":"string"}},{"name":"sig","in":"query","required":true,"description":"DER-encoded ECDSA signature over the raw 32-byte challenge, as hex.","schema":{"type":"string"}},{"name":"tag","in":"query","required":false,"description":"Fixed value `login`; required by LNURL-auth protocols.","schema":{"type":"string","enum":["login"]}}],"responses":{"200":{"description":"Signature valid — key staged for the session flow","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LnurlValidateResponse"}}}},"400":{"description":"Missing/invalid params, unknown challenge, or signature verification failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/link/lightning/complete":{"post":{"tags":["Authentication — Sign-in method linking"],"summary":"Link a Lightning key to the authenticated Account","description":"Attaches the Lightning key to the session's Account — additive only (ADR 0020). Two paths converge on the same Account-bound challenge: **headless** clients supply the DER `sig` (verified here over the raw challenge bytes); the **browser** flow omits it because the wallet callback already verified and staged the key, and the staged key must match `pubkey`.\n\nOn success the key (canonical lowercase hex) and its link timestamp are written; the session is untouched (identity is the Account ID, ADR 0004) and no cookie is set. Accounts are never merged: a 409 is returned when the method is already attached or the key already belongs to another Account.","operationId":"linkLightningComplete","security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkLightningCompleteRequest"}}}},"responses":{"200":{"description":"Key linked — session unchanged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkSuccess"}}}},"400":{"description":"Missing params, unknown/expired challenge, invalid signature, or the wallet callback has not staged the key yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"401":{"description":"No valid session cookie","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"409":{"description":"Method already linked, or the key belongs to another Account (merging is not supported)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"500":{"description":"database failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}}}}},"/api/auth/session":{"get":{"tags":["Authentication — NextAuth","Sessions"],"summary":"Get current session","description":"NextAuth.js session endpoint. Returns the JWT-decoded session for the current `authjs.session-token` cookie, or `null` when unauthenticated.","operationId":"getSession","responses":{"200":{"description":"Session info (or null)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}}}}},"/api/auth/csrf":{"get":{"tags":["Authentication — NextAuth","Sessions"],"summary":"Get CSRF token","description":"NextAuth.js CSRF token endpoint — required before signing in via NextAuth flows.","operationId":"getCsrf","responses":{"200":{"description":"CSRF token object","content":{"application/json":{"schema":{"type":"object","properties":{"csrfToken":{"type":"string"}}}}}}}}},"/api/auth/signin":{"post":{"tags":["Authentication — NextAuth","Sessions"],"summary":"Sign in via NextAuth provider","description":"NextAuth sign-in endpoint. Used by the browser flow after a pubkey appears under `k1:{k1}` in Redis (via `/validate` or `/verify`). Body is form-encoded: `{ csrfToken, pubkey, k1, callbackUrl }`.","operationId":"nextAuthSignIn","responses":{"302":{"description":"Redirect to `callbackUrl` (session cookie set)"},"401":{"description":"k1 not found in Redis or pubkey mismatch"}}}},"/api/auth/signout":{"post":{"tags":["Authentication — NextAuth","Sessions"],"summary":"Sign out","description":"Clears the session cookie.","operationId":"nextAuthSignOut","responses":{"302":{"description":"Redirect to `callbackUrl` (session cookie cleared)"}}}},"/api/auth/callback/lightning":{"post":{"tags":["Authentication — NextAuth","Sessions"],"summary":"NextAuth lightning provider callback (two-phase flow)","description":"Completes sign-in after `/verify` when using the two-phase flow. Form-encoded: `{ csrfToken, pubkey, k1 }`. The NextAuth authorize callback reads `k1:{k1}` from Redis and creates sessions only when the stored pubkey matches.","operationId":"nextAuthCallbackLightning","responses":{"302":{"description":"Redirect to `callbackUrl` (session cookie set)"},"401":{"description":"k1 not found in Redis or pubkey mismatch"}}}},"/api/uploads":{"post":{"tags":["Content"],"summary":"Mint upload signatures","description":"Authenticated endpoint that mints signed upload URLs for direct PUT uploads to Supabase Storage. The caller uploads bytes directly to the returned `signedUrl` with the file `Content-Type`; no auth header is required on the PUT itself. Signed URLs expire after 2 hours. Requests accept 1-20 files per call.","operationId":"requestUploadSignature","security":[{"sessionCookie":[]},{"nostr":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSignatureRequest"}}}},"responses":{"200":{"description":"Batch upload signatures minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSignatureResponse"}}}},"400":{"description":"Validation failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"401":{"description":"Invalid NIP-98 request. Responses carry `WWW-Authenticate: Nostr`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"500":{"description":"Internal error while minting upload signatures","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}}}}},"/api/posts":{"get":{"tags":["Content"],"summary":"List posts (feed)","description":"Cursor-paginated feed of posts with optional filters. Anonymous browsing supported; a session cookie or a NIP-98 signed request is used to compute per-post lock/unlock state and to include the caller's own archived posts.\n\nArchived posts are hidden from everyone except their owner. `isUnlocked` is always `true` when the post has no files, price is 0, the viewer owns the post, or the viewer has a `paid` purchase transaction.","operationId":"listPosts","security":[{},{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"author","in":"query","required":false,"description":"Filter by creator alias (resolved to the account id internally).","schema":{"type":"string"}},{"name":"price_min","in":"query","required":false,"description":"Minimum price in satoshis.","schema":{"type":"string"}},{"name":"price_max","in":"query","required":false,"description":"Maximum price in satoshis (0 = only free posts).","schema":{"type":"string"}},{"name":"includes_file_type","in":"query","required":false,"description":"Comma-separated bucket names: `images`, `videos`, `audio`, `other`, or `all`.","schema":{"type":"string","example":"images,videos"}},{"name":"purchased","in":"query","required":false,"description":"When `true`, only posts purchased by the authenticated user.","schema":{"type":"string","enum":["","true","false"]}},{"name":"unlocked","in":"query","required":false,"description":"When `true`, only posts already unlocked (free or purchased).","schema":{"type":"string","enum":["","true","false"]}},{"name":"locked","in":"query","required":false,"description":"When `true`, only locked/paywalled posts (not free, not purchased, not owned).","schema":{"type":"string","enum":["","true","false"]}},{"name":"cursor","in":"query","required":false,"description":"Created-at value of the last post from the previous page (cursor pagination).","schema":{"type":"string","example":"2026-08-01T12:00:00.000Z"}},{"name":"page_size","in":"query","required":false,"description":"Page size (default 5, capped at 10).","schema":{"type":"integer","minimum":1,"maximum":10,"example":5}}],"responses":{"200":{"description":"Array of posts (files locked or unlocked per viewer state)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Post"}}}}},"400":{"description":"Validation failure (e.g. oversized `page_size` or non-numeric `page_size`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Invalid NIP-98 request. Responses carry `WWW-Authenticate: Nostr`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}}}},"post":{"tags":["Content"],"summary":"Create a post","description":"Creates a post from already-uploaded objects. Files must reference objects uploaded under the caller prefix (`u/{accountId}/...`) and are verified against Storage before insert. Success returns a `Location` header plus either the canonical `Post` body or a fallback `{ id }` when the immediate read-after-create hydration fails; callers can always fetch the canonical representation from `GET /api/posts/{postId}`.","operationId":"createPost","security":[{"sessionCookie":[]},{"nostr":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostCreateRequest"}}}},"responses":{"201":{"description":"Post created","headers":{"Location":{"description":"Relative URL of the created post resource.","schema":{"type":"string","example":"/api/posts/{postId}"}}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Post"},{"$ref":"#/components/schemas/PostCreateAccepted"}]}}}},"400":{"description":"Validation failed or invalid file reference for one or more files","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"401":{"description":"Invalid NIP-98 request. Responses carry `WWW-Authenticate: Nostr`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"500":{"description":"Internal error while creating or hydrating the post","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}}}}},"/api/posts/{postId}":{"get":{"tags":["Content"],"summary":"Get a single post","description":"Returns one post with access control. Anonymous reads are allowed; a session cookie or a NIP-98 signed request computes `isOwner`/`isUnlocked` and gives the owner access to their archived posts. Locked posts (`isUnlocked=false`) return file entries with empty `objectName`/`data`/`blurDataURL` so content is never leaked; unlocked posts include full data. Archived posts return 404 unless the viewer is the owner.","operationId":"getPost","security":[{},{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"postId","in":"path","required":true,"description":"Post id (UUID).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Post with files","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"401":{"description":"Invalid NIP-98 request. Responses carry `WWW-Authenticate: Nostr`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"404":{"description":"Post not found (or archived and not yours)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["Content"],"summary":"Edit your post","description":"Owner-only partial update for a Post. Accepts any subset of `title`, `body`, `price`, and `is_archived`. At least one recognized field is required.\n\nIf the resulting price is greater than zero, the Post must have at least one file attached. Owners can archive (`is_archived: true`) or unarchive (`is_archived: false`) through this endpoint.","operationId":"updatePost","security":[{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"postId","in":"path","required":true,"description":"Post id (UUID).","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostPatchRequest"}}}},"responses":{"200":{"description":"Post updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Validation error or paid-without-files rule violation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"No valid session cookie and no valid NIP-98 request. NIP-98-specific 401s carry `WWW-Authenticate: Nostr`; ordinary unauthenticated requests return the same typed envelope without that header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"403":{"description":"Post exists but is not owned by the caller","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Post not found (including archived posts of another owner)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["Content"],"summary":"Archive your post","description":"Owner-only archive operation. This endpoint never hard-deletes a Post: it sets `is_archived=true` and is idempotent (retries on already archived owner posts still return 200). Unarchive via `PATCH /api/posts/{postId}` with `is_archived: false`.","operationId":"deletePost","security":[{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"postId","in":"path","required":true,"description":"Post id (UUID).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Post archived","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid post id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"No valid session cookie and no valid NIP-98 request. NIP-98-specific 401s carry `WWW-Authenticate: Nostr`; ordinary unauthenticated requests return the same typed envelope without that header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"403":{"description":"Post exists but is not owned by the caller","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Post not found (including archived posts of another owner)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/posts/{postId}/unlock":{"post":{"tags":["Payments"],"summary":"Request an Unlock (invoice or balance)","description":"Unlock a locked Post for the authenticated Payer. Two methods are supported:\n\n- **`invoice`** (default): create a fresh OpenNode Lightning Charge and return `{ chargeId, lightning_invoice, amount, expires_at, request_id }`. Pay the invoice out-of-band, then confirm with `POST .../unlock/confirm`.\n- **`balance`**: settle the Unlock synchronously from the Payer's on-platform balance in one call, returning `{ success, transactionId, postId }`.\n\nReturns `409 alreadyUnlocked` without creating a Charge when the Post is free, owned by the Payer, has no files, or is already purchased. There is no client-supplied idempotency key: `chargeId` doubles as the invoice idempotency key (echoed on confirm, where the settlement upsert absorbs duplicates), and the balance method is idempotent by construction via its deterministic transaction id.\n\n**Analytics:** the optional `utm_*` query parameters are attached to the unlock funnel events (analytics-only, never used for authorization); the flow (api / nip98) is derived from how the request authenticated.","operationId":"requestUnlock","security":[{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"postId","in":"path","required":true,"description":"Post id (UUID).","schema":{"type":"string","format":"uuid"}},{"name":"utm_source","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_medium","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_campaign","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_term","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_content","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockRequest"}}}},"responses":{"200":{"description":"Invoice created (method=invoice) or balance settled (method=balance)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockResponse"}}}},"400":{"description":"Unknown method, validation error, or insufficient balance","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"No valid session cookie and no valid NIP-98 request. NIP-98-specific 401s carry `WWW-Authenticate: Nostr`; ordinary unauthenticated requests return the same typed envelope without that header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Post is already unlocked for this Payer","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","const":"alreadyUnlocked"}},"required":["error"]}}}},"429":{"description":"Invoice creation rate limit exceeded (~5/min per Payer)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/posts/{postId}/unlock/confirm":{"post":{"tags":["Payments"],"summary":"Confirm an Unlock after paying the invoice","description":"Re-fetches the Charge from OpenNode and requires `status === \"paid\"`. Derives the Post id from the verified `order_id` (never trusts the URL or body). Verifies the amount matches the Post price, then records a `purchase` transaction. Returns `{ success: true, transactionId, postId }`. Duplicate confirms are idempotent (upsert keyed on OpenNode charge id). **Analytics:** the optional `utm_*` query parameters are attached to the unlock funnel events (analytics-only, never used for authorization); the flow (api / nip98) is derived from how the request authenticated.","operationId":"confirmUnlock","security":[{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"postId","in":"path","required":true,"description":"Post id (UUID) — cross-checked against the Charge's verified order_id; a mismatch is rejected with 404.","schema":{"type":"string","format":"uuid"}},{"name":"utm_source","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_medium","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_campaign","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_term","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}},{"name":"utm_content","in":"query","required":false,"description":"Analytics-only campaign attribution (ADR 0018).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockConfirmRequest"}}}},"responses":{"200":{"description":"Unlock confirmed and transaction recorded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockConfirmResponse"}}}},"400":{"description":"Charge not paid, amount mismatch, or the Post belongs to the confirming Payer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"No valid session cookie and no valid NIP-98 request. NIP-98-specific 401s carry `WWW-Authenticate: Nostr`; ordinary unauthenticated requests return the same typed envelope without that header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"403":{"description":"Charge does not belong to this Payer or binds a different Post","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Post derived from order_id not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/comments":{"get":{"tags":["Content"],"summary":"List comments for a post","description":"Offset-paginated comments for a post, newest first. Public read; posting comments is paid and handled by client/server actions with Lightning invoices. A session cookie or a NIP-98 signed request is accepted uniformly with the rest of the authenticated subset; the response does not branch on the identity today.","operationId":"listComments","security":[{},{"sessionCookie":[]},{"nostr":[]}],"parameters":[{"name":"postId","in":"query","required":true,"description":"Post id to fetch comments for.","schema":{"type":"string","format":"uuid"}},{"name":"offset","in":"query","required":false,"description":"Number of comments to skip.","schema":{"type":"integer","minimum":0,"default":0}},{"name":"limit","in":"query","required":false,"description":"Maximum number of comments to return (default 10, capped at 100).","schema":{"type":"integer","minimum":1,"maximum":100,"default":10}}],"responses":{"200":{"description":"Array of comments","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}}}},"400":{"description":"Missing `postId` or validation failure (e.g. oversized `limit`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Invalid NIP-98 request. Responses carry `WWW-Authenticate: Nostr`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}}}}},"/api/me":{"get":{"tags":["Account"],"summary":"Current user account id, alias and balance","description":"Returns the authenticated user's account id (identity), npub, alias, and full available-balance breakdown. Authentication is a session cookie or a NIP-98 signed request (`Authorization: Nostr`).\n\nBalance formula: `availableBalance = earnedAmount − routingFees − pwbSpendings − withdrawnAmount − withdrawalFees`.","operationId":"getMe","security":[{"sessionCookie":[]},{"nostr":[]}],"responses":{"200":{"description":"Current user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}}},"401":{"description":"No valid session cookie and no valid NIP-98 request. NIP-98-specific 401s carry `WWW-Authenticate: Nostr`; ordinary unauthenticated requests return the same typed envelope without that header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppErrorEnvelope"}}}},"500":{"description":"Internal error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/opennode/withdrawal":{"post":{"tags":["Webhooks"],"summary":"OpenNode withdrawal webhook (LNURL-withdraw)","description":"Webhook fired by OpenNode when a user completes an LNURL-withdraw. `multipart/form-data`. Integrity is enforced via HMAC-SHA256 of the transaction id using the OpenNode API key, compared with `hashed_order` using a timing-safe comparison. Requests without a valid hash are ignored.","operationId":"opennodeWithdrawalWebhook","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/WithdrawalWebhookPayload"}}}},"responses":{"200":{"description":"Webhook acknowledged","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["OK"]},"success":{"type":"boolean"}},"required":["status","success"]}}}}}}},"/api/video":{"get":{"tags":["Video"],"summary":"next-video asset handler","description":"Library-managed endpoint owned by `next-video` — serves signed video asset metadata and handles Mux upload callbacks. Not part of the app's public business API.","operationId":"videoGet","responses":{"200":{"description":"Asset metadata or playback response"},"404":{"description":"Unknown asset"}}},"post":{"tags":["Video"],"summary":"next-video upload callback","description":"Library-managed upload/processing callback for video assets.","operationId":"videoPost","responses":{"200":{"description":"Callback acknowledged"}}}}},"components":{"securitySchemes":{"sessionCookie":{"type":"apiKey","in":"cookie","name":"authjs.session-token","description":"Encrypted NextAuth JWT session cookie set by the `.../login` endpoints (or browser sign-in). On HTTPS the actual cookie name is `__Secure-authjs.session-token`. Send the cookie exactly as received."},"nostr":{"type":"apiKey","in":"header","name":"Authorization","description":"NIP-98 HTTP Auth: `Authorization: Nostr <base64(JSON kind-27235 event)>`. The event is bound to the request URL, HTTP method and body hash, is accepted within ±60 seconds of server time and can be used exactly once. It authenticates an existing Account (matched by its npub) for that single request — it never creates an Account, never issues a session cookie and is not a login method. The Account must have an npub; Lightning-only Accounts are not eligible. When the header is present the signed event decides the identity: an invalid header fails with 401 instead of falling back to the session cookie."}},"schemas":{"LnurlChallengeResponse":{"type":"object","title":"LNURL challenge response","properties":{"challenge":{"type":"string","description":"64-char hex k1. Headless clients must hex-decode this value to the raw 32-byte message before signing it (LNURL-auth uses a DER-encoded ECDSA signature over those bytes, not over the ASCII hex string), then POST the hex `challenge` plus signature to `/api/auth/lightning/lnurl-auth/login`.","pattern":"^[0-9a-f]{64}$"},"lnurl":{"type":"string","description":"LNURL URI string for QR-code/wallet scanning: it starts with the `lightning:` scheme prefix and contains a bech32 LNURL payload after that prefix. For interactive wallets: scan as-is. For the **plugin path**: strip the `lightning:` scheme prefix and pass the bare `LNURL1...` string to the plugin. For the **raw ECDSA fallback**: ignore this field entirely and sign the `challenge` hex directly — never bech32-decode the full URI string.","example":"lightning:LNURL1DP68GURN8GHJ7CNFW3EKJMTS9E3K7MF0V9CXJTMPW46XSTMVD9NKSARWD9HXWTMVDE6HYMPDV96HG6P0WESKC6TYV96X20MTXY7KZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPVYN8GCT884KX7EMFDCURSC59"}},"required":["challenge","lnurl"]},"NostrChallengeResponse":{"type":"object","title":"NIP-07 challenge response","properties":{"challenge":{"type":"string","description":"64-char hex challenge to embed in a Nostr event (content or tag).","pattern":"^[0-9a-f]{64}$"}},"required":["challenge"]},"LnurlVerifyRequest":{"type":"object","title":"LNURL signature verification request","properties":{"challenge":{"type":"string","pattern":"^[0-9a-f]{64}$"},"pubkey":{"type":"string","description":"33-byte compressed secp256k1 public key as 66-char hex."},"sig":{"type":"string","description":"DER-encoded ECDSA signature over the raw 32-byte challenge, as hex."}},"required":["challenge","pubkey","sig"]},"LnurlLoginRequest":{"allOf":[{"$ref":"#/components/schemas/LnurlVerifyRequest"}],"title":"LNURL single-call login request"},"NostrEvent":{"type":"object","title":"Nostr event (NIP-01)","description":"Standard Nostr event. To authenticate, `content` must equal the challenge, or `tags` must include `[\"challenge\", <challenge>]`. The event `id` is SHA-256 of `[0, pubkey, created_at, kind, tags, content]`, signed with BIP-340 Schnorr.","properties":{"id":{"type":"string","description":"32-byte event id as hex (SHA-256 of the serialized event)."},"pubkey":{"type":"string","description":"32-byte x-only public key as 64-char hex."},"created_at":{"type":"integer","description":"Unix timestamp in seconds.","example":1750000000},"kind":{"type":"integer","description":"Event kind (1 or 22242/NIP-42 style are accepted)."},"tags":{"type":"array","items":{"type":"array","items":{"type":"string"}},"example":[["challenge","0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]]},"content":{"type":"string","description":"Free text — must contain the challenge for auth."},"sig":{"type":"string","description":"64-byte BIP-340 Schnorr signature as hex."}},"required":["id","pubkey","created_at","kind","tags","content","sig"]},"NostrVerifyRequest":{"type":"object","title":"NIP-07 verify request","properties":{"challenge":{"type":"string","pattern":"^[0-9a-f]{64}$"},"event":{"$ref":"#/components/schemas/NostrEvent"}},"required":["challenge","event"]},"NostrLoginRequest":{"type":"object","title":"NIP-07 single-call login request","properties":{"pubkey":{"type":"string","description":"32-byte x-only public key as 64-char hex; must match `event.pubkey`."},"challenge":{"type":"string","pattern":"^[0-9a-f]{64}$"},"event":{"$ref":"#/components/schemas/NostrEvent"}},"required":["pubkey","challenge","event"]},"LinkChallengeResponse":{"type":"object","title":"Sign-in method link challenge response","properties":{"challenge":{"type":"string","description":"64-char hex challenge, bound to the authenticated Account and single-use (5-minute TTL). Embed it in the signed event content (alongside the human-readable intent) or a `[\"challenge\", …]` tag.","pattern":"^[0-9a-f]{64}$"}},"required":["challenge"]},"LinkLightningChallengeResponse":{"type":"object","title":"Lightning link challenge response","properties":{"challenge":{"type":"string","description":"64-char hex k1, bound to the authenticated Account and single-use (5-minute TTL). Headless clients hex-decode it to the raw 32-byte message before signing (ECDSA DER).","pattern":"^[0-9a-f]{64}$"},"lnurl":{"type":"string","description":"LNURL URI for QR-code/wallet scanning (`lightning:LNURL1...`). Its callback is link-specific and carries the Account id in the path, so the wallet's LUD-04 callback can stage the key without a session.","example":"lightning:LNURL1DP68GURN8GHJ7CNFW3EKJMTS9E3K7MF0V9CXJTMPW46XSTMVD9NKSARWD9HXWTMVDE6HYMPDV96HG6P0WESKC6TYV96X20MTXY7KZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPV9SKZCTPVYN8GCT884KX7EMFDCURSC59"}},"required":["challenge","lnurl"]},"LinkLightningCompleteRequest":{"type":"object","title":"Lightning link complete request","properties":{"challenge":{"type":"string","pattern":"^[0-9a-f]{64}$"},"pubkey":{"type":"string","description":"33-byte compressed secp256k1 public key as 66-char hex."},"sig":{"type":"string","description":"DER-encoded ECDSA signature over the raw 32-byte challenge, as hex. Required for headless clients; omit on the browser path, where the wallet callback already staged the key."}},"required":["challenge","pubkey"]},"LinkNostrCompleteRequest":{"type":"object","title":"Nostr link complete request","properties":{"pubkey":{"type":"string","description":"32-byte x-only public key as 64-char hex; must match `event.pubkey`."},"challenge":{"type":"string","pattern":"^[0-9a-f]{64}$"},"event":{"$ref":"#/components/schemas/NostrEvent"}},"required":["pubkey","challenge","event"]},"LinkSuccess":{"type":"object","title":"Sign-in method linked","properties":{"success":{"type":"boolean","const":true},"id":{"type":"string","format":"uuid","description":"The Account id — unchanged by linking (identity is the Account ID)."}},"required":["success","id"]},"UnlinkSignInMethodRequest":{"type":"object","title":"Unlink sign-in method request","properties":{"method":{"type":"string","enum":["lightning","nostr"],"description":"The attached sign-in method to remove."}},"required":["method"]},"VerifySuccess":{"type":"object","title":"Verification success (two-phase flow)","properties":{"success":{"type":"boolean","const":true},"k1":{"type":"string","description":"The challenge value — exchange it via NextAuth sign-in (`/api/auth/callback/lightning`)."}},"required":["success","k1"]},"LoginSuccess":{"type":"object","title":"Login success (single-call flow)","properties":{"success":{"type":"boolean","const":true},"id":{"type":"string","format":"uuid","description":"The authenticated user's account id (identity)."}},"required":["success","id"]},"LnurlValidateResponse":{"type":"object","title":"LNURL validate callback response","properties":{"status":{"type":"string","const":"OK"},"success":{"type":"boolean","const":true},"k1":{"type":"string"}},"required":["status","success","k1"]},"AuthError":{"type":"object","title":"Auth error","properties":{"success":{"type":"boolean","const":false},"error":{"type":"string","description":"Generic error message."}},"required":["success","error"]},"ErrorResponse":{"type":"object","title":"Generic error","properties":{"error":{"type":"string"}},"required":["error"]},"AppErrorEnvelope":{"type":"object","title":"App error envelope","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["UNAUTHORIZED","VALIDATION_FAILED","INVALID_FILE_REFERENCE","NIP98_INVALID","NIP98_EXPIRED","NIP98_REPLAY","ACCOUNT_NOT_FOUND","INTERNAL"]},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]},"Session":{"type":"object","description":"NextAuth session or `null` when unauthenticated.","properties":{"user":{"type":"object","properties":{"name":{"type":"string","description":"The user's account id."}}},"expires":{"type":"string","format":"date-time"}}},"Post":{"type":"object","title":"Post","properties":{"id":{"type":"string","format":"uuid"},"created_by":{"type":"string","description":"Creator account id."},"created_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"price":{"type":"integer","description":"Price in satoshis."},"is_archived":{"type":"boolean"},"archived_by":{"type":["string","null"],"description":"Admin account id if archived by an admin."},"body":{"type":["string","null"]},"isUnlocked":{"type":"boolean","description":"true when price=0, viewer owns it, it has no files, or a paid purchase exists."},"isOwner":{"type":"boolean"},"author":{"type":"string","description":"Author display name (alias > npub > id); npub and id values are trimmed `first5…last5`, so use `authorSlug` for links."},"authorSlug":{"type":"string","description":"Untrimmed canonical profile URL segment (alias > npub > id). Build profile links as `/{encodeURIComponent(authorSlug)}`."},"files":{"type":"array","description":"Locked posts return entries with empty `objectName`/`data`/`blurDataURL` so no content leaks.","items":{"$ref":"#/components/schemas/PostFile"}}},"required":["id","created_by","created_at","title","price","is_archived","archived_by","body","isUnlocked","isOwner","author","authorSlug","files"]},"PostCreateAccepted":{"type":"object","title":"Post create accepted fallback","description":"Fallback response when the post was created but the immediate hydration read failed. Use `GET /api/posts/{id}` to fetch the canonical representation.","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]},"PostPatchRequest":{"type":"object","title":"Post patch request","description":"Owner-only partial update payload. Include at least one recognized field.","properties":{"title":{"type":"string","minLength":3,"maxLength":100},"body":{"type":"string"},"price":{"type":"integer","minimum":0},"is_archived":{"type":"boolean"}},"additionalProperties":true,"anyOf":[{"required":["title"]},{"required":["body"]},{"required":["price"]},{"required":["is_archived"]}]},"UploadSignatureRequest":{"type":"object","additionalProperties":false,"properties":{"files":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"filename":{"type":"string","minLength":1},"mime":{"type":"string","minLength":1,"description":"Storage-supported media MIME (`image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/avif`, `video/mp4`, `video/webm`, `video/quicktime`, `video/x-msvideo`, `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/webm`, `audio/flac`, `audio/aac`) or any other non-media MIME such as `application/pdf` for the `other` bucket."},"size":{"type":"integer","minimum":0,"maximum":52428800,"description":"Optional declared size in bytes; when supplied, requests are rejected early if it exceeds 50 MiB. Omitting it defers size enforcement to Storage."}},"required":["mime"]},"minItems":1,"maxItems":20}},"required":["files"]},"UploadSignatureResponse":{"type":"object","properties":{"uploads":{"type":"array","items":{"type":"object","properties":{"bucket":{"type":"string","enum":["images","videos","audio","other"]},"objectName":{"type":"string"},"signedUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}},"required":["bucket","objectName","signedUrl","expiresAt"]}}},"required":["uploads"]},"PostCreateFileRef":{"type":"object","additionalProperties":false,"properties":{"bucketName":{"type":"string","enum":["images","videos","audio","other"]},"objectName":{"type":"string"}},"required":["bucketName","objectName"]},"PostCreateRequest":{"type":"object","additionalProperties":false,"properties":{"title":{"type":"string","minLength":3,"maxLength":100},"body":{"type":"string"},"price":{"type":"integer","minimum":0,"default":0},"files":{"type":"array","items":{"$ref":"#/components/schemas/PostCreateFileRef"},"maxItems":20,"default":[]}},"required":["title"]},"PostFile":{"type":"object","title":"Post file","properties":{"bucketName":{"type":"string","enum":["images","videos","audio","other"]},"objectName":{"type":"string","description":"Empty when locked."},"data":{"type":"string","description":"Signed content URL; empty when locked."},"blurDataURL":{"type":"string","description":"Plaiceholder blur preview; empty when locked."},"public":{"type":"boolean"},"mux_asset_id":{"type":["string","null"]},"playbackId":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":true},"downloadUrl":{"type":"string"},"processing_status":{"type":"string","enum":["pending","ready","failed"]},"processing_error":{"type":["string","null"]},"processing_error_type":{"type":["string","null"],"enum":["transient","permanent",null]}}},"Comment":{"type":"object","title":"Comment","properties":{"id":{"type":"string","format":"uuid"},"body":{"type":"string"},"post_id":{"type":"string","format":"uuid"},"created_by":{"type":"string","description":"Commenter account id."},"created_at":{"type":"string","format":"date-time"},"reply_for":{"type":["string","null"]},"transaction_id":{"type":"string","description":"Paid Lightning transaction id."},"accountAlias":{"type":"string","description":"Commenter display name (alias > npub > id); npub and id values are trimmed `first5…last5`, so use `accountSlug` for links."},"accountSlug":{"type":"string","description":"Untrimmed canonical profile URL segment (alias > npub > id). Build commenter links as `/{encodeURIComponent(accountSlug)}`."}},"required":["id","body","post_id","created_by","created_at","reply_for","transaction_id","accountAlias","accountSlug"]},"AvailableBalance":{"type":"object","title":"Available balance breakdown","properties":{"earnedAmount":{"type":"integer","description":"Total sats earned from sales/tips/deposits."},"routingFees":{"type":"integer","description":"Total Lightning routing fees paid on earnings."},"pwbSpendings":{"type":"integer","description":"Total sats spent via pay-with-balance."},"withdrawnAmount":{"type":"integer","description":"Total sats withdrawn to a Lightning wallet."},"withdrawalFees":{"type":"integer","description":"Total withdrawal fees."},"availableBalance":{"type":"integer","description":"Net balance."}},"required":["earnedAmount","routingFees","pwbSpendings","withdrawnAmount","withdrawalFees","availableBalance"]},"MeResponse":{"type":"object","title":"Me response","properties":{"id":{"type":"string","format":"uuid","description":"User's account id (= session.user.name)."},"npub":{"type":["string","null"],"description":"User's Nostr key in NIP-19 npub1 form, when present."},"alias":{"type":["string","null"],"description":"Human-readable alias, when set."},"availableBalance":{"$ref":"#/components/schemas/AvailableBalance"}},"required":["id","npub","alias","availableBalance"]},"WithdrawalWebhookPayload":{"type":"object","title":"OpenNode withdrawal webhook payload (form-data)","properties":{"id":{"type":"string","description":"OpenNode transaction id."},"hashed_order":{"type":"string","description":"HMAC-SHA256(id, key=OPENNODE_API_KEY) — integrity proof."},"status":{"type":"string","description":"e.g. `confirmed`."},"amount":{"type":"string","description":"Withdrawn amount (satoshi units from OpenNode)."},"fee":{"type":"string","description":"Withdrawal fee."},"lnurl_withdrawal":{"type":"string","description":"JSON string: `{ \"id\": ..., \"external_id\": <user pubkey> }`."}},"required":["id","hashed_order","status","amount","fee","lnurl_withdrawal"]},"UnlockRequest":{"type":"object","title":"Unlock request","properties":{"method":{"type":"string","enum":["invoice","balance"],"default":"invoice","description":"`invoice` (default) — create a Lightning Charge the Payer pays out-of-band; `balance` — settle immediately from on-platform balance."}}},"UnlockResponse":{"oneOf":[{"type":"object","title":"Invoice unlock response","description":"Returned when method=invoice.","properties":{"chargeId":{"type":"string","description":"OpenNode Charge id — pass to /unlock/confirm. Doubles as the invoice idempotency key: the confirm settlement upsert is keyed on this id, so duplicate confirms settle once."},"lightning_invoice":{"type":"string","description":"BOLT-11 payment request string."},"amount":{"type":"integer","description":"Amount in satoshis."},"expires_at":{"type":"string","description":"ISO 8601 invoice expiry timestamp (OpenNode's value, or a 5-minute fallback)."},"request_id":{"type":"string","description":"Opaque per-request nonce embedded in the Charge's order_id (`{accountId}::{postId}::{request_id}`) and returned here for log/dashboard correlation only — not an idempotency mechanism."}},"required":["chargeId","lightning_invoice","amount","expires_at","request_id"]},{"type":"object","title":"Balance unlock response","description":"Returned when method=balance.","properties":{"success":{"type":"boolean","const":true},"transactionId":{"type":"string","description":"Recorded purchase transaction id."},"postId":{"type":"string","format":"uuid"}},"required":["success","transactionId","postId"]}]},"UnlockConfirmRequest":{"type":"object","title":"Unlock confirm request","properties":{"chargeId":{"type":"string","description":"OpenNode Charge id from the /unlock invoice response — the invoice idempotency key. Duplicate confirms are absorbed by the settlement upsert keyed on this id."}},"required":["chargeId"]},"UnlockConfirmResponse":{"type":"object","title":"Unlock confirm response","properties":{"success":{"type":"boolean","const":true},"transactionId":{"type":"string","description":"Recorded purchase transaction id."},"postId":{"type":"string","format":"uuid"}},"required":["success","transactionId","postId"]}}},"tags":[{"name":"Authentication — LNURL-auth (Lightning)","description":"Passwordless auth by proving ownership of a secp256k1 key pair (ECDSA, DER signatures, compressed 33-byte pubkeys). Marries the LNURL-auth protocol (LUD-04) with NextAuth sessions."},{"name":"Authentication — NIP-07 (Nostr)","description":"Passwordless auth with Nostr keys (BIP-340 Schnorr signatures, 32-byte x-only pubkeys, NIP-01/NIP-42 event formats)."},{"name":"Authentication — Sign-in method linking","description":"Session-authenticated endpoints that attach a different sign-in method to an existing Account (ADR 0020). Additive only: one Lightning key and one npub per Account, Account-bound single-use challenges, no merging."},{"name":"Content","description":"Posts and comments — the marketplace content layer."},{"name":"Payments","description":"Unlock paid Posts via Lightning invoices (client-confirm flow) or on-platform balance."},{"name":"Account","description":"The authenticated user's identity and balance."},{"name":"Webhooks","description":"External-service callbacks (OpenNode)."},{"name":"Video","description":"Library-managed video asset endpoints (next-video)."},{"name":"Sessions","description":"Standard NextAuth.js endpoints used by the browser sign-in flow."}]}