Commit Graph

465 Commits

Author SHA1 Message Date
Wanjohi
8c3e460312 Fix webhook signature verification (#344)
Two commits.

**The webhook fix.** Every delivery was being refused as a signature
mismatch.
The provider signs one of two ways and the SDK helper only implements
the older
one, so a `whsec_`-prefixed secret — which is what any secret created
now is —
was mangled before verification. The scheme is now read off the secret
itself,
so rotating one cannot put the two out of step. Verified against a real
signature, not only against a rejection.

**A leak fix that was never pushed.** `apps/nesdoctor/src/sys.rs` and
`ask.rs`
still cite an internal document by filename in comments that reach a
public
repo. The commit fixing it existed only on a local branch. Rebased on
and
included here; the third file it touched had already been fixed
differently.

344 tests pass.
prod-8c3e460
2026-09-18 22:37:32 +00: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
2dfb7f4007 docs: stop citing internal documents from public comments
Three comments named a file in the internal repo: a contract, a design
guide, and a user-research document with section numbers. A filename plus
a section tells a reader what to ask for, which is the escape the marker
convention exists to prevent.

Each comment now states the requirement on its own terms. The render-node
requirement carries a decision marker; the other two have no decision
behind them, so they cite nothing.
2026-09-19 01:32:40 +03:00
Wanjohi
de325792a2 Release: billing, burn limits and organisations (#343)
Promotes #342 to production. Merging this cuts a release onto the
machine.

## What changes for anything already running

**`x-nestri-admin-token` stops working**, and `ADMIN_SHARED_SECRET` is
no longer
read. Nothing in any tree sends it, and the machine's `api.env` still
carries one
— it becomes an unused line, harmless but worth deleting.

**Routes that disappear:** `POST /games`, `GET /waitlist`, and all of
`/pairing-code`. The pairing-code core module and its table stay, so
returning to
that flow is a route file rather than a migration.

**Routes that change shape:** `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`;
`POST /session` responds `{ data, billing }` rather than `{ data }`.

## Migrations

Two, applied by the release before the swap:

- `0015` — the organisation table, and machine ownership becomes
team-or-org with
a check constraint. Safe in one step: every existing row has a team and
no
  organisation, so all of them already satisfy it.
- `0016` — burn counters and rate segments. Two new tables, nothing
altered.

## Billing is inert until it is configured

With `POLAR_*` unset — which is how the machine will come up — every
team is
free, no checkout can start, and the webhook refuses every delivery.
That is a
deployment without billing rather than a broken one, so this can land
before the
provider settings exist.

Filling them in needs, in order: the product created in the production
Polar
account, a webhook endpoint pointing at the API's public
`/billing/webhook`, and
the four values written to `/etc/nestri/api.env`.

## Rollback

The previous release stays on the machine and the symlink swaps back.
The
migrations do not roll back with it: `0015` and `0016` only add, and
`0015`
relaxes two NOT NULLs, so the prior binary runs against the newer schema
without
noticing.
prod-de32579
2026-09-18 22:16:42 +00: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
Kristian Ollikainen
c2ede3cdba feat: nescapture instrumentation, custom Mesa patches (#341)
Whoops, forgot to push these ones..

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:03:27 +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
Kristian Ollikainen
6811c93d51 feat: nescapture capture improvements and drive mounts (#337)
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:58:22 +03:00
Wanjohi
2924e113ca feat(machine): mint a public name, and stop routing on the id (#340)
## Why

The first machine routed end to end earlier today on
`mch_0b10cc….nestri.link`. It worked, and the hostname should not be the
primary key.

Three problems, only the first cosmetic:

- **Ids are monotonic.** `Identifier.ascending` sorts by creation time,
so a public hostname discloses roughly when a machine was registered and
where it falls among its owner's others.
- **An id cannot be rotated.** A name that has been scraped or shared
with the wrong person could only be changed by re-registering the
machine — changing its identity to fix its name.
- **The hostname is also the OAuth audience.** The 302 this edge serves
carries `client_id=mch_0b10cc…`, so the id reaches redirect URLs,
browser history and every access log on the way to the issuer.

Settled in `edge-routing.md` §1.3 before any code was written.

## What changed

`machine.slug` — two words and four digits (`amber-otter-4821`), unique
across the fleet, minted at registration and returned to the host.
DNS-safe by construction, which an id was not: an id carries `_`, which
is not a legal hostname character and works only until it meets a
stricter resolver or TLS stack than the one it was tested against.

Words rather than random characters because the people reading one aloud
are operators on a call. The list is curated, not a dictionary dump —
every pair is shown to strangers.

Reserved labels (`edge`, `api`, `auth`, …) are refused at mint time, not
filtered per request. A machine minted onto `edge` would take the
published-key path away from every host in the fleet at once, and a
request-time filter leaves the bad row in the database.

Minting retries on the unique index rather than checking first: two
registrations in the same instant both read "free" and both write, so
the index is the thing that actually decides. Only a name collision
retries — a duplicate id or secret means something a new name cannot
fix.

## The migration is hand-written, and here is why

`drizzle-kit generate` produced `ALTER TABLE "machine" ADD COLUMN "slug"
text NOT NULL;`. Against a populated table:

```
ERROR:  column "slug" of relation "machine" contains null values
```

A default would be worse than the failure — every existing row would
take the same value, the unique index would reject them, and a shared
value on a routing key must not exist even for an instant. So: nullable,
backfill, then require.

Verified against a database at `0013` with three machine rows, applying
`0014`:

```
ALTER TABLE
UPDATE 3
ALTER TABLE
CREATE INDEX

               id               |       slug
--------------------------------+------------------
 mch_0b10cc1bd001yby6nq5atKThz5 | host-a0fd57-7631
 mch_0b10cc1bd001yby6nq5atKThz6 | host-e60922-2988
 mch_0b10cc1bd001yby6nq5atKThz7 | host-e03632-9192
```

All 14 migrations also apply cleanly to an empty database.

The backfill is deterministic (`md5(id)`), so re-running produces the
same names rather than a second set. Backfilled names are `host-…` and
are not minted names — they exist so the constraint can be applied.

## Paired change

`nescore` on `dev` moves the edge's routing query from `m."id"` to
`m."slug"`. **Both must be deployed for routing to keep working** — the
edge selects a column this migration creates. Order: this PR (migration
runs before the swap), then the edge.

Boxes still key on their id, because a box has no name and nothing dials
one. Safe only while one of the two spaces is unreachable — flagged in
the code and in §1.3: both must share a single unique index before a box
serves anything.

## What this does not verify

- **No browser has been through the renamed flow.** The audience changes
with the hostname, so the sign-in redirect now carries the slug as
`client_id`. That path has not been walked end to end since the change.
- **The wordlist has had one pass, not a review.** It is chosen so no
pair reads as a slur or a person, but that is my judgement and it is the
kind of thing worth a second reader before strangers see generated
names.
- **`nestripc-1` will land on a backfilled `host-…` name**, not a minted
one. It should be re-minted after deploy; there is exactly one row and
no route depends on the old id once the edge ships.
- The API's type errors (10) are unchanged and pre-existing;
`packages/core` likewise.
prod-2924e11
2026-09-17 22:56:40 +00:00
Wanjohi
ebc0242b49 Merge branch 'prod' into dev 2026-09-17 22:53:30 +00: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
Wanjohi
c0ecbcb47e fix(auth): make the sign-in screen readable, and draw it in the product's design language (#338)
## Why

Two separate faults on the same screen, found while trying to sign in
for the first time.

**The email field was unreadable.** `[data-component='input']` computed
its own background one step lighter than the page and never set `color`.
Form controls do not inherit it, so the text someone typed was the UA
default — black glyphs over a near-black field. There was no
`color-scheme` either, so the browser rendered the control in light
appearance to begin with. That rule was the only `input` selector in the
stylesheet.

**The screen was still the upstream template's** — its font, its accent,
its logo. `issuer()` takes a `theme` and calls `setTheme`, but nothing
had passed one since `packages/auth` became a vendored fork.

## What changed

The theme is restored and the page is redrawn in the design language the
rest of the product uses: black, a neutral grey ramp, one brand accent,
Mona Sans for the display line and Geist for anything read or typed, two
dashed bands closing into a box on a wide screen, a dashed vertical
either side of the column, the wordmark, and the line of copy the
product opens with.

**Dark only.** The scheme that let one theme serve both light and dark
derived every colour from the background's lightness through `oklch(from
...)`, and that derivation is exactly what left the field's text the
colour of its own background. Values are now stated, not computed.

The brand colour appears in two places: the wordmark, and the field's
border on focus.

## Evidence

Measured against a render of the same design built from its own source,
at 1280×900:

| | reference | this |
|---|---|---|
| band rules | y `79`, `820` | y `79`, `820` |
| column rules | x `106`, `1172` | x `106`, `1172` |
| wordmark box | x `505–774`, h `45` | x `505–774`, h `45` |
| heading span | `66px` | `66px` |
| button height | `60px` | `60px` |

Both screens (email and code) and a 390px viewport were rendered and
looked at, not just diffed.

`bun test packages/auth` → 66 pass, 0 fail. Typecheck, `oxfmt` and
`oxlint` clean.

## What this does not verify

- **Nothing has been signed in with.** The screens were rendered
directly from `CodeUI`; no code has been minted, mailed or redeemed
through this page. That is the next thing, and it happens on
`auth.nestri.io` after this merges.
- **Fonts are a new third-party runtime dependency on the sign-in
path.** Mona Sans and Geist come from the jsdelivr Fontsource CDN,
because self-hosted font packages need a bundler and nothing
preprocesses this page. If jsdelivr is unreachable the page falls back
to `system-ui` and stays usable, but serving them from our own origin is
probably the right end state.
- **Only Chromium was used.** The autofill rules are `-webkit-` prefixed
and were reasoned about, not observed; no Firefox or Safari render was
taken.
- **`docs/deploy.md` is stale** and not touched here. It still says
Cloudflare Workers is "what production and sandbox are today";
production is long-lived processes on a VM behind a tunnel. Worth a
separate change.
prod-c0ecbcb
2026-09-17 20:18:53 +00:00
Wanjohi
aecae0c69e style(auth): tighten the sign-in field, and stop calling the runtime a worker
The email field sat a little taller than the action below it. Two comments
also described this page as rendering inside a worker, which is not how the
control plane runs; what they were reaching for is that nothing preprocesses
this file, so the design tokens are written out longhand.
2026-09-17 23:15:56 +03:00
Wanjohi
74391714d0 feat(auth): draw the sign-in screen in the product's design language
The screen was still the upstream template's: its font, its accent, its
logo, and a theme that tried to serve a light and a dark scheme from one
set of colours by deriving each one from the background's lightness.

That derivation is replaced with stated values, and the page is dark only.
Black, a neutral grey ramp, one brand accent, Mona Sans for the display
line and Geist for anything read or typed; two dashed bands closing into a
box on a wide screen, a dashed vertical either side of the column, the
wordmark, and the line of copy the rest of the product opens with.

The email field keeps the fix from the previous commit and takes the brand
colour on focus, which is the only place it appears besides the wordmark.

Measured against a render of the same design built from its own source:
the band rules, the column rules, the wordmark box and the button height
land on identical pixels.
2026-09-17 23:05:39 +03:00
Wanjohi
4ed36e0d38 fix(auth): give the sign-in input a text colour, and restore the theme
The email field computed its own background one step lighter than the page
and never set `color`. Form controls do not inherit it, so the text someone
typed was the UA default — black, over a near-black field. There was no
`color-scheme` either, so the browser rendered the control in light
appearance to begin with.

The theme was a second, separate loss: `issuer()` still takes one and calls
`setTheme`, but nothing had passed one since `packages/auth` became a
vendored fork, so every sign-in rendered as OpenAuth — its font, its
periwinkle, its logo. Restored from the pre-fork config, with the logo and
favicon repointed because both URLs it carried now 404 and a broken `logo`
renders a broken image rather than falling back.
2026-09-17 22:54:00 +03:00
Wanjohi
155d487525 ci: a merge to prod releases the control plane (#336)
The first promotion to `prod` in this repository. Two commits, both CI —
the
application code is byte-identical to what is already running, so what
this
merge actually tests is the release path itself.

**What it adds**

`release-prod.yml`: tests against a real Postgres, both servers
executed,
the bind default asserted, then three binaries published as an immutable
`prod-<sha>` release. A failure anywhere means no release exists, so the
machine keeps serving what it has.

`nestri-migrate`: a compiled migrator with the migrations baked in,
because
the deploy runs migrations before it swaps a release into place and had
nothing to run. It reimplements drizzle's bookkeeping — same table, same
sha256, same high-water mark — because this database was first migrated
by
drizzle-kit and a disagreement between the two means a migration applied
twice. All fourteen hashes were verified by hand against the live
database,
and CI now asserts the agreement on every build.

**Verified before opening this**

- Nothing pending against the deployed schema; fourteen rows unchanged.
- A scratch database migrated from empty to the same 22 tables and the
same
  high-water mark, idempotent on a second run.
- Exit 2 with no `DATABASE_URL`, rather than quietly defaulting to
localhost.
- The workflow itself, green on `dev` via `workflow_dispatch`,
publishing
  nothing.

**What is still untested**, and what merging this will test: migrations
run
*through the deploy*, and the deploy refusing to swap when they fail.
prod-155d487
2026-09-16 22:54:26 +00:00
Wanjohi
7859c8e13a ci: restart the issuer before the API
Units come back in the order their artefacts appear in the manifest, and the
API reaches the issuer over AUTH_INTERNAL_URL -- so api-then-auth means the
API spends a moment talking to a service that is restarting.
2026-09-17 01:44:50 +03:00
Wanjohi
4668c98785 ci: a merge to prod releases the control plane, migrator included
`release-prod.yml` for api and auth, mirroring the edge's. The gate is not
"you may not merge" — it is that merging does not deploy: tests run against a
real Postgres, both binaries are executed, and a failure anywhere means no
release exists, so the machine keeps serving what it has.

The new piece is `nestri-migrate`, because the deploy runs migrations *before*
it swaps a release into place and had nothing to run.

`drizzle-kit migrate` reads the migrations folder at runtime, which is right on
a laptop and wrong on a server: the deploy ships flat, checksummed files into
bin/, and a migrator that needs a directory beside it can be pointed at the
wrong directory. So the folder is baked into the binary — generated on every
build rather than committed, so it cannot drift — and the artefact's checksum
then covers every statement it will run.

It reimplements drizzle's bookkeeping in thirty lines of SQL rather than
calling into `db.dialect.migrate`: same table, same schema, same sha256 over
the whole file, same high-water-mark comparison. That equivalence is the one
thing that must not rot, because the production database was first migrated by
drizzle-kit and a disagreement means a migration applied twice. Two checks
hold it: CI applies the migrations with drizzle-kit and then asserts the
embedded set reports nothing pending, and the hashes were verified by hand
against the live database — all fourteen match to the byte.

Proven before shipping, against the real database: nothing pending on the
deployed schema, 14 unchanged rows, and a scratch database migrated from empty
to the same 22 tables and the same high-water mark, idempotent on a second run.
Refuses with exit 2 when DATABASE_URL is absent rather than defaulting to
localhost, which would be a migrator reporting success having migrated nothing.

setup-bun is pinned to a commit and not to `v2`. A moving major tag is fine
everywhere else in this repository; this workflow is the only thing between a
merge and a process serving users, and there is no first-party bun action to
prefer instead.
2026-09-17 01:44:16 +03:00
Wanjohi
b00f1064ae fix(api,auth)!: bind loopback by default, and let a container ask for more
Both servers bound `0.0.0.0`. That was harmless while the only deployment was
docker-compose.yml, which publishes these ports on 127.0.0.1 and makes the
container's own bind irrelevant. As ordinary processes on a rented machine
there is no such wrapper, and `0.0.0.0` is a listener on the internet — in
front of an issuer that sets cookies without `Secure` and mints sign-in codes,
because it expects something else to be terminating TLS.

So the default is `127.0.0.1` and `HOST` is there for the deployment that
genuinely needs every interface. compose now sets `HOST: 0.0.0.0` explicitly,
which is not a workaround: inside a container, binding loopback is what would
make the published port unreachable. The right answer differs by deployment,
which is why it is a variable rather than a constant.

Both now log the address they bound, not the one they hoped for.

BREAKING CHANGE: api and auth no longer listen on every interface by default.
A deployment that relied on that must set HOST=0.0.0.0.
2026-09-16 23:10:22 +03:00
Wanjohi
e3c9416170 docs(dns): the origin is a tunnel, and there is nothing to cut over from
`After the move off Workers` described proxied `A` records to the host and an
ordering to avoid a gap while Worker routes were removed. Both are wrong.

There is no `A` record because there is no address to publish: each name is a
proxied CNAME to the tunnel, and the machine's address appears nowhere. And
there is no ordering problem because `api.nestri.io` and `auth.nestri.io` have
no DNS records and do not resolve — only the sandbox pair was ever deployed, so
these names are created for the first time with no window to protect.

The `nestri.link` section promised an origin certificate as the one key on our
host. There is no key on our host at all. Measured 2026-09-16: a public HTTPS
request served correctly while nothing listened on 443 or 8443 and both origins
spoke plain HTTP on loopback; the certificate was Cloudflare's, covering the
apex and one level, and a two-label name was refused at the handshake with no
certificate presented.
2026-09-16 21:57:20 +03:00
Kristian Ollikainen
15ad60bf49 feat: nescapture pacing, improvements and deps update (#334)
Make nescapture better with proper queue family checks, FPS limiting,
semaphore usage and other.. also updated deps like pollster and
pixelforge.

<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=64083904"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>

The final review contains no accepted findings, so the PR appears safe
to merge.

<h3>Summary</h3>

- This PR reworks `nescapture` frame capture around a four-slot DMA-BUF
ring, tracks presentation queue families, adds semaphore-based
capture/present ordering, introduces capture frame-rate pacing, carries
presentation timestamps through encoding, and updates Vulkan-related
dependencies.

<sub>Reviews (1) · Last reviewed commit: ["feat: Update deps and remove
deprecated
..."](79e21f4aac)</sub>

<!-- /greptile_comment -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:58:57 +03:00
Kristian Ollikainen
8246aa5538 feat: resident guest init (#333)
Get this thing going..







<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=63134761"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>

The PR appears safe to merge; all previous findings are resolved and the
latest readiness change introduces no established actionable regression.

<h3>Summary</h3>

- Establishes required guest filesystems, runtime directories, device
permissions, and service processes.
- Reports initialization and service deaths over the lifecycle channel.
- Supports launch, restart, and shutdown commands for a resident guest.
- Separates service and workload identities and configures per-launch
runtime environments.
- Removes the currently inactive nescope screenshot option and makes
capture-chain verification fail explicitly when compositor readback is
unavailable.
- Reworks the guest image around `nesinit` as PID 1 without a
distribution service manager.

<h3>Diagram</h3>

```mermaid
sequenceDiagram
    participant Host
    participant Init as nesinit
    participant FS as Guest filesystems
    participant Services as Service stack
    participant Workload

    Init->>Host: Ready(protocol version)
    Host->>Init: Boot(mount descriptors)
    Init->>FS: Establish and mount shares
    Init->>Services: Spawn services in order
    Services-->>Init: Required sockets ready
    Init->>Host: Initialized(service names)
    Host->>Init: Launch(id, exec, on_exit)
    Init->>Workload: Spawn with isolated UID/runtime
    Init->>Host: Started(id)
    Workload-->>Init: Exit status
    Init->>Host: WorkloadExited(id, status)
    Host->>Init: Launch / Restart / Shutdown
```

<sub>Reviews (4) · Last reviewed commit: ["fix(nesinit): readiness is a
socket
that..."](731d34df9d)</sub>

<!-- /greptile_comment -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:45:13 +03:00
Wanjohi
ec8b13d0c9 fix: A host-only session cookie, and a column for where a host actually is (#331)
Closes #330.

Two gaps that block a reverse proxy sitting in front of user-owned
hardware and
authenticating browsers on its behalf, plus one thing found on the way
that is
worse than either.

## 1. `machine` records where a host is

`machine` said who owns a host, which team it belongs to and when it was
last
seen, and nothing about how to reach it — so a request arriving for a
machine
could be authorised perfectly and then have nowhere to go.

`endpoint_id` is **reported, never assigned**: a host holds the secret
half of
that identity and is the only thing that can know the public half first,
so it
rides on the beat it already sends as itself. Migration `0013`.

- **Nullable**, because "has never reported one" is a real state that
every host
registered before today is in. Null reads as *not reachable yet*; a
default
  would read as an address and route somewhere wrong.
- **Unique**, because an endpoint id belongs to one host. Two rows
claiming the
same one would send a request addressed to one machine to another
machine's
agent, which is the one mistake here the authorisation in front of it
cannot
  catch.
- **Omitting the field leaves the stored value alone.** An agent that
does not
  say where it is has not moved, and an absent field must never read as
"nowhere" — that would take every host shipped before this field off the
map
  on its next beat. There is a test for exactly that.

## 2. The session cookie, and why it is not shaped the way the issue
asked

The issue asked for a `__Host-nestri-session` set on sign-in carrying
the access
token unwrapped. Building it turned up two reasons that cannot work, and
item 3
of the issue is the reason why:

1. **There is nowhere to set it.** The sign-in UI is served by the
issuer on its
own hostname, and the web client has no auth code at all. A `__Host-`
cookie
set at sign-in is host-only *to the issuer* — a host that does not need
one.
2. **The value would be a control-plane credential** sitting on hardware
the
   control plane does not run.

Item 3 said the hand-off had to be settled from both ends and did not
pick a
shape. It is settled now, in the way the issue's own two rules point at:
**the
proxy is an ordinary public OAuth client, one per hostname**, and what
crosses
in the URL is an **authorization code** — single-use, sixty seconds
long,
redeemed exactly once by the store that already exists here, and
exchanged over
a back channel. A code in an access log is worthless by the time anybody
reads
it, which is the hazard the issue named. No credential in a URL, and no
caller-supplied return address.

What this repo owes that flow is one rule, and it is the whole of the
change to
the issuer: **a client id that is a single hostname under the host zone,
whose
`redirect_uri` is `https` and that same hostname at one reserved path,
is an
allowed client.** Everything else keeps the behaviour it had.

Making the client id the hostname is load-bearing rather than tidy. A
token's
`aud` is its client id, so the session that comes back is **bound to the
host it
will live on**, with no change to how tokens are minted — and it is not
a
credential on any other host, nor a control-plane credential at all.
That is a
better answer than "the access token, exactly", and it costs nothing.

## 3. `/authorize` was an open redirector

Found while testing the above. A refused client's `redirect_uri` was
still used
to deliver the refusal — and the check that approves that URI is the one
that
just failed:

```
GET /authorize?client_id=web&redirect_uri=https://somewhere.example/callback
  -> 302 https://somewhere.example/callback?error=unauthorized_client
```

No sign-in required, on the hostname people are asked to type a password
into.
A refusal is now a page here. This is in `packages/auth` and is
independent of
everything above; it is in this PR because the flow above is built on
that path
and shipping one without the other would have been odd.

## Tests

Every case below fails against the unmodified code and passes after.
`379 pass,
0 fail` across `packages` and `apps`.

- `apps/auth/test/allow.test.ts` — the allowed case; a code is never
sent
anywhere but the client id; only the reserved path; `https` only; one
label,
because `a.b.zone` is not a host id; another zone does not get in by
using the
path; the three existing rules unchanged; and the refusal is a page
rather
  than a redirect.
- `packages/core/src/machine/machine.test.ts` — reported and read back,
a
silent beat leaves it alone, four malformed ids refused, two machines
cannot
  claim one id.
- `apps/api/test/heartbeat.test.ts` — a beat with no body is still a
beat, a
  host cannot report where *another* host is, a malformed id is a `400`.

## What this does not verify

- **No end-to-end run against a real browser.** The flow is exercised
from the
proxy's side and from this side separately; nothing has driven a browser
  through sign-in and out the other end.
- **The token's shape is asserted to the written contract, not captured
from a
running issuer.** If minting changes, these tests pass and a signed-in
person
  is redirected to sign in again.
- **`machine_endpoint_id_unique` is not exercised under concurrency.**
Two hosts
reporting the same id in the same instant is a database-level race that
the
  tests assert the *outcome* of, sequentially.
- **Nothing here makes a hand-off silent.** The issuer keeps no session
of its
  own, so a second hostname asks for an email code again. That is not a
regression — nothing anywhere is silent today — but it is the next piece
of
work, and it is what the issue's item 1 will eventually be, on the
issuer's
  own hostname.
- Pre-existing `tsc` errors remain in
`apps/api/app/utils/{hook,validator}.ts`
  and `packages/core/src/session/session.test.ts`; none is touched here.




<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR adds a durable, host-reported endpoint identity and permits
tightly constrained OAuth callbacks for individual hostnames. It also
prevents rejected OAuth clients from controlling the error redirect.
- Adds a nullable, unique machine endpoint ID with migration and
heartbeat reporting.
- Preserves endpoint IDs when legacy agents send bodyless heartbeats.
- Converts duplicate endpoint claims into the API’s stable HTTP 409
conflict response.
- Allows HTTPS authorization callbacks only at the reserved path on the
matching single-label host.
- Returns unauthorized-client failures locally instead of redirecting to
an unapproved URI.

<h3>Confidence Score: 5/5</h3>

The PR appears safe to merge; the previously reported endpoint-conflict
failure is now translated and tested as a stable HTTP 409 response.

No actionable new failure remains. The prior endpoint-conflict finding
is fully fixed by translating PostgreSQL uniqueness failures into
`already_exists`, which the API maps to 409, with both domain-level and
route-level coverage.

<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| apps/auth/src/index.ts | Adds strict host callback authorization while
retaining the existing same-domain and local-development rules. |
| packages/auth/src/issuer.ts | Prevents unauthorized clients from using
their rejected redirect URI as an open-redirect destination. |
| packages/core/src/machine/index.ts | Persists optional endpoint
reports and translates duplicate endpoint claims into a stable domain
conflict. |
| apps/api/app/routes/machine.ts | Extends machine heartbeats with
optional endpoint reporting and documents the 409 response. |
| packages/core/migrations/0013_machine_endpoint_id.sql | Adds the
nullable endpoint column and database-enforced uniqueness invariant. |


<h3>Sequence Diagram</h3>

```mermaid
sequenceDiagram
    participant B as Browser
    participant A as Auth issuer
    participant P as Host proxy
    participant H as Machine
    participant D as Database

    H->>A: Authenticated heartbeat with optional endpointId
    A->>D: Update lastSeen and, when supplied, endpointId
    D-->>A: Stored or unique conflict
    A-->>H: 200 or typed 409

    B->>A: /authorize for host.nestri.link
    A->>A: Validate matching HTTPS reserved callback
    A-->>B: Provider flow
    B->>A: Complete authentication
    A-->>P: Short-lived authorization code
    P->>A: Exchange code
    A-->>P: Host-audience session tokens
```

<sub>Reviews (2): Last reviewed commit: ["fix(machine): a taken endpoint
id is a
c..."](51d25f3e8c)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=61026693)</sub>

<details><summary><h4>Context used (5)</h4></summary>

- Knowledge Base — [API HTTP composition and
authorization](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/api-http-and-auth.md)
- Knowledge Base — [Core domain and
persistence](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-domain-data.md)
- Knowledge Base — [Machines, pairing, and access
tokens](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-machines-and-pairing.md)
- Knowledge Base — [Authentication
platform](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/auth-platform.md)
- Knowledge Base — [Auth providers, sessions, and
storage](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/auth-providers-and-storage.md)
</details>


<!-- /greptile_comment -->
2026-09-06 21:24:36 +00:00
Wanjohi
51d25f3e8c fix(machine): a taken endpoint id is a conflict, not a server fault
A host reporting an endpoint id another machine already holds hit the unique
index, and the raw refusal reached the global handler as a 500 -- telling a
host its beat broke the server rather than that the id is taken. It is now the
409 every other conflict here gives, and the route documents it.

Checked-then-written would be worse rather than better: two hosts reporting the
same id in the same instant both read "nobody holds it" and both write, which
is precisely what the index is for. The read would add a query and remove
nothing.

Before:
  expect(res.status).toBe(409)
  Received: 500
2026-09-07 00:19:34 +03:00
Wanjohi
49ae45624e fix(auth): a host may receive a code at its own name, and a refusal is not a redirect
Two changes to who may start a flow here, and where a refusal is delivered.

A host reached at its own hostname sits on a different registrable domain from
this issuer, deliberately: that is what stops a cookie set there from ever
reaching this one. The default rule allows a redirect back to whatever hostname
the request arrived on, so it refused exactly the case the separation created.
Which is a real problem rather than a theoretical one, because a session cookie
without a Domain attribute is host-only, so a browser arriving at one of those
hostnames for the first time carries no cookie whether or not it is signed in,
and sending it here to sign in again changes nothing.

So a client id that is a single hostname under that zone, whose redirect_uri is
https and that same hostname at one reserved path, is allowed. Making the
client id the hostname is the load-bearing part: a token's audience is its
client id, so the session that comes back is bound to the host it will live on
and is not a credential anywhere else.

Separately, and worth its own paragraph: a refused client's redirect_uri was
still used to report the refusal. The check that approves that URI is the one
that just failed, so /authorize was an open redirector to anywhere at all --
no sign-in required, on the hostname people are asked to type a password into.
It is now a page here. Before:

  GET /authorize?client_id=web&redirect_uri=https://somewhere.example/callback
  -> 302 https://somewhere.example/callback?error=unauthorized_client
2026-09-06 23:44:51 +03:00
Wanjohi
6603383ad1 feat(machine): record where a host can be reached, as the host reports it
The machine table said who owns a host, which team it belongs to and when it
was last seen, and nothing about how to reach it. Anything standing in front of
a host and authenticating browsers on its behalf could therefore authorise a
request perfectly and then have nowhere to send it.

Reported, never assigned. A host holds the secret half of this identity and is
the only thing that can know the public half first, so it rides on the beat it
already sends as itself. Omitting the field leaves the stored value alone --
an agent that does not mention where it is has not moved, and an absent field
must never read as "nowhere", which would take every host shipped before this
field off the map on its next beat.

Nullable, because "has never reported one" is a real state that every host
registered before today is in. Unique, because an endpoint id belongs to one
host: two rows claiming the same one would send a request addressed to one
machine to another machine's agent, which is the one mistake here that the
authorisation in front of it cannot catch.
2026-09-06 23:44:37 +03:00
Wanjohi
0e94620808 feat(nesinit): carry the session's address out of the guest (#328)
## What was missing

`neshub` serves the session's address on a socket, and its own flag has
always
said how that address gets out:

> *"neshub listens; nesinit dials and carries the ticket to the host,
because
> the person who needs it is outside this VM and stdout here is a log
file
> inside one."*

Nothing dialled it. `grep -rn ticket apps/nesinit/src` returned nothing
at all,
so the address never left the guest — and the one lifecycle message for
it,
`Ticket`, had no sender.

This adds the carrier.

## Three decisions worth reading

**Polled, not read once.** An address is not a value, it is the best
answer so
far: an endpoint discovers more ways to reach it after it binds — a
local one
immediately, a relayed one seconds later. Reading once means whoever
asked first
decides, and the first answer is the one that works on a local network
and fails
from anywhere else. Only a *changed* answer is forwarded, so an
unchanged one
costs nothing.

**Dials, does not listen.** The opposite of the payload relay next door,
and
deliberately so. There the guest listens because the workload starts
later; here
the server is the long-lived one. Dialling also makes "not bound yet" an
error to
retry rather than a connection to wait for without knowing whether it is
coming
— which is the ordinary case at boot, since this starts before the
server does.

**The address is never logged.** It is a capability to reach the
session, and a
log inside the guest is the one place it has no reason to be. The log
line says
whether it is the first one and nothing else.

## Failing first

The carrier's tests fail against unmodified code by not compiling:
`ticket.rs`
does not exist and `session::run` takes three arguments. Said plainly
rather
than manufactured. The behavioural gap is better shown as the `grep`
above —
nothing in the guest ever sent a `Ticket`, so the message had one end.

`nesinit`: **35 tests passing**, up from 27. Four on the carrier itself
(an address arrives; a better one replaces it; a socket that is not
there yet is
waited out rather than failed; an empty answer is not an address) and
two on the
session (an address reaches the caller as `Ticket`; a carrier that stops
does
not end the session).

## Verified in a real guest

Built static for musl, run as PID 1 in a real microVM under a real VMM
with a
real vsock. It dialled out, completed the handshake at version 2, took a
boot
descriptor, started its workload, read the address that workload
published and
sent it up the channel — twice, the second time because a better one
appeared.
The caller saw `nestri:local-only` and then `nestri:with-relays`.

Guest boot to init was **310 ms**.

## Review round (f74de9b, on top of two fixes from a hardware run)

`18864b9` and `00a2bda` came from running this on real hardware and are
the
reason it works at all: nothing was mounting `/proc` or anywhere
writable, so
the relay and the address socket both failed with `EROFS` and the
session was
reported as a workload that ran and published nothing. And one look at
the
socket had a size cap but no time cap, so a peer that accepted and then
said
nothing stopped the search for a better address permanently.

Four review findings on top, all real:

| finding | outcome |
|---|---|
| a tmpfs over the share tree hid the install, user, work and log-share
directories the image prepares | moved to `/run`, where a runtime socket
belongs; a test asserts the share tree is never mounted over, and
another that nothing is mounted before the mount containing it |
| the relay directory's mode stopped the workload reaching the socket |
the directory is searchable and writable only by init — which is what
makes the socket unreplaceable — and the socket itself is what the
workload may connect to |
| the address was built once and served forever, so polling could only
return the first one | rebuilt per read from the endpoint, which is what
makes polling worth doing |
| the workload could replace the socket and publish an address of its
choosing | `SO_PEERCRED` before a byte is read; an address from the
workload's user is refused and logged loudly. **Fails closed**, so the
host's default workload uid moved off the services' uid and the image
grew a second user — see the thread |

**These two PRs are now order-dependent.** This one merged alone,
against a host
still defaulting the workload to the services' uid, refuses every
legitimate
address.

Also bumps `tinyvec` by a patch release. It does not build on this
toolchain —
`vec` resolves to the module, not the macro — which made every crate
depending
on an endpoint unbuildable, `neshub` included. Pre-existing: the
lockfile named
the same version before this branch. It is why `neshub` could be built
and
tested here at all.

**40 tests** in `nesinit` (was 27), 3 in `neshub`, fmt clean, and no
clippy
warning in any file this branch touches.

## What this does not verify

- **The separated-uid pairing has not been run on hardware.** The rig
has no
init system, so the only process it can start is the workload — which
means
the address producer *is* the workload and the two cannot be given
different
uids in it. The refusal and the root stand-down are both verified on a
real
VM; the legitimate combination is covered by a unit test over a real
socket
  and real kernel credentials, and by nothing else.
- **The image was not built.** The second user is one `useradd` in a
Dockerfile
  that needs a base image this machine does not have.

- **The real address server was not in the loop.** The socket was served
by a
stand-in written for the test, which speaks the same one-line protocol.
The
real one has never had anything read from it, and its behaviour when it
learns
a new address — whether it re-serves the newer one on the next dial,
which is
  the entire premise of polling — is **asserted, not measured**.
- **No media.** Nothing rendered or encoded; the address pointed at
nothing.
- **Nobody connected to it.** The address left the guest. It was never
dialled.
- **Not the real guest image.** A 3 MB rootfs built for this, with no
init
system, no compositor and no Mesa. The image build does not ship this
binary
at all — its `default` runlevel still registers services on the
assumption
  that something else starts the payload.
- **The relay was down for the whole run**, because the test root was
read-only
and `/nestri` could not be created. That is non-fatal by design and the
session
ran anyway, but it means the payload layer and this carrier have not
been
  exercised in the same boot.
- **One address, one guest, one session.** Nothing concurrent.







<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR carries changing session tickets from `neshub` through `nesinit`
to the host and establishes writable runtime filesystems for guest
sockets.
- Rebuilds tickets from the endpoint on every IPC connection so newly
learned addresses can propagate.
- Polls the ticket socket and forwards changed tickets over the
lifecycle channel.
- Moves the payload socket into a dedicated `/run/nestri` runtime
directory.
- Adds workload/service UID separation infrastructure and
peer-credential checks.
- Fixes the previously reported stale-ticket and filesystem-mount
failures.

<h3>Confidence Score: 3/5</h3>

The PR is not yet safe to merge because the unresolved ticket-source
impersonation issue remains for root workloads and for deployments that
do not select the new workload UID.

The stale-ticket issue and both filesystem findings are fixed. However,
the unresolved ticket-source finding is only partially addressed:
`Untrusted::refuse` deliberately disables peer rejection when the
descriptor selects UID 0, allowing a root workload to replace the `/tmp`
socket and supply an attacker-chosen ticket. The new `nesplay` account
also does not enforce separation because the workload UID still comes
from an external descriptor producer; if that producer continues using
UID 1000, the carrier rejects the legitimate UID-1000 `neshub` peer as
well.

**Files Needing Attention:** apps/nesinit/src/ticket.rs,
build/Dockerfile

<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| apps/nesinit/src/ticket.rs | Adds ticket polling and peer-credential
rejection, but the protection is disabled for root workloads and depends
on external UID separation. |
| apps/nesinit/src/session.rs | Integrates ticket forwarding into the
lifecycle loop and records the descriptor-provided workload UID before
startup. |
| apps/nesinit/src/filesystems.rs | Establishes early mounts without
hiding the session share tree and makes the payload socket directory
traversable. |
| apps/nesinit/src/payload.rs | Relocates the payload socket under
`/run/nestri` and grants workloads permission to connect. |
| apps/neshub/src/ipc_listener.rs | Rebuilds the served ticket per
connection so later endpoint addresses are included. |
| build/Dockerfile | Adds a distinct `nesplay` account, although the
external boot-descriptor producer must select that UID for separation to
hold. |


<h3>Sequence Diagram</h3>

```mermaid
sequenceDiagram
    participant Hub as neshub
    participant Socket as Ticket Unix socket
    participant Init as nesinit carrier
    participant Session as nesinit session
    participant Host as Host controller

    loop Every polling interval
        Init->>Socket: Connect
        Socket->>Hub: Accept connection
        Hub->>Hub: Rebuild ticket from endpoint.addr()
        Hub-->>Init: Current ticket
        Init->>Init: Check peer UID and compare with prior ticket
        alt Trusted and changed
            Init->>Session: Queue ticket
            Session->>Host: GuestToHost::Ticket
        end
    end
```

<sub>Reviews (3): Last reviewed commit: ["fix(nesinit): do not mount
over the
shar..."](f74de9beb8)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60934382)</sub>

<!-- /greptile_comment -->
2026-09-06 15:24:10 +00:00
Wanjohi
f74de9beb8 fix(nesinit): do not mount over the share tree, and check who serves an address
Four findings from review, all of them real.

The relay's directory was mounted on the tree a session's shares live in. A
fresh tmpfs there hides every directory the image prepared underneath it: the
install, the user state, the work directory, and the mount point the log share
is attached to from fstab. A box would have come up with a socket and without
any of the places its workload looks for its files, and the exact-path check
could not notice, because what fstab mounts is a directory inside that tree
rather than the tree itself. It moves to /run, which is where a runtime socket
belongs, is a tmpfs already, and has nothing else mounted inside it.

It was also owned by this process and closed to everyone else, which stopped
the workload traversing it to reach the relay at all. The directory is now
readable and searchable, and still writable by nothing but this process, which
is what makes the socket in it unreplaceable; the socket itself is what the
workload is allowed to connect to. The permission belongs on the socket rather
than on the path.

The address served to a reader was built once at startup and served forever, so
a reader that polls for a better one could only ever get the first. An endpoint
does not know all of its own addresses when it binds: the first is the one that
works on the same network and fails from anywhere else. It is now rebuilt per
read, which is what makes polling for it worth doing.

And the address was taken from whoever held a path in a directory the workload
can write. Workload code could unlink the socket a service was listening on,
bind its own, and every read afterwards would hand the client an address of its
choosing -- a session given to somebody else rather than a session that fails.
The peer's credentials are now checked before a byte is read, from the kernel
rather than from anything the peer says about itself, and an address served by
the workload's own user is refused and said loudly.

That check is only worth something while the workload has a user of its own, so
the image grows one. Two users, and they must stay two: one runs the services
that ship in the image, the other is who a workload runs as. Sharing one does
not weaken the check, it makes every session fail it.

A workload running as root is every user at once and cannot be told apart from
anything; the check stands down there and says so at boot instead, because
refusing root would refuse whatever legitimately serves the address as well.

Also bumps tinyvec by a patch release. It does not build on this toolchain --
`vec` resolves to the module and not the macro -- which made every crate that
depends on an endpoint, including this one, unbuildable. Pre-existing and
nothing to do with this change; the lockfile said the same version before it.
2026-09-06 18:20:30 +03:00
KAAL1
00a2bdab30 fix(nesinit): give one look at the address socket a deadline
There was a cap on how much this would read and none on how long it would
wait. The far end can accept a connection and then write nothing, and a
read with no deadline turns that into a poll loop that never runs again:
the address already forwarded stays correct, and the better one that
arrives afterwards is never seen. That is the failure this polls to avoid,
reached by a different route.

Five seconds, against a two second interval, so an answer that is merely
slow still lands and one that is never coming is abandoned.

The test hangs against the code as it was, which is the whole point of it.
2026-09-06 14:24:04 +03:00
KAAL1
18864b97f0 fix(nesinit): mount what the guest needs before anything asks for it
The root arrives read-only and this process is PID 1, so until it mounts
them there is no /proc and nowhere in the filesystem to put a socket.
Nothing else in the guest is an init system, so nothing else was going to.

The symptom was three failures that look unrelated and share one cause. On
a real box the payload relay could not bind, with EROFS; whatever serves
the session's address could not bind either, the same way; and this process
could not make itself ineligible for the OOM killer, because /proc was not
there to write to. What the caller saw was a workload that ran and
published nothing, which is true and says nothing about why.

/proc is mounted first and unconditionally: finding out what an image
already mounted requires it, and it is therefore the one entry that cannot
be checked that way itself. Everything after it is skipped when it is
already present, so an image that does this properly is not mounted over.

Failures warn rather than abort. Refusing to boot would replace a session
that fails with a reason by a guest that never dialled out at all, and the
second is harder to diagnose from the outside.

The relay's directory is named by the module that owns the socket rather
than spelled again here, with a test tying the two together: a rename that
reached one and not the other would put the relay back exactly as it was.
2026-09-06 14:23:56 +03:00
Wanjohi
2a7be92a41 ci: run each half only when that half changes (#329)
## Why

Both jobs ran on every pull request. A change to a Rust binary waited on
a
Postgres service and a full TypeScript test run; a change to a
TypeScript route
spent a runner compiling Rust. Neither result told anyone anything.

## What changed

A `paths:` filter belongs to a **workflow**, not to a job — so the two
jobs
become two workflows. That is the entire cost of the change:

| | |
|---|---|
| `.github/workflows/web.yml` | the TypeScript half — `bun test` over
the control-plane apps and shared packages |
| `.github/workflows/nesdoctor.yml` | the Rust half — `fmt`, `clippy`,
`test`, and a no-network run |
| `.github/workflows/ci.yml` | deleted; it was the two of them together
|

**Both job bodies are carried over unchanged.** Parsed and compared
rather than
eyeballed:

```
web        job body identical to ci.yml: True
nesdoctor  job body identical to ci.yml: True
```

Only the triggers differ. `push` is untouched (see the first note
below).

## The filters, and where they come from

**`web`** — every TypeScript workspace member, plus the things that
reach all of
them. `packages/` is entirely TypeScript so it is taken whole; `apps/`
is mostly
Rust, so its two TypeScript members are named.

```
apps/api/**  apps/auth/**  packages/**
package.json  bun.lock  tsconfig.json  oxlintrc.json
.github/workflows/web.yml
```

**`nesdoctor`** — its own directory, plus the workspace root and
lockfile, which
pin every version it builds against. No other member is listed because
it
depends on no other member; its dependency tree is four external crates
deep and
that is deliberate.

```
apps/nesdoctor/**  Cargo.toml  Cargo.lock
.github/workflows/nesdoctor.yml
```

## Verified by simulating the filters, not by reading them

The failure mode of a path filter is *silence* — a wrong pattern means
the job
never runs and the pull request goes green. So the globs were
implemented in
GitHub's dialect (`*` stops at a slash, `**` crosses them) and run
against real
change sets, including the actual file list of the last merged PR:

```
the enrolment PR, actual file list             → web
a TS route only                                → web
a core module only                             → web
a migration only                               → web
the auth worker                                → web
the shared auth package                        → web
the bun lockfile                               → web
lint config                                    → web
nesdoctor source                               → nesdoctor
nesdoctor README                               → nesdoctor
the Rust workspace root                        → nesdoctor
the Cargo lockfile                             → nesdoctor
another Rust app                               → (nothing)
a shared Rust crate                            → (nothing)
the web workflow itself                        → web
the nesdoctor workflow itself                  → nesdoctor
docs only                                      → (nothing)
the root README                                → (nothing)
a stray root artefact                          → (nothing)
both halves at once                            → web, nesdoctor
```

`another Rust app` and `a shared Rust crate` firing nothing is correct
**today**
— CI covers `nesdoctor` alone, and the rest of the Rust half has never
been
under it. It stops being correct the moment a second member is added to
CI, and
each new member wants its own filter alongside its own job.

Both jobs were also run locally with the exact commands the workflows
use:
`nesdoctor` — fmt clean, clippy clean under `-D warnings`, 18 tests
pass, and
the binary runs; `web` — migrations apply and 363 tests pass, 0 fail.

## Three things found on the way, none of them fixed here

1. **`push: branches: [main]` is inert.** There is no `main` branch —
the
default is `dev` — so the push half of this trigger has never fired and
does
not fire now. I carried it over verbatim rather than "fixing" it to
`dev`,
because that would *add* CI runs and this PR exists to remove them. One
word
   either way; your call.
2. **A new TypeScript app will silently not be tested** until someone
adds it to
`web.yml`'s list. Nothing detects this. It is written as a comment in
the
   file, in the place someone editing that list will be looking.
3. **`nesdoctor.json` is committed at the repo root** and appears to be
a report
generated on someone's machine — it carries a specific CPU, kernel and
disk
layout. It is an output rather than an input, so no filter references
it.
   Probably wants deleting and gitignoring, separately.

## Before turning on required status checks

Path-filtered workflows do not report at all when they do not match,
which
branch protection reads as *expected but missing* — a pull request that
touches
only docs would never become mergeable. There are no required checks on
`dev`
today (`required_status_checks: null`, checked), so nothing is broken by
this.
If you enable them later, the usual answer is a companion job that
always runs
and reports success under the same name.

## What this does not verify

- **The simulation implements GitHub's glob dialect; it is not GitHub.**
This
pull request is the first real exercise of it: it changes both workflow
files, each of which lists itself, so **both jobs should run here** —
which
is the intended behaviour, since a change to how the tests run is a
change
worth running. Anything else on the checks tab means a filter is wrong.

(An earlier draft of this section predicted *neither* would run. That
was
wrong, and the simulator says so: `this PR itself → web, nesdoctor`.
Left
visible because it is exactly the mistake path filters invite —
reasoning
  about which files a change touches without checking.)

  **Confirmed on the runner**, which is no longer a prediction:

  ```
  nesdoctor  /  nesdoctor  →  success  (pull_request)
  web        /  web        →  success  (pull_request)
  ```
- **`actionlint` was not available**, so the workflow files are
validated as
YAML and by parsing their trigger and job structure, not by a
schema-aware
  linter.
- **Nothing is measured.** No before/after timings — the saving is "a
job that
  had no reason to run does not run", not a number I benchmarked.


<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR splits the combined CI workflow into independently filtered web
and nesdoctor workflows while preserving their existing job bodies.
- Web tests now run for changes to current TypeScript workspace members
and their shared configuration.
- Nesdoctor checks now run for changes to its crate, Cargo workspace
inputs, or its workflow.
- Unrelated pull requests no longer start both test stacks.

<h3>Confidence Score: 5/5</h3>

The PR appears safe to merge; the new filters cover the current inputs
of both preserved CI jobs.

No actionable failure remains: current workspace members and build
inputs are covered, no in-repository consumer relies on the old workflow
identity, and the split does not increase permissions or action
exposure.

<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| .github/workflows/web.yml | Extracts the unchanged TypeScript test job
into a workflow filtered to all current web workspace members and
relevant shared inputs. |
| .github/workflows/nesdoctor.yml | Extracts the unchanged nesdoctor
checks into a workflow filtered to the crate and its Cargo workspace
inputs. |


<h3>Flowchart</h3>

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  PR[Pull request changes] --> W{Matches web paths?}
  PR --> N{Matches nesdoctor paths?}
  W -->|Yes| WT[Run Bun install, migrations, and tests]
  W -->|No| WS[Skip web workflow]
  N -->|Yes| NT[Run fmt, clippy, tests, and no-network smoke run]
  N -->|No| NS[Skip nesdoctor workflow]
```

<sub>Reviews (1): Last reviewed commit: ["ci: run each half only when
that half
ch..."](f27ea3a132)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60935535)</sub>

<!-- /greptile_comment -->
2026-09-06 11:10:47 +00:00
Wanjohi
f27ea3a132 ci: run each half only when that half changes
Both jobs ran on every pull request, so a change to a Rust binary waited on a
Postgres service and a full TypeScript test run, and a change to a TypeScript
route spent a runner compiling Rust. Neither told anyone anything.

A `paths` filter belongs to a workflow rather than to a job, so the two jobs
become two workflows. That is the whole cost of the change: both job bodies are
carried over unchanged, and only the triggers differ.

The filters are written from what each job actually reads. `packages/` is
entirely TypeScript so it is taken whole, and the two TypeScript apps are named
because the rest of `apps/` is Rust. The Rust job takes its own directory plus
the workspace root and lockfile, which pin every version it builds against, and
nothing else — it depends on no other member of the workspace.

The failure mode of a path filter is silence: a job that does not run leaves a
green pull request. So the one thing a future change has to remember is written
where it will be read — adding a TypeScript app means adding it to that list.
2026-09-06 14:05:37 +03:00
Wanjohi
b95d939aeb feat(api): record which host holds a Steam token for whom (#327)
## What this is

A host that signs someone into Steam ends up holding a refresh token.
The
control plane needs to know that happened; it must not know the
credential.
This adds the table, the domain module and the three
machine-authenticated
routes that record the outcome, and a test that the surface refuses a
token.

| | |
|---|---|
| `steam_enrolment` | `(machine_id, user_id)` primary key, `steam_id`,
`state`, `enrolled_at`, `last_ok_at`, `revoked_at`. **No token column.**
|
| `Enrolment.record` | upsert to `enrolled` — keeps the original
`enrolled_at`, clears a previous refusal |
| `Enrolment.markStale` | machine-scoped update; `null` when there is
nothing to mark |
| `Enrolment.listByMachine` | what this host is believed to hold, oldest
first |
| `POST /machine/enrolment` | `{userId, steamId}` → the enrolment |
| `POST /machine/enrolment/stale` | `{userId}` → the enrolment, now
stale; `404` if absent |
| `GET /machine/enrolment` | the list |

All three take the host from its own credentials, never from a body, so
a box
can neither report onto nor read another box's hardware. `data` is the
object
itself — never `{"data": {"enrolment": …}}`.

## The test failing first

Both new files, against unmodified code:

```
✗ POST /machine/enrolment > the outcome is recorded and `data` is the enrolment itself
✗ POST /machine/enrolment > the machine is taken from the credentials, never the body
✗ POST /machine/enrolment > re-enrolling keeps the first `enrolledAt` and adopts the new Steam account
✗ POST /machine/enrolment > one Steam account on two hosts is two enrolments
✗ POST /machine/enrolment > a user nobody has heard of is refused rather than crashing
✗ POST /machine/enrolment > a Steam id has to look like one
✗ POST /machine/enrolment > machine credentials are required
✗ POST /machine/enrolment/stale > a refused token moves the enrolment to stale
✗ POST /machine/enrolment/stale > re-enrolling after a refusal returns the row to enrolled
✗ POST /machine/enrolment/stale > an enrolment this host does not have is a 404
✗ POST /machine/enrolment/stale > a host cannot mark another host’s enrolment stale
✗ POST /machine/enrolment/stale > machine credentials are required
✗ GET /machine/enrolment > a host with no enrolments gets an empty list, not a 404
✗ GET /machine/enrolment > every enrolment this host is expected to hold, and no other host’s
✗ GET /machine/enrolment > machine credentials are required
✗ The enrolment surface refuses a token > POST /machine/enrolment rejects every credential-shaped field
✗ The enrolment surface refuses a token > POST /machine/enrolment/stale rejects every credential-shaped field
✗ The enrolment surface refuses a token > the published surface has exactly three enrolment routes and no field for a credential

error: Cannot find module './enrolment.js' from 'packages/core/src/steam/enrolment.test.ts'

 0 pass
 19 fail
 1 error
Ran 19 tests across 2 files.
```

And after, against a database migrated from zero:

```
 363 pass
 0 fail
 1047 expect() calls
Ran 363 tests across 29 files. [8.39s]
```

`bunx oxlint` clean; `oxfmt --check` clean on every file in the diff.
`tsc
--noEmit` on `apps/api` and `packages/core` reports exactly the same 5
and 2
pre-existing errors as `dev` does — none in files this touches.

## How the token is kept out, in three places rather than one

1. **The column list.** A core test asserts `information_schema.columns`
for
the table is exactly the seven contract columns. Adding `refresh_token`,
or
   an `encrypted_token`, or a `secret`, fails it.
2. **The request bodies are `.strict()`**, derived from the domain
schema with
`Info.pick(...)` so they cannot drift from it. `refreshToken`, `token`,
`accessToken`, `challengeUrl` and `clientId` are each rejected with a
`400`.
3. **The published surface.** A test walks `/doc`, collects every
request-body
property and parameter under `/machine/enrolment*`, and asserts the set
is
exactly `{userId, steamId}` — so any new field on this surface has to be
argued for in that test, not only a token-shaped one. It also asserts
the
   route list is exactly the two paths.

Checked by hand against a live server: the rejected key's **name**
reaches the
log, its **value** does not (`grep -c eyJsecret` over the server log →
0).

## Driven over real HTTP, not only `app.request`

A real `bun run apps/api/app/server.ts`, a real registered machine,
curl:

```
GET  /machine/enrolment       → {"data":[]}                                    [200]
POST /machine/enrolment       → {"data":{…,"state":"enrolled","lastOkAt":null}} [200]
POST again, new steam account → same enrolledAt, new steamId                    [200]
POST + refreshToken           → Unrecognized key: "refreshToken"                [400]
POST + challengeUrl+clientId  → Unrecognized keys: "challengeUrl", "clientId"   [400]
POST /machine/enrolment/stale → {"data":{…,"state":"stale"}}                    [200]
GET  /machine/enrolment       → the one row, stale                              [200]
no credentials / wrong secret → Machine credentials required                    [403]
stale for an absent enrolment → This machine has no enrolment for that user     [404]
POST naming another machine   → Unrecognized key: "machineId"                   [400]
```

## Two places the contract was read rather than followed literally, both
worth a look

- **`state` is a Postgres enum, not `text`.** The three values are the
whole
state machine, so the database refuses a fourth rather than storing it.
If
you would rather have `text`, say so and I will change it — but a typo'd
  state is otherwise a silent write.
- **The row has no `id` and no `time_deleted`**, which departs from the
  every-table convention in `packages/core/CLAUDE.md`. The pair *is* the
identity, and `enrolled_at` would make `time_created` a second answer to
the
same question. The row's life is bounded by the machine's and the
user's, and
both foreign keys cascade. Flagging it because it is the sort of thing a
  reviewer should agree to on purpose.

## Review rounds: three findings, all real, all fixed

- **An overlong `userId` returned a 500.** Ids live in a `char(30)`
column, so
an overlong one is refused by Postgres with `22001` — not the `23503`
the
handler catches — and fell through to the global error boundary.
Measured
  before fixing: 44 characters → `500`, absent-but-well-formed → `404`.
`Identifier.schema` had no callers anywhere in the tree, so it now
asserts
the exact width and the separator as well as the prefix, and
`Enrolment.Info`
uses it for both foreign keys. The route picks up the constraint through
its
existing `Info.pick(...)`, so the answer is a `400` naming `userId`.
Verified
  against a live server: zero 500s across the run.
- **`user_id` was unindexed.** The key begins with `machine_id`, so
neither the
cascade behind deleting a user nor "which hosts hold a token for me" can
use
it. `steam_enrolment_user_idx` added. The migration has not been
released, so
it is folded in and regenerated through `drizzle-kit` rather than
followed by
  a corrective `0013`.

- **Every documented id was one character short.** `Examples.Id` emitted
25
payload characters where an id has 26, so the published examples were 29
characters — invalid against the width the previous fix started
enforcing.
  Never broken at runtime, since an example is not parsed; wrong in the
  documentation people copy from. The width now comes from
`Identifier.LENGTH` rather than being typed out, in both places that had
counted it by hand, and the third hand-written copy of the same literal
in
  `apps/api/app/routes/steam.ts` now calls the generator instead.

Each is covered by a test, including one that walks four differently
misshapen
ids. The existing "a user nobody has heard of" test now uses a
well-formed
absent id, so it exercises the foreign-key path it was written for
rather than
passing for the wrong reason.

## Files outside this lane's ownership

Four, each the smallest possible diff:

- `apps/api/app/index.ts` — one import, one `.route('/machine', …)`
line.
- `packages/core/src/examples.ts` — one `Examples.SteamEnrolment` block.
- `packages/core/src/id.ts` — `Identifier.schema` gains the width and
separator
checks described above, and `LENGTH` is exported so the examples can
derive
from it. `schema` had **no callers in the tree** before this branch, so
nothing else can be affected by the tightening; this lane is its first.
- `packages/core/src/id.test.ts` — new. Pins a generated id, the schema
for
  one, and the documented example together, for every prefix.
- `apps/api/app/routes/steam.ts` — one line: a hand-written `usr_XXX…`
example
literal, wrong by the same character, replaced with
`Examples.Id('user')`.
Owned by no lane this week. Flagging it because it is the only change
here
  outside enrolment's own surface.
- `packages/core/CLAUDE.md` — one row in the sub-module table, and the
sentence
  saying `steam/` owns no table is now false, so it is reworded.

`packages/core/src/steam/index.ts` is this lane's, and the change there
is one
word: `STEAM_ID_RE` is exported so the enrolment schema uses the same
rule
rather than a second copy of the same regex.

## What this does not verify

- **No host has ever called these routes.** The other half of this seam
was
written from the same document without either side reading the other's
code.
  A disagreement, if there is one, surfaces on first contact — not here.
- **Nothing enforces that a token never arrives.** The three guards
above fail
when somebody adds a field *to this surface*. They say nothing about a
route
  added elsewhere, and no test can.
- **`revoked` has no writer.** The value exists in the enum and nothing
sets
  it. Revocation is not built here.
- **`last_ok_at` has no writer**, so it is null in every row this
creates. It
  has never been exercised with a value.
- **Authorisation is not tested here and is not this lane's.** Whether a
person
may reach a given box is decided before a request arrives; these routes
authenticate a *machine*, which is a different question. Nothing here
would
  catch a mistake in the other one.
- **The `404` for an unknown user comes from catching a foreign-key
violation**
(`23503`), not from a lookup. It is exercised for a user id that does
not
exist. It has not been exercised against a user deleted concurrently
with the
  insert, which is the same code path but a race I did not reproduce.
- **The index is not measured.** It is added because two readers exist
that
cannot use the primary key, not because a plan was compared. On a table
this
  size neither would be slow yet.
- **Nothing else is measured.** No timing, no throughput, no load. The
only
  numbers above are test counts and HTTP status codes.


<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR adds machine-authenticated Steam enrolment reporting without
transmitting or storing Steam credentials.
- Adds record, stale-state, and machine-scoped listing operations in the
core domain.
- Adds corresponding `/machine/enrolment` API routes with strict request
validation.
- Adds the enrolment table, state enum, foreign keys, user index, and
migration metadata.
- Aligns identifier validation and OpenAPI examples with the fixed
30-character identifier format.
- Adds domain, API, schema, authorization, and credential-exclusion
tests.

<h3>Confidence Score: 5/5</h3>

The PR appears safe to merge; the prior identifier-example issue is
fixed and no blocking correctness, security, or repository-rule
violations remain.

The current code fully addresses all previous findings: malformed
identifiers are rejected before database access, the user foreign key
has its own index, and shared examples now satisfy the identifier
schema. The changes since the previous review introduce no new
actionable failures.

<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| apps/api/app/routes/enrolment.ts | Adds three strictly validated,
machine-authenticated enrolment routes scoped through the authenticated
machine actor. |
| packages/core/src/steam/enrolment.ts | Adds validated enrolment
recording, stale-state updates, machine-scoped listing, and
serialization. |
| packages/core/src/steam/enrolment.sql.ts | Defines credential-free
enrolment persistence with a composite key, cascading foreign keys, and
a user lookup index. |
| packages/core/migrations/0012_steam_enrolment_without_a_token.sql |
Creates the enrolment enum, table, constraints, and user index
consistently with the Drizzle model. |
| packages/core/src/id.ts | Tightens identifier validation to the exact
prefixed 30-character storage format. |
| packages/core/src/examples.ts | Corrects shared identifier examples to
produce schema-valid 30-character values. |
| apps/api/test/enrolment.test.ts | Covers route shape, authentication,
machine isolation, validation, lifecycle behavior, and rejection of
credential fields. |


<h3>Sequence Diagram</h3>

```mermaid
sequenceDiagram
    participant H as Authenticated host
    participant A as API
    participant C as Core Enrolment domain
    participant D as PostgreSQL
    H->>A: "POST /machine/enrolment {userId, steamId}"
    A->>A: Derive machineId from actor credentials
    A->>C: record(machineId, userId, steamId)
    C->>D: Upsert enrolment metadata
    D-->>C: Enrolment row
    C-->>A: Serialized enrolment
    A-->>H: "{data: enrolment}"
    H->>A: "POST /machine/enrolment/stale {userId}"
    A->>C: markStale(authenticated machineId, userId)
    C->>D: Machine-and-user-scoped update
    D-->>H: Updated enrolment or 404
    H->>A: GET /machine/enrolment
    A->>C: listByMachine(authenticated machineId)
    C->>D: Select this machine's rows
    D-->>H: "{data: enrolments[]}"
```

<sub>Reviews (3): Last reviewed commit: ["fix(core): document an id that
is
actual..."](fe5297acbd)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60932045)</sub>

<details><summary><h4>Context used (3)</h4></summary>

- Knowledge Base — [API HTTP composition and
authorization](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/api-http-and-auth.md)
- Knowledge Base — [API catalog, machine, and account
endpoints](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/api-catalog-and-machine-endpoints.md)
- Knowledge Base — [Core domain and
persistence](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-domain-data.md)
</details>


<!-- /greptile_comment -->
2026-09-06 10:59:26 +00:00
Wanjohi
fe5297acbd fix(core): document an id that is actually a valid id
The example generator emitted twenty-five payload characters where an id has
twenty-six, so every documented id was twenty-nine characters — one short of
the width the column holds and, since last commit, one short of what the schema
publishing it will accept. Nothing caught it because an example is never
parsed: it is copied into documentation and read by people.

The width now comes from the generator's own constant instead of being typed
out, in the two places that had counted it by hand. Counting twenty-six of
anything by eye is a thing people get right once and never re-check.

A test pins the three together — a generated id, the schema for one, and the
documented example must all agree, for every prefix. It fails on the
off-by-one that prompted this, and on a prefix without its separator, which
would otherwise read as an id of that type because it starts with the same
three letters.
2026-09-06 13:57:12 +03:00
Wanjohi
3d24a8e130 feat(nesinit): carry the session's address out of the guest
Whatever serves media in a box knows how it can be reached, and the person who
needs to know is not in the box. Standard output here is a log file inside a
VM, so the control channel is the delivery path rather than a convenience --
which makes this init's job and not a detail of whichever component happens to
bind the port. The socket it reads was already documented as being read this
way; nothing read it.

Polled rather than read once, because an address is not a value but the best
answer so far. An endpoint discovers more ways to reach it after it binds, so
the first answer is the one that works on a local network and fails from
anywhere else. Only a changed answer is forwarded.

It dials rather than listens, which is the opposite of the relay next door and
deliberate: there the guest listens because the workload starts later, and here
the server is the long-lived one. Dialling also makes a server that has not
bound yet something to retry rather than something to wait for without knowing
whether it is coming.

The address itself is never logged. It is a capability to reach the session,
and a log inside the guest is the one place it has no reason to be.

A carrier that stops does not end a session: whatever was already reported is
still correct, and the workload's exit still has to be.
2026-09-06 13:55:35 +03:00
Wanjohi
64a90abf75 fix(api): a misshapen id is bad input, not a server fault
Ids are stored in a fixed-width column, so an overlong one is refused by
Postgres rather than simply matching nothing. That refusal is not a foreign-key
violation, so it fell through to the global error boundary and reached the
caller as a 500 — telling a host to retry something that can never succeed.
Measured: a 44-character user id returned 500, where an absent but well-formed
one correctly returned 404.

`Identifier.schema` is the natural place for the check and had no callers yet,
so it now asserts the exact width an id has as well as its prefix — including
the separator, without which `usrsomething` reads as a user id. The enrolment
schema uses it for both foreign keys, so the refusal happens where the input
arrives and names the field.

Also index `steam_enrolment.user_id`. The primary key begins with the machine,
which answers what one host holds and nothing else, so neither of the two
things that read by user alone can use it: the cascade behind deleting a user,
and asking which hosts hold a token for one person. The table's migration has
not been released, so this is folded into it rather than following it with a
correction.
2026-09-06 13:51:20 +03:00
Wanjohi
6429ec4ff7 feat(api): record which host holds a Steam token for whom
A host that signs a person into Steam ends up holding a refresh token. The
control plane needs to know that happened — to show it, and so a host that
lost its disk can find out what it is expected to hold — but it must not know
the credential, because the token is bound to the address that obtained it and
a copy anywhere else is the account-theft signal Steam watches for.

So `steam_enrolment` stores the outcome and has no token column, no encrypted
token column, and no column that could hold one later. The safeguard is that
the credential is never sent here at all; a nullable column would be the first
step in undoing it, so a test asserts the column list exactly and fails if one
appears. Three machine-authenticated routes go with it: report a completed
sign-in, report that Steam refused the token, and list what this host should
have. All three take the host from its own credentials, so a box can neither
report onto nor read another box's hardware. Their bodies are strict, so a
host that sends a token is told it is wrong rather than quietly believed —
which also keeps the value out of the request log.

The Steam id is deliberately not unique. One account signed in on two hosts is
two rows and two tokens, and a unique index there would look like hygiene while
refusing somebody their second box.

There is no `pending` state: a sign-in challenge lives about two minutes inside
one process, and nothing outside it needs to know it exists. Nothing revokes
yet, and `last_ok_at` has no writer — a successful logon happens where there is
no credential to report it with — so the column exists with the shape it will
need and stays null rather than being filled with the nearest event that was
easy to observe.
2026-09-06 13:27:51 +03:00
Wanjohi
7f7e39de60 docs: point the test database setting at the right database
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.
2026-09-05 18:08:26 +03:00
Wanjohi
dae2990cbe docs(core): say which database the tests actually need
The helper told you to use "an isolated database for tests", which reads
as a database of its own and is not what the suite wants. Route tests
reach the database through the app and core tests reach it directly, so
two different values put the fixtures in one database and the assertions
in the other — around forty failures, none of them in the code that
caused it, and nothing in the output naming the setting.

Also drops a type import nothing uses.
2026-09-05 18:08:26 +03:00
Wanjohi
b296918ab4 feat(api): record what a host says it is running
A host agent already sends a full inventory snapshot on a cadence, and
nothing served the endpoint it sends it to — so every one of those calls
answered 404. It fails quietly by design, because a dropped snapshot is
meant to be corrected by the next one, which is exactly why nobody
noticed: the only symptom is a line in the agent's own log.

Kept separate from the heartbeat because the two have different loss
tolerance. A dropped beat moves a host towards offline and unplaces it;
a dropped snapshot costs nothing until the next one arrives. Folding them
together would let a malformed inventory field make a healthy host look
dead.

Three rules decide what a snapshot may do, and the last two are why this
is one core function rather than a loop in the route:

- a box we know, that the snapshot names, takes the reported state
- a box we know that was running, and that the snapshot omits, is
  stopped and says so — absence inside a snapshot is information
- a box the snapshot names that is not placed on the calling host is
  never created, only reported back as a divergence

The scope is in the `where` clause and not in the agent asking politely
about its own boxes: a machine credential is a long-lived secret sitting
on hardware in somebody's living room.

`pid` and `uptimeS` are accepted and deliberately dropped. A pid is a
number in another machine's namespace, and uptime is derivable from a
run's start time, which is already stored and already trustworthy.
2026-09-05 18:08:25 +03:00