Accounts, sessions and the single sign-on that the other CloudsForge products hand off to, plus the marketing site and the admin console.
| Path | What it is | Port |
|---|---|---|
services/nimbus |
Identity service: registration, login, JWT issue/refresh, SSO exchange | 4001 |
apps/site |
Marketing site and account portal | 3000 |
apps/admin |
Admin console | 3002 |
pnpm install
pnpm dev:services # nimbus on :4001
pnpm dev:apps # site on :3000, admin on :3002services/nimbus needs a Postgres and reads its configuration from a .env
alongside the service. See services/nimbus/.env.example.
- Shared code comes from npm, not a workspace symlink:
@cloudsforge/shared(API contracts) and@cloudsforge/ui(account chrome and design tokens) are published fromcloudsforge-online/shared-libs. Both sit at0.x, where^0.1.0resolves patch-only — deliberate while the contracts still move, sincesharedis the wire format between services and clients. - The apps take no build-time configuration. Nothing reads
import.meta.env.VITE_*; hosts are resolved in the browser at runtime bycloudsforgeHosts(), so one image serves every environment. - The signing key is encrypted at rest, and replaceable.
NIMBUS_KEY_SECRETis required — Nimbus refuses to boot without it, rejects a placeholder or anything under 24 characters, and never falls back to storing the key in the clear. It was plaintext JSONB in a database every service shares one Postgres role on, which turned any read-only leak (a stolen backup, aSELECT-only injection) into silent, offline, unrevocable admin in every product; the envelope is the same scrypt + AES-256-GCM shape ForgeKeyvault uses for custody keys. Replacing a key is three admin calls — mint, wait one access-token TTL, activate — and/admin/signing-keys/:kid/activaterefuses until the key has been in the JWKS long enough for every service to have fetched it. See MAP.md §2.1. - The password-reset link is built from
NIMBUS_PUBLIC_URL, never the request. Everything else Nimbus renders derives its URLs from the host it was asked on, which is right when the link goes back to the same browser. A reset link is emailed, so a request-derived host let a stranger have this deployment's own relay send a victim a genuine, correctly branded email pointing wherever they liked. Unset, it falls back toNIMBUS_ISSUER; compose sets it tohttps://account.<apex>. - Password reset emails over any SMTP relay, or none. Tokens are single-use,
30-minute, hashed at rest, and burn every refresh-token family when spent. Set
SMTP_HOST,SMTP_USER,SMTP_PASSandSMTP_FROManddeliverPasswordReset()(services/nimbus/src/passwordReset.ts) mails the link throughsrc/mailer.ts— a generic nodemailer transport with no provider in it, so Brevo, Resend, SendGrid, Mailtrap, a Gmail app password or your own postfix are the same four settings and switching is never a code change. See the stack's.env.example, "Outbound mail", for free credentials in about two minutes. LeavingSMTP_HOSTunset is a supported mode, not a broken one: the request is recorded and surfaced in the admin console under Users, where an operator issues the link and passes it on out of band. The user-facing copy says whichever of the two is true. - The admin console reads ForgeKeyvault through Nimbus. Keyvault holds
custody keys, binds to loopback and is deliberately never routed, so the
browser cannot reach it;
services/nimbus/src/routes/vault.tsrelays two read paths over the compose network, forwarding the operator's own token so keyvault still authorises and audits the real person. This needsKEYVAULT_URL: http://forge-keyvault:4005on thenimbusservice in the stack'sdocker-compose.yml— it is set onpayandforge-mintbut not yet onnimbus. Reveal is not relayed unlessNIMBUS_VAULT_REVEAL_PROXY=true. - "Nothing is stuck." is now a fact the console asked for. The withdrawals
panel used to make one unqualified request, which Forge Pay caps at the newest
200 rows across every status, and then count and filter that array in the
browser — so a stuck payment with two hundred newer ones behind it produced an
empty human-intervention queue that was not empty. A status chip is a query
now, and the stuck line is its own
?status=stuckread that does not depend on which chip is selected; a read that fails says so instead of rendering as zero (apps/admin/src/lib/withdrawalQueue.ts, MAP.md §4.3). - An outage no longer reads as reassurance. The Prices page's treasury and
withdrawals panels load out-of-band so neither can take the price panel down,
and they used to write that as
.catch(() => setTreasuries([]))— so a 502 from the pay relay, or a gateway timeout on the chain-balance read, rendered as "No treasury addresses configured." and "No withdrawals yet." to an operator who had opened the page because something was wrong. A failed read is now an unknown and never an absence: only an answered request may state one, and the panel shows the server's own sentence in its place (lib/panelRead.ts, MAP.md §4.4). - The console can now return the money on a stuck withdrawal. Forge Pay had
the whole remediation path and nothing called it, so a payment that never
landed left a user's custodial coin balance debited until somebody hand-rolled
a bearer POST from a shell. Prices → Withdrawals has an Abandon and refund
action (type
ABANDONto confirm) that relays throughservices/nimbus/src/routes/pay.ts. Pay asks the chain first and refuses unless it can prove the signed bytes can never be applied; that refusal is shown word for word, because it is the only statement of why and each one implies a different next action. This is only safe against a Forge Pay that checks the nonce (its CF-06) — an older pay refunded on a missing receipt, which on an EVM chain proves nothing. - The public site is tested against the code it describes. The front page and
/roadmap said EMBER credited at the chain tip and that game cosmetics lived in
your own browser where nobody else saw them. Neither had been true for a while:
Forge Pay reads an EMBER balance 60 blocks back — the depth Hearth publishes to
exchanges — and a cosmetic is a database row, entitlement-checked on equip and
fanned out to every roster you appear on. The roadmap promises "nothing claimed
here that you cannot go and use" and was breaking it by understating what
runs.
apps/site/src/lib/site.test.tsnow reads the confirmation depth out of the shared deposit registry and fails the build if the copy quotes another number, and sweeps the module's strings for retired claims. Marketing copy is the only part of this estate with no compiler behind it, which is exactly why it drifts. - Services ship no build artifact.
tsxruns the TypeScript sources directly andbuildistsc --noEmit, so CI builds the Docker images for real — a green typecheck cannot tell you whether the image resolves its dependencies.
MIT — see LICENSE.
Parts of this repository were produced with AI assistance, and it seems worth saying so plainly rather than leaving it to be inferred.
- Code — written with Claude Opus 5 and Claude Opus 4.8 (Anthropic), reviewed and directed by a human, and gated on the same tests and CI as anything else here.
- Artwork — brand marks, icons and in-game art generated with OpenAI's image models
(GPT Image 1, 1.5 and 2), driven by the manifest pipeline in
asset-forge.
The models were used under paid API access and the output is the project's to use. Nothing here is claimed to be hand-written that is not, and nothing is claimed to work that has not been tested.