Two problems found in review, both in the key store.
Nothing stopped a kind from having two live keys, and the bootstrap path
walks straight into it: two workers starting against an empty table both
find no key and both insert one. From then on each signs and encrypts with
its own. That is not the harmless split the comment here claimed — the
issuer reaches for a single key rather than the published set when it
decrypts a session cookie and when it verifies an access token, so a cookie
written by one worker is unreadable to the other and a token minted by one
is rejected by the other. It stays silent until someone cannot sign in.
A partial unique index over the kind, where the key has not been retired,
makes the second insert a dropped write instead. Both workers then read the
table again and use the key that won, which is all that matters. The
conflict clause stops naming a target: both indexes on the table mean the
same thing at this call site, that the row already exists in some form.
Creating a key is now attempted once rather than retried, because a store
declining the write is an expected answer and spinning on it would hang the
request instead of failing it.
Separately, a key pair reported the algorithm the issuer currently uses
rather than the one stored on the key it was built from, so a retained key
would advertise the wrong algorithm in a token header and in the JWKS after
a rotation — which defeats keeping it. The material was already being
imported with the stored value; only what was handed back disagreed.
Retiring a key and creating its replacement now have to happen together, so
that a kind never has two live keys and never has none.
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.
The mutual exclusion was asserted only through sequential calls, where
the winner had already committed before the rival began. That never
reaches the case the design is for: both attempts reading the run as
unclaimed before either writes.
Two tests, because the first can pass for the wrong reason. The
concurrent transitions depend on how the transactions interleave; the
paired updates skip the read entirely, so nothing but the predicate in
the where clause can refuse the second.
Both fail with two winners if the check is moved out of the write and
left in the read above it.
The agent side sends a claim token on every write; this side rejected the
field outright, so every state report and every ticket publish answered
400. Both bodies now take it.
Underneath that, nothing compared a holder. A run was reachable by any
caller on the right machine, and a box names exactly one machine — so two
attempts polling the same job presented identical credentials and were
told apart only by which one's select landed first. That is timing, not a
rule, and no caller could be told which case it was in.
The row now remembers which attempt holds it. Taking a claim requires
there to be no holder; every write after it requires the caller to be the
holder. The same state reported by a different attempt is a lost race and
not a retry, and is refused whatever the state is - which is the only
thing that separates the two 200s from the 409s.
The ticket is held to the claim too, for a worse reason than a double
start: the client re-reads the address rather than keeping the first, so
a ticket written by a losing attempt produces a client that connects,
successfully, to a machine running nothing.
The holder is never cleared, including on a terminal state, so a settled
claim cannot be replayed and a finished run still records which attempt
ran it. It is not in what goes out - holding one permits writing to a
run, and the owner reading their own session is not the holder.
The cap on how many codes a sign-in could ask for was held per attempt,
keyed by a value in the caller's own cookie. That bounds nothing. The
caller decides how many attempts to start, and starting a fresh one costs
them a discarded cookie — so either replaying an older cookie or simply
beginning again walked straight around it, and the only thing left
spacing the mail out was the interval between sends.
The count now sits against the claim, over a window. That is the thing
being protected: the mailbox belongs to somebody who did not ask to hear
from us, and whoever is pointing at it is not the party to trust with the
tally.
A resend also left the previous code live, with a budget of guesses of
its own. Several resends therefore meant several working codes and
several times the chances at them, which made asking for a new code the
cheapest way to buy more tries at the old one. A new code now retires the
one before it.
Reported against the replay path. The replay was real and the same hole
was wider than that: starting a new attempt needed no replay at all.
A user code is eight characters from a twenty-five character alphabet,
which is a large space but a fixed one, and the endpoint that checked
them had no opinion about how often you asked. That is the guessing
attack RFC 8628 section 5.2 asks implementations to limit, and nothing
here did.
Wrong codes are now counted per caller address over a rolling window,
and the endpoint stops answering once the budget is gone. Getting a code
right is not charged for, so somebody who mistypes once and then succeeds
is not walking towards a lockout. A caller whose address cannot be
established shares one bucket with every other such caller, which makes
stripping the headers that say where you are buy a smaller budget rather
than an unlimited one.
The counter lives in the general-purpose store and is approximate. The
number that decides this is whether somebody is working through the code
space, and a handful either way does not change that answer.
A six-digit code has a million values, and nothing was counting how many
of them a caller tried. The code travelled in an encrypted cookie the
caller held, verification compared against that cookie, and a wrong
answer simply re-rendered the form. Nobody has to be the person the code
was mailed to: type somebody else's address into the first screen and the
code goes to their mailbox while the cookie stays with you. At that point
the only thing between a stranger and an account is a million requests,
and the constant-time comparison protecting the code was guarding a door
you could just keep knocking on.
Guesses are now counted on the server, under a name that changes with
every code. That placement is the point: a counter kept beside the code,
in the cookie, is a counter the guesser can wind back by replaying an
older copy. Starting over is still allowed and still costs a fresh code
sent to the mailbox being aimed at, which is where somebody notices. A
correct code spends its record too, so its remaining guesses do not carry
into the next one.
The cookie also lived for twenty-four hours, which made the pin a
password with a million possible values and a day to try them. Ten
minutes now, and the code stops being accepted when the clock says so
rather than when the cookie happens to go away.
Resend had no limit either, so the button was a way to mail a stranger as
fast as requests go out. Codes to one address are spaced, and one attempt
at signing in can only ask for so many.
Both refusals say the same thing on purpose. Which of the two it was is a
fact about somebody else's mailbox.
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.
Three rules here are enforced across a lookup and then a write, and each
was only as good as whatever stopped the two from interleaving. Nothing
did.
The connection cap counted with `select ... for update` over the
connections a user already had. That locks the rows it finds, and when
it finds none it locks nothing — there are no gap locks under read
committed — so several first-time links all counted zero and all
inserted. Six concurrent links against a cap of four produced six. The
count now happens under a lock on the account's own row, which is the
one thing every caller for that account is guaranteed to contend on.
Creating an account from a verified address looked the address up and
then inserted. Two tabs finishing the same sign-in both found nothing,
and the loser got the driver's constraint violation instead of the
account the winner had just made. The unique index is the thing that
actually arbitrates, so the loser now reads back what the winner wrote.
Claiming an address on an older account had the same shape and now gives
the same sentence a screen would have shown a moment earlier.
The tests run each call several times at once against a real database,
because run one at a time all three pass whether or not any of this
exists.
The migration adds session.claim_token, but neither the schema nor the
snapshot knew about it. Nothing breaks today because the two agree with
each other; it breaks the moment someone declares the field, because
generate then diffs against a snapshot without it and emits
ALTER TABLE "session" ADD COLUMN "claim_token" text;
which fails on every database the migration has already run against.
Declared with no writer yet, so the schema, the snapshot and the
database say the same thing.
Connecting a Steam account wrote the row itself, so the limit on how many one
person may connect was enforced on the sign-in path and nowhere else — and
this is the path the settings screen calls, which makes it the one that would
have gone over. It now resolves who is asking and hands over to the single
place the rule lives.
Two things fall out of that. A Steam account already connected to somebody
else is a conflict rather than a silent success returning the other person's
row id, and a Steam id of the wrong shape is refused before a lookup.
A program with no browser — the desktop app — had a client for RFC 8628 and
nothing to point it at. This serves the other half: a device authorization
request that hands back a code, a page a person enters that code on, and a
token endpoint that answers the poll.
Both of the paths the client already implements are now reachable. Polling
faster than the advertised interval gets slow_down, and each warning widens
the interval so ignoring one costs more than the last; refusing gets
access_denied, so a request nobody started stops instead of being polled until
it ages out. The interval is capped, because it only ever grows and a code has
to stay pollable for the whole of its life.
The codes live in the same storage as the other short-lived grants rather than
in a table, since that is what they are. User codes are drawn from an alphabet
with no vowels and no look-alike pairs, and are accepted back in whatever case
and spacing a person retyped them in.
Runs against a database where every user was created by a gaming sign-in, so
most rows have no email at all and nothing has ever stopped two rows from
sharing one. The address is normalized first, duplicates are separated before
the unique index exists — the older row keeps the address, the newer one is
asked for a new one and loses nothing else — and the index is partial so that
accounts with no address do not collide with each other.
Verified against a database built to contain the awkward rows rather than
against an empty schema, by the script alongside it: an account with no
address, one with both, one with two connections, a duplicated address in two
different cases, an account already over the connection cap, and a deleted row
holding an address a live row also holds. Removing the de-duplication makes
the index creation fail, which is how we know the fixtures are load-bearing.
Also adds a nullable column recording which attempt holds a session run. It is
not part of the change above and carries no reason of its own; the endpoint
that reads and writes it arrives separately, and it is here because a schema
change has one owner at a time.
Signing in with Steam used to create the account. That made a second Steam
account a second person, and it made losing a Steam account lose everything
attached to it — the boxes, the team, the billing history.
Invert it. A user comes into existence by verifying an email address and
nothing else; a Steam account hangs off a user that already exists, capped at
four. Signing in with Steam resolves an account and refuses when there is
none, so the accounts made before this keep working — they already have the
connection this looks for — while nothing new is created behind a persona.
The cap lives here rather than in the schema because a unique index cannot
count the rows sharing a foreign key. The email column gains a partial unique
index instead, which is the constraint that can be expressed, and the address
is trimmed and lower-cased at the edge so two spellings are not two accounts.
Four things the session endpoints did not do, or did wrongly.
The box had three states and nothing wrote them. A box read `created`
while a run on it was `live`, so every screen showing a person what their
hardware is doing was reading a column no code had ever moved. A run
reaching `live` now makes its box `running`, and a terminal run stops it:
`ended` cleanly, `failed` not, carrying the reason the agent gave. Not
every run state maps — a box has no `starting` on purpose, because that
transition is synchronous from the agent's side and a state nobody sets
is a state that lies. Both writes are one transaction, since "this run is
live" and "the box under it is running" are one fact in two tables, and a
box stuck `running` with nothing on it has nothing to correct it.
`POST /session` accepted any game in the catalog. A run launches as a
Steam account that has to own the game, so one outside the caller's
library is a box that starts, tries to launch and fails minutes later
with nothing to point at; it is now refused up front. Told apart from a
game that does not exist rather than hidden, because the catalog is
public and "you do not own this" is a sentence a person can act on. The
library is a synced copy, so this refuses a game bought since the last
sync — that is a staleness bug in the sync, not a reason to start runs
that cannot work.
Publishing a ticket only refused terminal runs, so a host could publish
an address for a run it had never claimed. A ticket is the address of
something being brought up, so only `starting` and `live` accept one, and
the state is in the write rather than only in the check above it. The two
refusals stay separate answers because they are different mistakes: one
agent skipped a step, the other has nothing left to reach.
The migration that adds the one-active-run index stopped older duplicate
runs without clearing the ticket they had published, which is the
invariant that same migration exists to establish. It clears it now,
verified against a box carrying two unstopped runs.
Nine tests, each checked against the unfixed code first.
Two invariants the session endpoint stated but did not hold.
A box runs one thing at a time. `POST /session` read `activeForBox` and
refused when something was already running, but the read and the insert
are two statements with nothing between them: two requests that both saw
"nothing is running" each got a row, and the job poll then handed the
host the same box to start twice. Demonstrated at 2 rows and 2 jobs from
one box. That is the failure the state claim exists to prevent, one step
earlier, and it takes the same answer — a partial unique index on the
predicate the read asks about, so the database refuses the second insert.
`Session.request` turns that refusal into the same 409 in the same words,
so a caller cannot tell which of the two caught it.
The migration resolves any existing duplicates before creating the index,
keeping each box's newest unstopped run because that is the one a person
is waiting on, and stopping the rest rather than deleting them.
Separately, a run that reached `ended` or `failed` kept the last ticket
it published. Publishing a new one is already refused, so the stale
address was both the only ticket a client could read for a dead run and
the one nothing was allowed to replace — and a client that polls would
dial it. Terminal transitions now clear it, in `setState` as well as in
the compare-and-set, so the invariant does not depend on which writer
stopped the run.
Seven tests, each checked against the unfixed code first. The published
descriptions for the ticket field and the read endpoint now say that a
stopped run has no address.
A run of a box had core support and no HTTP surface. This adds both halves
of it: a person asks for a run and reads it back, and the host agent the box
is placed on is handed the work and reports what happened.
The access rule is the point. An agent may only see or touch a run whose box
is placed on its own hardware, and that is a `where` clause on every one of
the three agent endpoints rather than a check next to them — host credentials
are long-lived secrets sitting on hardware in somebody's home, so what one
leaking can reach has to be decided by the query. "No such run" and "not your
run" are the same refusal, so ids cannot be discovered by reporting states
at them.
`Session.setState` updated on the id alone, which means two agents polling
the same work both succeed and both start the same box. There is one host
today, which is exactly why that would have been built wrong and stayed
wrong. The state a run is moving out of is now part of the `where` clause,
so the database picks the winner; the loser gets a conflict rather than a
silent no-op. Three cases that look alike are kept apart: re-reporting a
state you already reported changes nothing and is not an error, a transition
that does not exist is refused with the run left where it was, and another
host reporting anything is forbidden.
Asking for a run makes no decision about where it happens — a box already
names its hardware, so the run inherits it by join. Placement therefore
gets an interface at box creation, where the decision actually is, with the
single-host case as its implementation and a deliberate refusal when there
is more than one candidate and no policy to choose with.
Tests cover the wire shape from both sides, the query scoping, the claim,
and the timestamp idempotence a run's billing rests on.
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.
Second half of G1's "neslet registers against api.nestri.io and heartbeats".
Registration already worked; there was no heartbeat endpoint at all — grep for
it across apps/api and packages/core returned nothing, and neslet's own
main.rs says the same from its side.
POST /machine/heartbeat, machine credentials only. Two decisions worth stating
because neither is obvious from the diff:
**It returns the interval.** The auth middleware already touches lastSeen on
every authenticated machine request, so an endpoint that only did that would
add an endpoint and no capability. What a host cannot know on its own is how
often the control plane wants to hear from it, so the response carries the
cadence. A fleet whose interval can only change by shipping a new agent is a
fleet whose interval never changes.
**It takes no body.** neslet has a HostSummary ready to send, and week 2 owns
box state reporting. Accepting fields nothing acts on yet would mean a wire
shape we would have to keep, chosen before the thing that consumes it exists.
Online-ness is derived from lastSeen rather than stored: a host that stops
beating goes offline through the passage of time, which is the one mechanism
that cannot itself fail. Three missed beats, not one — a single missed beat is
a lost packet, and treating that as offline would make placement flap.
Also: the machine actor's teamID stops being optional. It was `...(teamId ? {}
: {})` in the middleware, a branch for a state that cannot exist now that
machine.team_id is notNull.
134 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.
`bun run db:push` has been failing on **every pull request** with
`error: Script not found "db:push"`. A red check has meant nothing for
as long as that's been true.
There are **two independent causes**, and fixing only the reported one
leaves the job red.
## 1. The script isn't at the root
`db:push` lives in `packages/core/package.json`; CI runs from the root.
Added root passthroughs for `db:migrate` and `db:push`, so the command
CI runs is also the one a human can run.
## 2. `drizzle.config.ts` enabled TLS for any `DATABASE_URL`
```ts
ssl: !!process.env.DATABASE_URL ? { rejectUnauthorized: false } : false
```
That's true for *any* URL — so it failed against every plain Postgres,
**including CI's own `postgres:18-alpine` service container**. And
`drizzle-kit` reports that failure as a spinner and a non-zero exit with
no message attached, which is why it would have been maddening to find
from a log.
Measured against a local container:
| | result |
|---|---|
| `DATABASE_URL` set (TLS on) | migrations fail, no error text |
| `DATABASE_URL` unset, same database | all seven apply |
TLS is now decided by the connection string: an explicit `sslmode` wins,
otherwise a local host gets none (it doesn't speak TLS at all) and any
other host gets TLS without chain verification, which is what a hosted
Postgres usually needs. The URL is parsed once rather than eight times.
## CI now applies migrations instead of `push`
`drizzle-kit push` diffs the schema against whatever is already in the
database and is a development tool — CI wants exactly what's committed
in `packages/core/migrations`. And `push` under `strict: true` asks for
confirmation, which on a runner is a **hang**, not a failure.
## Verified
Locally against `postgres:18-alpine` from an empty database, running
exactly what the workflow runs:
```
bun install --frozen-lockfile ✓
bun run db:migrate ✓ 7 migrations applied
bun test ✓ 113 pass, 0 fail, 297 expect() calls
```
Worth landing ahead of #310 so that a red check starts meaning something
again.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR repairs the database-backed web CI job by exposing core database
commands at the workspace root, applying committed migrations instead of
schema push, and selecting PostgreSQL TLS behavior from the connection
URL.
- Adds root passthrough scripts for database migration and schema push
commands.
- Adds the core `drizzle-kit migrate` command and runs it in CI.
- Disables TLS for local PostgreSQL while honoring explicit `sslmode`
settings.
- Keeps migration and test steps pointed at the same temporary CI
database.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge, with the migration command, working
directory, connection settings, and test database remaining aligned.
The changed CI path reaches the committed migration history through the
intended core package configuration, uses plaintext for the local
PostgreSQL service, and then tests against the same migrated database;
no changed-code defect remains.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| .github/workflows/ci.yml | Replaces schema push with committed
migration execution while preserving the shared CI database URL. |
| package.json | Adds root-level passthroughs to the core package's
database commands. |
| packages/core/drizzle.config.ts | Parses the database URL once and
selects TLS based on explicit mode or local-versus-remote host
inference. |
| packages/core/package.json | Adds the `drizzle-kit migrate` script
consumed by the root command and CI workflow. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
PR[Pull request or main push] --> CI[Web CI job]
CI --> PG[PostgreSQL 18 service]
CI --> Install[Bun frozen install]
Install --> Root[Root db:migrate script]
Root --> Core[packages/core db:migrate]
Core --> Config[drizzle.config.ts]
Config --> Migrations[Committed migrations]
Migrations --> PG
PG --> Tests[Bun tests]
```
<sub>Reviews (1): Last reviewed commit: ["fix(ci): the web job has been
failing
on..."](203e882fbd)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=59430932)</sub>
<!-- /greptile_comment -->
CLAUDE.md was 1,324 lines and all of it was about the TypeScript half, written
before there was another half. Every line of it loaded on every turn regardless
of what was being worked on, which is a real cost paid constantly for context
that is usually irrelevant.
Split by where it applies, so each guide loads when you are in the directory it
describes:
packages/core/CLAUDE.md 694 domain modules, fn(), actor, errors, auth
apps/api/CLAUDE.md 284 routes, registration, error flow
docs/alchemy.md 345 stages, bindings, secrets, the CLI
CLAUDE.md 72 the repo, both toolchains, two hard rules
Nothing was rewritten or dropped — the three files are the original text,
verified identical after the split. What the root file now carries is only what
is true repo-wide: the layout, the commands, where the detail lives, and the two
rules that are not style preferences. One of those is that nothing closed may
enter this repo, which is here because it has already been caught once.
The README described a streaming platform in four bullets and did not mention
that half the repository is Rust that runs inside a virtual machine. It now says
what each component does, why a micro-VM rather than a container, what is
deliberately absent, and what decides whether a thing is open — data is, capacity
is not.
It also says plainly that this is mid-rewrite and the docs are behind. Someone
arriving at a repo whose documentation does not match its tree should be told
that by the README rather than discover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Squashes the current state of the internal working tree onto this history.
The two trees had grown apart with no common ancestor, so this is a content
sync rather than a merge, and the published history is preserved rather than
rewritten — a force-push here would break every existing fork and clone to no
benefit.
What lands:
- Waitlist: API route, core module, and migration 0006 alongside game aliases.
- User verification.
- CI, oxfmt config, editor settings.
- Assorted fixes across the API routes and core modules.
The repository's own README, the wordmark and the per-package READMEs are kept
from this side; the internal tree had dropped them and they are what a stranger
arriving here reads first.
The marketing site in the internal tree is deliberately not here. It is a
separate product with its own repo and its own licence, and this repo is the
open one — a closed component does not belong in it regardless of how convenient
the directory looked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Description
Next step would be having full DE environment variant I guess? I'll see
later if it's doable in this PR or if I'll do separate one for keeping
things small and manageable for once 😅
- Added easily doable variants for runners, with simple CI build matrix.
- Added playsite in CI builds finally.
- Some CI formatting and naming fixes.
- Removed PR full runner builds as they kept failing due to lack of disk
space on GH runner.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* New dedicated runner images for Steam, Heroic, and Minecraft plus a
common runtime and builder images.
* **Chores**
* CI/workflow reorganization to build and publish more runner variants
and base images.
* Installer and package tweaks (package manager flags, CUDA enablement)
and updated build tooling.
* Unified startup to use a constructed launch command; removed two
default environment exports.
* Added container ignore patterns.
<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
## Description
### First commit
Restructured protobuf schemas to make them easier to use across
languages, switched to using them in-place of JSON for signaling as
well, so there's no 2 different message formats flying about. Few new
message types to deal with clients and nestri-servers better (not final
format, may see changes still).
General cleanup of dead/unused code along some bug squashing and package
updates.
TODO for future commits:
- [x] Fix additional controllers not doing inputs (possibly needs
vimputti changes)
- [x] ~~Restructure relay protocols code a bit, to reduce bloatiness of
the currently single file for them, more code re-use.~~
- Gonna keep this PR somewhat manageable without poking more at relay..
- [x] ~~Try to fix issue where with multiple clients, static stream
content causes video to freeze until there's some movement.~~
- Was caused by server tuned profile being `throughput-performance`,
causing CPU latency to be too high.
- [x] Ponder the orb
### Second + third commit
Redid the controller polling handling and fixed multi-controller
handling in vimputti and nestri code sides. Remove some dead relay code
as well to clean up the protocol source file, we'll revisit the meshing
functionality later.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added software rendering option and MangoHud runtime config;
controller sessions now support reconnection and batched state updates
with persistent session IDs.
* **Bug Fixes**
* Restored previously-filtered NES-like gamepads so they connect
correctly.
* **Chores**
* Modernized dependencies and protobuf tooling, migrated to
protobuf-based messaging and streaming, and removed obsolete CUDA build
steps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
## Description
Oops.. another massive PR 🥲
This PR contains multiple improvements and changes.
Firstly, thanks gst-wayland-display's PR
[here](https://github.com/games-on-whales/gst-wayland-display/pull/20).
NVIDIA path is now way more efficient than before.
Secondly, adding controller support was a massive hurdle, requiring me
to start another project
[vimputti](https://github.com/DatCaptainHorse/vimputti) - which allows
simple virtual controller inputs in isolated containers. Well, it's not
simple, it includes LD_PRELOAD shims and other craziness, but the
library API is simple to use..
Thirdly, split runner image into 3 separate stages, base + build +
runtime, should help keep things in check in future, also added GitHub
Actions CI builds for v2 to v4 builds (hopefully they pass..).
Fourth, replaced the runner's runtime Steam patching with better and
simpler bubblewrap patch, massive thanks to `games-on-whales` to
figuring it out better!
Fifth, relay for once needed some changes, the new changes are still
mostly WIP, but I'll deal with them next time I have energy.. I'm spent
now. Needed to include these changes as relay needed a minor change to
allow rumble events to flow back to client peer.
Sixth.. tons of package updates, minor code improvements and the usual.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* End-to-end gamepad/controller support (attach/detach, buttons, sticks,
triggers, rumble) with client/server integration and virtual controller
plumbing.
* Optional Prometheus metrics endpoint and WebTransport support.
* Background vimputti manager process added for controller handling.
* **Improvements**
* Multi-variant container image builds and streamlined runtime images.
* Zero-copy video pipeline and encoder improvements for lower latency.
* Updated Steam compat mapping and dependency/toolchain refreshes.
* **Bug Fixes**
* More robust GPU detection, input/fullscreen lifecycle,
startup/entrypoint, and container runtime fixes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
## Description
Adds PEER_URL env variable for setting peer URL (query param still takes
priority if set).
- Useful for self-hosters
- Was a pain to figure out
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- New Features
- Support configuring the peer server URL via an environment variable,
with automatic fallback to the URL parameter or a default.
- Server-provided configuration is securely passed to the client to
simplify deployment setup.
- Chores
- Excluded common build artifacts and IDE directories from container
contexts to reduce image size and speed up builds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
## Description
Works in apptainer now.. podman is still the goat since apptainer needs
docker treatment and even more..
- Added container detection so podman can be used to it's fullest, the
non-sane ones are handled separately..
- Added video bit-depth option, cuz AV1 and 10-bit encoding go well
together.
- Some other package updates to nestri-server.
- General tidying up of scripts to make multi-container-engine handling
less of a pain.
- Updated old wireplumber lua script to new json format.
Further changes:
- Removed unused debug arg from nestri-server.
- Moved configs to config file folder rather than keeping them in
containerfile.
- Improved audio configs, moved some into wireplumber to keep things
tidy.
- Bit better arg handling in nestri-server.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Optional 10‑bit video support and auto‑launch of an app after display
setup.
* **Changes**
* Standardized runtime/user env to NESTRI_* with updated home/cache
paths and explicit LANG; password generation now logged.
* Improved container/GPU detection and startup logging; reduced blanket
root usage during startup; SSH setup surfaced.
* WirePlumber/PipeWire moved to JSON configs; low‑latency clock and
loopback audio policies added; audio capture defaults to PipeWire.
* **Chores**
* GStreamer/libp2p dependency upgrades and Rust toolchain pinned; NVIDIA
driver capability exposed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Adds a basic standalone "play site" that mimics current one in apps/www.
This is so self-hosters don't need to host whole site, but can just use
small version of it.
Yet to test so marking as draft, not at home currently so may take some
time. Also might be good idea to make Caddy-powered container out of
this later?
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
- New Features
- Introduces a standalone Play site with server output, accessible on
0.0.0.0:3000.
- Streams video via WebRTC into a canvas with continuous frame
rendering.
- Fullscreen and pointer lock support with optional keyboard lock for
navigation keys.
- Room-based routing with offline and loading states.
- Responsive 16:9 canvas and improved default layout styling.
- Chores
- Adds a multi-stage container build for efficient runtime images and a
lightweight init process.
- Includes configuration and project setup for the standalone package.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: DatCaptainHorse <datcaptainhorse@users.noreply.github.com>
## Description
Whew..
- Steam can now run without namespaces using live-patcher (because
Docker..)
- Improved NVIDIA GPU selection and handling
- Pipeline tests for GPU picking logic
- Optimizations and cleanup all around
- SSH (by default disabled) for easier instance debugging.
- CachyOS' Proton because that works without namespaces (couldn't figure
out how to enable automatically in Steam yet..)
- Package updates and partial removal of futures (libp2p is going to
switch to Tokio in next release hopefully)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- SSH server can now be enabled within the container for remote access
when configured.
- Added persistent live patching for Steam runtime entrypoints to
improve compatibility with namespace-less applications.
- Enhanced GPU selection with multi-GPU support and PCI bus ID matching
for improved hardware compatibility.
- Improved encoder selection by runtime testing of video encoders for
better reliability.
- Added WebSocket transport support in peer-to-peer networking.
- Added flexible compositor and application launching with configurable
commands and improved socket handling.
- **Bug Fixes**
- Addressed NVIDIA-specific GStreamer issues by setting new environment
variables.
- Improved error handling and logging for GPU and encoder selection.
- Fixed process monitoring to handle patcher restarts and added cleanup
logic.
- Added GStreamer cache clearing workaround for Wayland socket failures.
- **Improvements**
- Real-time logging of container processes to standard output and error
for easier monitoring.
- Enhanced process management and reduced CPU usage in protocol handling
loops.
- Updated dependency versions for greater stability and feature support.
- Improved audio capture defaults and expanded audio pipeline support.
- Enhanced video pipeline setup with conditional handling for different
encoder APIs and DMA-BUF support.
- Refined concurrency and lifecycle management in protocol messaging for
increased robustness.
- Consistent namespace usage and updated crate references across the
codebase.
- Enhanced SSH configuration with key management, port customization,
and startup verification.
- Improved GPU and video encoder integration in pipeline construction.
- Simplified error handling and consolidated write operations in
protocol streams.
- Removed Ludusavi installation from container image and updated package
installations.
- **Other**
- Minor formatting and style changes for better code readability and
maintainability.
- Docker build context now ignores `.idea` directory to streamline
builds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>