Files
netris-nestri/apps/api/app/routes/session.ts
Wanjohi 51dabddbd8 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.
2026-09-04 22:12:31 +03:00

367 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Actor } from '@nestri/core/actor';
import { Box } from '@nestri/core/box/index';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
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';
import { z } from 'zod';
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
/**
* Requesting a run, and carrying one out.
*
* Two very different callers meet on one resource here. A person asks for a
* run and then watches it; the host agent is handed the work and reports what
* happened. The rule that keeps them apart is that an agent may only see or
* touch a run whose box is placed on its own hardware, and it is enforced in
* the query rather than by the agent asking for its own work — a host
* credential is a long-lived secret on hardware in somebody's home, and what
* one leaking can reach is decided here.
*/
export namespace SessionApi {
/**
* One answer for "no such run" and "not your run".
*
* Both are the same refusal on purpose: an agent that could tell the
* difference could discover which ids exist by reporting states at them.
*/
function notYours(): never {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'No such session, or it is not on this machine'
);
}
function conflict(message: string): never {
throw new VisibleError('already_exists', ErrorCodes.Validation.INVALID_STATE, message);
}
/** The person a run belongs to, refusing a host acting as its owner. */
function actingPerson(): string {
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Requesting or reading a session requires a user session'
);
}
return actor.properties.userID;
}
const StateReport = z
.object({
state: Session.ReportableState.meta({
description: 'Where the run has got to',
example: 'starting'
}),
errorMessage: z.string().max(1024).nullable().optional().meta({
description: 'Why it failed. Kept only for a run that did',
example: Examples.Session.errorMessage
})
})
.strict();
export const route = new Hono()
.post(
'/',
notPublic,
describeRoute({
tags: ['Session'],
summary: 'Ask for a run of a box',
description:
'Creates the run in state `requested`, which is the work order the boxs host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one.',
responses: {
201: {
content: { 'application/json': { schema: Result(Session.Info) } },
description: 'The run has been requested'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404],
409: ErrorResponses[409]
}
}),
validator(
'json',
z
.object({
boxId: z.string().min(1).meta({
description: 'The box to run',
example: Examples.Session.boxId
}),
gameId: z.string().min(1).meta({
description: 'The game to launch',
example: Examples.Session.gameId
}),
linkedAccountId: z.string().min(1).optional().meta({
description:
'Which linked account is playing. Defaults to the one the caller signed in with',
example: Examples.Session.linkedAccountId
})
})
// Strict, so that naming hardware is a validation error rather
// than a field quietly ignored. There is nothing to choose:
// asking for a run is not where a box is placed.
.strict()
),
async (c) => {
const body = c.req.valid('json');
const userId = actingPerson();
const box = await Box.fromID(body.boxId);
if (!box || box.userId !== userId) {
// Somebody else's box and a box that was never created are the
// same answer, so ids cannot be probed for.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such box, or it is not yours'
);
}
const game = await Game.fromID(body.gameId);
if (!game) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such game'
);
}
// 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 ||
(actor.type === 'user' ? actor.properties.linkedAccountID : '') ||
'';
if (!linkedAccountId) {
// Which account is playing is the question the "who's playing?"
// screen asks, and some credentials carry no answer to it. Then
// the caller has to say.
throw new VisibleError(
'validation',
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
'Say which linked account is playing',
'linkedAccountId'
);
}
const linked = await LinkedAccount.fromID(linkedAccountId);
if (!linked || linked.userId !== userId) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'That account is not linked to you'
);
}
// A box runs one thing at a time. Refusing is the honest answer;
// starting a second run would leave two rows that both think they
// own the same hardware.
//
// This read is the message, not the guarantee — two callers can
// both pass it. `Session.request` is refused by a unique index on
// the same predicate, and answers with the same 409 in the same
// words, so which one caught it is not visible from here.
const active = await Session.activeForBox(box.id);
if (active) {
conflict(Session.BOX_BUSY);
}
const session = await Session.request({
id: Identifier.ascending('session'),
boxId: box.id,
gameId: game.id,
linkedAccountId
});
return c.json({ data: session }, 201);
}
)
.get(
'/:id',
notPublic,
describeRoute({
tags: ['Session'],
summary: 'Read a run you asked for',
description:
'Poll this while a run starts. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else. Once the run reaches a terminal state the ticket is null: stop polling and stop dialling it.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
description: 'The run as it stands'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'param',
z.object({
id: z.string().meta({ description: 'The run to read', example: Examples.Session.id })
})
),
async (c) => {
const session = await Session.forOwner({
id: c.req.valid('param').id,
userId: actingPerson()
});
if (!session) {
// Owner-scoped in the query, so somebody else's run and one that
// never existed answer the same way.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such session, or it is not yours'
);
}
return c.json({ data: session });
}
)
.post(
'/:id/state',
machineOnly,
describeRoute({
tags: ['Session'],
summary: 'Report where a run has got to',
description:
'For the host the runs box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
description: 'The run as it stands after the report'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
409: ErrorResponses[409]
}
}),
validator('param', z.object({ id: z.string() })),
validator('json', StateReport),
async (c) => {
const body = c.req.valid('json');
const result = await Session.transition({
id: c.req.valid('param').id,
machineId: Actor.machineID,
state: body.state,
errorMessage: body.errorMessage ?? null
});
switch (result.outcome) {
case 'forbidden':
notYours();
case 'illegal':
conflict(`A run in state ${result.session?.state} cannot become ${body.state}`);
case 'lost':
conflict('Another caller moved this run first');
default:
// `moved` and `unchanged` are both success. An agent retrying
// after a lost response must not be told it broke something.
return c.json({ data: result.session });
}
}
)
.post(
'/:id/ticket',
machineOnly,
describeRoute({
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. 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) } },
description: 'The ticket is published'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
409: ErrorResponses[409]
}
}),
validator('param', z.object({ id: z.string() })),
validator(
'json',
z
.object({
ticket: z.string().min(1).meta({
description: 'The current connect ticket',
example: Examples.Session.ticket
})
})
.strict()
),
async (c) => {
const result = await Session.publishTicket({
id: c.req.valid('param').id,
machineId: Actor.machineID,
ticket: c.req.valid('json').ticket
});
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:
return c.json({ data: result.session });
}
}
);
/**
* The host agent's side of the same resource, mounted where a host looks
* for it: everything a box asks about itself lives under one prefix.
*/
export const machineRoute = new Hono().get(
'/jobs',
machineOnly,
describeRoute({
tags: ['Session'],
summary: 'Ask for work',
description:
'Returns the runs waiting to be started on the calling host, and only those — the host comes from its own credentials and the scope is the query, so a box cannot see work for another. Poll at the cadence the heartbeat hands down. Each job carries its kind, so a second kind of work is an addition rather than a change of shape.',
responses: {
200: {
content: { 'application/json': { schema: Result(z.array(Session.Job)) } },
description: 'Work waiting for this host, oldest first'
},
403: ErrorResponses[403]
}
}),
async (c) => {
return c.json({ data: await Session.listJobsForMachine(Actor.machineID) });
}
);
}