Skip to content

Latest commit

 

History

History
270 lines (205 loc) · 13.1 KB

File metadata and controls

270 lines (205 loc) · 13.1 KB

CloudsForge — the assembled system

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.


1. The spine

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.

The loop that is the thesis

   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.


2. Services

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".


3. Routing

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.


4. The env contract

.env is gitignored; .env.example documents it. Services read config two ways, and the difference is a recurring source of bugs:

  • env_file: .env hands 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.


5. Identity, and how a session moves between products

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.


6. Observability

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.


7. What does not work yet, and other sharp edges

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:

  1. Pin a treasury per chain and network through keyvault's Nimbus admin API — POST /admin/treasuries/:chain/:network/mint to create the address, then PUT /admin/treasuries/:chain/:network to pin it, in that order. Nothing sweeps to an unpinned candidate.
  2. PAY_SWEEP_ENABLED=true.
  3. 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.


8. Working on it

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>.