This repository is not a product. It is the thing that turns nine repositories into one
running system: it clones them into repos/, composes them, routes them, and carries the
two pieces of infrastructure nobody buys and everybody uses.
Each product repo carries its own MAP.md describing what is inside it. This one describes
what happens between them.
Verified against source, not documentation. findings.md in this directory records the audit
that produced most of what follows, including the parts that were wrong before.
One crypto world. Mine it, trade it, mint it, spend it, play in it.
| Verb | Product | Repo | Public host | Honest status |
|---|---|---|---|---|
| Mine | Hearth · EMBER | hearth |
hearth. + explorer. |
Testnet live, 3 nodes in compose |
| Trade | Crucible | crucible |
crucible. |
Engine complete; live path gated |
| Mint | ForgeMint | forge-mint |
mint. |
Deploys real contracts, EVM + Solana |
| Spend | Forge Pay · Shards | forge-pay |
pay. |
Deposit, convert, spend, withdraw |
| Play | Games — Ninety Days After | ninety-days-after |
play. |
Deep simulation, playable |
Games is a category, not a title. The data model already allows a second world.
Below the five, three things that are not products and must never appear in a product grid as peers: Nimbus (the account), ForgeKeyvault (custody), Lantern (operations). An account is not something a person chooses; it is something they are given.
mine EMBER on your own laptop Hearth — Homefire PoW, CPU-friendly
│
▼
deposit to a custodied address forge-keyvault mints the ember1… key
│ forge-pay watches at depth 3
▼
convert to Shards, or to EMBER forge-pay /coins/convert{,-to-ember}
│
▼
spend it anywhere we make Crucible fees · ForgeMint orders
│ cosmetics · season pass · worlds
▼
withdraw back out on-chain forge-pay /withdrawals (see §7)
Every arrow exists in code. The last one has a caveat that matters — §7.
Eighteen containers. docker-compose.yml is the source of truth; this table is a reading of it.
| Service | Port | Binding | Source | Waits for |
|---|---|---|---|---|
postgres |
5432 | loopback | postgres:17-alpine |
— |
nimbus |
4001 | published | repos/platform |
postgres |
game |
4002 | published | repos/ninety-days-after |
postgres, nimbus |
pay |
4003 | loopback | repos/forge-pay |
postgres, nimbus, hearth-seed |
forge-mint |
4004 | published | repos/forge-mint |
postgres, nimbus, pay, keyvault |
forge-keyvault |
4005 | loopback | repos/forge-keyvault |
postgres |
crucible |
4006 | published | repos/crucible |
postgres, nimbus, pay |
lantern |
4010 | published | infra/lantern |
postgres |
beacon |
4011 | published | infra/beacon |
nothing, deliberately |
site |
3000 | published | repos/platform (apps/site) |
— |
game-client |
3001 | published | repos/ninety-days-after |
— |
admin |
3002 | published | repos/platform (apps/admin) |
— |
hearth-site |
3003 | published | repos/hearth/site |
— |
hearth-web |
8080 | published | repos/hearth/web |
hearth-seed |
hearth-testnet-seed |
8645/8646 | loopback | repos/hearth/node |
— |
hearth-testnet-miner1 |
8647 | loopback | repos/hearth/node |
seed |
hearth-testnet-miner2 |
8649 | loopback | repos/hearth/node |
seed |
cv-web |
8081 | published | nginx:alpine + ./cv |
— |
Loopback is a security boundary, not a preference. pay exposes /internal/* routes that
read, debit and credit any user on a userId parameter behind one shared secret;
forge-keyvault holds custody keys; postgres is postgres. Published on 0.0.0.0 these were
reachable from every host on the LAN, and the tunnel forwarded them to the internet on top.
Crucible and ForgeMint have no separate web container, and this is correct rather than an
omission: each service builds its own SPA and serves it via @fastify/static alongside its API,
so one hostname is both.
depends_on uses service_healthy, not service_started, wherever boot order actually matters —
"the container exists" is not "the RPC answers".
deploy/cloudflared/config.example.yml maps public hostnames onto local ports. cloudflared runs
as a host process and dials 127.0.0.1, so it can reach loopback-bound services — which is why
the ingress rules matter as much as the bindings.
| Hostname | → | Notes |
|---|---|---|
cloudsforge.online, www. |
3000 | marketing site |
play. |
3001 | game client |
admin. |
3002 | operator console |
hearth. |
3003 | Hearth marketing |
explorer. |
8080 | block explorer + wallet |
nimbus., account. |
4001 | identity, on two hostnames |
api. |
4002 | game API |
pay. |
4003 | /internal/* refused at the edge, first rule wins |
mint. |
4004 | API + SPA |
crucible. |
4006 | API + SPA |
lantern. |
4010 | operations, admin role enforced on the token |
beacon. |
4011 | status and functional tests, same admin gate |
savvanis.life |
8081 | CV |
forge-keyvault has no ingress rule and must never get one.
CI (.github/workflows/ci.yml) enforces two invariants a human would eventually break: that the
GHCR overlay names an image for every buildable service except lantern, and that no hostname
routes an /internal path.
.env is gitignored; .env.example documents it. Services read config two ways, and the
difference is a recurring source of bugs:
env_file: .envhands the container every variable in.env.environment:sets specific values that override it.
The trap: a service-to-service URL in .env is written for a developer on the host, where
localhost:4003 is Forge Pay. Inside a container, localhost is that container. Any service
that needs to reach a peer must have the compose-network address set explicitly under
environment:, or env_file silently points it at itself.
This has now bitten three times — the game reaching pay (PAY_API_URL), Nimbus reaching keyvault
(KEYVAULT_URL), and Nimbus reaching pay (PAY_API_URL). All three are fixed and all three
looked healthy while broken.
Shared anchors at the top of the compose file define the CORS origin list, the portal return-URL
allowlist and the Nimbus issuer, all derived from CLOUDSFORGE_APEX so a deployment sets one
variable.
One users table with no product column. RS256 tokens with a single audience — cloudsforge —
for every product, and JWKS at a well-known path each service verifies against.
Moving between products is a 60-second, single-use, origin-bound code:
app → POST /portal/handoff (Nimbus, caller's token, Origin checked)
← redirectUrl with #cf_code=…
app → POST /auth/exchange (code, same Origin)
← access + refresh tokens
The code is hashed at rest, redeemed by a conditional UPDATE … RETURNING so a race cannot
redeem it twice, and bound to the origin that asked for it. Return URLs are allowlisted, so this
is not an open redirect.
Refresh tokens rotate, and reusing a burnt one revokes the whole family.
Two services are reached only through Nimbus, never from the browser: forge-keyvault (holds
custody keys, deliberately unrouted) and forge-pay's admin surface (its CORS refuses a
cross-origin PUT). Nimbus proxies both server-side and forwards the caller's own token, so Pay
still records which admin set a price.
Lantern (infra/lantern, port 4010) reads container log streams from the Docker socket
read-only, parses pino/nginx/hearthd/postgres into one shape, groups errors into issues, and takes
browser-side failures at /ingest/client.
It is built from this repository rather than pulled, deliberately: a server clones only stack,
so the tool you need when a deploy goes wrong is the one thing guaranteed to be present. The GHCR
overlay does not override it. LANTERN_TOKEN is the break-glass for when Nimbus is the thing that
broke — set it, or an auth outage locks you out of the logs about the auth outage.
It is plain JavaScript with no build step, and should stay that way for the same reason.
Read this section before deploying.
Withdrawals are accepted before they can be funded — until you decide to fund them.
PAY_WITHDRAWALS_ENABLED defaults to true while PAY_SWEEP_ENABLED defaults to false.
Payouts come from a treasury rather than straight out of deposit addresses, and the sweeper that
funds it ships off. That default is a custody decision and not a missing feature: ForgeKeyvault
has a signable sweep shape whose destination must be a treasury an operator pinned inside the
vault, so the sweeper can run and is simply not switched on.
A fresh deployment therefore accepts a withdrawal, holds it pending while logging "the treasury
cannot cover it", and refunds it automatically once PAY_WITHDRAWAL_STUCK_MINUTES elapses
(default 60) — safe, because nothing was ever signed. Nobody loses money; they lose an hour
waiting for an answer that could have been given at once.
Making withdrawals work takes three steps, and only the last two are environment variables:
- Pin a treasury per chain and network through keyvault's Nimbus admin API —
POST /admin/treasuries/:chain/:network/mintto create the address, thenPUT /admin/treasuries/:chain/:networkto pin it, in that order. Nothing sweeps to an unpinned candidate. PAY_SWEEP_ENABLED=true.- Optionally
PAY_TREASURY_TARGET_<COIN>(PAY_TREASURY_TARGET_EMBER=250) to hold a float. It defaults to zero, which leaves the sweeper purely demand-driven.
Step 3 is the one worth arguing about. A withdrawal must be payable to any address a user names,
so the treasury's destination is unconstrained by design — every coin in the treasury is inside
the blast radius of KEYVAULT_SERVICE_TOKEN, and every coin left in a deposit address is outside
it. A float buys immediacy on the first withdrawal after a quiet period, at the price of standing
exposure; zero pays a confirmation depth of latency instead. Set it as a stated product decision.
This applies to the EVM, Ember and XRP families. Bitcoin and Solana deposit addresses are
still refused before decryption — neither has an output policy built — so on those two chains the
treasury genuinely must be funded by hand. DEPOSIT-SWEEP.md has the full argument;
repos/forge-pay/services/pay/src/env.ts is the authoritative commentary.
The testnet chain state is not portable across a MIN_TARGET change. Hearth raised it to lift
a ceiling that capped the network at a few hundred CPUs. Replay re-validates targets, so stored
chains fail to load and cross-version peers reject each other. Before the first up after that
change:
docker compose down
docker volume rm stack_testnet-seed-data stack_testnet-miner1-data stack_testnet-miner2-data
docker compose build can report success while building nothing. It exits 0 with every target
CANCELED when one target fails. A pnpm release-age refusal on a transitive dependency did exactly
this, and the stack then came up healthy on stale images. Check image timestamps, not the exit
code.
@cloudsforge/hearth-node is intentionally skewed. 0.2.0 emits a records key in the signed
transaction body; forge-keyvault pins ^0.1.0 on purpose, because a record-carrying body hashes
differently on the two builds and a signature would be valid on exactly one. Do not let a blanket
dependency update pull it forward.
Publishing shared-libs is a manual step. CI's npm token is dead. Releases go out with
pnpm --filter @cloudsforge/<pkg> publish — always pnpm, never npm, because the publishConfig
rewriting that points main at dist/ is a pnpm feature and npm publish would ship
main: src/index.ts and break every consumer. Each release also needs every consumer to widen its
range and add the new version to minimumReleaseAgeExclude.
cv-web serves a personal site (savvanis.life) from the company stack. Harmless, but it is
not part of the product and should not be mistaken for one.
scripts/clone-all.sh # clone the nine product repos into repos/
scripts/pull-all.sh # fast-forward them all
cp .env.example .env # then set POSTGRES_PASSWORD and the service tokens
docker compose up -d --build
docker-compose.ghcr.yml is the deploy overlay: it replaces every buildable service with a
published image so a server pulls rather than builds. lantern is the one deliberate exception.
Health: every service has a healthcheck, so docker compose ps tells the truth about readiness.
Logs: Lantern at :4010, or docker compose logs -f <service>.