Files
smirk-backend-core/openapi.json
T
jwinterm fb3c547d93 feat: website auth, username + key registration, NIP-05 directory
- api/website.rs: sign-in-with-wallet (dapp connect). challenge bound to origin
  + short TTL; verify is single-use (remove-before-verify, race-safe), expiry-
  checked, signature via Result<(),AppError> verifiers (no oracle), resolves an
  EXISTING user by the proven key's pubkey_hash (never mints identity), mints a
  Platform::Web session.
- api/users.rs: username set/get + by-username lookup (JWT-gated, 409 on taken,
  constant-shape lookup); per-asset key registration bound to the JWT user
  (no spoofable user_id) + public key-directory reads.
- api/nip05.rs: /.well-known/nostr.json directory (username -> nostr_pubkey).
- Wired into api/mod.rs; website+users nested at /api/v1, nip05 at root; all
  paths/schemas registered (openapi.json now 17 endpoints).
- main.rs: periodic GC of expired web challenges (bounds the in-memory store).

Adversarially reviewed (register_key authz + website identity-binding both
hold; no critical/high). 69 tests green. Deferred LOW: per-IP rate-limit on the
website routes lands with the router-assembly unit.
2026-06-22 18:13:47 -04:00

1173 lines
39 KiB
JSON

{
"openapi": "3.1.0",
"info": {
"title": "smirk-backend-core",
"description": "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet.",
"contact": {
"name": "John Winter Murphy",
"email": "jw@such.software"
},
"license": {
"name": ""
},
"version": "0.3.0"
},
"paths": {
"/.well-known/nostr.json": {
"get": {
"tags": [
"nip05"
],
"summary": "NIP-05 resolution for Smirk usernames.",
"description": "Looks up the user by lowercased `?name=` and, if they have a linked\n`nostr_pubkey`, returns it in `names` (with the default relay hints under\n`relays`). An unknown name, an unlinked user, or an absent `name` parameter\nall return an empty document — this endpoint never errors on the lookup path\nand never discloses which usernames exist.",
"operationId": "well_known_nostr",
"parameters": [
{
"name": "name",
"in": "query",
"description": "NIP-05 local part to resolve",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "NIP-05 directory document (empty when unresolved)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WellKnownResponse"
}
}
}
}
}
}
},
"/auth/check-restore": {
"post": {
"tags": [
"auth"
],
"summary": "Check whether a wallet restore is valid (created here + keys match).",
"description": "Two governors run BEFORE any user lookup: a per-IP limit\n([`RESTORE_IP_THRESHOLD`]) that bounds distinct-fingerprint scanning, and a\nper-fingerprint failure limit ([`RESTORE_FAIL_THRESHOLD`]); either trips a\n429. The per-IP limit uses [`client_ip`] so an untrusted `X-Forwarded-For`\ncannot evade it. Every attempt is recorded (peppered fingerprint, salted IP).\n\nThe known/unknown branches are equalized: the unknown branch runs the same\nkey-comparison loop against an empty stored set, so the two paths do the same\nwork and the response does not become a timing oracle for existence beyond the\nalready-intentional `exists` field.",
"operationId": "check_restore",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CheckRestoreRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Restore validation result",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CheckRestoreResponse"
}
}
}
}
}
}
},
"/auth/extension": {
"post": {
"tags": [
"auth"
],
"summary": "Register a new extension wallet or authenticate an existing one.",
"description": "The BTC pubkey hash is the unique identity. A signed timestamp proves control\nof the BTC private key (defeats registration with a stolen public key). When\nthe PoW gate applies and the wallet is new, a valid `altcha_solution` is\nrequired; a returning user (a known `pubkey_hash`) bypasses PoW.\n\n## Derivation rotation is authenticated (account-takeover defense)\n\nA known `seed_fingerprint` at a NEW `pubkey_hash` (the wallet changed its\nderivation scheme) may re-point the existing user row — but ONLY when the\nrequest also carries a `rotation_signature` that verifies against the BTC key\nalready on file for that user (proving control of the seed-derived key, not\nmerely knowledge of the fingerprint, which `check_restore` discloses and is\nnot secret). A bare fingerprint match WITHOUT a valid rotation proof is\ntreated as a brand-new identity: a fresh user row is created on the new\n`pubkey_hash` and the matched victim row is never modified. The rotation path\nis gated by PoW exactly like any other new-pubkey registration.",
"operationId": "extension_register",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExtensionRegisterRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Wallet registered or authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
}
}
}
}
},
"/auth/logout": {
"post": {
"tags": [
"auth"
],
"summary": "Revoke the session backing a refresh token. Idempotent: an unknown or\nalready-revoked token still returns success (logout should never error).",
"operationId": "logout",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LogoutRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LogoutResponse"
}
}
}
}
}
}
},
"/auth/me": {
"get": {
"tags": [
"auth"
],
"summary": "The authenticated user's own info.",
"operationId": "get_me",
"responses": {
"200": {
"description": "Current authenticated user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserInfo"
}
}
}
},
"401": {
"description": "Missing or invalid token"
}
}
}
},
"/auth/nostr": {
"post": {
"tags": [
"auth"
],
"summary": "Sign in with a Nostr identity (NIP-98 HTTP Auth, login grade).",
"description": "The `Nostr <base64(event)>` token is read from the `Authorization` header and\nverified against the canonical `config.identity.public_api_url` + `/auth/nostr`\n(never the request Host). The npub must ALREADY be linked to a user (see\n[`nostr_link`]); this endpoint NEVER creates a user — an unlinked npub is 401.",
"operationId": "nostr_login",
"responses": {
"200": {
"description": "Session for the linked Nostr identity",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
}
},
"401": {
"description": "Invalid NIP-98 token or no linked account"
}
}
}
},
"/auth/nostr/link": {
"post": {
"tags": [
"auth"
],
"summary": "Link a Nostr identity (npub) to the authenticated user.",
"description": "Dual auth: the Bearer JWT identifies the user; a NIP-98 *signed-action* proof\n(not a login token) proves control of the npub AND binds a server-issued\nsingle-use `nonce`, the purpose `nostr_link`, and this exact request via the\nrequest-descriptor hash. On success the npub is stored so a later\n[`nostr_login`] resolves to the same wallet; a collision is 409.\n\n## Descriptor binding is a CONTRACT (not an implementation detail)\n\nThe `payload` tag binds `descriptor_sha256(request_descriptor(\"POST\",\n\"/api/v1/auth/nostr/link\", \"\", b\"\"))` — an EMPTY body hash, with no query.\nThe JSON `{nostr_token, nonce}` rides in the HTTP body but is deliberately NOT\npart of the signed descriptor (it carries the proof itself, so it cannot also\nbe inside it). The wallet MUST build the identical descriptor. This exact\nmethod/path/query/empty-body shape is a cross-impl contract and must be pinned\nin a shared test vector (mirroring the nip98.rs interop test) before\n`consume_link_nonce` is un-stubbed — otherwise the binding silently breaks or\nweakens at integration time.\n\nTODO(operator-surface): the server-nonce *issue* (`GET\n/auth/nostr/link-challenge`, rand 32 bytes, stored) + the ATOMIC single-use\n*consume* (a `DELETE ... WHERE nonce=$1 RETURNING` so a replay loses the race,\nnever check-then-delete), plus the wallet's signed-action token builder, are\nwired in the operator-surface phase. Until then [`consume_link_nonce`] always\nreturns `false` and this handler FAILS CLOSED: it validates the full proof\nshape but refuses every link, because it cannot yet prove the nonce was issued\nby this server and is unused.",
"operationId": "nostr_link",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NostrLinkRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Linked npub",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NostrLinkResponse"
}
}
}
},
"401": {
"description": "Invalid proof or missing session"
},
"409": {
"description": "npub already linked to another account"
}
}
}
},
"/auth/pow-challenge": {
"post": {
"tags": [
"auth"
],
"summary": "Issue a fresh proof-of-work challenge for the wallet to solve before calling\n`/auth/extension`. The solved payload is sent back as the request's\n`altcha_solution`.",
"description": "Stateless: the challenge embeds an HMAC signature over its own fields plus an\nexpiry, so no issued-challenge store is needed. See [`crate::core::pow`].",
"operationId": "pow_challenge",
"responses": {
"200": {
"description": "Proof-of-work challenge for wallet registration"
}
}
}
},
"/auth/refresh": {
"post": {
"tags": [
"auth"
],
"summary": "Rotate a refresh token.",
"description": "Verifies the JWT, then re-looks-up the ACTIVE session by peppered hash\n(`get_session_by_token_hash` already filters revoked/expired) — a missing row\nmeans the token was revoked, expired, or already rotated, and is rejected.\nThe session's `user_id` is asserted equal to the JWT `sub` (defense-in-depth\nagainst any future token-confusion class). The old session is revoked and a\nfresh pair issued (revoke-then-issue); the `revoke_session` race-loser also\nrejects, so a stolen token cannot be reused.",
"operationId": "refresh_token",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshTokenRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Token refreshed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
}
}
}
}
},
"/auth/website/challenge": {
"post": {
"tags": [
"auth"
],
"summary": "Issue a website-auth challenge bound to the requesting origin.",
"description": "The challenge is stored in `state.web_challenges` keyed by its own nonce and\nexpires shortly (see [`WebChallenge`]). The returned `challenge` message is\nwhat the wallet signs; `challenge_id` is the nonce to present at verify.",
"operationId": "website_challenge",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebsiteChallengeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Website authentication challenge created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebsiteChallengeResponse"
}
}
}
},
"400": {
"description": "Invalid origin"
}
}
}
},
"/auth/website/verify": {
"post": {
"tags": [
"auth"
],
"summary": "Verify a website-auth signature and mint a `Platform::Web` session.",
"description": "The challenge is CONSUMED (removed from the map) before any signature work,\nso it is strictly single-use: a replayed `challenge_id` — or the loser of two\nconcurrent verifies — finds nothing and is rejected. After consume we check\nexpiry, verify the signature over the challenge message with the algorithm\nfor the submitted asset, resolve the ALREADY-registered user by the proven\nkey's `pubkey_hash` (never creating one), and issue a session. All failure\nmessages are literals (no oracle).",
"operationId": "website_verify",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebsiteVerifyRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Website authentication verified; session issued",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
}
},
"400": {
"description": "Invalid asset or signature format"
},
"401": {
"description": "Invalid/expired challenge, bad signature, or unknown wallet"
}
}
}
},
"/health": {
"get": {
"tags": [
"system"
],
"summary": "Liveness probe.",
"description": "Returns `200` with `{ \"status\": \"ok\" }` whenever the server is up. Used by\nload balancers and uptime checks; requires no authentication.",
"operationId": "health",
"responses": {
"200": {
"description": "Service is up",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
}
}
}
}
}
},
"/keys": {
"post": {
"tags": [
"users"
],
"summary": "Register or update one of the authenticated user's per-asset public keys.",
"description": "Idempotent: the DB upsert keys on `(user_id, asset, key_type)`, so re-posting\nthe same asset replaces the stored key material in place.",
"operationId": "register_key",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterKeyRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Key registered or updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserKeyInfo"
}
}
}
},
"400": {
"description": "Invalid asset"
},
"401": {
"description": "Missing or invalid token"
}
}
}
},
"/users/by-username/{username}": {
"get": {
"tags": [
"users"
],
"summary": "Resolve a username to its user and that user's receiving public keys.",
"description": "Public: this is how a sender discovers where to send. The lookup is\ncase-insensitive (the handle is canonicalized lowercase). An unknown handle\nreturns the same constant-shape response with `registered: false`.",
"operationId": "lookup_username",
"parameters": [
{
"name": "username",
"in": "path",
"description": "Username to resolve",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Username resolution result",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LookupUsernameResponse"
}
}
}
}
}
}
},
"/users/me/username": {
"get": {
"tags": [
"users"
],
"summary": "The authenticated user's own username (or `null`).",
"operationId": "get_my_username",
"responses": {
"200": {
"description": "Caller's username",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MyUsernameResponse"
}
}
}
},
"401": {
"description": "Missing or invalid token"
}
}
},
"post": {
"tags": [
"users"
],
"summary": "Claim or update the authenticated user's username.",
"description": "The submitted value is lowercased, validated, and checked against the\nreserved list before the DB write. The UNIQUE constraint is the atomic claim:\na collision (taken or reserved) is a 409, never a 500.",
"operationId": "set_username",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetUsernameRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Username claimed or updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetUsernameResponse"
}
}
}
},
"401": {
"description": "Missing or invalid token"
},
"409": {
"description": "Username is taken or reserved"
}
}
}
},
"/users/{user_id}/keys": {
"get": {
"tags": [
"users"
],
"summary": "List a user's per-asset receiving public keys.",
"description": "Public: a sender fetches these to construct a payment to `user_id`.",
"operationId": "get_user_keys",
"parameters": [
{
"name": "user_id",
"in": "path",
"description": "User id (UUID)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The user's public keys",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserKeysResponse"
}
}
}
},
"400": {
"description": "Malformed user id"
}
}
}
},
"/users/{user_id}/keys/{asset}": {
"get": {
"tags": [
"users"
],
"summary": "Fetch a user's receiving public key for one asset.",
"operationId": "get_user_key_for_asset",
"parameters": [
{
"name": "user_id",
"in": "path",
"description": "User id (UUID)",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "asset",
"in": "path",
"description": "Asset (btc, ltc, xmr, wow, grin)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The user's key for this asset",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserKeyInfo"
}
}
}
},
"400": {
"description": "Invalid asset"
},
"404": {
"description": "User has no key for this asset"
}
}
}
}
},
"components": {
"schemas": {
"AssetPublicKey": {
"type": "object",
"description": "A per-asset public key submitted by the wallet.",
"required": [
"asset",
"public_key"
],
"properties": {
"asset": {
"type": "string"
},
"public_key": {
"type": "string"
},
"public_spend_key": {
"type": [
"string",
"null"
],
"description": "XMR/WOW only: public spend key."
}
}
},
"AssetSignature": {
"type": "object",
"description": "One asset's signature over the challenge message.",
"required": [
"asset",
"signature",
"public_key"
],
"properties": {
"asset": {
"type": "string",
"description": "Asset whose key produced the signature (`btc`, `ltc`, `xmr`, `wow`, `grin`)."
},
"public_key": {
"type": "string",
"description": "The public key that produced the signature (format depends on asset)."
},
"signature": {
"type": "string",
"description": "Signature over the challenge message. BIP-137 base64 for BTC/LTC; 64-byte\nhex Ed25519 for XMR/WOW/Grin."
}
}
},
"AuthResponse": {
"type": "object",
"description": "Successful session response: a JWT pair plus minimal user info.",
"required": [
"access_token",
"refresh_token",
"expires_in",
"user",
"is_new"
],
"properties": {
"access_token": {
"type": "string"
},
"expires_in": {
"type": "integer",
"format": "int64",
"description": "Access-token lifetime in seconds."
},
"is_new": {
"type": "boolean",
"description": "`true` when this request created the user (extension registration only)."
},
"refresh_token": {
"type": "string"
},
"user": {
"$ref": "#/components/schemas/UserInfo"
}
}
},
"CheckRestoreRequest": {
"type": "object",
"description": "Ask whether a wallet (by seed fingerprint) was created on this backend, and\nwhether the submitted keys match.",
"required": [
"fingerprint",
"keys"
],
"properties": {
"fingerprint": {
"type": "string",
"description": "Seed fingerprint (16 or 64 hex chars)."
},
"keys": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetPublicKey"
},
"description": "Submitted per-asset public keys, for verification."
}
}
},
"CheckRestoreResponse": {
"type": "object",
"description": "Restore-check result. Constant-shape: the same fields are always present so\nthe response is not a structure oracle, and `user_id` is never returned (no\nenumeration).\n\nNOTE: `exists` is an INTENTIONAL disclosure (the wallet uses it to decide\nwhether to offer a restore). It is throttled per fingerprint AND per IP, and\nnothing downstream treats fingerprint-existence as authority — the\nderivation-rotation path in [`extension_register`] requires a key-control\nproof, not a bare fingerprint.",
"required": [
"exists"
],
"properties": {
"exists": {
"type": "boolean",
"description": "Whether the fingerprint exists on this backend."
},
"keys_valid": {
"type": [
"boolean",
"null"
],
"description": "Whether every submitted key matches the stored key. `None` when the\nfingerprint does not exist."
},
"wow_start_height": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "WOW scan-start height, if the wallet was created here."
},
"xmr_start_height": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "XMR scan-start height, if the wallet was created here."
}
}
},
"ExtensionRegisterRequest": {
"type": "object",
"description": "Register a new extension wallet or re-authenticate an existing one.",
"required": [
"keys",
"signed_timestamp",
"signature"
],
"properties": {
"altcha_solution": {
"type": "object",
"description": "Optional proof-of-work solution. Required when the PoW gate applies to\nthis pubkey (see [`pow_applies`]); otherwise ignored."
},
"keys": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetPublicKey"
},
"description": "Public keys for each supported asset. A `btc` key is required (it is the\nidentity)."
},
"rotation_signature": {
"type": [
"string",
"null"
],
"description": "Derivation-rotation proof: BIP-137 base64 signature of the SAME\n`smirk-auth-{signed_timestamp}` message under the BTC key ALREADY ON FILE\nfor the user identified by `seed_fingerprint`. Required to re-point an\nexisting user row; without it (or if it does not verify against the stored\nkey) a fingerprint match is treated as a brand-new identity and the\nexisting row is never touched. See [`extension_register`]."
},
"seed_fingerprint": {
"type": [
"string",
"null"
],
"description": "Seed fingerprint `hex(SHA256(SHA256(seed))[..])`. Used for restore and to\nLOCATE a candidate user row for the derivation-rotation path. By itself it\nis NOT authority: a rotation also requires `rotation_signature` below."
},
"signature": {
"type": "string",
"description": "BIP-137 base64 signature of `smirk-auth-{signed_timestamp}` under the\nSUBMITTED (new) BTC key. Proves control of the key in `keys`."
},
"signed_timestamp": {
"type": "integer",
"format": "int64",
"description": "Unix seconds that were signed to prove BTC key ownership."
},
"username": {
"type": [
"string",
"null"
],
"description": "Optional reserved username."
},
"wallet_birthday": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Wallet creation time (unix seconds), to bound chain scans."
},
"wow_start_height": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"xmr_start_height": {
"type": [
"integer",
"null"
],
"format": "int64"
}
}
},
"HealthResponse": {
"type": "object",
"description": "Liveness response.",
"required": [
"status"
],
"properties": {
"status": {
"type": "string",
"description": "Always `\"ok\"` when the service is reachable."
}
}
},
"LogoutRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"LogoutResponse": {
"type": "object",
"required": [
"success"
],
"properties": {
"success": {
"type": "boolean"
}
}
},
"LookupUsernameResponse": {
"type": "object",
"description": "Result of resolving a username. Constant-shape: the same fields are always\npresent, so the response is not a structure oracle.",
"required": [
"registered"
],
"properties": {
"public_keys": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PublicKeysInfo",
"description": "The user's per-asset receiving public keys, if registered."
}
]
},
"registered": {
"type": "boolean",
"description": "Whether a user owns this username."
},
"user_id": {
"type": [
"string",
"null"
],
"description": "The resolved user id (UUID string), if registered."
},
"username": {
"type": [
"string",
"null"
],
"description": "The canonical (lowercased) username, if registered."
}
}
},
"MyUsernameResponse": {
"type": "object",
"properties": {
"username": {
"type": [
"string",
"null"
],
"description": "The caller's username, or `null` if they have not claimed one."
}
}
},
"NostrLinkRequest": {
"type": "object",
"description": "Link a Nostr identity to the authenticated user. This is a STATE CHANGE, so\nit carries a signed-action proof, not a login-grade token.",
"required": [
"nostr_token",
"nonce"
],
"properties": {
"nonce": {
"type": "string",
"description": "The server-issued single-use nonce the signed action must bind (the\nevent's `challenge` tag)."
},
"nostr_token": {
"type": "string",
"description": "The `Nostr <base64(event)>` signed-action token proving control of the\nnpub AND committing to the server nonce + this request."
}
}
},
"NostrLinkResponse": {
"type": "object",
"required": [
"nostr_pubkey"
],
"properties": {
"nostr_pubkey": {
"type": "string",
"description": "The linked x-only Nostr pubkey (hex)."
}
}
},
"PublicKeysInfo": {
"type": "object",
"description": "A user's per-asset receiving public keys, by asset.",
"properties": {
"btc": {
"type": [
"string",
"null"
]
},
"grin": {
"type": [
"string",
"null"
]
},
"ltc": {
"type": [
"string",
"null"
]
},
"wow": {
"type": [
"string",
"null"
]
},
"xmr": {
"type": [
"string",
"null"
]
}
}
},
"RefreshTokenRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"RegisterKeyRequest": {
"type": "object",
"description": "Register or update one of the caller's per-asset public keys.",
"required": [
"asset",
"public_key"
],
"properties": {
"asset": {
"type": "string",
"description": "Asset the key is for (`btc`, `ltc`, `xmr`, `wow`, `grin`)."
},
"public_key": {
"type": "string",
"description": "The public key (format depends on the asset)."
},
"public_spend_key": {
"type": [
"string",
"null"
],
"description": "XMR/WOW only: the public spend key."
}
}
},
"SetUsernameRequest": {
"type": "object",
"required": [
"username"
],
"properties": {
"username": {
"type": "string",
"description": "Desired username (3-32 chars, lowercase `[a-z0-9_]`, no leading/trailing\nunderscore). Submitted values are lowercased before validation."
}
}
},
"SetUsernameResponse": {
"type": "object",
"required": [
"username"
],
"properties": {
"username": {
"type": "string",
"description": "The username as stored (lowercased)."
}
}
},
"UserInfo": {
"type": "object",
"description": "Minimal, non-enumerable user info returned to a signed-in client.",
"required": [
"id"
],
"properties": {
"id": {
"type": "string"
},
"nostr_pubkey": {
"type": [
"string",
"null"
],
"description": "Linked Nostr pubkey (x-only hex), if any."
},
"username": {
"type": [
"string",
"null"
]
}
}
},
"UserKeyInfo": {
"type": "object",
"description": "A single per-asset public key.",
"required": [
"asset",
"public_key"
],
"properties": {
"asset": {
"type": "string",
"description": "Asset the key is for (lowercase: `btc`, `ltc`, `xmr`, `wow`, `grin`)."
},
"public_key": {
"type": "string"
},
"public_spend_key": {
"type": [
"string",
"null"
],
"description": "XMR/WOW only: the public spend key."
}
}
},
"UserKeysResponse": {
"type": "object",
"description": "A user's full set of per-asset public keys.",
"required": [
"keys"
],
"properties": {
"keys": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserKeyInfo"
}
}
}
},
"WebsiteChallengeRequest": {
"type": "object",
"description": "Request a website-auth challenge for a calling origin.",
"required": [
"origin"
],
"properties": {
"origin": {
"type": "string",
"description": "Origin of the requesting website (e.g. `https://smirk.cash`). Bound into\nthe challenge message so a signature for one origin is not reusable at\nanother."
}
}
},
"WebsiteChallengeResponse": {
"type": "object",
"description": "The challenge to sign, with the handle and expiry the wallet echoes back.",
"required": [
"challenge",
"challenge_id",
"expires_at"
],
"properties": {
"challenge": {
"type": "string",
"description": "The exact message string to sign with the wallet's key."
},
"challenge_id": {
"type": "string",
"description": "Opaque handle to present at verify time (this is the challenge nonce)."
},
"expires_at": {
"type": "string",
"description": "When the challenge expires (RFC 3339)."
}
}
},
"WebsiteVerifyRequest": {
"type": "object",
"description": "Verify a website-auth challenge with a single asset signature.",
"required": [
"challenge_id",
"signature"
],
"properties": {
"challenge_id": {
"type": "string",
"description": "The `challenge_id` returned by [`website_challenge`] (the challenge nonce)."
},
"signature": {
"$ref": "#/components/schemas/AssetSignature",
"description": "The wallet's signature over the challenge message."
}
}
},
"WellKnownResponse": {
"type": "object",
"description": "NIP-05 well-known document.\n\n* `names` maps each resolved local part to its x-only Nostr pubkey (hex).\n* `relays` maps each returned pubkey to a list of recommended relay URLs.\n\nBoth maps are empty when the name is unknown, unlinked, or omitted.",
"required": [
"names",
"relays"
],
"properties": {
"names": {
"type": "object",
"description": "`{ local_part: nostr_pubkey_hex }`. Empty when nothing resolved.",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"relays": {
"type": "object",
"description": "`{ nostr_pubkey_hex: [relay_url, ...] }`. Empty when nothing resolved.",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"propertyNames": {
"type": "string"
}
}
}
}
}
},
"tags": [
{
"name": "system",
"description": "Service health and metadata."
},
{
"name": "auth",
"description": "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."
}
]
}