Commit Graph
239 Commits
Author SHA1 Message Date
Wanjohi aa824a1905 feat(box): a listed box names its machine's host and whether it is the fleet
The desktop app gives a new cloud user a fleet box and then opens that
machine's Steam sign-in on its hostname; GET /box now says which hostname,
and which box is the fleet one.
2026-10-11 12:08:07 +03:00
Wanjohi ac713e62ae feat(billing): sizes move up one; 1080p is the reference and the default
xs is a box with no stream, sm 720p, md 1080p, lg 1440p and xl 4K. Each
resolution keeps its price, so md is now 1x. New boxes default to md, and
existing boxes move up one size so none of them drops a resolution.
2026-10-10 15:18:23 +03:00
Wanjohi 0f38898fd9 fix(session): commit the migration that adds a run's size 2026-10-10 15:13:54 +03:00
Wanjohi 8608506682 feat(session): a run picks its own size
POST /session takes an optional tier. The host starts the run at that
size, the allowance is checked against it, and the run is billed at it.
A run asked for without one runs at its box's size, as before.

The size belongs to the run rather than the box: a box is a game's
install on a machine, and the size is chosen each time it is played, so
playing bigger never means a second box and a second install.
2026-10-10 14:03:13 +03:00
Wanjohi 05a3fee87a feat(session): list your runs that have not stopped
Asking for a run on a busy box is refused, but the refusal does not say
what holds the box. A client that just lost its connection needs to know
whether the run in the way is the game it asked for, which it can rejoin,
or another one it should offer to stop. GET /session answers that.
2026-10-10 11:19:20 +03:00
Wanjohi 152337296d fix(library): downloads only from your boxes' hosts, named by their GPU
GET /library picked each game's most recent download row from any host,
so one person's library could show another person's machine id and
progress. It now reads only hosts the caller's boxes are on.

Boxes and library downloads gain `gpu`, the host's first reported card
model, so a client names a place by hardware a person recognises rather
than by a machine id. Null until a host reports one.
2026-10-10 10:03:49 +03:00
Wanjohi 8054e62bd9 feat(machine): let the owner read what their machine has downloaded
GET /machine/:id/downloads lists every game the machine is downloading,
has ready or failed to download, with progress and each game's name.
The library already carries a download per game, but it is the most
recent one across every host, which is not what a person watching their
own machine set up wants to see.

Owner-scoped in the query: a machine that is not yours answers 404, the
same as one that does not exist.
2026-10-09 12:50:44 +03:00
Wanjohi f0bfe82d58 feat(session): let the owner end a run
POST /session/:id/stop. A run no host has claimed ends at once; one that
is starting or live is marked with time_stop_requested and its host is
handed a session.stop with reason `user` on every poll until it reports
the run ended. The host already ends a run the same way whatever the
reason, so no host change is needed.

Asking twice keeps the first time asked, and asking of a finished run
changes nothing. Somebody else's run answers 404, and a host cannot
stop a run as its owner.
2026-10-09 12:27:09 +03:00
Wanjohi 3fbcf459f2 machine: a host reports its GPUs, and the machine keeps them
The host report gains gpus: each card's model, VRAM in MiB when the
driver says, and the PCI id it was named by. Kept on the machine (a jsonb
column, written only when it changes) and returned by /machine. What a
person calls the machine, and the first measured part of the capacity a
scheduler will place by.
2026-10-09 11:37:40 +03:00
Wanjohi ba9191825c library: a sync drops the games the account no longer owns
A sync is the whole of what the account owns, but it only ever upserted,
so a revoked licence or a library from a previous Steam account stayed
listed forever. A sync that fully lands now soft-deletes the rest; an
empty list prunes nothing, and a game owned again comes back.
2026-10-08 12:19:05 +03:00
Wanjohi 4445f4836e api: accept the host's own tier label in a report
The report schema pinned each box's tier to our size enum, but hosts name
a box's shape by resolution (1080p). Every report naming a box was a 400,
so stop reasons never reached the run. Placement already stores the size;
the reported label is free text and is not stored.
2026-10-08 12:14:46 +03:00
Wanjohi 5bf520c464 Name the run's owner on session.start (#362)
Adds `userId` (the box owner) to `session.start` so a host knows whose
Steam sign-in plays the run. API tests: 161 pass.
2026-10-07 15:43:25 +00:00
Wanjohi 543fd3f361 fix(steam): link a Steam account only through Steam's own sign-in
POST /steam/link took a Steam id from the request body and linked it. A
Steam id is public, so anyone could claim any account, its library, and
the one free weekend that is keyed on it.

Linking is now POST /steam/link/start, which returns a Steam sign-in URL
tied to a one-time, ten-minute request, and GET /steam/link/callback,
where the browser comes back. The callback accepts the id only if Steam
confirms the assertion, the assertion was issued for exactly this
callback, and the id is read from Steam's claimed identity URL in the
one shape Steam issues it. The request is spent on use.
2026-10-07 10:52:19 +03:00
Wanjohi d92bc13ed9 feat(machine): an install command that adds a host to the fleet
An install token can now name an organisation instead of a team, and the
host it registers belongs to that organisation's fleet. Only members of
the organisation may mint one, since fleet hardware is rented to other
people. A token for both is refused, by the route and by a check
constraint, as it already is for a machine.
2026-10-07 09:42:51 +03:00
Wanjohi 47f8ff6f61 feat(trial): a free weekend on rented GPUs, for teams without a plan
A team with no plan may run on the fleet from Friday 00:00 to Monday
00:00, Helsinki time, for two hours of live play per weekend. One trial
per team, per email address and per Steam account; the claim outlives
the account, so closing it and signing up again is not a second trial.
Once five teams pay, the trial closes for everyone.

A trial run is marked on the session. Its time is counted from the run's
own live interval and kept out of the plan's allowance. Unlike an
allowance, the trial ends a run: the host's work poll now carries a
session.stop job for any trial run past its hours or its window, which
an agent that does not know the kind already skips.
2026-10-07 09:30:28 +03:00
Wanjohi 2da95deb36 feat(billing): bill a run on rented hardware to the box owner's team
A fleet machine belongs to an organisation, which is not something that
pays, so fleet runs were neither checked nor metered. The payer is now
the box owner's personal team, and the meter that starts and stops with
a run asks the same function the start check does, so the team checked
and the team billed cannot differ.
2026-10-07 09:25:32 +03:00
Wanjohi 08bbb22a1c feat(machine): rented hardware is open during a run you asked for
Fleet machines refused everyone, because nothing could yet say who had
paid for them. A run on one can only exist once it passed the start
check, so a starting or live run of your own box on that machine is now
what grants access, and it ends with the run. Holding a box is not
enough: a box costs nothing to hold.
2026-10-07 09:24:18 +03:00
Wanjohi f82666254e feat(box): create a box, on your own host or on rented GPUs
POST /box places a box once, at creation. On your own host it keeps the
existing rule (exactly one host, or refuse). On the fleet it picks an
online machine owned by the fleet organisation, the one holding the
fewest boxes. Each person holds at most one fleet box. Which organisation
is the fleet is deploy configuration, so no deployment rents hardware it
was not told to.

No machine online is a 429, since the same request succeeds once one is
back.
2026-10-07 09:23:12 +03:00
Wanjohi 9d7239a280 Merge branch 'feat/team-and-box-reads' into dev 2026-10-07 08:49:07 +03:00
Wanjohi 1f82954037 style(auth): ring a refused or wrong answer red while it shakes
A code field silently dropped a character it does not accept, so typing
a letter into the emailed code, which is digits only, looked exactly
like a field that does not work. It now shakes the boxes and rings them
red, the same answer a wrong code gets. Dashes and spaces are still
taken quietly, since they are how a code is written.

The shake an error page plays on load now rings the field red too,
fading back to its usual border once it settles, on every field
including the email address. Reduced motion keeps the red and drops
the movement, since the red is the message.
2026-10-02 06:10:05 +03:00
Wanjohi 890ada8ead fix(auth): type code boxes through one real field
The boxes were one input each, with script moving focus between them.
That worked in a clean browser and failed in an ordinary one: on the
emailed code, whose first box is marked as a one-time-code field, typing
and pasting did nothing, while the device code, which is not marked,
worked. A password manager or autofill prompt hooking that field and a
script moving focus between six fields were working against each other.
It also left gaps: pasting into the third box with the second one empty
produced a code with a character missing.

Now the real input is stretched invisibly over the boxes and is the only
thing typed into, pasted into or autofilled; the boxes only display what
it holds, with the next one ringed and showing a caret. With nothing
moving focus there is nothing to fight, and a code cannot have a gap.
The browser's own validation message works again too, since the field
it points at is where the boxes are.
2026-10-02 06:03:48 +03:00
Wanjohi 39e4bebf64 feat(auth): draw code fields as one box per character
The emailed code and the device code are now typed into a row of boxes,
with a dash drawn between the device code's two halves. Typing moves to
the next box, backspace on an empty box goes back, the arrow keys move,
and a pasted or autofilled code, dashes and all, is spread across them.

It is an enhancement of the field and not a replacement for it. The
server still sends the single plain input, which works on its own with
script off; a short inline script draws the boxes over it and keeps it
in step. The real input is still the one that submits and the one whose
validity dims the button, so nothing else had to learn about boxes.
Pressing the button early moves to the first empty box, since the
browser's own message would point at a field nobody can see.
2026-10-02 06:00:08 +03:00
Wanjohi 6d654f666f fix(auth): accept a device code typed with its dash
A device code is shown as BCDF-3467 and will be typed or pasted that
way. The field allowed exactly 8 characters, so a correct code was cut
to BCDF-346 before the server, which ignores separators, ever saw it.
A grouped code's field now has room for its separators and a pattern
that allows a dash or a space between groups, and the device code is
declared as two groups of four.
2026-10-02 05:55:44 +03:00
Wanjohi e9b716fa63 style(auth): shake the field when its answer was wrong
Every error arrives as a new page, so an animation that runs on load
runs exactly when one does, with no script to trigger it. It plays once,
only on a form showing an error alert, and not at all for anyone who
has asked for reduced motion.
2026-10-02 05:55:44 +03:00
Wanjohi 11f62c69aa fix(auth): say "a device" on the confirm screen, and move the warning under it
The confirm screen named the client asking to sign in. That name is
chosen by whoever started the request, so it let a stranger's request
introduce itself as anything at all; and to the person reading, it is
just a device. It now says "A device is asking to sign in", and a test
checks that a client's self-chosen name never appears.

The warning about choosing Deny moves under the two buttons, as small
print in the same style as the terms line on the email screen, so the
question and its two answers read first.
2026-10-02 05:50:55 +03:00
Wanjohi 86394dd769 fix(auth): show one alert at a time on the code screen
A wrong code showed "Invalid code" above "Code sent to ...", two answers
to one question in two colours. An error now replaces the confirmation
instead of joining it.
2026-10-02 05:48:10 +03:00
Wanjohi 97459421f8 style(auth): white focus, and a dim button until the form can be sent
A focused field was ringed in the brand orange, which reads as an error
and competes with the wordmark. Focus is now grey while the value is
incomplete and white once it would pass, so the field says it is done.

The submit button is dimmed while any field in its form is invalid. It
uses the browser's own validity, so there is still no script, and the
code field gains a pattern so a partly typed code counts as invalid.
The button stays clickable, so pressing it early still gets the
browser's message saying what is missing.
2026-10-02 05:48:09 +03:00
Wanjohi 36de16af7a fix(auth): say what went wrong under the input, and drop the default headline
Alerts were a bordered, tinted box with an icon, above the fields. They
are now a line of small text under the fields: red when something went
wrong, green when something just happened, no box and no icon.

The sign-in screens no longer carry the "One place for all the ways you
play" heading. Screens that pass their own heading (device approve,
deny and the rest) keep it.
2026-10-02 05:42:11 +03:00
Wanjohi 8326d88894 refactor(auth): make every issuer screen a value of its own
The issuer's screens were written inline in its route handlers, so the
only way to look at one was to reach the condition that produces it: an
expired cookie, a refused grant, ten wrong guesses. They now live in
ui/issuer.ts and the handlers call them, with no change to copy, status
or markup.

The app's theme moves to its own module for the same reason: it can be
used without importing the issuer and its database stores.
2026-10-02 05:27:27 +03:00
Wanjohi 61eeecce43 fix: Change wording 2026-10-02 05:18:35 +03:00
Wanjohi bc59f7842b feat(api): create a team; slugs the website serves as pages are reserved
POST /team makes another team with the caller as owner. The website now
serves a team at nestri.io/<slug>, so Team.Slug refuses its top-level
pages (home, new, pricing, ...) and a personal team whose generated slug
would be one gets a suffix instead.
2026-09-28 23:56:13 +03:00
Wanjohi df657e84ab feat(api): read and rename your teams, list their members, list your boxes
GET /team, PATCH /team/:id (owner or admin; a taken slug is a 409 on
slug), GET /team/:id/members, GET /box. A team you are not on is a 404
everywhere, so ids cannot be probed. Team.Name and Team.Slug are exported
so a client checks the same rule the API does.
2026-09-28 23:09:27 +03:00
Wanjohi 065af689b3 feat(api): serve the host installer and its downloads
GET /install.sh serves a POSIX script, embedded in the API at build time
so the script and the routes that redeem its token ship together. It
checks the platform, asks where box images should live, downloads the
host agent at a pinned version, verifies it against SHA256SUMS, installs
it for the calling user and hands over with the token in the environment
rather than argv.

GET /install/:component/:version/:asset redirects to a one-minute signed
URL on a private S3-compatible bucket, so every download passes through a
route that can be logged or switched off. The SigV4 signer is written
against Web Crypto and checked against AWS's published example.
2026-09-28 09:12:23 +03:00
Wanjohi f691b56c0c feat(core): one-time install tokens that register a host to a team
Installing on a host should be a command a person pastes, not a user
session copied onto a machine. A token is issued for a team, spent by its
first use, expires after an hour and is stored only as a digest, because
it travels in that command and so lands in shell history.

Redeeming is one conditional update inside the registration transaction:
concurrent redemptions of one token register exactly one machine, and a
registration that fails leaves the token unspent.
2026-09-28 08:36:09 +03:00
Wanjohi a09ad1e09a fix(billing): read the payload that was actually sent
Deliveries arrived intact, verified correctly, and applied to nobody.

Checking the signature here rather than through the SDK means the body is
exactly what was sent, and what is sent is snake_case. The SDK's parser renames
fields to camelCase on the way through, so the reader written against it looked
for `externalId` in a payload that says `external_id` — found nothing, decided
the delivery was about a customer we did not create, and acknowledged it.

That is the worst shape a bug of this kind can take. Every visible signal was
healthy: a 200 back to the provider, no retries, no errors, and a plan that
silently never changed.

Both spellings are now read, so neither path can regress the other.
2026-09-19 01:42:42 +03:00
Wanjohi abd27f2d5f fix(billing): a customer may already exist, and may belong to somebody else
Registering a team assumed creating a subscription would create the customer it
names. It does not — the customer has to exist first, and creating one fails if
the address is already taken, which it often is: a checkout taken before the
team existed leaves one behind, and so does making one by hand.

So the lookup is now in three steps. A customer already carrying this team id is
used. Otherwise one is found by address and adopted. Otherwise one is made.

**A customer carrying a different team's id is left alone.** Taking it would
move where that subscription is billed, and the team that lost it would go quiet
rather than fail — which is the kind of thing found a month later in a revenue
figure that does not add up.

A team whose owner has no address yet gets no customer, reported rather than
guessed around: an invented address makes a customer nobody can be reached at,
and the next call fixes it once there is a real one.

Checked against the provider rather than only in tests: all four paths — new,
repeated, taken by another team, and no address — behave as written.
2026-09-19 01:39:56 +03:00
Wanjohi 5b90bd0f0c fix(billing): verify the signing scheme the secret actually says it is
Every delivery was refused as a signature mismatch, and nothing in the error
said why.

The provider signs one of two ways. A `whsec_` prefix means Standard Webhooks,
where the secret is a base64 key the verifier decodes; anything else is the
older scheme, where the secret is used as its own raw bytes. Which one a secret
belongs to is decided by when it was created, and every secret created now is
the new one.

The SDK's helper only implements the older scheme — it base64-encodes whatever
it is handed, so a Standard Webhooks secret becomes the literal bytes of the
string including its prefix, and every signature then fails against a key
derived quite differently.

The scheme is now read off the secret rather than configured, so rotating one
cannot put the two out of step. The verifier for the new scheme is the same
library the SDK uses underneath; nothing here hand-rolls crypto.

Tested with a real signature rather than only a rejection. A mismatch is easy to
assert by accident, and a test that only proved bad input is refused would have
passed against the broken version too.

Also teaches the product script to check shape and not just name: a one-time
product where a subscription is wanted cannot be subscribed to at all, and
reporting it as already-there hands back an id that fails at its first use.
2026-09-19 01:33:26 +03:00
Wanjohi 916473bbbd 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.
2026-09-18 22:13:48 +00:00
Wanjohi 60c4f61bfa feat(billing): free is a subscription too, and the product says which plan
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.
2026-09-19 01:07:01 +03:00
Wanjohi b01d8eabb3 fix(billing): an organization token already names its organization
Creating a product with one sends `organization_id` alongside a token that
implies it, which is refused outright rather than ignored — so which kind of
token is in hand has to be known before the call, not after. A personal token
can see several organizations and still has to say which.
2026-09-19 00:59:36 +03:00
Wanjohi 757ff79233 feat(core,api): take payment, and let the provider decide who is paid up
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.
2026-09-19 00:57:57 +03:00
Wanjohi 4c22586d59 feat(core): price a run by the size it holds, on hardware we pay for
The rate was a constant. That was correct for everything the system can
currently run and wrong the moment it can run anything else, because a tier
buys a share of a card — so a bigger one on our own hardware is more of
something we bought being spent, and a flat rate there sells a whole card for
the price of a quarter of one.

So a run's rate now comes from what the run is: its size tier, and whose
hardware it sits on.

On the caller's own hardware the tier changes nothing. There is no share of a
card of ours in play, so a run costs one unit a second whatever size it asked
for. Charging somebody more for taking more of a GPU they bought is a tax on
their own hardware, and not doing that is most of what this model is for.

This exposed a bug in what went before. Resegmenting recomputed one shared rate
and wrote it to every open stretch, which was harmless while all runs cost the
same and would have quietly repriced an expensive run as whatever the last one
to start was. Each stretch now keeps its own rate, which is also the more
honest shape: a run's rate is a property of that run, and nothing about it
changed because a sibling appeared or the clock ticked.

The account's total is now the sum of what its runs cost rather than a count
times one rate — an expensive run and a cheap one alongside it are not two of
anything. Concurrency still lands exactly where it did, as there being more to
add, and no run gets dearer because another started.

There is no hardware factor yet and its absence is deliberate: nothing records
which card a host has, so a table keyed on a model would be keyed on nothing.
A faster card should cost more, and that starts with a column.

The reference tier is pinned at exactly one unit a second, checked rather than
assumed. The unit is a second of a reference session, so moving it would
silently redefine every allowance — the same stored number would mean a
different number of hours.
2026-09-19 00:32:01 +03:00
Wanjohi b4776fad2f feat(core,api): record burn as rate segments, and refuse the next run when spent
The counters this fills are the ones the windows already knew how to read.
What was missing was anything that put a number in them.

Burn is recorded as segments: one stretch of one run at one unchanging rate,
opened when the rate becomes true and closed when it stops being. Not a row per
session, because a session's rate does not survive its own lifetime — a second
run changes what the account spends per second while the first is still going,
and a rate that applied from that moment must not be backdated over the time
before it. Not a row per event either, because burn accrues against an envelope
that is held rather than per thing consumed.

Closing a segment is what moves burn into the counters, so a long run lands
incrementally instead of all at the end. Burn that only arrives when a session
stops is burn that cannot refuse the next one, and a bar that does not move
while something is running is a bar nobody believes.

The counters are written with the staleness rule as a single statement: add to
the total if its stamp is still inside the window, otherwise start again from
this amount. Reading and then deciding would be two statements with a gap, and
the gap is where a concurrent tick doubles or vanishes. The first tick for a
team and the thousandth are the same call, for the same reason.

The gate sits at the one moment it is allowed to speak — before a run starts,
never again. A limit refuses the next run and never interrupts one already
going; someone losing a session mid-game to a meter does not come back. Every
window is checked rather than the shortest, because they protect different
things over different spans.

The answer comes back with the created run rather than being thrown away: the
response carries where each window stands, what the account spends per second
now, and what one more run would cost. Every surface that can start a run has
to show that before the click, and a second call for it is a call nobody makes.
Asking twice would also let the number shown and the number billed disagree.

Accrual is wired to the run's own state transition, in the same transaction
that moves it. A session that went live without its meter starting is free
hardware; one that ended without its meter stopping bills forever. Both are
silent, so neither may be a second write that might not happen.
2026-09-19 00:19:39 +03:00
Wanjohi b819367a09 feat(core): burn windows, and the rules that make an allowance mean something
The unit is one second of a reference session — baseline size, baseline card,
running alone, on hardware we own. Every factor is a multiple of that, so an
allowance is measured in time and a bar prints the stored number instead of
converting into it. Integers throughout.

Three rolling windows, one function. A counter is stored beside the time it was
last written, and a counter whose timestamp falls outside its window reads as
zero — so the reset is implied by the clock and nothing has to run for a window
to roll clear. No scheduled job to misfire, and no race between a reset and a
write landing together. The same rule on the write side is one statement rather
than a read followed by a decision.

The check is pure, and it is the same arithmetic the meter draws from. The
complaint about usage limits is almost never the limit, it is being surprised
by one, and two implementations that agree today are how a full bar and a
refusal start disagreeing.

Two rules on the allowances, enforced rather than remembered:

- A window's allowance must exceed the window itself. Because the windows roll,
  one uninterrupted session asymptotes at exactly the window length, so an
  allowance at or below it is a wall that someone playing alone will meet.
- Each longer allowance must be under what the shorter window already permits,
  or it can never be reached — a number that looks like a limit, reads like a
  promise, and never once fires.

Allowances are configuration rather than constants, because they will be
retuned against real burn far more often than this code changes, and a rate
that needs a deploy is a rate that stays wrong until the next one. The shipped
set is explicitly a placeholder: coherent enough to test against, not a pricing
decision.
2026-09-18 23:44:00 +03:00
Wanjohi 15631f5d25 feat(core,api): an organisation owns hardware, and a domain says who belongs
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 there was nowhere
to put it, so it had to be registered under an employee's personal team, where
it was that person's property and their account going away took it with them.

Ownership becomes an either/or. A machine names a team or an organisation,
exactly one, enforced by a check constraint rather than by convention: both
null is a host nothing can bill, and both set is two answers to "whose is
this?" where whichever join a query happens to take decides who pays. Hardware
an organisation owns has no team and no person at all, which is the point.

The organisation is deliberately not a billing subject and has no plan columns.
It says who owns the metal; a team pays for what it uses either way.

Membership is derived from a verified email domain rather than stored. An
address is already the root identity, so a second record of who belongs where
is a second answer that can disagree with the first — and deriving it means
signing in with a personal address still gets an ordinary personal account,
which is what lets one person hold a company account and use the consumer
product. Nothing is granted on an unverified domain or an unverified address:
either one is a string somebody typed.

Entitlement on fleet hardware refuses everyone for now, with a reason that says
so. What grants a run on metered hardware is a plan, and there is nothing to
ask yet, so it fails closed rather than giving the expensive case away. The
branch is written out so the plan check has one obvious place to land.

Routes are read-only, and nothing seeds an organisation. Creating one grants
membership to everyone who can receive mail at a domain, so it is an operator
action against the database — a migration that inserted one would insert it
into every deployment, including ones we have nothing to do with. See
docs/deploy.md.
2026-09-18 23:16:07 +03:00
Wanjohi 4a2a4412c2 style: apply the formatter across the tree
The formatter had never been run over these files, so `oxfmt` on a couple of
touched files rewrote two dozen others and buried the real change. Running it
everywhere once makes the next diff mean something.

No behaviour changes: import ordering, line joining, and reindented JSON in
the generated migration snapshots. Both test suites and both typechecks give
the same answers as before, including the two type errors this does not fix.
2026-09-18 23:03:08 +03:00
Wanjohi 40b4270161 refactor(api)!: remove the shared operator secret, and let hosts sync their own
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`.
2026-09-18 22:59:06 +03:00
Wanjohi 04210cc9f1 refactor(auth): describe sign-in screens as data, draw them in one place (#339)
Providers stop returning `Response`. Each says what it needs from the
person — an address, a pin, a yes-or-no — as a `Screen`, and one
`Renderer` draws it.

The old shape made every provider a small web framework: it had to know
about markup, about the stylesheet's attribute names, about how a page
is assembled. So each grew its own callback signature, its own copy, and
its own `new Response(jsx.toString())` — and there was no shared
vocabulary left to style.

### What that had already cost

- **The device flow never got a design.** Its two pages were built by
concatenating HTML strings, with an inline `style` on the user code. Six
more replies were `text/plain` — including the one a person gets when
their sign-in cookie expires.
- **The password screens were already broken.** They render
`data-component="input"`, `data-component="link"`,
`data-component="form-footer"` against a stylesheet that was rewritten
for the code flow. Nobody noticed because password sign-in is not
switched on. They are deleted here, not repaired.
- **A provider could not be named or marked without editing the
library.** `DiscordProvider` has existed all along; wiring it up
rendered **"Continue with discord"**, lowercase, no icon, because the
marks and display names were two hardcoded `const` records inside the
code that drew the chooser.

### What changed

`ui/screen.ts` — four screen kinds (`choose`, `form`, `confirm`,
`message`) and a field vocabulary, as plain data. No JSX, no hono.

`ui/render.tsx` — the only file that knows what a button looks like.
`Renderer` is one method, so replacing the presentation layer wholesale
means implementing that and nothing else.

`Provider.display` — each provider carries its own name and mark
(`ui/mark.ts`, raw SVG strings so `provider/*.ts` never imports a
rendering library). The chooser is built from what providers declare;
`issuer({ chooser })` is left with only the two decisions a deployment
makes that a provider cannot — whether to offer it, and what to put
first.

The theme global is gone. It was `globalThis`, with a comment conceding
as much: every component depended on something invisible at the call
site, untestable in isolation, and shared mutable state on a runtime
that keeps one module instance across requests. It is a closure argument
now, and `Theme` shrinks to the values a deployment sets that the
stylesheet cannot.

`kind: 'segments'` is named by meaning rather than widget — a code read
off one screen and typed into another. The emailed pin and the device
user code are the same field now; they used to describe it separately,
in different files.

### Adding things, after

```ts
providers: {
  code: CodeProvider({ ... }),
  discord: DiscordProvider({ clientID, clientSecret }),   // icon and name included
  password: PasswordProvider(PasswordUI({ ... })),        // in the design, because it has none of its own
}
```

Neither touches CSS. Neither touches the renderer.

### Notes

- **No Tailwind.** I floated it and then dropped it: `packages/auth`
deploys straight from `src/` both ways (`wrangler.jsonc` points `main`
at `src/index.ts`; `server.ts` runs the same handler under Bun), so
adding a CSS toolchain would fight both. The stringly-typed
`data-component` problem is solved by the typed components instead —
nobody adding a screen writes one. The stylesheet grew ~95 lines for the
new primitives and that is the last CSS this change needs.
- **Design tokens stay independent** of the website rather than shared,
since auth is a separate Worker on a separate hostname with its own
release cadence. The brand values are copied, with a comment naming the
site as the source. Easy to reverse if you'd rather couple them.
- `src/provider/oauth2.ts` has a pre-existing `TS2578: Unused
'@ts-expect-error'` on `dev`. Untouched — verified it fails the same way
without this branch.

### Verification

- `packages/auth`: 66/66 pass. The 27 device tests are **unmodified**
and still pass, which is the behaviour argument — statuses, cookies and
the confirmation step are unchanged.
- `apps/auth`: 23/23 pass.
- `tsc --noEmit` clean on both, apart from the pre-existing error above.
- Every screen rendered and eyeballed in a browser.

Net −757 lines.
2026-09-17 22:52:42 +00:00
Wanjohi cfb8ec26a0 feat(machine): mint a public name, and stop routing on the id
A host's hostname was its primary key. That worked and disclosed three
things it should not have: ids here are monotonic, so an id in a hostname
tells anyone who reads a URL roughly when that machine was registered and
where it falls among its owner's others; an id is the primary key, so a
name that had to change could only change by re-registering the machine,
which is changing its identity to fix its name; and the hostname is also
the OAuth audience and the cookie scope, so the id travelled into redirect
URLs and browser history.

Machines now carry a minted name -- two words and four digits, unique
across the fleet, DNS-safe by construction, which an id was not. The words
are a curated list rather than a dictionary, because every pair is shown to
strangers. Names the fleet's own infrastructure answers on are refused at
mint time: one minted onto the edge's own label would take the published
key path away from every host at once.

Minting retries on the unique index rather than checking first, because two
registrations in the same instant both read "free" and both write. Only a
name collision retries; a duplicate id or secret means something a new name
cannot fix.

The migration adds the column in three steps. Generated as a NOT NULL
column it fails outright against a populated table, and a default would be
worse: every row would share one value on a routing key.
2026-09-18 00:36:13 +03:00
Wanjohi eedb143b46 refactor(auth): describe sign-in screens as data, draw them in one place
Providers no longer return a `Response`. Each one says what it needs from
the person — an address, a pin, a yes-or-no — as a `Screen`, and a single
`Renderer` decides how that is drawn.

The old arrangement made every provider a small web framework. It had to
know about markup, about the stylesheet's attribute names, about how a page
is assembled, so each grew its own callback signature and its own copy of
`new Response(jsx.toString())`. Three consequences, all of them visible in
the tree before this change:

- The device flow never got a design at all. Its two pages were built by
  concatenating HTML strings, with an inline `style` on the user code, and
  six of its replies were `text/plain` — unstyled black-on-white in the
  middle of signing in, which is also what a person got when their sign-in
  cookie expired.
- The password screens were drifting. They were written against attribute
  names the stylesheet no longer had, and nobody noticed because password
  sign-in is not switched on. They are deleted here rather than repaired;
  the flow is now six screen descriptions and no markup.
- A provider could not be named or marked without editing the library. The
  brand marks and display names were two hardcoded records inside the code
  that drew the chooser, so anything missing from them rendered as its own
  lowercase identifier with no icon. Providers now declare `display`
  themselves, and the chooser is built from what they say.

Also removes the theme global. It was `globalThis`, with a comment
conceding as much, which made every component depend on something invisible
at the call site — untestable in isolation, and shared mutable state on a
runtime that keeps one module instance across requests. The theme is now a
closure argument, and the same change shrinks `Theme` to the handful of
values a deployment sets that the stylesheet cannot.

Adding a screen now touches no CSS, and swapping the presentation layer
means implementing one method. The code flow's tests demonstrate the
second: they render screens as JSON.

Behaviour is unchanged. Status codes, cookies and the confirmation step are
the same, which the device tests cover unmodified.
2026-09-18 00:16:33 +03:00