Skip to content

Repository files navigation

CloudsForge Platform

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

Running it

pnpm install
pnpm dev:services     # nimbus on :4001
pnpm dev:apps         # site on :3000, admin on :3002

services/nimbus needs a Postgres and reads its configuration from a .env alongside the service. See services/nimbus/.env.example.

Notes

  • Shared code comes from npm, not a workspace symlink: @cloudsforge/shared (API contracts) and @cloudsforge/ui (account chrome and design tokens) are published from cloudsforge-online/shared-libs. Both sit at 0.x, where ^0.1.0 resolves patch-only — deliberate while the contracts still move, since shared is 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 by cloudsforgeHosts(), so one image serves every environment.
  • The signing key is encrypted at rest, and replaceable. NIMBUS_KEY_SECRET is 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, a SELECT-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/activate refuses 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 to NIMBUS_ISSUER; compose sets it to https://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_PASS and SMTP_FROM and deliverPasswordReset() (services/nimbus/src/passwordReset.ts) mails the link through src/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. Leaving SMTP_HOST unset 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.ts relays two read paths over the compose network, forwarding the operator's own token so keyvault still authorises and audits the real person. This needs KEYVAULT_URL: http://forge-keyvault:4005 on the nimbus service in the stack's docker-compose.yml — it is set on pay and forge-mint but not yet on nimbus. Reveal is not relayed unless NIMBUS_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=stuck read 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 ABANDON to confirm) that relays through services/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.ts now 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. tsx runs the TypeScript sources directly and build is tsc --noEmit, so CI builds the Docker images for real — a green typecheck cannot tell you whether the image resolves its dependencies.

License

MIT — see LICENSE.

How this was built

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.

Releases

Packages

Used by

Contributors

Languages