Every team now exists with the payment provider, free ones included. An upgrade
then changes a subscription rather than inventing a customer, and there is one
question to ask about anybody instead of two.
A subscription at nothing a month needs no payment, so it is created outright
rather than by sending somebody through a checkout to pay zero.
It runs after the team rows are committed and cannot affect them. Signing up is
not allowed to depend on a third party being reachable, so this cannot fail the
call and does not retry — a team that misses it is free, which is what it would
have been anyway, and the next call puts it right because the operation is
idempotent.
This broke the webhook mapping, which read the plan off the event type. A free
subscription announces itself with the same `subscription.created` a paid one
does, so every new signup would have landed on the paid allowance. The plan now
comes from the product, and a product we do not sell is left alone rather than
guessed at — somebody selling something else through the same account must not
be able to change what a team may run by doing so.
The external id stays the team. It is the billing subject, and keying on the
user would collapse somebody with two teams into one customer with no way to
say which subscription belonged to which.
Checkout, the customer portal, and the webhook that moves a team's plan.
Nothing money-shaped is stored. No price, no currency, no card detail — a
subscription's existence and its state are the whole of what crosses back,
because they are the only two facts the product needs and anything more would
be a second copy of a record somebody else is authoritative for.
Currency is deliberately not ours to hold. A product carries a price per
currency on their side and the customer's location picks one at checkout, so
there is no figure in this API that could drift from the one somebody is
charged. The team id travels as the customer's external id, which keeps the
mapping on their side rather than putting a foreign primary key in our schema.
Access follows their state, and the interesting cases are where that is not the
same as "paying right now". Cancelling keeps the plan: they paid to the end of
the period and turning them off when they click it takes something they bought.
A failed card keeps it too, because a retry that ends in payment should not
have cost them access in the middle. Only a revoked subscription takes it away,
which is the one moment nothing is left that was paid for. An event we do not
recognise changes nothing at all — new types are added by people who do not
know what we do with them, and a default that moved a plan would eventually
cancel an account nobody cancelled.
The webhook is the only route here no session protects, because its caller has
no account and never will. A signature over the raw body stands in for one, and
it is checked before the body is looked at — a body that has been parsed and
re-serialized is not the body that was signed. With no secret configured it
refuses everything rather than accepting anything, since otherwise knowing the
URL would be enough to set somebody's plan. Note also what is absent: no route
sets a plan, so there is no endpoint for granting yourself a subscription.
The product is written down as a definition with a script rather than clicked
into a dashboard, because the two environments are separate servers and nothing
made in one can be moved to the other. Promoting it is running the same script
with the other token, which is the only version of that which cannot drift. It
writes nothing without --apply and refuses to add a second product with a name
already taken.
A single secret that turned any request into an operator was the only
credential several routes accepted, and it had no caller left: the device
pairing it existed for is on hold, and nothing in this tree or any client
sent it. What remained was a key that bypassed authentication entirely,
required to boot, and checked by nobody.
Every route behind it had a better answer available:
- Library and game sync move to host credentials. Both took a `userId` in
the body, which meant one secret could write into anybody's library. A
host now says which of its enrolled users a batch is for, and that claim
is checked against the Steam sign-ins it actually holds — one box carries
several people's accounts, so the pair is the unit.
- Download-state reporting narrows to hosts alone, and the body that could
name a different host is gone. Which host is reporting comes from its own
credentials, and a body that still names one is refused rather than
ignored.
- Linking a Steam account is always for the caller.
- Creating a game by hand is deleted; syncing already upserts the catalogue.
- Reading the waitlist is deleted. Every address on it belongs to someone
who has not agreed to anything, and answering it over HTTP made that list
something a leaked key could drain.
- The pairing-code routes are deleted with the flow they served. The domain
module and its table stay, so returning to it is a route file rather than
a migration.
Nothing in the API now accepts a credential that stands for more than one
caller: every request resolves to a specific user or a specific host, which
is what lets a route say "the caller's own library" and mean it.
BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and
`ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and
the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync`
now require host credentials and take `userId` in the body; `POST /steam/link`
no longer accepts `userId`; `POST /games/download-state` no longer accepts
`hostId`.
Same correction as the one on the helper itself: "isolated" here means
isolated from anything you care about, not isolated from DATABASE_URL.
Filling this in with a second database name is a plausible reading that
fails the suite in a way that does not point back here.
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.
Moving the issuer's state into Postgres removed the last thing that tied
either app to one hosting provider. What was left was a deployment tool
describing resources that no longer existed — so this replaces it with
`wrangler`, which is what actually deploys a Worker, and adds a second way
to run each app that involves no provider at all.
Each app now has a `wrangler.jsonc` with an environment per stage, and a
`Dockerfile` beside it. The handler is the same one in both cases; what
differs is only where its settings come from. Two of them gained a second
spelling so that nothing has to branch on the runtime: Postgres arrives as
a pooled binding or as `DATABASE_URL`, and the route to the issuer is a
service binding or `AUTH_INTERNAL_URL`.
That last one is new, and it is a split the binding was already making
without saying so. `AUTH_ISSUER_URL` has to be the issuer's public name,
because it is compared literally against every token's `iss` claim — but
the public name is often not routable from inside a deployment. So the
name and the route are two settings now rather than one that cannot be
both.
DNS moves out of code and into `docs/dns.md`, which lists every hostname
and what it is for. Six records that change roughly never did not need a
tool, and the table outlives whatever is answering the names — which is
the point, since some of them will stop being Workers. The sandbox
hostnames are hyphenated rather than nested for the same reason: a
certificate covering `*.nestri.io` covers one label and not two, so
`api-sandbox.nestri.io` can become an ordinary origin later without a
certificate having to be ordered for it first.
Also drops `EMAIL_DEV_LOG` from committed configuration into `.dev.vars`,
which `wrangler deploy` cannot upload. Printing a live sign-in code to a
log should not be one forgotten override away from production.
Squashes the current state of the internal working tree onto this history.
The two trees had grown apart with no common ancestor, so this is a content
sync rather than a merge, and the published history is preserved rather than
rewritten — a force-push here would break every existing fork and clone to no
benefit.
What lands:
- Waitlist: API route, core module, and migration 0006 alongside game aliases.
- User verification.
- CI, oxfmt config, editor settings.
- Assorted fixes across the API routes and core modules.
The repository's own README, the wordmark and the per-package READMEs are kept
from this side; the internal tree had dropped them and they are what a stranger
arriving here reads first.
The marketing site in the internal tree is deliberately not here. It is a
separate product with its own repo and its own licence, and this repo is the
open one — a closed component does not belong in it regardless of how convenient
the directory looked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Description
- [x] Adds support for AWS SSO, which makes us (the team) able to use
SST and update the components independently
- [x] Splits the webpage into the landing page (Qwik), and Astro (the
console) in charge of playing. This allows us to pass in Environment
Variables to the console
- ~Migrates the docs from Nuxt to Nextjs, and connects them to SST. This
allows us to use Fumadocs _citation needed_ that's much more beautiful,
and supports OpenApi~
- Cloudflare pages with github integration is not working on our new CF
account. So we will have to push the pages deployment manually with
Github actions
- [x] Moves the current set up from my personal CF and AWS accounts to
dedicated Nestri accounts -
## Related Issues
<!-- List any related issues (e.g., "Closes #123", "Fixes #456") -->
## Type of Change
- [ ] Bug fix (non-breaking change)
- [x] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that changes existing
functionality)
- [x] Documentation update
- [ ] Other (please describe):
## Checklist
- [x] I have updated relevant documentation
- [x] My code follows the project's coding style
- [x] My changes generate no new warnings/errors
## Notes for Reviewers
<!-- Point out areas you'd like reviewers to focus on, questions you
have, or decisions that need discussion -->
Please approve my PR 🥹
## Screenshots/Demo
<!-- If applicable, add screenshots or a GIF demo of your changes
(especially for UI changes) -->
## Additional Context
<!-- Add any other context about the pull request here -->