mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
Billing: burn windows, organisations, and payment (#342)
Nine commits, in the order they are easiest to read.
## The shared operator secret is gone
`x-nestri-admin-token` had no caller left — the device pairing it
existed for is
on hold, and nothing in any tree sent it. What remained was a key that
bypassed
authentication entirely and was required to boot.
Every route behind it had a better answer. The two Steam sync routes
took a
`userId` **in the body**, so one secret could write into anybody's
library; they
now authenticate as the host holding that person's Steam sign-in, and
the claim
is checked against the enrolment record. Download-state narrows to hosts
alone.
Creating a game by hand is deleted (syncing already upserts the
catalogue), as is
reading the waitlist — 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.
Nothing in the API now accepts a credential standing for more than one
caller.
## An organisation owns hardware
Two kinds of machine were modelled as one. A host somebody brings is
theirs,
reached through a team, and should die with their account. A host bought
to serve
other people's workloads is none of those things, and had to be
registered under
an employee's personal team — where their account going away took it
with them.
Ownership is now an either/or, enforced by a check constraint rather
than a
convention: both null is a host nothing can bill, and both set is two
answers to
"whose is this?". Membership of an organisation is derived from a
verified email
domain rather than stored, so signing in with a personal address still
gets an
ordinary personal account.
Not a billing subject. A team pays for what it uses either way.
## Burn, and the three windows
The unit is one second of a reference session, so an allowance is
measured in
time and a bar prints the stored number rather than converting into it.
Counters are stored beside the time they began, and a total whose stamp
has
rolled outside its window reads as zero — so a window clears without
anything
running. No schedule to misfire, no race between a reset and a write.
Two rules on the allowances are enforced rather than remembered: an
allowance
must exceed its own window, or one uninterrupted session hits a wall;
and each
longer one must sit under what the shorter already permits, or it never
binds and
is decoration.
Burn is recorded as segments at one rate, because a run's rate does not
survive
its own lifetime. On our own hardware a bigger tier costs more; on a
caller's own
card it does not, since there is no share of a card of ours being spent.
The gate is at the one moment it may speak — before a run starts, never
again. A
limit refuses the next run and never interrupts one going.
## Payment
Checkout, portal, and a webhook. No price, no currency and no card
detail is
stored: a subscription's existence and its state are the whole of what
crosses
back. Free is a real subscription too, created outright since nothing a
month
needs no payment, so an upgrade changes a subscription rather than
inventing a
customer.
The webhook is the only route no session protects. A signature over the
raw body
stands in for one, checked before the body is parsed, and with no secret
configured it refuses everything.
## Breaking
- `x-nestri-admin-token` is no longer accepted; `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 need host credentials
and take `userId` in the body
- `POST /steam/link` no longer accepts `userId`; `POST
/games/download-state` no longer accepts `hostId`
- `POST /session` responds `{ data, billing }` rather than `{ data }`
## Checks
342 tests pass. Typecheck unchanged from before the branch — the two
pre-existing
errors in `utils/hook.ts` and `utils/validator.ts` are untouched.
Billing is inert until configured: unset `POLAR_*` means every team is
free, no
checkout starts, and the webhook refuses every delivery.
This commit is contained in:
@@ -89,15 +89,11 @@ cd apps/auth
|
||||
bunx wrangler secret put EMAIL_SEND_URL --env production
|
||||
bunx wrangler secret put EMAIL_API_KEY --env production
|
||||
bunx wrangler secret put EMAIL_FROM --env production
|
||||
|
||||
cd ../api
|
||||
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 API has no secrets of its own to put here: every caller it accepts proves
|
||||
who it is — a session token from the issuer, a personal access token, or a
|
||||
registered host's own credentials — so there is nothing shared to leak.
|
||||
|
||||
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 —
|
||||
@@ -116,6 +112,45 @@ binding names a script that has to exist. The custom domains in the config are
|
||||
what create the DNS records — there is no separate step, and no separate tool
|
||||
holding the other half of that fact.
|
||||
|
||||
## Organisations, and why none are created for you
|
||||
|
||||
An organisation owns hardware outright — a host that serves other people's
|
||||
workloads rather than its registrant's — and gathers the teams whose members
|
||||
sign in with its email domain. Membership is derived from that domain, so the
|
||||
`domain_verified` flag is the whole of the access decision: an address on a
|
||||
verified domain *is* membership, and nothing grants anything on an unverified
|
||||
one.
|
||||
|
||||
**Nothing seeds one, deliberately, and it must stay that way.** A migration
|
||||
that inserted a row here would insert it into every deployment, including
|
||||
somebody else's — handing every account on that domain membership of a
|
||||
deployment its owners have nothing to do with. Seeding business data is what
|
||||
makes a schema migration a back door.
|
||||
|
||||
So it is an operator action, run once against the database, by whoever is
|
||||
allowed to decide that a domain is really theirs:
|
||||
|
||||
```sql
|
||||
INSERT INTO organisation (id, name, slug, domain, domain_verified)
|
||||
VALUES (
|
||||
'org_' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 26),
|
||||
'Example',
|
||||
'example',
|
||||
'example.com',
|
||||
true
|
||||
);
|
||||
```
|
||||
|
||||
Two things to get right, because nothing checks them for you. The domain is
|
||||
lower-cased and has no `@` — it is compared literally against the domain half
|
||||
of an address. And `domain_verified` should be `true` only for a domain you
|
||||
control: everyone who can receive mail at it becomes a member the next time
|
||||
they sign in, with no further step.
|
||||
|
||||
Hardware is then registered to it by a member, with `organisationId` instead of
|
||||
a team on `POST /machine/register`. Such a host has no owner and no team, which
|
||||
is the point — it outlives the account of whoever ran the command.
|
||||
|
||||
## Containers
|
||||
|
||||
```sh
|
||||
@@ -139,7 +174,6 @@ 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 | — |
|
||||
| `ADMIN_SHARED_SECRET` | — | required; operator access |
|
||||
| `PORT` | default `1337` | default `3000` |
|
||||
|
||||
[`docker-compose.yml`](../docker-compose.yml) at the root wires all of it
|
||||
|
||||
Reference in New Issue
Block a user