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.
| Key | Default | Meaning |
|---|---|---|
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_bytes | 0 | RECOMMENDED (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_passwords | true | RECOMMENDED (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.enabled | true | RECOMMENDED (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_window | 30 | RECOMMENDED (public). Attempts one wallet may make per window, shared across all the guarded routes together. 0 = no limit |
rate_limit.window_secs | 300 | RECOMMENDED (public). Length of the fixed window, in seconds. 0 = no limit |
rate_limit.durable | false | RECOMMENDED (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.enabled | true | RECOMMENDED (public). Master switch for that budget. On by default, for the same reason as rate_limit.enabled |
nonce_rate_limit.max_per_window | 30 | RECOMMENDED (public). Challenge requests one pubkey may make per window. 0 = no limit |
nonce_rate_limit.window_secs | 300 | RECOMMENDED (public). Length of the fixed window, in seconds. 0 = no limit |
nonce_rate_limit.durable | false | RECOMMENDED (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_hours | 24 | RECOMMENDED (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_secs | 86400 | RECOMMENDED (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_capacity | 4096 | Entry 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_capacity | 1024 | Entry 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_sessions | 8 | Full-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_secrets | 4096 | Concurrent 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_seen | false | Mark 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
routemust 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 —
/addinand/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 omitsrouteinherits the/addindefault, so a message can quote a prefix that appears nowhere in your file — the accusation is still correct, only the search fails. -
A
routemust 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.
/addinand/addin/helpmay 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__andspecbeneath 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’srootholds — 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/testingstill mounts, and so does/addin/help/sub. -
A
rootthat 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 Requiredasking for that signature costs exactly what a success or a429costs (why). What a wallet gets formax_per_window = 30therefore 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 a429. Size the budget for the clients you actually ship, not for the ideal one. Which mix your clients actually produce is observable:sithbit.api.refusalscounts both statuses per route template — not per method — in Monitoring. 0means “no limit”, never “refuse everything”. That holds formax_per_windowandwindow_secsalike, matching the conventionserver_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, preferenabled = 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. Settingdurable = truemoves 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-updemands 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_CAProws per request, and the plaintext cache must hold one whole scan window: asummary_capacityat or below that window is raised to one above it and a warning namingcache.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_capacityat 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 amax_cached_sessionsormax_session_secretsof0, 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:
| Setting | Farm value | What it buys |
|---|---|---|
summary_capacity | 16384 | a 100% hit rate up to 8 concurrent sessions, ~6–16 MB resident |
summary_capacity | 65536 | the 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_sessions | 32 or more | that 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_capacitysizes 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_urlrefuses to start. - Key shape. Every key is
sithbit:summary:v1:<blob key>with a JSON value; thev1is 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_secsis the bound: every entry expires on its own, so the server’s footprint is one day’s summaries at most, at the default.maxmemorywithmaxmemory-policy allkeys-lruon 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_authnames where it comes from: a key source with the same four kinds and the same shape asjwt.key_file— a bare string or{ kind = "file", path = … }for a file, orakv/asm/gsmfor a cloud secret manager. The secret’s bytes are the password, trailing whitespace trimmed, soredis_urlstays 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 sendAUTH "", 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 arediss://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, andredis_cais 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 arediss://URL the selector is refused too. - The build.
"redis"needs therediscargo feature, which is off by default so the default build stays dependency-free; the docker image’sallfeature set includes it.rediss://gives TLS through rustls, like every other TLS surface in the tree. - Where it runs.
docker-compose.prod.example.ymlcarries a commented-outredis:service (redis:7-alpine, on the compose network only, no volume — nothing in it is precious) beside the two commentedACCOUNT_API_CACHE__KIND/ACCOUNT_API_CACHE__REDIS_URLenvironment lines onaccount-api; a fleet uncomments the three. A managed Redis exists iniac/for all three clouds, each an opt-in unit whose token reaches the API throughredis_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.
| Key | Default | Meaning |
|---|---|---|
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.json | RECOMMENDED (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"