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
Moving the issuer's state into Postgres removed the last thing that tied
either app to one hosting provider. What was left was a deployment tool
describing resources that no longer existed — so this replaces it with
`wrangler`, which is what actually deploys a Worker, and adds a second way
to run each app that involves no provider at all.
Each app now has a `wrangler.jsonc` with an environment per stage, and a
`Dockerfile` beside it. The handler is the same one in both cases; what
differs is only where its settings come from. Two of them gained a second
spelling so that nothing has to branch on the runtime: Postgres arrives as
a pooled binding or as `DATABASE_URL`, and the route to the issuer is a
service binding or `AUTH_INTERNAL_URL`.
That last one is new, and it is a split the binding was already making
without saying so. `AUTH_ISSUER_URL` has to be the issuer's public name,
because it is compared literally against every token's `iss` claim — but
the public name is often not routable from inside a deployment. So the
name and the route are two settings now rather than one that cannot be
both.
DNS moves out of code and into `docs/dns.md`, which lists every hostname
and what it is for. Six records that change roughly never did not need a
tool, and the table outlives whatever is answering the names — which is
the point, since some of them will stop being Workers. The sandbox
hostnames are hyphenated rather than nested for the same reason: a
certificate covering `*.nestri.io` covers one label and not two, so
`api-sandbox.nestri.io` can become an ordinary origin later without a
certificate having to be ordered for it first.
Also drops `EMAIL_DEV_LOG` from committed configuration into `.dev.vars`,
which `wrangler deploy` cannot upload. Printing a live sign-in code to a
log should not be one forgotten override away from production.
The issuer kept everything behind one get/set/remove/scan interface, which
is what a library that must run on any provider's cache can offer. Three of
the things kept there could not actually be served by it.
An authorization code must be redeemable once and a refresh token spendable
once, and through get and set the check and the write are separate steps —
so two requests arriving together both read an unspent record, and both mint
a session. In the refresh case that also means the reuse which reveals a
stolen token is never recorded, because recording it is the write that the
second caller overwrites. Each now has a table and an interface of its own:
redeeming is one `delete ... returning`, spending is one
`update ... where time_used is null returning *`, so exactly one caller is
ever told it went first. This is the same argument the device grant already
made, applied to the two records that had it too.
Signing keys move for a different reason. Nothing races for them; they are
the one record whose loss ends every session at once, and a cache is a place
things may be evicted from. They are retired by setting a column rather than
deleted, so the tokens they signed stay verifiable until they expire.
Both credential tables store a hash and never the credential, as the device
grant does. An authorization code travels in a query string and so passes
through history, referrer headers and any log along the redirect; a refresh
token resumes a session outright.
What is left in the generic store is the rate-limit counters — written far
more often than read, meaningless within the hour, and allowed to be
approximate, since a lost increment costs one guess out of ten. Those move
to Postgres too, so the only key-value binding this deploys with is gone and
the control plane's state is one database. That was the point: nothing here
now depends on a primitive a self-hoster cannot run.
The generic scan also gained the separator on its prefix, so scanning `a`
cannot return what is under `ab` — subjects and email addresses are both
prefixes of longer subjects and email addresses.
Deploying this signs everyone out. The signing keys and refresh tokens are
in a store that is being left behind, so the issuer starts with a fresh key
set and every existing token stops verifying.
Anybody could ask for a device code and be handed a link with the user
code already in it. Following that link started a sign-in, and finishing
the sign-in approved the grant. So sending somebody the link was enough:
they saw an ordinary sign-in prompt, completed it, and whoever kept the
device code polled and collected their access and refresh tokens. The
victim never saw a question, because there was not one.
There is now. Signing in says who the browser belongs to; it does not say
the person meant to hand an account to a program somewhere else. Those
are two questions and only the second authorizes anything, so the flow
ends at a page that names the program, shows the code back so it can be
compared with what the device is displaying, and offers Approve and Deny.
Approving is a POST carrying a value from the cookie, so another site
cannot submit it on somebody's behalf. Denial moved onto the same page:
it used to be a GET anyone could fire, which meant a link scanner could
cancel a real sign-in and a stranger with a user code could grief one.
Three more things that were wrong underneath.
The grant was read, modified and written back as a whole record. A poll
that read a pending grant and then wrote its bookkeeping erased an
approval that landed in between, and the client polled a dead grant until
it expired. Grants moved to a table, where approving is one conditional
update and redeeming is one delete that returns what it deleted, so
neither party can undo the other and two polls cannot both be served.
Tokens were minted when the person clicked and left sitting in storage
until collected. They are minted at redemption now, so the lifetime the
client is told about starts when it receives them, and a grant nobody
collects leaves no usable refresh token behind.
The client identifier was never checked, at either end. It is validated
when the grant is created and has to match when the code is redeemed —
without that, a leaked code is redeemable by anyone, and the identifier
the token carries is whatever the last caller claimed. The device code
is also stored as a hash now, since it is the credential the tokens are
handed to.
The store is an interface because the issuer cannot reach the database,
and because the guarantees are the point: every method is one operation,
and no caller reads a grant, decides, and writes it back.
Signing in with a gaming account or with an SSH key could both bring a
user into existence. That makes an account only as recoverable as the
thing that created it, and gives one person as many accounts as they
have gaming logins — neither of which is what an account is supposed to
be now that verifying an address is what creates one.
Both are unwired rather than deleted. The provider implementations stay
where they are, because connecting a gaming account is still something
this product does; it just does it from the API, against a user who
already exists, which is a connection hanging off an identity rather
than an identity of its own.
The worker test followed: it exercised the two flows that are gone, and
now covers the one that is left plus an assertion that the other two are
not routed, so they cannot come back quietly.
Wires the pin-code provider, which existed and was never reachable, and makes
it the only branch that can create an account. Steam now resolves an existing
connection instead of minting a user from a persona, and refuses when there is
no account behind it — which is an answer the interface renders rather than an
implicit signup.
Delivery is a small provider-neutral POST rather than a vendor SDK: configure
an endpoint, a key and a from address. With none of them set it logs the code
outside production so a local sign-in works, and throws in production, because
a screen that says "check your email" when nothing was sent leaves someone
waiting instead of telling anybody.
A person who has only ever signed in by email has no connected account, and
the token says so with an empty value — the same one a server-to-server caller
has always carried.
Comments and served API descriptions here had grown references that only make
sense to someone with our internal notes: relative paths that escape this
tree, filenames and titles of documents nobody outside can open, quoted prose
from them, and the name of a component that has no public surface — once in an
OpenAPI description, which is published output rather than source.
None of it was load-bearing. Every case restates as what the code actually
requires, and every rewrite came out shorter: "in the words the host agent
reports" for a component name, "republished as addresses are discovered" for a
quoted phrase, "a size tier sets vCPU, RAM and the output geometry" for a
sentence that had been carrying a path.
Internal reasoning is now cited exactly one way, ref(d-NNNN) in a source
comment, with the rule that the sentence must still stand if the marker is
deleted. CLAUDE.md leads with it, because the previous version of this mistake
was made by people who knew the repo was public and it still took ten
occurrences to notice, so "be careful" is not a mechanism.
Commit messages get the stricter rule and carry no references at all: a
comment can be fixed by the next commit and a published message cannot be
fixed at all. Git hooks now enforce both halves.
The check caught a real one while being written: the CLAUDE.md table spelled
out the paths it was prohibiting, which discloses them to exactly the reader
it protects against.
138 tests, 0 fail.
Migration 1 of 0048, and the first of the seven weeks — nothing about a live
feed works without these two tables, so it is not a cleanup during them.
box a VM someone owns: an id that is also its DNS label, an editable
label, an owning user, the machine it sits on, a tier and a state.
Owned by a person and placed on a team's hardware, which are two
different relationships, hence both userId and machineId.
session one run of one box by one linked Steam account, and what costs
money. Separate from box because the ticket changes after bind as
addresses are discovered — the vsock contract calls it "a stream,
not one value" — so it is a column a client polls, not a value it
is handed once.
Box states are neslet's own three and no more. `starting` and `stopping` are
the obvious additions and both are omitted because nothing would ever write
them; a failed box is `stopped` with stopClean false, which is how neslet
models it too.
The generated migration would have failed on live rows in three ways, so it
is hand-written and tested against a database seeded at the old schema:
- machine.team_id becomes notNull, and *every existing row is null* because
the old registration path passed null. Personal teams are backfilled for
machine owners first, reusing a team they already own rather than minting
a second, with the owner membership row repaired where missing.
- game_download.host_id becomes a foreign key. It held free-form strings,
so unattributable rows are deleted before the cast — the only destructive
statement here, and a considered loss: it is a progress report neslet
re-derives from disk.
- Team.createPersonal was written and documented in packages/core/CLAUDE.md
as part of the login flow and never actually called, so no user has a
team. ensurePersonal is idempotent and now runs on every login, which is
what backfills accounts the migration does not reach.
Verified on a seeded legacy database: three null-team machines backfilled, an
existing team reused rather than duplicated, a blank display name handled, and
both unattributable download rows dropped while the attributable one survived.
Also fixes two things this work ran into rather than caused:
- Database.client() built a new postgres pool on every call, and use()
called it twice per invocation — pools of ten connections held for a 30s
idle timeout. Invisible in a Worker where requests are short; the suite
crossed 100 connections and Postgres said "sorry, too many clients
already" in whichever file ran last, which reads as a flaky test rather
than a leak. Now one pool per connection string.
- download.test.ts asserted against `hst_…` host ids, which is exactly the
unattributable row the new foreign key exists to refuse.
There is no "no team" any more: PATCH /machine/:id took teamId null to mean
"mine alone" and now requires a team, because the personal team is the one to
name. Its test is updated to the new contract rather than deleted.
113 → 128 tests, 0 fail.