feat(api): the session endpoint, and a claim that only one caller can win (#317)

## What this is

`POST /session`, `GET /session/:id`, and the three endpoints the host
agent
calls: `GET /machine/jobs`, `POST /session/:id/state`, `POST
/session/:id/ticket`.
Core had `Session` and `Box` and no HTTP surface at all; this adds the
surface
and the two things core was missing to support it safely.

### The access rule

An agent may only see or touch a run whose box is placed on its own
hardware,
and that is a `where` clause on all three agent endpoints — not a check
sitting
next to the query, and not the agent asking politely for its own work.
Host
credentials are long-lived secrets on hardware in somebody's living
room, so
the blast radius of one leaking is decided by the join and nowhere else.

`Session.listJobsForMachine`, `forMachine`, `compareAndSetState` and
`publishTicket` all scope to the machine inside the statement. The
compare-and-set is scoped there too, and not only by the read above it:
a
caller checking first and the write being scoped are different
properties, and
only one of them survives somebody adding a second caller.

"No such run" and "not your run" are the same 403 with the same body, so
reporting states at ids cannot be used to discover which ids exist.

### The claim

`Session.setState` updated on the id alone. Two agents polling the same
run
both succeeded and both started the same box. There is one host today,
which
is exactly why that would have been built wrong and stayed wrong.

`Session.compareAndSetState` puts the state being moved *out of* into
the
`where` clause, so the database picks the winner and the loser matches
zero
rows. `Session.transition` layers the classification on top and returns
one of
five outcomes, which the route maps:

| Case | Answer |
|---|---|
| same host re-reports a state it already reported | `200`, nothing
changes |
| a different host reports anything | `403` |
| a transition not in the table | `409`, row does not move |
| a legal transition something else won first | `409` |
| it happened | `200` |

`setState` is left exactly as it was — it is the unscoped primitive the
core
tests already pin, and the new path is additive.

### Placement

`POST /session` makes no placement decision. A box names its hardware,
so a
run inherits it by join, and the request body is `.strict()` so that
naming a
machine there is a validation error rather than a field quietly ignored.

The interface went where boxes are actually placed: `Placement.Placer`
in
`packages/core/src/box/placement.ts`, with `Placement.onlyHost` as the
implementation and `Box.createPlaced` as the one caller. A real
scheduler
replaces that file and nothing else.

## The test failing first

Tests were written and run against unmodified code. Verbatim, trimmed to
the
result lines (the full log is stack traces for the same failures):

```
(fail) POST /session > a request creates the job, in the envelope both ends read [199.00ms]
(fail) POST /session > creating a session makes no placement decision [55.00ms]
(fail) POST /session > a box somebody else owns is not there to run [106.00ms]
(fail) POST /session > a box already running refuses a second run rather than picking one [51.00ms]
(fail) POST /session > you can only play as an account you have linked [85.00ms]
(fail) POST /session > a host cannot ask for a session on its owner’s behalf [54.00ms]
(fail) POST /session > requesting a session requires a signed-in person [1.00ms]
(fail) GET /session/:id > the owner reads their own run, ticket and all [47.00ms]
(fail) GET /session/:id > somebody else’s run is not visible, and neither is its absence [97.00ms]
(fail) GET /session/:id > reading a run requires a signed-in person [1.00ms]
(fail) GET /machine/jobs > a host is handed the work for its own boxes, with the kind on the wire [50.00ms]
(fail) GET /machine/jobs > a host never sees work for a box on other hardware [84.00ms]
(fail) GET /machine/jobs > bad credentials are indistinguishable from none [41.00ms]
(fail) GET /machine/jobs > a person cannot poll for jobs [44.00ms]
(fail) POST /session/:id/state > the claim moves the row, and the job stops being offered [41.00ms]
(fail) POST /session/:id/state > the same host re-reporting a state it already reported is fine [54.00ms]
(fail) POST /session/:id/state > a different host reporting anything is refused, and learns nothing [99.00ms]
(fail) POST /session/:id/state > a transition that is not allowed is a conflict, and the row stays put [47.00ms]
(fail) POST /session/:id/state > a stopped run cannot be started again [47.00ms]
(fail) POST /session/:id/state > a duplicate live report does not extend a run somebody is billed for [44.00ms]
(fail) POST /session/:id/state > a state nobody defined is a validation error, not a conflict [49.00ms]
(fail) POST /session/:id/state > a person cannot report a state on their own session [48.00ms]
(fail) POST /session/:id/ticket > a later ticket replaces the first, because it is a better address [44.00ms]
(fail) POST /session/:id/ticket > a different host cannot publish an address for someone else’s run [75.00ms]
(fail) POST /session/:id/ticket > a stopped run has no address to publish [48.00ms]
(fail) POST /session/:id/ticket > a ticket has to say something [47.00ms]
(fail) Session routes in the spec > every path a caller needs is documented [48.00ms]
error: Cannot find module './placement.js' from '/home/…/packages/core/src/box/placement.test.ts'
(fail) Session jobs > a requested session is the job, and it carries its kind [37.00ms]
(fail) Session jobs > a job belongs to the machine its box is placed on and to no other [71.00ms]
(fail) Session jobs > only a requested session is work; a claimed one is not offered again [34.00ms]
(fail) Session claim > the claim is a compare-and-set, so the second attempt finds nothing to move [37.00ms]
(fail) Session claim > a machine that is not the box’s host cannot move the row [72.00ms]
(fail) Session claim > a session that does not exist is refused the same way as one that is not yours [31.00ms]
(fail) Session claim > re-reporting the state you already reported changes nothing [37.00ms]
(fail) Session claim > a transition off the table is refused and the row does not move [36.00ms]
(fail) Session claim > the timestamps survive a duplicate report, which is what billing rests on [33.00ms]
(fail) Session claim > publishing a ticket is scoped to the host too [73.00ms]
(fail) Session claim > a stopped session has no address to publish [36.00ms]
 7 pass
 39 fail
 1 error
Ran 46 tests across 3 files. [2.88s]
```

(46 rather than 49 because the three `Placement` tests never ran — the
module
they import did not exist.)

## And passing after

```
 49 pass
 0 fail
 126 expect() calls
Ran 49 tests across 3 files. [3.72s]
```

Full suite, against a fresh migrated database on `DATABASE_URL` and
`TEST_DATABASE_URL`:

```
 181 pass
 0 fail
 479 expect() calls
Ran 181 tests across 16 files. [7.89s]
```

Baseline before this branch was `138 pass, 0 fail, 371 expect() calls`
across
14 files, so 43 tests and 108 assertions are new and nothing regressed.

`tsc --noEmit -p apps/api` reports the same five pre-existing errors in
`app/utils/hook.ts` and `app/utils/validator.ts` as it does on `dev`,
and none
in the files this branch adds.

## Shared files touched

- `apps/api/app/index.ts` — three lines: one import, and two mounts
  (`/session`, plus `SessionApi.machineRoute` at `/machine`).
- `packages/core/src/box/index.ts` — one import and `Box.createPlaced`
added.
  Nothing existing changed.
- `packages/core/src/session/session.test.ts` — `scene()` now also
returns
  `machineId`; two new `describe` blocks appended.
- `packages/core/src/examples.ts` was **not** touched; the job schema's
  examples reuse the existing `Examples.Session`, `Examples.Box` and
  `Examples.Game` values.

No migration was created and none was needed.

## Judgement calls

1. **`GET /machine/jobs` lives in `session.ts`, mounted at `/machine`.**
The
path belongs under the prefix a host already uses for everything it asks
about itself, but the handler is this lane's code, so it is a second
Hono
instance (`SessionApi.machineRoute`) rather than an edit to
`machine.ts`.
2. **A job's payload beyond `kind` was not specified anywhere**, so it
is:
   `kind`, `sessionId`, `boxId`, `boxTier`, `gameId`, `steamAppId`,
`linkedAccountId`. `steamAppId` is in there because the agent launches
by
store id and not by our internal one, and `boxTier` because the tier
also
   sets output geometry. **This is the most likely place for the two
   implementations to disagree** — see the disagreement note below.
3. **A run's terminal states refuse a ticket** (`409`). Nothing said
what
publishing an address for a stopped run should do; writing one can only
   mislead a client that is still polling.
4. **A state report accepts only `starting`, `live`, `ended`,
`failed`.**
`requested` is written once, at creation, so reporting it is a `400`
rather
   than a no-op.
5. **A box already running refuses a second run** with `409`. Not stated
in so
many words, but `Session.activeForBox` documents itself as the query
that
should start refusing rather than picking a winner silently, and this is
the
   caller that would otherwise have made it pick.
6. **`Placement.onlyHost` refuses when there is more than one
candidate**
rather than taking the first row. Picking would be a scheduling policy
invented by accident and impossible to find later. It also refuses when
there are none, because `box.machineId` is not nullable and a placer
that
   cannot answer must say so.
7. **`POST /session` returns `201`**, and its body is `.strict()`.
8. **Person-facing 404s, agent-facing 403s.** Both are the settled shape
for
their realm and both are asserted identical between "does not exist" and
   "not yours".
9. **`POST /session` accepts an explicit `linkedAccountId`**, defaulting
to the
one the caller's session carries. A personal access token carries none,
so
requiring the session to supply it would make that credential unable to
start a run; the account is verified to belong to the caller either way.

## Where the specification was ambiguous or came out wrong

- **The lost-claim 409 is not reachable through the HTTP endpoint as
specified.** A box names exactly one machine, so both racers for a given
run
authenticate as *the same* machine — and the same machine re-reporting a
state it already reported is specified as `200`. Which of the two
answers a
caller gets therefore depends only on whether its read happened before
or
after the winner's write: concurrent gets `409`, a sequential retry gets
`200`. That is a defensible reading and it is what is implemented, but
it
means the two rules are distinguished by timing and not by anything a
caller
can see. If a second agent process per host is ever real, this needs a
claim
  token in the request rather than a timing accident.
- **The job payload is unspecified**, which is the one part of the wire
the
agent parses and the one thing a document written before two
implementations
exist was supposed to pin. If the other end's test expects different
field
names, that is this gap and not a bug in either implementation, and it
should
  be settled in the specification before either side moves.
- **Nothing said whether a person may report a state.** Implemented as
`403`:
terminal states are the agent's to write, and a person closing their app
is
a different fact from a run that stopped. That reading is also why
cancellation, listed below, has
  nowhere to live yet.

## What this does not verify

- **No live round trip.** Every assertion here goes through
`app.request`
against a real Postgres. No real host agent has ever called any of these
  five endpoints, and no client has ever read a ticket out of one.
- **The claim is not verified under concurrency.** The compare-and-set
is
pinned by stepping two attempts in sequence and showing the second
matches
zero rows. Two agents actually racing, on two connections, is not tested
and
  cannot be from a single-process test.
- **The wire shape is asserted against this lane's reading of the
specification, not against the other end.** That is deliberate — each
end
  writes its own test — but it means a shape agreement has *not* been
demonstrated. Both tests passing separately is the evidence, and it does
not
  exist yet.
- **No ticket in this repository has ever been a real iroh ticket.**
They are
opaque strings to every assertion here; that the value survives a round
trip
  says nothing about whether anything could connect to it.
- **`steamAppId` reaching the agent is untested end to end.** It is
asserted
  present and correct in the poll response and nothing consumes it.
- **Nothing reaps a stuck `requested`.** A run whose host never polls
sits
there forever, and no screen distinguishes that from "starting soon".
Left
open deliberately: a timeout here would hide the gap rather than close
it.
- **Cancellation has no path.** A person who closes their app between
`requested` and `starting` leaves work that will be claimed and started
into
an empty room. There is no endpoint for it and this branch did not
invent
  one.
- **Nobody writes `ended` when the agent itself dies.** The only writer
of
terminal states is the agent, so a host that loses power leaves a `live`
run
that keeps billing. The fix belongs with whatever decides host liveness
has
authority over a run's state, and that does not exist.
`Machine.isOnline`
  exists but nothing joins it to a session.
- **Placement is verified only for the single-host case and the two
refusals.**
  No test exercises a real alternative placer beyond an inline stub, and
nothing calls `Box.createPlaced` from an endpoint, because there is no
box
  creation endpoint yet.








<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR adds the HTTP and core-domain session lifecycle, including user
session requests, machine job polling, host-scoped state transitions and
ticket publication, placement support, box-state synchronization, and a
database constraint ensuring one active session per box.

- Adds authenticated user and machine session endpoints with
resource-level ownership and host scoping.
- Uses compare-and-set state transitions so only one caller can claim a
requested session.
- Adds a partial unique index and duplicate-data repair migration to
enforce one active run per box.
- Clears connection tickets when sessions become terminal.
- Adds placement abstractions and extensive API, domain, migration, and
concurrency-oriented tests.
- The latest changes document and test that ownership is checked per
user rather than per selected linked account; they do not resolve the
previously reported multi-account launch failure.

<h3>Confidence Score: 4/5</h3>

The PR is not yet safe to merge because selecting a different linked
account can still launch a game that only another account owns.

The previously reported ownership issue remains unresolved: the current
request path checks the game against the user-wide library and
separately verifies only that the selected linked account belongs to
that user. The new test explicitly accepts this behavior, so a user with
multiple Steam links can request a game through an account that does not
own it, causing the host launch to fail. The three resolved previous
findings are fully addressed or manually resolved and do not remain
outstanding.

**Files Needing Attention:** apps/api/app/routes/session.ts

<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| apps/api/app/routes/session.ts | Adds user and host session endpoints
with scoped authorization and validation, but the previously reported
selected-account ownership mismatch remains. |
| packages/core/src/session/index.ts | Adds job queries, host-scoped
compare-and-set transitions, ticket lifecycle handling, box-state
synchronization, and active-session conflict translation. |
| packages/core/src/session/session.sql.ts | Declares the partial unique
index enforcing at most one undeleted, unstopped session per box. |
| packages/core/migrations/0008_session_one_active_run_per_box.sql |
Repairs pre-existing duplicate active sessions, clears their stale
tickets, and creates the active-session unique index. |
| packages/core/src/box/placement.ts | Introduces a placement seam that
selects the sole owner host and refuses ambiguous or unavailable
placement. |
| apps/api/test/session.test.ts | Adds broad endpoint coverage and now
explicitly demonstrates the unresolved per-user rather than
per-linked-account ownership behavior. |


<h3>Sequence Diagram</h3>

```mermaid
sequenceDiagram
  participant U as User client
  participant A as Session API
  participant D as Core domain
  participant DB as PostgreSQL
  participant H as Host agent
  U->>A: POST /session
  A->>D: Validate box, game, library, and linked account
  D->>DB: Insert requested session
  DB-->>D: Enforce one active session per box
  H->>A: GET /machine/jobs
  A->>D: List requested sessions scoped to host
  D->>DB: Join session through box.machineId
  H->>A: POST /session/:id/state (starting)
  A->>D: Compare-and-set state for host
  D->>DB: Atomic scoped transition
  H->>A: POST /session/:id/ticket
  A->>D: Publish ticket while addressable
  U->>A: GET /session/:id
  A-->>U: Current state and ticket
  H->>A: POST /session/:id/state (ended/failed)
  D->>DB: Set terminal state and clear ticket
```

<sub>Reviews (4): Last reviewed commit: ["fix(api): say what the library
check
act..."](7bdca1240f)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60463182)</sub>

<details><summary><h4>Context used (3)</h4></summary>

- Knowledge Base — [API HTTP composition and
authorization](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/api-http-and-auth.md)
- Knowledge Base — [Core domain and
persistence](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-domain-data.md)
- Knowledge Base — [Users, identity, and game
libraries](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-identity-and-library.md)
</details>


<!-- /greptile_comment -->
This commit is contained in:
Wanjohi
2026-09-04 19:31:16 +00:00
committed by GitHub
12 changed files with 4582 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ import { IndexApi } from './routes/index.js';
import { LibraryApi } from './routes/library.js';
import { MachineApi } from './routes/machine.js';
import { PairingCodeApi } from './routes/pairing-code.js';
import { SessionApi } from './routes/session.js';
import { SteamApi } from './routes/steam.js';
import { UserApi } from './routes/user.js';
import { WaitlistApi } from './routes/waitlist.js';
@@ -44,6 +45,8 @@ const routes = app
.route('/games', GameApi.route)
.route('/pairing-code', PairingCodeApi.route)
.route('/machine', MachineApi.route)
.route('/machine', SessionApi.machineRoute)
.route('/session', SessionApi.route)
.route('/access-token', AccessTokenApi.route)
.route('/waitlist', WaitlistApi.route)
.onError((error, c) => {

View File

@@ -0,0 +1,375 @@
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 game nobody has synced for this person 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 a sentence a person can act on.
//
// **This is a weaker check than the one that matters.** A run
// launches as one account, but a library entry records only the
// person, so what is verified is "somebody this person has linked
// owns it" and not "the account playing owns it". For the one
// linked account most people have those are the same sentence;
// for two they are not, and the second account can ask for a game
// only the first owns. Answering the real question needs the
// account recorded on the library entry, which is a decision
// about what a library *is* and not something to infer here.
//
// The library is also a synced copy, so this refuses a game
// bought since the last sync. Both gaps let a launch fail late;
// neither is a reason to start runs already known to fail.
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) });
}
);
}

View File

@@ -0,0 +1,739 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { AccessToken } from '@nestri/core/access-token/index';
import { Box } from '@nestri/core/box/index';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Game } from '@nestri/core/game/index';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
import { Session } from '@nestri/core/session/index';
import { Library } from '@nestri/core/user/library';
import { LinkedAccount } from '@nestri/core/user/linked-account';
import { app } from '../app/index';
import './setup';
const sql = testDb();
const createdUserIds: string[] = [];
const createdGameIds: string[] = [];
async function newGame(steamAppId: number): Promise<string> {
const [row] = await Game.upsert({
id: Identifier.ascending('game'),
steamAppId,
slug: `session-route-${steamAppId}`,
name: `Session Route ${steamAppId}`
});
if (!row) throw new Error('expected a game row');
createdGameIds.push(row.id);
return row.id;
}
/**
* Everything one session needs, plus both sets of credentials that reach it.
*
* The person authenticates with a personal token, which is the one user
* credential a test can mint without an auth service; the host authenticates
* as itself with the secret registration hands back exactly once.
*/
async function scene(label: string, steamAppId: number) {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: owner.userId,
teamId: owner.teamId,
label
});
const box = await Box.create({
id: Identifier.ascending('box'),
userId: owner.userId,
machineId: registered.id,
label,
tier: 'sm'
});
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: owner.userId,
// Null on purpose: a token scoped to the user alone makes the caller a
// plain user actor, which is the credential a person browsing has.
teamId: null,
name: label
});
const gameId = await newGame(steamAppId);
// A run launches as a Steam account that owns the game, so the endpoint
// refuses one outside the caller's library. Every scene here is about
// something else, so the game is stocked.
await Library.upsert({
id: Identifier.ascending('userLibrary'),
userId: owner.userId,
gameId,
playtime2w: null,
playtimeForever: null,
lastPlayed: null
});
return {
owner,
box,
machineId: registered.id,
gameId,
user: {
authorization: `Bearer ${pat.token}`,
'content-type': 'application/json'
} as Record<string, string>,
host: {
'x-nestri-machine-id': registered.id,
'x-nestri-machine-secret': registered.secret,
'content-type': 'application/json'
} as Record<string, string>
};
}
async function requestSession(s: Awaited<ReturnType<typeof scene>>) {
const res = await app.request('/session', {
method: 'POST',
headers: s.user,
body: JSON.stringify({
boxId: s.box.id,
gameId: s.gameId,
linkedAccountId: s.owner.linkedAccountId
})
});
const body = (await res.json()) as any;
return { res, body };
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
if (createdGameIds.length > 0) {
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
createdGameIds.length = 0;
}
});
describe('POST /session', () => {
test('a request creates the job, in the envelope both ends read', async () => {
const s = await scene('route-create', 5500);
const { res, body } = await requestSession(s);
expect(res.status).toBe(201);
// The field names are the contract. A rename on either side produces a
// host that starts, reads nothing, and reports success — so the shape
// is asserted whole rather than field by field.
expect(Object.keys(body)).toEqual(['data']);
expect(body.data).toEqual({
id: body.data.id,
boxId: s.box.id,
gameId: s.gameId,
linkedAccountId: s.owner.linkedAccountId,
state: 'requested',
ticket: null,
timeStarted: null,
timeStopped: null,
errorMessage: null
});
expect(body.data.id.startsWith('ses_')).toBe(true);
});
test('creating a session makes no placement decision', async () => {
const s = await scene('route-noplacement', 5501);
const { body } = await requestSession(s);
// A session inherits its machine through its box, so there is nothing
// to choose here and no way for a caller to ask for a host.
expect(body.data).not.toHaveProperty('machineId');
const withHost = await app.request('/session', {
method: 'POST',
headers: s.user,
body: JSON.stringify({
boxId: s.box.id,
gameId: s.gameId,
linkedAccountId: s.owner.linkedAccountId,
machineId: s.machineId
})
});
expect(withHost.status).toBe(400);
});
test('a box somebody else owns is not there to run', async () => {
const mine = await scene('route-mine', 5502);
const theirs = await scene('route-theirs', 5503);
const res = await app.request('/session', {
method: 'POST',
headers: mine.user,
body: JSON.stringify({
boxId: theirs.box.id,
gameId: mine.gameId,
linkedAccountId: mine.owner.linkedAccountId
})
});
expect(res.status).toBe(404);
const unknown = await app.request('/session', {
method: 'POST',
headers: mine.user,
body: JSON.stringify({
boxId: Identifier.ascending('box'),
gameId: mine.gameId,
linkedAccountId: mine.owner.linkedAccountId
})
});
// Owner-scoped in the query, so somebody else's box and a box that was
// never created are the same answer.
expect(unknown.status).toBe(404);
expect(await res.json()).toEqual(await unknown.json());
});
test('a box already running refuses a second run rather than picking one', async () => {
const s = await scene('route-busy', 5504);
expect((await requestSession(s)).res.status).toBe(201);
const second = await requestSession(s);
expect(second.res.status).toBe(409);
expect(second.body.type).toBe('already_exists');
});
test('two requests racing for one box still start it once', async () => {
const s = await scene('route-race', 5509);
const [a, b] = await Promise.all([requestSession(s), requestSession(s)]);
// Which request wins is a timing detail; that exactly one does is not.
// The pre-check and the unique index answer identically, so the loser
// cannot tell which caught it.
//
// This asserts the endpoint's answer, not the invariant: two requests
// in one process usually interleave such that the pre-check catches
// the second, so it passes with the unique index dropped. The index is
// pinned in the core tests, where both callers can be made to read
// before either writes.
const statuses = [a.res.status, b.res.status].sort();
expect(statuses).toEqual([201, 409]);
expect([a.body, b.body].find((x) => x.type)?.type).toBe('already_exists');
expect(await Session.listByBox(s.box.id)).toHaveLength(1);
// The failure this prevents: the host offered the same box twice.
const jobs = await app.request('/machine/jobs', { headers: s.host });
expect(((await jobs.json()) as any).data).toHaveLength(1);
});
test('you can only play as an account you have linked', async () => {
const mine = await scene('route-account-mine', 5505);
const theirs = await scene('route-account-theirs', 5506);
const res = await app.request('/session', {
method: 'POST',
headers: mine.user,
body: JSON.stringify({
boxId: mine.box.id,
gameId: mine.gameId,
linkedAccountId: theirs.owner.linkedAccountId
})
});
expect(res.status).toBe(403);
});
test('you can only run a game you own', async () => {
const s = await scene('route-unowned', 5560);
// A real game in the catalog, simply not in this person's library.
const unowned = await newGame(5561);
const res = await app.request('/session', {
method: 'POST',
headers: s.user,
body: JSON.stringify({
boxId: s.box.id,
gameId: unowned,
linkedAccountId: s.owner.linkedAccountId
})
});
// Told apart from a game that does not exist, deliberately: the catalog
// is public, so there is nothing to hide, and a box that starts and
// then cannot launch is a worse answer minutes later.
expect(res.status).toBe(403);
expect(await Session.listByBox(s.box.id)).toHaveLength(0);
});
test('the library check is per person, not per account it plays as', async () => {
const s = await scene('route-multilink', 5562);
// A second Steam account on the same person. The unique index is on
// (provider, providerAccountId) and is global rather than per user, so
// nothing stops this — but a fixed id here would collide with its own
// previous run, hence one shaped like a SteamID64 and unique per run.
const second = await LinkedAccount.create({
id: Identifier.ascending('linkedAccount'),
userId: s.owner.userId,
provider: 'steam',
providerAccountId: `7656119${Date.now()}`.slice(0, 17),
profile: null
});
const res = await app.request('/session', {
method: 'POST',
headers: s.user,
body: JSON.stringify({
boxId: s.box.id,
gameId: s.gameId,
linkedAccountId: second
})
});
// Accepted, and this pins a known gap rather than asserting it is
// right: a library entry records the person and not the account the
// games came from, so "the account playing owns this" cannot be asked.
// The run will fail at launch exactly as it did before the check
// existed. Closing it means recording the account on the library
// entry, which changes what a library is and what the sync must send.
expect(res.status).toBe(201);
});
test('an unknown game is a 404 and not a foreign key crash', async () => {
const s = await scene('route-nogame', 5507);
const res = await app.request('/session', {
method: 'POST',
headers: s.user,
body: JSON.stringify({
boxId: s.box.id,
gameId: Identifier.ascending('game'),
linkedAccountId: s.owner.linkedAccountId
})
});
expect(res.status).toBe(404);
});
test('a host cannot ask for a session on its owners behalf', async () => {
const s = await scene('route-hostcreate', 5508);
const res = await app.request('/session', {
method: 'POST',
headers: s.host,
body: JSON.stringify({
boxId: s.box.id,
gameId: s.gameId,
linkedAccountId: s.owner.linkedAccountId
})
});
// A box holds credentials but is not the person who owns it.
expect(res.status).toBe(403);
});
test('requesting a session requires a signed-in person', async () => {
const res = await app.request('/session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ boxId: 'box_x', gameId: 'gam_x', linkedAccountId: 'lac_x' })
});
expect(res.status).toBe(401);
});
});
describe('GET /session/:id', () => {
test('the owner reads their own run, ticket and all', async () => {
const s = await scene('route-read', 5510);
const { body } = await requestSession(s);
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting' })
});
await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket: 'nodeaaa-one' })
});
const res = await app.request(`/session/${body.data.id}`, { headers: s.user });
expect(res.status).toBe(200);
const read = (await res.json()) as any;
expect(read.data.state).toBe('starting');
// A ticket may appear while the state is still `starting`, and the
// client is expected to re-read rather than cache the first one.
expect(read.data.ticket).toBe('nodeaaa-one');
});
test('somebody elses run is not visible, and neither is its absence', async () => {
const mine = await scene('route-read-mine', 5511);
const theirs = await scene('route-read-theirs', 5512);
const { body } = await requestSession(theirs);
const forbidden = await app.request(`/session/${body.data.id}`, { headers: mine.user });
const unknown = await app.request(`/session/${Identifier.ascending('session')}`, {
headers: mine.user
});
expect(forbidden.status).toBe(404);
expect(unknown.status).toBe(404);
expect(await forbidden.json()).toEqual(await unknown.json());
});
test('reading a run requires a signed-in person', async () => {
const res = await app.request('/session/ses_whatever');
expect(res.status).toBe(401);
});
});
describe('GET /machine/jobs', () => {
test('a host is handed the work for its own boxes, with the kind on the wire', async () => {
const s = await scene('route-jobs', 5520);
const { body } = await requestSession(s);
const res = await app.request('/machine/jobs', { headers: s.host });
expect(res.status).toBe(200);
const jobs = (await res.json()) as any;
expect(Object.keys(jobs)).toEqual(['data']);
expect(jobs.data).toHaveLength(1);
expect(jobs.data[0]).toEqual({
kind: 'session.start',
sessionId: body.data.id,
boxId: s.box.id,
boxTier: 'sm',
gameId: s.gameId,
steamAppId: 5520,
linkedAccountId: s.owner.linkedAccountId
});
});
test('a host never sees work for a box on other hardware', async () => {
const mine = await scene('route-jobs-mine', 5521);
const theirs = await scene('route-jobs-theirs', 5522);
await requestSession(theirs);
const res = await app.request('/machine/jobs', { headers: mine.host });
expect(res.status).toBe(200);
// Scoped in the query rather than by the host asking for its own work.
expect(((await res.json()) as any).data).toEqual([]);
});
test('bad credentials are indistinguishable from none', async () => {
const s = await scene('route-jobs-auth', 5523);
const wrong = await app.request('/machine/jobs', {
headers: { ...s.host, 'x-nestri-machine-secret': 'msk_wrong' }
});
const none = await app.request('/machine/jobs');
expect(wrong.status).toBe(403);
expect(none.status).toBe(403);
// Bad credentials fall through to public and are then forbidden, so
// probing tells an attacker nothing. Asserting the two are identical is
// the only way that stays true.
expect(await wrong.json()).toEqual(await none.json());
});
test('a person cannot poll for jobs', async () => {
const s = await scene('route-jobs-person', 5524);
const res = await app.request('/machine/jobs', { headers: s.user });
expect(res.status).toBe(403);
});
});
describe('POST /session/:id/state', () => {
test('the claim moves the row, and the job stops being offered', async () => {
const s = await scene('route-claim', 5530);
const { body } = await requestSession(s);
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting' })
});
expect(res.status).toBe(200);
expect(((await res.json()) as any).data.state).toBe('starting');
const jobs = await app.request('/machine/jobs', { headers: s.host });
expect(((await jobs.json()) as any).data).toEqual([]);
});
test('the same host re-reporting a state it already reported is fine', async () => {
const s = await scene('route-claim-retry', 5531);
const { body } = await requestSession(s);
const report = () =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting' })
});
expect((await report()).status).toBe(200);
// An agent retrying after a lost response must not be told it broke
// something.
const again = await report();
expect(again.status).toBe(200);
expect(((await again.json()) as any).data.state).toBe('starting');
});
test('a different host reporting anything is refused, and learns nothing', async () => {
const mine = await scene('route-claim-mine', 5532);
const theirs = await scene('route-claim-theirs', 5533);
const { body } = await requestSession(theirs);
const other = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: mine.host,
body: JSON.stringify({ state: 'starting' })
});
const unknown = await app.request(`/session/${Identifier.ascending('session')}/state`, {
method: 'POST',
headers: mine.host,
body: JSON.stringify({ state: 'starting' })
});
expect(other.status).toBe(403);
expect(unknown.status).toBe(403);
expect(await other.json()).toEqual(await unknown.json());
expect((await Session.fromID(body.data.id))?.state).toBe('requested');
});
test('a transition that is not allowed is a conflict, and the row stays put', async () => {
const s = await scene('route-claim-illegal', 5534);
const { body } = await requestSession(s);
const skipped = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'live' })
});
expect(skipped.status).toBe(409);
expect((await Session.fromID(body.data.id))?.state).toBe('requested');
});
test('a stopped run cannot be started again', async () => {
const s = await scene('route-claim-terminal', 5535);
const { body } = await requestSession(s);
const report = (state: string, errorMessage?: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, errorMessage })
});
expect((await report('starting')).status).toBe(200);
expect((await report('failed', 'the guest never came up')).status).toBe(200);
expect((await report('starting')).status).toBe(409);
const failed = await Session.fromID(body.data.id);
expect(failed?.state).toBe('failed');
expect(failed?.errorMessage).toBe('the guest never came up');
});
test('a duplicate live report does not extend a run somebody is billed for', async () => {
const s = await scene('route-claim-billing', 5536);
const { body } = await requestSession(s);
const report = (state: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state })
});
await report('starting');
const live = (await (await report('live')).json()) as any;
expect(live.data.timeStarted).not.toBeNull();
const again = (await (await report('live')).json()) as any;
expect(again.data.timeStarted).toBe(live.data.timeStarted);
});
test('a state nobody defined is a validation error, not a conflict', async () => {
const s = await scene('route-claim-bogus', 5537);
const { body } = await requestSession(s);
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'exploded' })
});
expect(res.status).toBe(400);
});
test('a person cannot report a state on their own session', async () => {
const s = await scene('route-claim-person', 5538);
const { body } = await requestSession(s);
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.user,
body: JSON.stringify({ state: 'starting' })
});
// Terminal states are written by the agent alone; a person closing the
// app is not the same fact as a run that stopped.
expect(res.status).toBe(403);
});
});
describe('POST /session/:id/ticket', () => {
test('a later ticket replaces the first, because it is a better address', async () => {
const s = await scene('route-ticket', 5540);
const { body } = await requestSession(s);
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting' })
});
const publish = (ticket: string) =>
app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket })
});
const first = await publish('nodeaaa-one');
expect(first.status).toBe(200);
expect(((await first.json()) as any).data.ticket).toBe('nodeaaa-one');
const second = await publish('nodeaaa-two');
expect(((await second.json()) as any).data.ticket).toBe('nodeaaa-two');
expect(await Session.listByBox(s.box.id)).toHaveLength(1);
});
test('a run nobody has claimed has no address to publish', async () => {
const s = await scene('route-ticket-early', 5546);
const { body } = await requestSession(s);
const early = await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket: 'nodeaaa-too-soon' })
});
// Publishing before reporting `starting` means the agent skipped the
// claim, which is the only mutual exclusion in the design.
expect(early.status).toBe(409);
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting' })
});
const now = await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket: 'nodeaaa-in-time' })
});
expect(now.status).toBe(200);
});
test('a different host cannot publish an address for someone elses run', async () => {
const mine = await scene('route-ticket-mine', 5541);
const theirs = await scene('route-ticket-theirs', 5542);
const { body } = await requestSession(theirs);
const res = await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: mine.host,
body: JSON.stringify({ ticket: 'nodeaaa-stolen' })
});
expect(res.status).toBe(403);
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
});
test('a stopped run has no address to publish', async () => {
const s = await scene('route-ticket-dead', 5543);
const { body } = await requestSession(s);
const report = (state: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state })
});
await report('starting');
await report('live');
await report('ended');
const res = await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket: 'nodeaaa-late' })
});
expect(res.status).toBe(409);
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
});
test('a run that stops loses the address it published', async () => {
const s = await scene('route-ticket-cleared', 5545);
const { body } = await requestSession(s);
const report = (state: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state })
});
await report('starting');
await report('live');
const published = await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket: 'nodeaaa-live' })
});
expect(((await published.json()) as any).data.ticket).toBe('nodeaaa-live');
await report('ended');
// The polling client is the reason. It reads this endpoint until it has
// an address, and an address left behind by a run that stopped is one
// it would dial — while publishing a replacement is already refused.
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
const after = (await read.json()) as any;
expect(after.data.state).toBe('ended');
expect(after.data.ticket).toBeNull();
});
test('a ticket has to say something', async () => {
const s = await scene('route-ticket-empty', 5544);
const { body } = await requestSession(s);
const res = await app.request(`/session/${body.data.id}/ticket`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ ticket: '' })
});
expect(res.status).toBe(400);
});
});
describe('The box a run happens on', () => {
test('the endpoints move the box, not just the run', async () => {
const s = await scene('route-box-state', 5550);
const { body } = await requestSession(s);
const report = (state: string, errorMessage?: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, errorMessage })
});
expect((await Box.fromID(s.box.id))?.state).toBe('created');
await report('starting');
await report('live');
// The screens that tell a person what their hardware is doing read the
// box, so a live run has to be visible there and not only on the run.
expect((await Box.fromID(s.box.id))?.state).toBe('running');
await report('failed', 'the guest never came up');
const stopped = await Box.fromID(s.box.id);
expect(stopped?.state).toBe('stopped');
expect(stopped?.stopClean).toBe(false);
expect(stopped?.stopReason).toBe('the guest never came up');
});
});
describe('Session routes in the spec', () => {
test('every path a caller needs is documented', async () => {
const res = await app.request('/doc');
const paths = Object.keys(((await res.json()) as any).paths);
expect(paths).toContain('/session');
expect(paths).toContain('/session/{id}');
expect(paths).toContain('/session/{id}/state');
expect(paths).toContain('/session/{id}/ticket');
expect(paths).toContain('/machine/jobs');
});
});