fix(deploy): require every credential, and give sandbox its own domain

Three things review caught, and one shape correction.

**No credential has a default any more.** The compose file shipped
`ADMIN_SHARED_SECRET` falling back to a value written in this repository —
and that header bypasses token verification entirely, so anyone reading
the file could act as an operator against any deployment that had not
overridden it. A default is worth less than it looks here: the deployment
that never set the variable is exactly the one where the default is public.
Every credential now comes from `.env`, and compose refuses to start naming
the variable it wanted. That also takes the last literal password out of a
tracked file.

**The origin ports are on loopback.** Both services speak plain HTTP and
mark no cookie `Secure`, because both expect to sit behind something that
terminates TLS. Published on every interface they were a way to reach the
issuer around that proxy, with sign-in codes and tokens in clear text.

**Mail settings are passed through rather than fixed.** The issuer was
pinned to printing sign-in codes to its log, and the three delivery
settings never reached it — so the documented way to configure mail could
not work, and every code and recipient went to the container log instead.
Printing codes is now asked for in `.env` like everything else, and with
nothing configured the issuer refuses to send rather than logging.

**Sandbox becomes a domain rather than a prefix.** `api.sandbox.nestri.io`
and `auth.sandbox.nestri.io`, because sandbox holds whatever is not
production and that set grows. One certificate for `*.sandbox.nestri.io`
then covers all of it, including unpredictable per-pull-request names,
and cannot be presented for production's own domain — which the zone-wide
wildcard the previous shape leaned on could.

Also drops `STEAM_API_KEY`. It was declared in two type definitions and
read by nothing: linking an account makes no outbound call that needs it.
This commit is contained in:
Wanjohi
2026-09-05 15:58:21 +03:00
parent 51ababc900
commit f30a1432f8
11 changed files with 133 additions and 63 deletions

View File

@@ -23,6 +23,7 @@ Hostnames, and why they are shaped the way they are: [`dns.md`](dns.md).
Three ways, in increasing order of how much they resemble a deployment.
```sh
cp .env.example .env # once; compose has no credentials of its own
docker compose up postgres # the database, for either of the next two
bun dev # both apps under the Workers runtime
bun run dev:server # both apps as plain processes
@@ -90,10 +91,14 @@ bunx wrangler secret put EMAIL_API_KEY --env production
bunx wrangler secret put EMAIL_FROM --env production
cd ../api
bunx wrangler secret put STEAM_API_KEY --env production
bunx wrangler secret put ADMIN_SHARED_SECRET --env production
```
`ADMIN_SHARED_SECRET` turns any request carrying it into an operator, so
generate it rather than choosing it — `openssl rand -hex 32` — and never give
it a default anywhere. What it is for is listed in
[`apps/api/README.md`](../apps/api/README.md).
The issuer refuses to send a sign-in code with its mail settings half
configured or absent, rather than falling back to printing codes to the log —
so a deployment that forgets these fails at the first sign-in attempt with a
@@ -134,14 +139,16 @@ Both images are stateless and hold no configuration. What they need:
| `AUTH_INTERNAL_URL` | — | only if that URL is unroutable from here |
| `EMAIL_SEND_URL` `EMAIL_API_KEY` `EMAIL_FROM` | all three, or none | — |
| `EMAIL_DEV_LOG` | `true` prints codes instead of sending | — |
| `STEAM_API_KEY` | — | to link a Steam account |
| `ADMIN_SHARED_SECRET` | — | operator access |
| `ADMIN_SHARED_SECRET` | — | required; operator access |
| `PORT` | default `1337` | default `3000` |
[`docker-compose.yml`](../docker-compose.yml) at the root wires all of it
together with a Postgres, and is the smallest complete answer to *"how do I run
this myself"*.
Neither image terminates TLS or serves a certificate. Put a reverse proxy in
front of them, point the hostnames at it, and keep the origin unreachable
except through it.
Neither image terminates TLS or serves a certificate, and neither marks the
cookies it sets `Secure`, because both expect to sit behind something that does
terminate TLS. So put a reverse proxy in front of them and keep the origin
unreachable except through it — `docker-compose.yml` publishes their ports on
loopback only for exactly this reason, and changing that to `0.0.0.0` is a way
to reach the issuer *around* the proxy with codes and tokens in clear text.

View File

@@ -17,38 +17,61 @@ tools is how they drift.
## The rule
**One label deep on `nestri.io`.** A certificate for `*.nestri.io` covers
`api-sandbox.nestri.io` and does not cover `api.sandbox.nestri.io`, and that is
the whole reason the sandbox names are hyphenated rather than nested. It costs
nothing while these are Workers — a custom domain gets its own certificate for
the exact hostname either way — and it is what lets any of these names become
an ordinary proxied origin later without also needing a certificate ordered for
it. A name should not have to change because the thing behind it did.
**A domain gets one certificate, obtained once, and it covers that domain and
nothing else.** Names are then grouped so that the grouping is the same shape
as the certificate: production sits directly under `nestri.io`, and everything
that is not production sits under `sandbox.nestri.io`.
That is why the sandbox names are nested rather than hyphenated. `sandbox` is a
domain, not a prefix — it holds whatever is not production, which today is the
API and the issuer and later is more. Once the shape is a domain, a single
certificate for `*.sandbox.nestri.io` covers all of it, including per-pull-
request deployments at `pr-<id>.sandbox.nestri.io` if those ever arrive; those
would be unbounded and unpredictable names, which is precisely the case that a
name-by-name certificate cannot serve and a domain-wide one can.
It also means a certificate that can be presented for a sandbox name cannot be
presented for `api.nestri.io`. Leaning on the zone-wide `*.nestri.io` instead
would have given every scratch deployment a certificate for production's own
domain, which is the opposite of what a sandbox is for.
Nothing extra is needed while these are Workers — a custom domain is issued its
own certificate for the exact hostname, at any depth. The rule binds on the day
they become origins, and it is written down now because that is the day it is
expensive to have got wrong.
## `nestri.io`
| Name | What it is | Answered today by |
| ------------------------ | --------------------------------- | ----------------------- |
| `api.nestri.io` | The API, production | Worker custom domain |
| `auth.nestri.io` | The issuer, production | Worker custom domain |
| `api-sandbox.nestri.io` | The API, sandbox | Worker custom domain |
| `auth-sandbox.nestri.io` | The issuer, sandbox | Worker custom domain |
| `doctor.nestri.io` | Where `nesdoctor` is downloaded | Static site |
| `nestri.io` | The website, and `ssh nestri.io` | Website |
| Name | What it is | Answered today by |
| ------------------------ | -------------------------------- | -------------------- |
| `nestri.io` | The website, and `ssh nestri.io` | Website |
| `api.nestri.io` | The API, production | Worker custom domain |
| `auth.nestri.io` | The issuer, production | Worker custom domain |
| `doctor.nestri.io` | Where `nesdoctor` is downloaded | Static site |
`auth.nestri.io` is the one name that cannot be changed casually. A token
carries the address it was minted through in its `iss` claim, and every API
request verifies that claim literally — so renaming the issuer invalidates
every token in circulation at once, including the refresh tokens that would
otherwise have recovered from it.
## `sandbox.nestri.io`
Everything that is not production, under one domain and one certificate.
| Name | What it is | Answered today by |
| ------------------------- | ------------------ | -------------------- |
| `api.sandbox.nestri.io` | The API, sandbox | Worker custom domain |
| `auth.sandbox.nestri.io` | The issuer, sandbox| Worker custom domain |
`auth.nestri.io` is the one name in either table that cannot be changed
casually. A token carries the address it was minted through in its `iss` claim,
and every API request verifies that claim literally — so renaming the issuer
invalidates every token in circulation at once, including the refresh tokens
that would otherwise have recovered from it. The sandbox issuer has the same
property and none of the consequences, which is the point of having one.
## After the move off Workers
Each of the first four becomes a proxied `A` record pointing at the host
running the containers, and nothing else about them changes: same names, same
certificates, same `iss` claim. Cloudflare keeps terminating public TLS, so
there is no certificate on our own host to renew, and the origin is not
addressable except through the proxy.
Each of the four control-plane names becomes a proxied `A` record pointing at
the host running the containers, and nothing else about them changes: same
names, same `iss` claim. Cloudflare keeps terminating public TLS, so there is
no certificate on our own host to renew, and the origin is not addressable
except through the proxy.
The order that matters, on the day: create the `A` records with the proxy on,
confirm the containers answer through them, *then* remove the Worker routes.