mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
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 box’s 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 run’s 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 run’s 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) });
|
||
}
|
||
);
|
||
}
|