Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ jobs:
build:
name: Typecheck + build
runs-on: ubuntu-latest
# Refresh-token rotation is a race between two UPDATEs contending for one row, and the
# decision it turns on is a column in that row. Nothing about it can be tested against a
# stub, so the suite gets a real Postgres; without this it skips its own database cases
# and the only thing left running is the log-serializer half.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: nimbus
POSTGRES_PASSWORD: nimbus
POSTGRES_DB: nimbus_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U nimbus"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand All @@ -20,6 +38,38 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm build
# Suites are named one by one rather than run with `pnpm -r test`, so that
# giving a workspace a suite is a deliberate act here and never a script
# that silently does not exist. All three workspaces have one today.
# The database cases skip themselves when NIMBUS_TEST_DATABASE_URL is
# unset, so it is set here and the name must contain "test" β€” the suite
# refuses any other database, having migrations and a DELETE in it.
- run: pnpm --filter @cloudsforge/nimbus test
env:
NIMBUS_TEST_DATABASE_URL: postgres://nimbus:nimbus@localhost:5432/nimbus_test
# The signing key is encrypted at rest and env.ts refuses to boot
# without a secret to encrypt it under (CF-16), so the suite needs one.
# A literal is right here and only here: this database exists for the
# length of one job and holds nothing but the keys these cases mint.
# It is long enough and is not on env.ts's placeholder list on purpose
# β€” a CI value that trips the fail-closed check would prove nothing
# except that the check runs.
NIMBUS_KEY_SECRET: ci-only-signing-key-secret-not-a-real-one
# The console's suite needs nothing: withdrawalQueue.ts imports no runtime
# module, which is the whole reason the withdrawals panel's rules are
# testable at all. It asserts WHICH REQUEST the panel sends β€” the defect
# CF-33 fixed was never in the arithmetic, so a test of the arithmetic
# would have passed against it.
- run: pnpm --filter @cloudsforge/admin test
# The marketing site's suite is the only thing in the estate that holds its
# PUBLIC CLAIMS to the code they describe: it reads EMBER's confirmation
# depth out of `@cloudsforge/shared`'s deposit registry β€” the same module
# forge-pay's watcher reads β€” and fails if the copy quotes a different
# number, or if a retired claim ("credits at the chain tip", "cosmetics are
# stored in your own browser") comes back. It needs no database and no
# browser. If it goes red after a shared-libs bump, the site is out of date,
# not the test.
- run: pnpm --filter @cloudsforge/site test

images:
name: Docker images
Expand Down
251 changes: 208 additions & 43 deletions MAP.md

Large diffs are not rendered by default.

78 changes: 70 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,36 @@ alongside the service. See `services/nimbus/.env.example`.
- **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.
- **Password reset is complete except for delivery.** Tokens are single-use,
30-minute, hashed at rest, and burn every refresh-token family when spent.
Nothing in this estate can send email, so `deliverPasswordReset()`
(`services/nimbus/src/passwordReset.ts`) is a marked seam that currently
delivers nothing: a user's own request is recorded and surfaced in the admin
console under Users, where an operator issues the link and passes it on out of
band. To finish it, replace that one function β€” the routes, the table and the
TTL are done, and the copy already says the truth in the meantime.
- **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
Expand All @@ -46,6 +68,46 @@ alongside the service. See `services/nimbus/.env.example`.
`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
Expand Down
4 changes: 3 additions & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "vite --port 3002",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3002",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test \"src/**/*.test.ts\""
},
"dependencies": {
"@cloudsforge/shared": "^0.4.0",
Expand All @@ -24,6 +25,7 @@
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
Expand Down
49 changes: 41 additions & 8 deletions apps/admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,14 +544,18 @@ export const api = {

// Forge Pay β€” operator surface, proxied by Nimbus.
//
// Not called direct, and for a narrower reason than the vault above: pay IS routed
// and IS on this origin's CORS allowlist, so these three GETs would work against
// HOSTS.pay. The PUT would not β€” pay's preflight answers
// `access-control-allow-methods: GET,HEAD,POST`, so no browser will send it, and
// widening that is a change to a repo this one does not own. Setting the price is
// the point of the panel, so all four go the one way that can carry it. Pay
// re-verifies the same bearer on the far side and attributes the change to the real
// operator. See services/nimbus/src/routes/pay.ts.
// Not called direct, and for a narrower reason than the vault above: keyvault is
// unroutable, whereas pay IS routed and IS on this origin's CORS allowlist, so every
// call below would work against HOSTS.pay β€” the PUT included, since forge-pay f3eb408
// (2026-07-28) widened pay's preflight to name PUT. They come through Nimbus anyway so
// that the panel has one origin, one auth path and one failure mode instead of two,
// and so that no part of it depends on the console's apex being kept on a CORS list
// in another service. Pay re-verifies the same bearer on the far side and attributes
// the change to the real operator.
//
// A future PUT or DELETE on pay's operator surface therefore needs no second proxy
// layer and no cross-repo negotiation β€” it is one more route in
// services/nimbus/src/routes/pay.ts, which is also where the full argument lives.
listAdministeredPrices(): Promise<{ prices: AdministeredPrice[] }> {
return request<{ prices: AdministeredPrice[] }>(NIMBUS_URL, '/admin/pay/prices')
},
Expand Down Expand Up @@ -581,4 +585,33 @@ export const api = {
`/admin/pay/withdrawals${qs}`,
)
},

/**
* Give up on a withdrawal and return the money to the user's custodial coin balance.
*
* Resolves with the withdrawal as it now stands β€” `failed`, carrying the refund
* reason β€” so the caller can replace its row rather than guess at the new state.
*
* REJECTS FAR MORE OFTEN THAN IT RESOLVES, and that is the design. Forge Pay asks the
* chain first and refuses unless it can PROVE the signed bytes can never be applied,
* because a refund of a payment that did land is the one thing it cannot take back.
* The `ApiError.message` on those refusals is pay's own sentence, and it is the only
* statement of WHY anywhere in the system β€” never replace it with wording of ours.
* The distinct outcomes worth handling by `code`:
*
* 409 withdrawal_on_chain | withdrawal_still_applicable β€” the network can still
* apply this payment. Not now, and possibly not ever; nothing was refunded.
* 409 withdrawal_unprovable β€” pay cannot ask the question (no treasury row for the
* coin, or an XRP blob whose sequence it never recorded). Nothing was refunded.
* 409 withdrawal_not_abandonable β€” the row moved on (confirmed, or already failed).
* 503 chain_unreachable β€” the node did not answer. The next action is to retry, not
* to escalate, which is why this is not a 5xx of ours.
*/
abandonWithdrawal(id: string): Promise<AdminWithdrawal> {
return request<AdminWithdrawal>(
NIMBUS_URL,
`/admin/pay/withdrawals/${encodeURIComponent(id)}/abandon`,
{ method: 'POST' },
)
},
}
Loading
Loading