fix(api): a run drives the box under it, and needs a game and a claim

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.
This commit is contained in:
Wanjohi
2026-09-04 22:12:31 +03:00
parent 0d8630379b
commit 51dabddbd8
5 changed files with 316 additions and 25 deletions

View File

@@ -5,6 +5,7 @@ import { Examples } from '@nestri/core/examples';
import { Game } from '@nestri/core/game/index';
import { Identifier } from '@nestri/core/id';
import { Session } from '@nestri/core/session/index';
import { Library } from '@nestri/core/user/library';
import { LinkedAccount } from '@nestri/core/user/linked-account';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
@@ -136,6 +137,27 @@ export namespace SessionApi {
);
}
// A run launches as a Steam account that has to own the game, so
// a game outside the caller's library is a box that starts, tries
// to launch, and fails minutes later with nothing to point at.
// Refusing here is the same answer sooner.
//
// Told apart from a game that does not exist rather than hidden:
// the catalog is public, so there is nothing to hide, and "you do
// not own this" is the 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 and
// not a reason to launch runs that cannot work.
const owned = await Library.findByUserAndGame({ userId, gameId: game.id });
if (!owned) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'That game is not in your library'
);
}
const actor = Actor.use();
const linkedAccountId =
body.linkedAccountId ||
@@ -274,7 +296,7 @@ export namespace SessionApi {
tags: ['Session'],
summary: 'Publish the address a client should connect to',
description:
'For the host the runs box is placed on, and no other. Republish freely: a later ticket is a better address for the same run, not a second run, and the address changes as more of them are discovered. A run that has stopped has no address, so that is 409.',
'For the host the runs box is placed on, and no other. Republish freely: a later ticket is a better address for the same run, not a second run, and the address changes as more of them are discovered. Only a run being brought up has an address: claim it by reporting `starting` first, and expect 409 both before that and once it has stopped.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
@@ -307,6 +329,8 @@ export namespace SessionApi {
switch (result.outcome) {
case 'forbidden':
notYours();
case 'unclaimed':
conflict('Claim this run by reporting `starting` before publishing an address');
case 'closed':
conflict('That run has stopped, so it has no address to publish');
default: