Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

account-api settings

Part of the configuration reference. Covers the account API’s own settings table; same-origin static mounts for the browser clients ([[static]]); the per-wallet mutation and per-pubkey login-challenge rate limits ([rate_limit], [nonce_rate_limit]); the in-memory summary-cache sizes ([cache]); and sithbit-console, the admin TUI that talks to this API’s /v1/admin routes.

account-api

Row markers follow the going-public legend.

KeyDefaultMeaning
bind_addr"127.0.0.1:8180"REQUIRED (public). HTTP listen address. The loopback default is reachable from this host only, so a public deployment names the address deliberately and puts TLS in front of it — a fronting proxy, or the [tls] section below
admin_wallets[]RECOMMENDED (public). Wallets allowed on the /v1/admin routes (account/queue inspection, dead-letter requeue — see Monitoring). Empty disables the admin surface: every admin call is 403
max_wallet_bytes0RECOMMENDED (public). Aggregate bytes ONE wallet may hold in this store — the twin of sithbitd’s key of the same name, applied to the copies POST /v1/mail/send writes; 0 (the default) is unbounded on both. Keep the two in step: compose writes into the store the mail servers read, so a ceiling only they hold is one any sender walks around by composing here. A compose that would cross it answers 413 Payload Too Large
enable_stored_passwordstrueRECOMMENDED (public). Whether an account may set a stored mail password (a non-empty PUT /v1/account/password; false answers 403). Off makes the deployment wallet-signature-only, which is what keeps delivered mail sealed at rest for every account — a stored password is plaintext-recoverable by the server, and the CRAM-MD5/APOP logins it exists for could never unwrap a sealed body’s key. Clearing (DELETE) and declaring the wallet-auth state (empty PUT) stay allowed, and passwords already stored keep working; pair it with the mail servers’ key of the same name
[rate_limit](on, 30 per 300s)RECOMMENDED (public). Per-wallet budget over the sensitive account mutations — mail password, pin-provider writes, auth-epoch rotation (below). Reads, login, timezone, DND and compose are never limited
rate_limit.enabledtrueRECOMMENDED (public). Master switch for that budget. On by default — a control that ships off protects nobody, because no operator finds it before the abuse does
rate_limit.max_per_window30RECOMMENDED (public). Attempts one wallet may make per window, shared across all the guarded routes together. 0 = no limit
rate_limit.window_secs300RECOMMENDED (public). Length of the fixed window, in seconds. 0 = no limit
rate_limit.durablefalseRECOMMENDED (public); set it once more than one replica serves the same accounts. Keep this budget’s windows in the shared [store] instead of this replica’s memory, so N replicas spend one budget (below). Off is the right default for a single replica and costs no store round trip per guarded request
[nonce_rate_limit](on, 30 per 300s)RECOMMENDED (public). Per-pubkey budget over login-challenge issuance (POST /v1/auth/nonce) — the same settings as [rate_limit], a separate budget keyed on the pubkey in the request body (below). Step-up challenge issuance is deliberately not limited
nonce_rate_limit.enabledtrueRECOMMENDED (public). Master switch for that budget. On by default, for the same reason as rate_limit.enabled
nonce_rate_limit.max_per_window30RECOMMENDED (public). Challenge requests one pubkey may make per window. 0 = no limit
nonce_rate_limit.window_secs300RECOMMENDED (public). Length of the fixed window, in seconds. 0 = no limit
nonce_rate_limit.durablefalseRECOMMENDED (public); set it once more than one replica serves the same accounts. The same switch as rate_limit.durable, for this budget. The two are independent — and the keys here are attacker-supplied, which is why a durable deployment sweeps lapsed windows on a timer
[store](same as sithbitd)REQUIRED (public). Point it at the same store so one database serves both. The default SQLite store admits exactly one instance, so a deployment running more than one names a kind that several can share (see Scaling out)
jwt.issuer / jwt.audience"sithbit"Token claims
jwt.key_file"jwt.key"REQUIRED (public). 32-byte signing key — auto-generated if missing, then unrecoverable; back it up (losing it invalidates all sessions). A key source: a file path (default) or a cloud secret-manager secret (auto-generation applies to the file form only). Every replica must load the same key: one that mints its own rejects the sessions the others issued
jwt.ttl_hours24RECOMMENDED (public). Token lifetime
[cache](the small-deployment defaults)The four bounded caches — their sizes, and since v0.110.0 which backend the plaintext summary cache lives in — every key optional (below). The defaults serve a small self-contained deployment; a farm raises summary_capacity and max_cached_sessions, sized by what one replica sees, or shares the plaintext cache outright with kind = "redis" (below)
cache.kind"local"RECOMMENDED (public); set "redis" once more than one replica serves the same accounts. Which backend holds the plaintext summary cache: "local" is this replica’s in-process map; "redis" shares one cache across every replica (below). The sealed-summary cache and the reading secrets stay per replica either way. "redis" needs the redis cargo feature — the docker image’s all feature set has it; a binary built without it refuses to start on "redis", naming the feature, rather than falling back to a private map the fleet would then not share
cache.redis_url"redis://127.0.0.1:6379/"RECOMMENDED (public) with kind = "redis". The shared server — redis:// plain, or rediss:// for TLS (via rustls; no system OpenSSL involved). A password in the URL is used and never logged (startup logs the kind only). A malformed URL refuses to start; an unreachable server does not — the API starts, and every read is a miss until the server answers
cache.redis_auth(unset)RECOMMENDED (public) with kind = "redis". Where the Redis password comes from — a key source: a file path (bare string or { kind = "file", path = … }) or a cloud secret (akv / asm / gsm), like jwt.key_file. The secret’s bytes (UTF-8, trailing whitespace trimmed) are the password, so redis_url stays host-only; a password embedded in the URL is still honoured, but this selector wins when both are set. Loaded once at startup: an unreadable or empty secret refuses to start (an empty password would send AUTH ""), and a missing file is never auto-generated. Setting it under kind = "local" refuses to start too — a selector that would be silently ignored is a misconfiguration
cache.redis_ca(unset)Set with kind = "redis" only when the server’s certificate is not signed by a CA in the platform root store — Google Memorystore’s SERVER_AUTHENTICATION mode presents an instance-specific Google-managed CA (its server_ca_certs); AWS ElastiCache and Azure Cache for Redis use publicly trusted certificates and need nothing here. A key source of the same four kinds and shape as redis_auth, whose bytes are one or more PEM certificates that replace the platform roots for this connection. Needs a rediss:// redis_url (a plain redis:// one would never read it — the pair refuses to start), and is refused under kind = "local". An unreadable, empty, or certificate-less value refuses to start — an empty root store would trust nothing and fail open forever, which reads as a down server, not the misconfiguration it is
cache.redis_ttl_secs86400RECOMMENDED (public) with kind = "redis". Seconds a shared summary lives before Redis expires it (a per-entry SETEX lifetime), which is what bounds the server’s memory even with no maxmemory-policy set. 0 refuses to start
cache.summary_capacity4096Entry cap of the shared plaintext summary cache, across every wallet and session. Past it the least-recently-used entry is evicted and rebuilt from its immutable blob on the next read — latency, never a wrong answer. A value at or below the search scan window is clamped up to one above it, with a warning (how to decide the value)
cache.session_summary_capacity1024Entry cap per session of the sealed-summary cache; past it that session’s own least-recently-used entry goes. Must exceed the search scan window or startup refuses (how to decide the value)
cache.max_cached_sessions8Full-quota sessions the sealed-summary cache holds at once (its ceiling is this × session_summary_capacity); past it the least-recently-used session’s whole map drops and that session re-decrypts on its next read. 0 refuses to start (how to decide the value)
cache.max_session_secrets4096Concurrent sessions that may hold a reading secret; when full the soonest-expiring session is evicted and simply logs in again. 0 refuses to start (how to decide the value)
[chain](absent)RECOMMENDED (public). Enables the authenticated /v1/chain on-chain read proxy + signed-transaction relay (the Thunderbird extension’s surface). Absent = those routes answer 503; an empty section uses the two defaults below
chain.grpc_endpoint"http://127.0.0.1:50051"REQUIRED (public). The mail-grpc gateway serving mailbox/key/alias/ frombox/tx-status reads. The default names a gateway on this host’s loopback; anything else is an address the operator sets
chain.grpc_tls(absent — plaintext)REQUIRED when the gateway has [auth] configured (every gateway not on this host’s loopback): the API’s half of the mutual TLS, the three keys below — all or none. That half is an operator obligation: the API cannot see the gateway’s configuration, so nothing here checks it — a table left absent against a gateway that enforces [auth] starts cleanly and fails per call, when the gateway refuses the unauthenticated connection. What the API does check at startup is that this table and the endpoint’s scheme agree: http:// with this table, or https:// without it, refuses to start. Add the API’s key to the gateway’s authorized_keys
chain.grpc_tls.cert(unset)REQUIRED when the gateway has [auth] configured. The api’s PEM client certificate — a path or a key source. Its Ed25519 key is what the gateway allow-lists
chain.grpc_tls.key(unset)REQUIRED when the gateway has [auth] configured. Private key for cert, PEM. Same source forms
chain.grpc_tls.gateway_key(unset)REQUIRED when the gateway has [auth] configured. The base58 Ed25519 key in the gateway’s certificate, pinned — no CA, no hostname check; any other certificate fails the handshake
chain.rpc_url"http://127.0.0.1:8899"REQUIRED (public). Solana JSON-RPC node for SOL balances and relaying client-signed transactions. The default is a local validator — a public deployment names its own RPC provider, since rate-limited public endpoints are not production-grade
mail.local_domains[]RECOMMENDED (public). Domains whose recipients live in this store: compose (POST /v1/mail/send) delivers them locally, resolving aliases and prechecking stamps over the [chain] gateway. Mirror sithbitd’s local_domains. Empty = every recipient rides a relay job
mail.auto_mark_seenfalseMark a message \Seen when it is fetched over GET /v1/mail/messages/{uid}. Off, a fetch stays a pure read and read state is the client’s explicit PATCH; turn it on for webmail clients that treat opening a message as reading it
[quota](enabled, sithbitd’s defaults)RECOMMENDED (public). Outbound-relay quotas + suspension on compose — the same keys, defaults, and ramp as sithbitd’s [smtp.quota] / [submission.quota]; the two are twins by design, keep them in step. Over-quota external recipients at compose answer 429, a suspended composer 403
[mail.dkim](absent)RECOMMENDED (public). DKIM keys for composed mail — the same one-or-many shape as sithbitd’s [spooler.dkim], selected by the sender’s domain. Absent = composed relay mail goes unsigned
[[static]](no entries)Serves static directories on the API’s own origin (browser pages reach /v1/… without CORS) — one array-of-tables entry per mount, see below. No entries = no static routes; a bare entry mounts the two defaults below
static.route"/addin"Route prefix this mount’s files appear under; must start with /, which is never added for you. Must be unique across entries — a repeated prefix, /, a missing leading slash, or a route on or below another entry’s harness nest (test, tests, __tests__, spec) aborts the process at startup
static.root"wwwroot"Directory this mount serves (point it at a built bundle, e.g. webclients/outlook/staging). A directory that does not exist is not a startup error: its requests 404
[tls](absent)REQUIRED (public) unless a fronting proxy terminates TLS. Terminates TLS on the API listener itself (dev sideloads, small deployments — production guidance is still a reverse proxy). Both keys are required when present; a missing/invalid file fails at startup, never per-connection
tls.certs(required)REQUIRED (public) whenever [tls] is present. PEM certificate chain — a key source: a file path (default) or a cloud secret-manager secret holding the PEM. Spelled as on every mail listener: the section is the same shared [tls] shape
tls.key(required)REQUIRED (public) whenever [tls] is present. PEM private key — same file-or-cloud key source as certs
[health], [observability](the shared defaults)RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table

[[static]] — same-origin static mounts

The browser clients SithBit ships — the Outlook add-in, the onboarding pages, a webmail shell — are static bundles: directories of HTML, JS, CSS and wasm that a server hands back unchanged, whose only job is to call this API’s /v1/… endpoints. They have to be served from somewhere, and [[static]] lets that somewhere be the API itself.

The reason is the browser’s same-origin rule. Serve the pages from a different scheme, host or port than the API and every call they make is cross-origin: preflight requests, a CORS allowlist to maintain, credential rules to get right, and a second web server whose configuration has to stay in step with the first. Serve them from the API’s own listener and the page and the API share one origin, so fetch("/v1/…") works with no CORS configuration anywhere.

These are directories the API finds, not files it ships. The bundles are built by webclients/<shell>/build.sh — a separate toolchain whose output is gitignored — and in production they are provisioned alongside the binary rather than inside it, at paths that differ per deployment. The one exception is the default root = "wwwroot", which is checked in and does travel with the crate. Which shells need a mount at all varies: sithbit-outlook.zip holds four entries (manifest.json, assets/ and the two package icons), and its manifest points Outlook at {{BASE_URL}}/addin/taskpane.html — substituted at package time by outlook/build.sh, default https://localhost:8180, overridable with SITHBIT_ADDIN_BASE_URL. An Office Add-in is a web app, so the taskpane’s real pages have to come from a [[static]] mount — which is why route defaults to /addin. The Thunderbird extension is the other case: its sithbit-thunderbird.xpi is 61 entries, wasm/mail_wasm.js and wasm/mail_wasm_bg.wasm among them, because a WebExtension packages its own resources and needs no server at all.

This is not a general-purpose web server, and several of the rules below exist to keep it from becoming one: the default is no mounts at all (a pure JSON API), the origin root cannot be mounted over, a bad entry aborts the process at startup, and every mount fences off the harness directories a JavaScript bundle typically ships with. Mounts carry no authentication — they sit alongside the API’s routes, not behind its JWT layer — so a mounted directory is public, which is right for a built bundle and wrong for anything else.

Static hosting is a list: one [[static]] entry per directory you want served, so one API instance can carry the Outlook add-in bundle, the onboarding pages, and a webmail shell at once, each on the API’s own origin. The operator-facing walkthrough is in account-api.

Commented block from account_api.toml (first entry shows the defaults):

# [[static]]
# route = "/addin"
# root = "wwwroot"

# [[static]]
# route = "/onboarding"
# root = "webclients/onboarding/staging"

Five rules the list obeys:

  • Each route must be unique. Two entries sharing a prefix abort the process while the router is built — before the listener binds, so it fails fast rather than half-serving. The abort names [[static]] and quotes the offending value verbatim:

    [[static]] route = "/addin" is already mounted by an earlier entry: each entry needs a prefix of its own. Drop one of the two entries, or give one of them a different route.
    

    so the first move is to grep your file for the quoted prefix. A trailing slash buys no second mount — /addin and /addin/ are one prefix and collide — and when the two entries spell it differently the message carries both spellings (… by an earlier entry (spelled "/addin" there)), so whichever line you grep for, you find one of them. Mounting at the origin root aborts with a message of its own: the API’s own routes live there, so serve the directory under a prefix instead. One hole in the grep, worth knowing: an entry that omits route inherits the /addin default, so a message can quote a prefix that appears nowhere in your file — the accusation is still correct, only the search fails.

  • A route must start with /. The leading slash is never added for you — route = "addin" is refused, so that the prefix serving requests is always the one spelled in the file:

    [[static]] route = "addin" cannot be mounted: a mount prefix has to start with "/". Write it as route = "/addin" — the slash is never added for you, so the prefix that serves is always the one in the file.
    
  • Nested prefixes are fine, except under a harness nest. /addin and /addin/help may both be mounted; the most specific prefix wins regardless of which entry is written first, so the order of the entries is cosmetic. The exception is the next rule.

  • Every mount 404s test, tests, __tests__ and spec beneath itself, and no entry may be mounted on or below one of them. Those four nests are hardcoded, unconditional, and independent of what the mount’s root holds — a JavaScript harness shipped inside a bundle is never reachable from the API’s origin. A second entry standing exactly on a nest, or anywhere below one, aborts the process; the check is order-independent, and the message refuses the deeper route of the two:

    [[static]] route = "/addin/test" cannot be mounted: route = "/addin" fences it off as a harness nest — every mount 404s "test", "tests", "__tests__" and "spec" beneath itself, so nothing configured there could ever be served. Drop one of the two entries, or give one of them a route that is not a harness directory of the other.
    
    [[static]] route = "/addin/test/sub" cannot be mounted: it sits inside "/addin/test", which route = "/addin" fences off as a harness nest — every mount 404s "test", "tests", "__tests__" and "spec" beneath itself, and a mount below one of those names would serve straight through that fence. Drop one of the two entries, or give one of them a route that is not inside a harness directory of the other.
    

    On the nest, the 404 wins and the mount could never have served a byte; below it, the deeper mount wins instead and would serve straight through the fence — which is why the wording differs, and why the second case is a break with earlier releases that built and served that list. Sharing a prefix with a harness name is not the same thing: /addin/testing still mounts, and so does /addin/help/sub.

  • A root that does not exist is not a startup error. That mount simply answers 404 per request — which is what you see when a bundle was never built or a relative path is read against a different working directory than you expected.

Migration from the single [static] table. The pre-list spelling — one [static] table, at most one mount — is now a load-time error, not a silently-ignored section: add the second pair of brackets and the same two keys are read unchanged.

# Before — one table, now refused at load:
# [static]
# route = "/addin"
# root = "wwwroot"

# After — one array-of-tables entry, the same two keys:
# [[static]]
# route = "/addin"
# root = "wwwroot"

The whole list can also be set through one environment variable, since __-nested names cannot address array entries: ACCOUNT_API_STATIC takes the entire array as a TOML value (inline tables, key = value — not JSON, which fails to parse and is then rejected as a string where a sequence was expected):

ACCOUNT_API_STATIC='[{ route = "/addin", root = "/srv/www/addin" }]'

[rate_limit] — the per-wallet mutation budget

Three of the API’s routes can change or revoke an account’s mail credentials: the mail password (PUT / DELETE /v1/account/password), the pin-provider writes (PUT / DELETE /v1/account/pin-provider) and auth-epoch rotation (POST /v1/account/auth-epoch, which logs every session out at once). Those three — and only those — sit behind a per-wallet budget. The reads, the token exchange, the timezone and DND settings and compose are never rate-limited: hammering them buys an attacker nothing worth the risk of throttling a legitimate client. Login-challenge issuance answers to a separate budget of its own, [nonce_rate_limit] below.

# [rate_limit]
# enabled = true
# max_per_window = 30    # attempts per wallet per window; 0 = no limit
# window_secs = 300      # fixed window length; 0 = no limit
# durable = false        # true = share one budget across replicas

Five properties decide whether the defaults suit your deployment:

  • One budget per wallet, shared by all three routes. The count is keyed on the authenticated wallet, not on the route or the client address, so a caller cannot spread a burst across password / pin-provider / auth-epoch and earn three allowances. Every attempt is charged, including the ones that are refused — the hammering is the thing being blunted, not just its successes. An unauthenticated request is never charged: it never identifies a wallet, and gets the usual 401.
  • A step-up refusal spends a slot too, so the number bounds attempts, not changes. The same three routes are also step-up gated — they want a fresh wallet signature on the request — and the budget is charged in front of that gate, so the 428 Precondition Required asking for that signature costs exactly what a success or a 429 costs (why). What a wallet gets for max_per_window = 30 therefore depends on its client: one that fetches a challenge first and signs its very first send gets 30 real changes, while one that tries the mutation bare and steps up on the cue spends two slots per change and gets 15. Only the client that re-sends the bare request in a loop is punished as intended — it never succeeds and finishes on a 429. Size the budget for the clients you actually ship, not for the ideal one. Which mix your clients actually produce is observable: sithbit.api.refusals counts both statuses per route template — not per method — in Monitoring.
  • 0 means “no limit”, never “refuse everything”. That holds for max_per_window and window_secs alike, matching the convention server_common’s listener limits already use, so a mistyped budget cannot lock an account out of its own settings. To switch the limit off deliberately, prefer enabled = false, which says so out loud.
  • In-process and per-replica by default, shared if you say so. With durable = false (the default) the counters live in the API process’s memory and nothing is shared through the store, so two replicas behind a load balancer grant a wallet two budgets — size the number per replica, and expect the effective ceiling to scale with the instance count. The table of live windows is bounded (4096 wallets per replica, not a setting) and fails open when full: the earliest-opened window is dropped to make room, handing that wallet a fresh budget rather than refusing traffic the limiter has no memory of. Setting durable = true moves the windows into the store and makes the budget one budget however many replicas charge it.
  • A fixed window, not a sliding one. The window opens on a wallet’s first charged attempt and resets when it expires, so a burst that straddles a boundary can spend up to twice the budget back to back. That is deliberate — a sliding window costs per-attempt bookkeeping to buy precision this control does not need.

An over-budget attempt is refused with 429 Too Many Requests and a Retry-After header carrying the whole seconds left on the window (floored at 1, since a Retry-After: 0 would invite the immediate retry the budget exists to prevent). A client that honours that header recovers on its own; one that retries blind simply keeps the window full. The refusal is written out in full — the exact response, why the body carries no machine-readable field, and why the other 429 this API can return has no Retry-After at all — under Rate limits on the sensitive mutations.

durable — one budget across replicas

Both budgets ship with their windows in the API process’s own memory. That is the right default for one replica and the only shape that works with no store wiring at all, but it means a second replica keeps its own count: two instances behind a load balancer hand the same wallet two budgets, three hand it three. durable = true moves that budget’s windows into the [store] every replica already shares, so the count is one count however many instances charge it.

The two budgets carry the switch independently — turning it on for [rate_limit] leaves [nonce_rate_limit] in memory — and each stores its windows under its own key prefix, so the two never collide on a wallet address that legitimately appears in both.

Three things are worth knowing before turning it on:

  • It trades away the monotonic clock, and that is forced rather than chosen. An in-memory window is timed by a monotonic instant, which no wall-clock change can move. A window shared between processes has to be comparable between them, and a monotonic instant means nothing outside the process that read it — so a durable window is timed by the charging replica’s wall clock. A backwards jump on that replica can reopen a window early, and replicas whose clocks disagree disagree about where a window’s edge falls. Keep the fleet on NTP, as the same requirement already applies to every lease in the store.
  • A store error admits. If the store cannot be reached the request is let through and a warning is logged. The limiter blunts abuse; it is not an authorization boundary, and refusing on a store outage would lock every account out of its own settings and every user out of logging in — a far larger blast radius than the abuse it prevents. This is the same fail-open direction the in-memory map already takes when its table is full.
  • Lapsed windows are swept on a timer. Unlike the in-memory table, the stored one has no fixed size, and [nonce_rate_limit] is keyed on an unauthenticated, caller-supplied pubkey — so without sweeping, rows accumulate at a rate an attacker chooses. The API sweeps every five minutes (not a setting) using the longest configured window as the cutoff. On DynamoDB the sweep is the store’s own TTL instead of a delete, so removal is asynchronous and can lag by up to about two days; that bounds growth, which is the point, and a lapsed window is already ignored by the charge whether or not its row is gone.

Every backend supports it — SQLite, PostgreSQL, DynamoDB, Azure Tables, Turso/libSQL and Cloudflare D1 — so the switch needs no change of store.

Where to set it in a cloud deployment. The shipped production documents under iac/appconfig/ spell both budgets out in full, so this is one edit to iac/appconfig/aws/account-api.toml (then cargo run -p appconfig-gen to regenerate the Azure flavor) rather than something only an ACCOUNT_API_RATE_LIMIT__DURABLE environment variable can reach. Both ship false, matching the topology iac/aws provisions: that module pins the account-api service to a single task, because without account_api_jwt_key_asm every replica mints its own JWT signing key and a token issued by one is rejected by the next. Give the replicas a shared signing key first, then scale out, then turn this on — in that order.

[nonce_rate_limit] — the per-pubkey login-challenge budget

POST /v1/auth/nonce writes a challenge row for any valid ed25519 pubkey, unauthenticated by design — asking for a challenge is how a login starts. That one route sits behind a budget of its own, keyed on the pubkey in the request body rather than on an authenticated wallet (there is none yet), and charged only after the pubkey parses, so a malformed request never spends a slot:

# [nonce_rate_limit]
# enabled = true
# max_per_window = 30    # challenge requests per pubkey per window; 0 = no limit
# window_secs = 300      # fixed window length; 0 = no limit
# durable = false        # true = share one budget across replicas

The machinery is [rate_limit]’s exactly — the same settings and defaults, the same fixed window, the same 0-means-no-limit rule, the same per-replica in-memory counters (two replicas grant a pubkey two budgets), and a table of its own, bounded and fail-open the same way — but a separate budget: spending one never touches the other, and each is switched and sized on its own. The refusal keeps that shape as well — 429 with Retry-After in whole seconds (floored at 1) — but its body prose names this budget: {"error": "too many login challenges; retry in N seconds"}, where the mutation budget says “account changes”. Neither route is ever told it spent the other’s allowance, and a client should key off the status and the header rather than the sentence.

Two boundaries, so the budget is not oversold:

  • It caps per-key hammering only. The count is keyed on the requested pubkey, so each fresh key an attacker generates arrives with a fresh budget — the challenge-row residue’s distinct-keys bound (see account-api) is not tightened by this control. What ages those rows out is the daemon’s hourly nonce sweep.
  • The step-up challenge is deliberately not limited. POST /v1/auth/step-up demands a valid token first, mints a row for that token’s own wallet only, and can only clobber its holder’s own slot — hammering it costs the caller nothing but their own outstanding challenge.

[cache] — the summary-cache sizes

The API keeps four bounded caches, and this section sizes them — and, since v0.110.0, names which backend the plaintext summary cache lives in (kind, below). Nothing here is required at any deployment size: the defaults fit a small, self-contained deployment, and a value the code cannot honour is caught at startup rather than per request.

# [cache]
# kind = "local"                    # "redis" shares the plaintext summary cache across replicas
# redis_url = "redis://127.0.0.1:6379/"  # the shared server (rediss:// for TLS); kind = "redis" only
# redis_ttl_secs = 86400            # seconds a shared summary lives; bounds Redis memory
# summary_capacity = 4096           # plaintext summaries, all sessions; LRU past it
# session_summary_capacity = 1024   # sealed summaries PER session; LRU past it
# max_cached_sessions = 8           # full-quota sessions the sealed cache holds
# max_session_secrets = 4096        # sessions holding a reading secret

What each bound does, and what happens past it:

  • summary_capacity — the shared plaintext summary cache, one map across every wallet and session. Past it the least-recently-used entry is evicted. An entry is derived from an immutable blob, so eviction costs a re-fetch and a re-parse on the next read and can never return a wrong answer.
  • session_summary_capacity — the sealed-summary cache’s quota, per session. Past it that session’s least-recently-used entry goes — its own entry only; one session’s traffic never evicts another’s.
  • max_cached_sessions — how many full-quota sessions the sealed cache holds. Its global ceiling is this × session_summary_capacity. Past the ceiling the least-recently-used session’s whole map drops and that session re-decrypts on its next read: it lands in the cold-cache state every session starts in, which is degradation, never breakage.
  • max_session_secrets — how many concurrent sessions may hold a reading secret. When full, the soonest-expiring session is evicted and simply logs in again.

Two startup validations, deliberately different in kind (CacheConfig::validated):

  • A too-small plaintext cache is clamped, with a warning. A keyed search scans up to SEARCH_SCAN_CAP rows per request, and the plaintext cache must hold one whole scan window: a summary_capacity at or below that window is raised to one above it and a warning naming cache.summary_capacity, the configured value and the clamped value is logged. Nothing else is affected — a small plaintext cache is a performance defect only.
  • A too-small sealed-summary quota, or any zero bound, refuses to start. A session_summary_capacity at or below the scan window would let a keyed search evict its own rows mid-scan and the sealed-summary ledger under-report, so it is a hard refusal; so is a max_cached_sessions or max_session_secrets of 0, which is a misconfiguration, not a request for “unbounded”. The refusal happens before the JWT key is minted, so a bad value never leaves a stray key file behind.

Starting values for a farm. The defaults are read off a measured hit-rate grid (kept on summary_cache.rs’s DEFAULT_CAPACITY, with its caveats: it is a pessimistic bound, not a forecast). Off that same grid, a larger farm-based deployment starts from:

SettingFarm valueWhat it buys
summary_capacity16384a 100% hit rate up to 8 concurrent sessions, ~6–16 MB resident
summary_capacity65536the same for 16 or more concurrent sessions, ~26–64 MB resident. Memory is the whole cost: eviction takes its victim from a stamp-ordered index, so it costs the same at any capacity
max_cached_sessions32 or morethat many concurrent keyed sessions keep their sealed maps instead of re-decrypting
max_session_secrets(default)already farm-sized: cheap per entry, and evicting one forces a user to log in again

The trap: size by one replica, not by the fleet. With the default kind = "local" each replica holds its own copy of every cache — nothing here is shared through the [store] the way rate_limit.durable shares a budget — so the number that matters is the concurrent sessions one replica sees behind the load balancer, not the fleet total. Sizing from the fleet total provisions every replica for all of it and over-provisions memory N-fold. This is one of the replica-aware limits on the Scaling out checklist. The one cache a fleet can share is the plaintext summary cache, below.

A shared backend for a fleet

kind = "redis" points every replica at one Redis server, so a fleet behind a load balancer warms one plaintext summary cache instead of N cold private ones, and a deploy no longer empties it. The scope is deliberately narrow:

  • What is shared: the plaintext summary cache only. The sealed-summary cache and the reading-secret stash stay per replica by design — they hold decrypted content and credentials that must die with the session, and moving either to a shared service is an exposure-class change, not a configuration one. summary_capacity sizes the local map only and is unused under "redis"; the other three sizes apply exactly as before.
  • Fail-open. A summary is re-derivable from its immutable blob, so nothing the server does is ever an error to a request: a read that fails, times out or returns something unparseable is a miss, and a write that fails is dropped. A server that is down at startup, or goes down later, makes the API slower, never wrong. The bound on “slower”: a dead host costs a call about two seconds (a 1 s connect timeout, one retry, a 500 ms response timeout); a refused port is instant. Only a malformed redis_url refuses to start.
  • Key shape. Every key is sithbit:summary:v1:<blob key> with a JSON value; the v1 is the value’s schema version, so an incompatible change to the shape bumps the prefix and old entries simply miss and age out. Share one server among fleets freely — the prefix keeps SithBit’s keys apart from anything else on it.
  • Memory. redis_ttl_secs is the bound: every entry expires on its own, so the server’s footprint is one day’s summaries at most, at the default. maxmemory with maxmemory-policy allkeys-lru on the server is an optional second bound that evicts the coldest entry early — safe here, for the same reason a local eviction is.
  • The password. redis_auth names where it comes from: a key source with the same four kinds and the same shape as jwt.key_file — a bare string or { kind = "file", path = … } for a file, or akv / asm / gsm for a cloud secret manager. The secret’s bytes are the password, trailing whitespace trimmed, so redis_url stays host-only and never carries a credential into a config file or an environment listing. A password embedded in the URL is still honoured, but the selector wins when both are set. The secret is loaded once at startup and an unreadable or empty one refuses to start — an empty password would send AUTH "", which is a silent misconfiguration, not a fail-open miss. A token on the wire is only as private as the connection, so pair the selector with a rediss:// URL.
  • The server’s CA. rediss:// verifies the server against the platform root store, which is enough for a publicly-signed certificate (ElastiCache, Azure Cache for Redis). Memorystore’s server-auth TLS presents an instance-specific Google-managed CA instead, and redis_ca is how the API trusts it: a key source of the same four kinds whose bytes are one or more PEM certificates, which replace the platform roots for this one connection. It is loaded once at startup, and a value that is unreadable, empty, or holds no certificate refuses to start rather than building an empty root store — that store would trust nothing and fail open on every read, indistinguishable from a down server. Without a rediss:// URL the selector is refused too.
  • The build. "redis" needs the redis cargo feature, which is off by default so the default build stays dependency-free; the docker image’s all feature set includes it. rediss:// gives TLS through rustls, like every other TLS surface in the tree.
  • Where it runs. docker-compose.prod.example.yml carries a commented-out redis: service (redis:7-alpine, on the compose network only, no volume — nothing in it is precious) beside the two commented ACCOUNT_API_CACHE__KIND / ACCOUNT_API_CACHE__REDIS_URL environment lines on account-api; a fleet uncomments the three. A managed Redis exists in iac/ for all three clouds, each an opt-in unit whose token reaches the API through redis_auth: ElastiCache (Valkey) on AWS, Azure Cache for Redis behind a private endpoint, and Memorystore on Google Cloud — the deploy chapter has each one’s operator steps.

What the metrics can and cannot see through the shared backend is on the monitoring page; the replica-level picture is on Scaling out.

sithbit-console

The management TUI over the account API’s /v1/admin routes: accounts, mailboxes, messages with chain states, queue depths, and dead-letter requeue/discard (see Monitoring; full tutorial and key reference in the sithbit-console appendix). It never touches the store directly — every read and action rides the API. Config file sithbit_console.toml (or SITHBIT_CONSOLE_CONFIG), env prefix SITHBIT_CONSOLE. Row markers follow the going-public legend.

KeyDefaultMeaning
api_url"http://127.0.0.1:8180"REQUIRED (public). Base URL of the account API. The default finds an API on this machine only; a remote one is named here as https://, since the admin session’s bearer token crosses the wire
keypair_file~/.config/solana/id.jsonRECOMMENDED (public). Solana keypair that signs the wallet-challenge login — its wallet must be on the API’s admin_wallets allowlist
gateway_endpoint"http://127.0.0.1:50051"RECOMMENDED (public). mail-grpc gateway the balances pane reads on-chain mailbox/frombox state through — a SolanaMail gRPC endpoint (GetMailbox default stamp price + mail count, GetFrombox per-sender stamps). The console carries no client-certificate table, so it can only reach a gateway that runs without [auth]
rpc_url"http://127.0.0.1:8899"RECOMMENDED (public). Solana JSON-RPC node the balances pane fetches native SOL balances from (getBalance)

The balances pane is the one console view that reads chain state directly rather than through the account API: press b on a wallet to see its native SOL, its mailbox’s default stamp price and mail count, and the prepaid stamps each other loaded wallet holds toward it. Those figures come straight from mail-grpc and the JSON-RPC node above — so a console used only for the admin panes can leave both endpoints at their loopback defaults (or unset the whole file). Every entry defaults, so an empty or absent sithbit_console.toml targets the loopback dev stack:

# api_url = "http://127.0.0.1:8180"
# keypair_file = "~/.config/solana/id.json"
# gateway_endpoint = "http://127.0.0.1:50051"
# rpc_url = "http://127.0.0.1:8899"