From bbe729e5c7a64e9a7d758696296183d50a6ef82f Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 4 Sep 2026 18:57:08 +0300 Subject: [PATCH 1/4] feat(api): the session endpoint, and a claim that only one caller can win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/app/index.ts | 3 + apps/api/app/routes/session.ts | 337 +++++++++++++ apps/api/test/session.test.ts | 564 ++++++++++++++++++++++ packages/core/src/box/index.ts | 24 + packages/core/src/box/placement.test.ts | 90 ++++ packages/core/src/box/placement.ts | 73 +++ packages/core/src/session/index.ts | 326 ++++++++++++- packages/core/src/session/session.test.ts | 243 +++++++++- 8 files changed, 1658 insertions(+), 2 deletions(-) create mode 100644 apps/api/app/routes/session.ts create mode 100644 apps/api/test/session.test.ts create mode 100644 packages/core/src/box/placement.test.ts create mode 100644 packages/core/src/box/placement.ts diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index bb4bae4e..2979a97f 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -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) => { diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts new file mode 100644 index 00000000..f84c9858 --- /dev/null +++ b/apps/api/app/routes/session.ts @@ -0,0 +1,337 @@ +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 { 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' + ); + } + + 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. + const active = await Session.activeForBox(box.id); + if (active) { + conflict('That box already has a run that has not stopped'); + } + + const session = await Session.create({ + 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.', + 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. A run that has stopped has no address, so that is 409.', + 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 '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) }); + } + ); +} diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts new file mode 100644 index 00000000..03c6ba5f --- /dev/null +++ b/apps/api/test/session.test.ts @@ -0,0 +1,564 @@ +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 { app } from '../app/index'; +import './setup'; + +const sql = testDb(); + +const createdUserIds: string[] = []; +const createdGameIds: string[] = []; + +async function newGame(steamAppId: number): Promise { + 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 + }); + + return { + owner, + box, + machineId: registered.id, + gameId: await newGame(steamAppId), + user: { + authorization: `Bearer ${pat.token}`, + 'content-type': 'application/json' + } as Record, + host: { + 'x-nestri-machine-id': registered.id, + 'x-nestri-machine-secret': registered.secret, + 'content-type': 'application/json' + } as Record + }; +} + +async function requestSession(s: Awaited>) { + 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('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('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 owner’s 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 else’s 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 different host cannot publish an address for someone else’s 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 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('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'); + }); +}); diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts index fd7926fc..bfcd36c7 100644 --- a/packages/core/src/box/index.ts +++ b/packages/core/src/box/index.ts @@ -5,6 +5,7 @@ import { Database } from '../db/index.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { BoxState, BoxTable, BoxTier } from './box.sql.js'; +import { Placement } from './placement.js'; /** * A VM someone owns. @@ -77,6 +78,29 @@ export namespace Box { } ); + /** + * Create a box and let something else decide where it runs. + * + * The placement seam is here, at creation, and nowhere else: `machineId` is + * set once and every later question about which hardware a box — or a run + * of it — belongs to is answered by joining through this row. A caller that + * knows the host still uses `create`; a caller acting for a person does not + * know and must not guess, which is what this overload is for. + */ + export const createPlaced = async ( + input: { id: string; userId: string; label: string; tier: Info['tier'] }, + placer?: Placement.Placer + ) => { + const machineId = await Placement.choose({ userId: input.userId, tier: input.tier }, placer); + return create({ + id: input.id, + userId: input.userId, + machineId, + label: input.label, + tier: input.tier + }); + }; + export const fromID = fn(Info.shape.id, async (id) => { return Database.use(async (tx) => { return tx diff --git a/packages/core/src/box/placement.test.ts b/packages/core/src/box/placement.test.ts new file mode 100644 index 00000000..77867198 --- /dev/null +++ b/packages/core/src/box/placement.test.ts @@ -0,0 +1,90 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { Fixtures } from '../db/fixtures.js'; +import { testDb } from '../db/test.js'; +import { Identifier } from '../id.js'; +import { Placement } from './placement.js'; +import { Box } from './index.js'; + +const sql = testDb(); + +const createdUserIds: string[] = []; + +async function newOwner(label: string) { + const o = await Fixtures.owner(label); + createdUserIds.push(o.userId); + return o; +} + +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; + } +}); + +describe('Placement', () => { + test('a box is placed when it is created, and the caller names no host', async () => { + const owner = await newOwner('place-one'); + const machineId = await Fixtures.machine(owner, 'place-one-host'); + + // No `machineId` in the input: choosing the host is the placer's job, + // and the whole point of the interface is that the caller cannot do it. + const box = await Box.createPlaced({ + id: Identifier.ascending('box'), + userId: owner.userId, + label: 'living room', + tier: 'sm' + }); + + expect(box.machineId).toBe(machineId); + }); + + test('nowhere to put it is an answer, not a crash', async () => { + const owner = await newOwner('place-none'); + + // `box.machineId` is notNull, so a placer with no candidate must refuse + // rather than hand back something the insert would reject. + await expect( + Placement.choose({ userId: owner.userId, tier: 'sm' }) + ).rejects.toThrow(); + }); + + test('more than one candidate is refused rather than picked silently', async () => { + const owner = await newOwner('place-two'); + await Fixtures.machine(owner, 'place-two-a'); + await Fixtures.machine(owner, 'place-two-b'); + + // There is no policy for choosing between hosts yet. Inventing one here + // is how a placement decision ends up buried in the caller: the refusal + // is what keeps the choice in one replaceable place. + await expect( + Placement.choose({ userId: owner.userId, tier: 'sm' }) + ).rejects.toThrow(); + }); + + test('the placer is swappable without touching box creation', async () => { + const owner = await newOwner('place-swap'); + const machineId = await Fixtures.machine(owner, 'place-swap-host'); + + const asked: unknown[] = []; + const box = await Box.createPlaced( + { + id: Identifier.ascending('box'), + userId: owner.userId, + label: 'bedroom', + tier: 'lg' + }, + async (input) => { + asked.push(input); + return machineId; + } + ); + + expect(box.machineId).toBe(machineId); + // The placer is told who the box is for and what size was asked for, + // which is the whole input a real scheduler needs. + expect(asked).toEqual([{ userId: owner.userId, tier: 'lg' }]); + }); +}); diff --git a/packages/core/src/box/placement.ts b/packages/core/src/box/placement.ts new file mode 100644 index 00000000..64f7d972 --- /dev/null +++ b/packages/core/src/box/placement.ts @@ -0,0 +1,73 @@ +import z from 'zod'; + +import { ErrorCodes, VisibleError } from '../error.js'; +import { Machine } from '../machine/index.js'; +import { BoxTier } from './box.sql.js'; + +/** + * Deciding which host a box runs on. + * + * This is a seam and not an algorithm. `box.machineId` is set once, when the + * box is created, and everything downstream — a session, its job, the state + * reports that follow — reaches the right hardware by joining through the box. + * So there is exactly one moment where placement happens, and the value of + * naming it now is that a real scheduler replaces this file and nothing else. + * + * The wrong shape, and the tempting one, is to place a box when a *run* is + * requested. That spreads the decision across every caller that starts + * something and leaves nowhere to put a scheduler later. + */ +export namespace Placement { + export const Request = z.object({ + userId: z.string().meta({ description: 'Who the box is for' }), + tier: z.enum(BoxTier.enumValues).meta({ description: 'The size that was asked for' }) + }); + + export type Request = z.infer; + + /** + * Answers "which host should run this box?" with a machine id. + * + * Asynchronous and allowed to refuse: capacity is a real answer, and a + * placer that cannot honour a request must say so rather than return + * something the insert would reject — `box.machineId` is not nullable. + */ + export type Placer = (request: Request) => Promise; + + /** + * The implementation there is hardware for: place it on the caller's host. + * + * Deliberately refuses when the answer is not forced. With no host there is + * nothing to place on; with several there is a choice to make and no policy + * to make it with, and picking the first row would be a scheduling decision + * taken by accident and impossible to find later. Refusing keeps the choice + * in this one function. + */ + export const onlyHost: Placer = async (request) => { + const hosts = await Machine.listByOwner(request.userId); + + if (hosts.length === 0) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'You have no registered host to run a box on' + ); + } + if (hosts.length > 1) { + // Not a caller error: the request is fine and the system cannot yet + // answer it. An orchestrator is what closes this. todo(d-0048) + throw new VisibleError( + 'internal', + ErrorCodes.Server.SERVICE_UNAVAILABLE, + 'More than one host could run this box, and choosing between them is not supported yet' + ); + } + + return hosts[0]!.id; + }; + + /** Place a box, using `onlyHost` unless a caller supplies its own placer. */ + export async function choose(request: Request, placer: Placer = onlyHost): Promise { + return placer(Request.parse(request)); + } +} diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index fbb0f3f9..aa3c9061 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1,9 +1,11 @@ -import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm'; import z from 'zod'; +import { BoxTable, BoxTier } from '../box/box.sql.js'; import { Database } from '../db/index.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; +import { GameTable } from '../game/game.sql.js'; import { SessionState, SessionTable } from './session.sql.js'; /** @@ -188,6 +190,328 @@ export namespace Session { } ); + /** + * The states an agent is allowed to move a run into. + * + * `requested` is missing on purpose: it is written once, when the row is + * created, and nothing may put a run back there. + */ + export const ReportableState = z.enum(['starting', 'live', 'ended', 'failed']); + + export type ReportableState = z.infer; + + /** + * Where a run may go next, and nowhere else. + * + * `requested → live` is missing although it is the tempting shortcut: + * skipping `starting` means nothing ever holds the claim, and the claim is + * the only mutual exclusion in this design. `ended` and `failed` are + * terminal, so their entries are empty rather than absent — a state with no + * exits is a fact worth writing down. + */ + export const NEXT_STATES: Record = { + requested: ['starting'], + starting: ['live', 'failed'], + live: ['ended', 'failed'], + ended: [], + failed: [] + }; + + /** + * A unit of work handed to the agent that will carry it out. + * + * There is no queue: a run in state `requested` *is* the work order, and + * the agent that fulfils it moves that same row along. Two sources of truth + * for one piece of work is how a queue and a database come to disagree + * about whether something ran. + * + * `kind` is on the wire while there is only one value, so that a second + * kind is an addition rather than a redesign of the poll. + */ + export const Job = z + .object({ + kind: z.literal('session.start').meta({ + description: 'What the agent is being asked to do', + example: 'session.start' + }), + sessionId: z.string().meta({ + description: 'The run to report progress against', + example: Examples.Session.id + }), + boxId: z.string().meta({ + description: 'The box to start', + example: Examples.Session.boxId + }), + boxTier: z.enum(BoxTier.enumValues).meta({ + description: 'The size the box was asked for, which also sets output geometry', + example: Examples.Box.tier + }), + gameId: z.string().meta({ + description: 'The game to launch', + example: Examples.Session.gameId + }), + steamAppId: z.number().int().meta({ + description: 'The same game, in the id the store knows it by', + example: Examples.Game.steamAppId + }), + linkedAccountId: z.string().meta({ + description: 'Which linked account is playing', + example: Examples.Session.linkedAccountId + }) + }) + .meta({ + ref: 'SessionJob', + description: 'One run waiting to be started, as handed to the agent that will start it' + }); + + export type Job = z.infer; + + /** + * The work waiting for one host. + * + * The scope is the join and not a filter the caller asks for: a box names + * the hardware it is placed on, a run reaches its hardware through its box, + * and so what one set of long-lived credentials can see is decided by this + * `where` clause rather than by whoever is holding them. + */ + export const listJobsForMachine = fn(z.string(), async (machineId) => { + return Database.use(async (tx) => { + return tx + .select({ session: SessionTable, box: BoxTable, game: GameTable }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .innerJoin(GameTable, eq(SessionTable.gameId, GameTable.id)) + .where( + and( + eq(BoxTable.machineId, machineId), + eq(SessionTable.state, 'requested'), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ) + ) + .orderBy(SessionTable.timeCreated) + .then((rows) => + rows.map( + (row): Job => ({ + kind: 'session.start', + sessionId: row.session.id, + boxId: row.box.id, + boxTier: row.box.tier as Job['boxTier'], + gameId: row.game.id, + steamAppId: row.game.steamAppId, + linkedAccountId: row.session.linkedAccountId + }) + ) + ); + }); + }); + + /** One run, visible only to the host its box is placed on. */ + export const forMachine = fn( + z.object({ id: Info.shape.id, machineId: z.string() }), + async (input) => { + return Database.use(async (tx) => { + return tx + .select({ session: SessionTable }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(SessionTable.id, input.id), + eq(BoxTable.machineId, input.machineId), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ) + ) + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row.session) : null; + }); + }); + } + ); + + /** One run, visible only to the person who owns its box. */ + export const forOwner = fn(z.object({ id: Info.shape.id, userId: z.string() }), async (input) => { + return Database.use(async (tx) => { + return tx + .select({ session: SessionTable }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(SessionTable.id, input.id), + eq(BoxTable.userId, input.userId), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ) + ) + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row.session) : null; + }); + }); + }); + + /** The boxes one host is responsible for, as a subquery to scope a write. */ + function boxesOn(tx: Parameters[0]>[0], machineId: string) { + return tx + .select({ id: BoxTable.id }) + .from(BoxTable) + .where(and(eq(BoxTable.machineId, machineId), isNull(BoxTable.timeDeleted))); + } + + /** + * Move a run from one exact state to another, or do nothing at all. + * + * This is the claim, and it is why `setState` is not enough on its own: + * updating on the id alone means two agents polling the same work both + * succeed and both start the same box. The current state is part of the + * `where` clause, so the database decides the winner and the loser gets + * null rather than a row. There is one host today, which is exactly why + * this would otherwise be built wrong and stay wrong. + * + * The host is in the same `where` clause. The caller checking first is not + * the same thing as the write being scoped, and only one of the two is + * still true when somebody adds a second caller. + */ + export const compareAndSetState = fn( + z.object({ + id: Info.shape.id, + machineId: z.string(), + from: z.enum(SessionState.enumValues), + to: z.enum(SessionState.enumValues), + errorMessage: Info.shape.errorMessage + }), + async (input) => { + const now = sql`now()`; + return Database.use(async (tx) => { + return tx + .update(SessionTable) + .set({ + state: input.to, + errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null, + ...(input.to === 'live' + ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } + : {}), + ...(input.to === 'ended' || input.to === 'failed' + ? { timeStopped: sql`coalesce(${SessionTable.timeStopped}, ${now})` } + : {}) + }) + .where( + and( + eq(SessionTable.id, input.id), + eq(SessionTable.state, input.from), + isNull(SessionTable.timeDeleted), + inArray(SessionTable.boxId, boxesOn(tx, input.machineId)) + ) + ) + .returning() + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + } + ); + + /** + * What happened when an agent reported a state. + * + * Four outcomes that look alike from a distance and are not, which is the + * whole reason this is not a boolean: + * + * - `forbidden` — no such run, or it is not on this host. One answer for + * both, so reporting states at ids cannot be used to discover them. + * - `unchanged` — already in that state. A retry after a lost response is + * not a broken agent and must not be told it is. + * - `illegal` — not a transition that exists. The row does not move. + * - `lost` — a legal transition that something else got to first. + * - `moved` — it happened. + */ + export type TransitionOutcome = 'forbidden' | 'unchanged' | 'illegal' | 'lost' | 'moved'; + + export interface TransitionResult { + outcome: TransitionOutcome; + session: Info | null; + } + + export const transition = fn( + z.object({ + id: Info.shape.id, + machineId: z.string(), + state: z.enum(SessionState.enumValues), + errorMessage: Info.shape.errorMessage + }), + async (input): Promise => { + const current = await forMachine({ id: input.id, machineId: input.machineId }); + if (!current) return { outcome: 'forbidden', session: null }; + if (current.state === input.state) return { outcome: 'unchanged', session: current }; + if (!NEXT_STATES[current.state].includes(input.state)) { + return { outcome: 'illegal', session: current }; + } + + const moved = await compareAndSetState({ + id: input.id, + machineId: input.machineId, + from: current.state, + to: input.state, + errorMessage: input.errorMessage + }); + // The state read above is not the state written below, and the gap + // is where two agents race. Nothing moved means somebody else did. + if (!moved) return { outcome: 'lost', session: current }; + return { outcome: 'moved', session: moved }; + } + ); + + export interface TicketResult { + outcome: 'forbidden' | 'closed' | 'published'; + session: Info | null; + } + + /** + * Publish a ticket for a run, on behalf of the host it is placed on. + * + * A ticket may appear while the state is still `starting` — it is + * republished as addresses are discovered, so the client polls and re-reads + * rather than keeping the first one. A run that has stopped is refused: an + * address for something that is not there can only mislead whoever is + * still polling. + */ + export const publishTicket = fn( + z.object({ + id: Info.shape.id, + machineId: z.string(), + ticket: z.string().min(1) + }), + async (input): Promise => { + const current = await forMachine({ id: input.id, machineId: input.machineId }); + if (!current) return { outcome: 'forbidden', session: null }; + + return Database.use(async (tx) => { + return tx + .update(SessionTable) + .set({ ticket: input.ticket }) + .where( + and( + eq(SessionTable.id, input.id), + notInArray(SessionTable.state, ['ended', 'failed']), + isNull(SessionTable.timeDeleted), + inArray(SessionTable.boxId, boxesOn(tx, input.machineId)) + ) + ) + .returning() + .then((rows): TicketResult => { + const row = rows.at(0); + return row + ? { outcome: 'published', session: serialize(row) } + : { outcome: 'closed', session: current }; + }); + }); + } + ); + export function serialize(input: typeof SessionTable.$inferSelect): z.infer { return { id: input.id, diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts index 12206713..d2803a63 100644 --- a/packages/core/src/session/session.test.ts +++ b/packages/core/src/session/session.test.ts @@ -41,7 +41,7 @@ async function scene(label: string, steamAppId: number) { label, tier: 'sm' }); - return { owner, box, gameId: await newGame(steamAppId) }; + return { owner, machineId, box, gameId: await newGame(steamAppId) }; } afterAll(async () => { @@ -173,3 +173,244 @@ describe('Session', () => { expect(await Session.listByBox(box.id)).toHaveLength(0); }); }); + +describe('Session jobs', () => { + test('a requested session is the job, and it carries its kind', async () => { + const { owner, machineId, box, gameId } = await scene('ses-job-kind', 5410); + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + const jobs = await Session.listJobsForMachine(machineId); + expect(jobs).toHaveLength(1); + // The kind is on the wire from the first day there is only one, so the + // second kind is an addition rather than a redesign. + expect(jobs[0]!.kind).toBe('session.start'); + expect(jobs[0]!.sessionId).toBe(session.id); + expect(jobs[0]!.boxId).toBe(box.id); + expect(jobs[0]!.boxTier).toBe('sm'); + expect(jobs[0]!.gameId).toBe(gameId); + expect(jobs[0]!.steamAppId).toBe(5410); + expect(jobs[0]!.linkedAccountId).toBe(owner.linkedAccountId); + }); + + test('a job belongs to the machine its box is placed on and to no other', async () => { + const mine = await scene('ses-job-mine', 5411); + const theirs = await scene('ses-job-theirs', 5412); + + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: theirs.box.id, + gameId: theirs.gameId, + linkedAccountId: theirs.owner.linkedAccountId + }); + + // The scope is the join, not a filter the caller asks for. A machine + // credential is a long-lived secret on hardware in somebody's home, so + // what one leaking can reach is decided here. + expect(await Session.listJobsForMachine(mine.machineId)).toHaveLength(0); + expect((await Session.listJobsForMachine(theirs.machineId)).map((j) => j.sessionId)).toEqual([ + session.id + ]); + }); + + test('only a requested session is work; a claimed one is not offered again', async () => { + const { owner, machineId, box, gameId } = await scene('ses-job-claimed', 5413); + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + expect(await Session.listJobsForMachine(machineId)).toHaveLength(1); + await Session.transition({ + id: session.id, + machineId, + state: 'starting', + errorMessage: null + }); + expect(await Session.listJobsForMachine(machineId)).toHaveLength(0); + }); +}); + +describe('Session claim', () => { + async function requested(label: string, steamAppId: number) { + const s = await scene(label, steamAppId); + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: s.box.id, + gameId: s.gameId, + linkedAccountId: s.owner.linkedAccountId + }); + return { ...s, session }; + } + + test('the claim is a compare-and-set, so the second attempt finds nothing to move', async () => { + const { machineId, session } = await requested('ses-cas', 5420); + + const won = await Session.compareAndSetState({ + id: session.id, + machineId, + from: 'requested', + to: 'starting', + errorMessage: null + }); + expect(won?.state).toBe('starting'); + + // The same attempt again. The row is no longer `requested`, so the + // update matches nothing — which is what stops two agents from both + // starting the same box. Updating on the id alone would succeed twice. + const lost = await Session.compareAndSetState({ + id: session.id, + machineId, + from: 'requested', + to: 'starting', + errorMessage: null + }); + expect(lost).toBeNull(); + expect((await Session.fromID(session.id))?.state).toBe('starting'); + }); + + test('a machine that is not the box’s host cannot move the row', async () => { + const { session } = await requested('ses-cas-mine', 5421); + const other = await scene('ses-cas-other', 5422); + + const result = await Session.transition({ + id: session.id, + machineId: other.machineId, + state: 'starting', + errorMessage: null + }); + expect(result.outcome).toBe('forbidden'); + expect((await Session.fromID(session.id))?.state).toBe('requested'); + + // And the compare-and-set is scoped in the same query, not only by the + // classification above it. + expect( + await Session.compareAndSetState({ + id: session.id, + machineId: other.machineId, + from: 'requested', + to: 'starting', + errorMessage: null + }) + ).toBeNull(); + }); + + test('a session that does not exist is refused the same way as one that is not yours', async () => { + const other = await scene('ses-cas-ghost', 5423); + const result = await Session.transition({ + // A well-formed id for a row that was never written. + id: Identifier.ascending('session'), + machineId: other.machineId, + state: 'starting', + errorMessage: null + }); + // Same answer as somebody else's session: an agent must not be able to + // learn which ids exist by reporting states at them. + expect(result.outcome).toBe('forbidden'); + }); + + test('re-reporting the state you already reported changes nothing', async () => { + const { machineId, session } = await requested('ses-repeat', 5424); + + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + const again = await Session.transition({ + id: session.id, + machineId, + state: 'starting', + errorMessage: null + }); + // A retry after a lost response is not a broken agent. + expect(again.outcome).toBe('unchanged'); + expect(again.session?.state).toBe('starting'); + }); + + test('a transition off the table is refused and the row does not move', async () => { + const { machineId, session } = await requested('ses-illegal', 5425); + + // Skipping `starting` means nothing ever holds the claim, and the claim + // is the only mutual exclusion here — so it is refused however tempting + // the shortcut looks. + const skipped = await Session.transition({ + id: session.id, + machineId, + state: 'live', + errorMessage: null + }); + expect(skipped.outcome).toBe('illegal'); + expect((await Session.fromID(session.id))?.state).toBe('requested'); + + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'failed', errorMessage: 'no' }); + + // Terminal is terminal: a dead session cannot be resurrected. + const raised = await Session.transition({ + id: session.id, + machineId, + state: 'live', + errorMessage: null + }); + expect(raised.outcome).toBe('illegal'); + expect((await Session.fromID(session.id))?.state).toBe('failed'); + }); + + test('the timestamps survive a duplicate report, which is what billing rests on', async () => { + const { machineId, session } = await requested('ses-idempotent', 5426); + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + const live = await Session.transition({ + id: session.id, + machineId, + state: 'live', + errorMessage: null + }); + expect(live.session?.timeStarted).not.toBeNull(); + + const repeat = await Session.transition({ + id: session.id, + machineId, + state: 'live', + errorMessage: null + }); + expect(repeat.session?.timeStarted).toBe(live.session!.timeStarted); + }); + + test('publishing a ticket is scoped to the host too', async () => { + const { machineId, session } = await requested('ses-ticket-scope', 5427); + const other = await scene('ses-ticket-other', 5428); + + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + + const refused = await Session.publishTicket({ + id: session.id, + machineId: other.machineId, + ticket: 'stolen' + }); + expect(refused.outcome).toBe('forbidden'); + expect((await Session.fromID(session.id))?.ticket).toBeNull(); + + // A ticket may appear while the state is still `starting`. + const first = await Session.publishTicket({ id: session.id, machineId, ticket: 'one' }); + expect(first.outcome).toBe('published'); + expect(first.session?.ticket).toBe('one'); + expect(first.session?.state).toBe('starting'); + + const second = await Session.publishTicket({ id: session.id, machineId, ticket: 'two' }); + expect(second.session?.ticket).toBe('two'); + }); + + test('a stopped session has no address to publish', async () => { + const { machineId, session } = await requested('ses-ticket-dead', 5429); + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'ended', errorMessage: null }); + + const result = await Session.publishTicket({ id: session.id, machineId, ticket: 'late' }); + expect(result.outcome).toBe('closed'); + expect((await Session.fromID(session.id))?.ticket).toBeNull(); + }); +}); From 0d8630379b79ee7414fc58760eceb5a5955ce10b Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 4 Sep 2026 21:58:57 +0300 Subject: [PATCH 2/4] fix(api): a box gets one run, and a stopped run keeps no address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/app/routes/session.ts | 11 +- apps/api/test/session.test.ts | 52 + .../0008_session_one_active_run_per_box.sql | 30 + .../core/migrations/meta/0008_snapshot.json | 2299 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/session/index.ts | 73 +- packages/core/src/session/session.sql.ts | 17 +- packages/core/src/session/session.test.ts | 116 + 8 files changed, 2594 insertions(+), 11 deletions(-) create mode 100644 packages/core/migrations/0008_session_one_active_run_per_box.sql create mode 100644 packages/core/migrations/meta/0008_snapshot.json diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index f84c9858..e8fb696d 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -164,12 +164,17 @@ export namespace SessionApi { // 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('That box already has a run that has not stopped'); + conflict(Session.BOX_BUSY); } - const session = await Session.create({ + const session = await Session.request({ id: Identifier.ascending('session'), boxId: box.id, gameId: game.id, @@ -185,7 +190,7 @@ export namespace SessionApi { 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.', + '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) } }, diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index 03c6ba5f..de58463c 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -191,6 +191,29 @@ describe('POST /session', () => { 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); @@ -539,6 +562,35 @@ describe('POST /session/:id/ticket', () => { 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); diff --git a/packages/core/migrations/0008_session_one_active_run_per_box.sql b/packages/core/migrations/0008_session_one_active_run_per_box.sql new file mode 100644 index 00000000..fc391067 --- /dev/null +++ b/packages/core/migrations/0008_session_one_active_run_per_box.sql @@ -0,0 +1,30 @@ +-- A box runs one thing at a time, and now the database is what says so. +-- +-- `POST /session` reads `session.activeForBox` and refuses when something is +-- already running, but the read and the insert are two statements. Two requests +-- that both read "nothing is running" before either inserts each get a row, and +-- `/machine/jobs` then hands the host the same box to start twice. A partial +-- unique index on the same predicate the read asks about makes the second +-- insert fail instead. ref(d-0048) +-- +-- The index cannot be created while any box already has two unstopped runs, so +-- the duplicates are resolved first. Keeping the newest is the only choice that +-- matches what a person saw: their most recent request is the one they are +-- waiting on. The older rows are stopped rather than deleted, because a session +-- is the billing unit and rows that were once real do not vanish from it. +-- +-- `ended` and not `failed`: nothing about these runs failed. They were work +-- nobody picked up, and `failed` carries a reason there is none of. +UPDATE "session" s +SET "state" = 'ended', "time_stopped" = now() +WHERE s."time_stopped" IS NULL + AND s."time_deleted" IS NULL + AND EXISTS ( + SELECT 1 FROM "session" newer + WHERE newer."box_id" = s."box_id" + AND newer."time_stopped" IS NULL + AND newer."time_deleted" IS NULL + AND (newer."time_created", newer."id") > (s."time_created", s."id") + );--> statement-breakpoint + +CREATE UNIQUE INDEX "session_box_active_unique" ON "session" USING btree ("box_id") WHERE time_stopped is null and time_deleted is null; diff --git a/packages/core/migrations/meta/0008_snapshot.json b/packages/core/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..9d189a9e --- /dev/null +++ b/packages/core/migrations/meta/0008_snapshot.json @@ -0,0 +1,2299 @@ +{ + "id": "2c16a831-745e-4e1d-ad95-11aaa30e31f0", + "prevId": "6f1c8157-bfc0-4b61-b31e-9e117d8d0510", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index b1f7ec6b..ffa36f48 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1788460224524, "tag": "0007_box_session_team_notnull", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1788547836146, + "tag": "0008_session_one_active_run_per_box", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index aa3c9061..893da4f1 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -3,6 +3,7 @@ import z from 'zod'; import { BoxTable, BoxTier } from '../box/box.sql.js'; import { Database } from '../db/index.js'; +import { ErrorCodes, VisibleError } from '../error.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { GameTable } from '../game/game.sql.js'; @@ -18,6 +19,15 @@ import { SessionState, SessionTable } from './session.sql.js'; * value once. */ export namespace Session { + /** + * One wording for "that box is busy", however it was discovered. + * + * Both the read in {@link activeForBox} and the unique index behind + * {@link request} report it, and a caller must not be able to tell which, + * because that would only tell it how close the race was. + */ + export const BOX_BUSY = 'That box already has a run that has not stopped'; + export const Info = z .object({ id: z.string().meta({ @@ -41,7 +51,8 @@ export namespace Session { example: Examples.Session.state }), ticket: z.string().nullable().optional().meta({ - description: 'Current iroh connect ticket, or null before neshub mints one', + description: + 'Current connect ticket. Null before one has been minted, and null again once the run has stopped — a run that is not there has no address', example: Examples.Session.ticket }), timeStarted: z.string().nullable().optional().meta({ @@ -83,6 +94,41 @@ export namespace Session { } ); + /** Postgres refusing a second row for the same key. */ + function isUniqueViolation(err: unknown): boolean { + const e = err as { code?: string; cause?: { code?: string } }; + return e?.code === '23505' || e?.cause?.code === '23505'; + } + + /** + * Ask for a run of a box, and let the database refuse a second one. + * + * The same argument as {@link compareAndSetState}, one step earlier. A + * caller reading {@link activeForBox} first and the insert being refused + * are different properties: the read is a nicer error message, the unique + * index is the invariant. Two requests that both read "nothing is running" + * before either inserts each get a row, and the host is then handed the + * same box to start twice — which is exactly what the state claim exists + * to prevent. + * + * The refusal is the same 409 a caller gets from the read, and worded + * identically, because from outside they are the same fact and which one + * answered is a timing detail. + */ + export const request = fn( + Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }), + async (input) => { + try { + return await create(input); + } catch (err) { + if (isUniqueViolation(err)) { + throw new VisibleError('already_exists', ErrorCodes.Validation.INVALID_STATE, BOX_BUSY); + } + throw err; + } + } + ); + export const fromID = fn(Info.shape.id, async (id) => { return Database.use(async (tx) => { return tx @@ -99,9 +145,11 @@ export namespace Session { /** * The run currently occupying a box, if any. * - * Newest first and limited to one: a box has at most one live session by - * construction, and if that ever stops being true this is the query that - * should start refusing rather than picking a winner silently. + * At most one row can match: `session_box_active_unique` is a unique index + * on this exact predicate, so "newest first, limited to one" describes the + * query and not a choice being made. Reading this before inserting gives a + * caller a better message than a constraint violation; it is not what makes + * the answer single. See {@link request}. */ export const activeForBox = fn(Info.shape.boxId, async (boxId) => { return Database.use(async (tx) => { @@ -177,7 +225,12 @@ export namespace Session { ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } : {}), ...(input.state === 'ended' || input.state === 'failed' - ? { timeStopped: sql`coalesce(${SessionTable.timeStopped}, ${now})` } + ? { + timeStopped: sql`coalesce(${SessionTable.timeStopped}, ${now})`, + // A stopped run has no address, whichever writer + // stopped it. + ticket: null + } : {}) }) .where(and(eq(SessionTable.id, input.id), isNull(SessionTable.timeDeleted))) @@ -395,7 +448,15 @@ export namespace Session { ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } : {}), ...(input.to === 'ended' || input.to === 'failed' - ? { timeStopped: sql`coalesce(${SessionTable.timeStopped}, ${now})` } + ? { + timeStopped: sql`coalesce(${SessionTable.timeStopped}, ${now})`, + // A stopped run has no address. Publishing one is + // already refused, so keeping the last one would + // leave the only readable ticket for a dead run + // being the one nothing may replace — and a client + // that polls would dial it. + ticket: null + } : {}) }) .where( diff --git a/packages/core/src/session/session.sql.ts b/packages/core/src/session/session.sql.ts index 71b03b9b..9c4c31fc 100644 --- a/packages/core/src/session/session.sql.ts +++ b/packages/core/src/session/session.sql.ts @@ -1,7 +1,8 @@ -import { index, pgEnum, pgTable, text } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; -import { id, timestamps, ulid, utc } from '../db/types.js'; import { BoxTable } from '../box/box.sql.js'; +import { id, timestamps, ulid, utc } from '../db/types.js'; import { GameTable } from '../game/game.sql.js'; import { LinkedAccountTable } from '../user/linked-account.sql.js'; @@ -66,6 +67,18 @@ export const SessionTable = pgTable( (t) => [ index('session_box_idx').on(t.boxId), index('session_state_idx').on(t.state), + // A box runs one thing at a time, and this is where that is true. + // + // The caller checking first is a nicer error message; it is not the + // invariant. Two requests that both read "nothing is running" before + // either inserts each get a row, and the host is then handed the same + // box to start twice — the identical failure the state claim exists to + // prevent, one step earlier. So the predicate is exactly the one + // `Session.activeForBox` asks about, and the database refuses the + // second row rather than a caller remembering to. + uniqueIndex('session_box_active_unique') + .on(t.boxId) + .where(sql`time_stopped is null and time_deleted is null`), // Metering reads "sessions in this window", and this table is what // billing sums, so the time index is not speculative. ref(d-0048) index('session_started_idx').on(t.timeStarted) diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts index d2803a63..4da9c9d5 100644 --- a/packages/core/src/session/session.test.ts +++ b/packages/core/src/session/session.test.ts @@ -58,6 +58,18 @@ afterAll(async () => { } }); +/** A scene with the run already requested. */ +async function requestedRun(label: string, steamAppId: number) { + const s = await scene(label, steamAppId); + const session = await Session.request({ + id: Identifier.ascending('session'), + boxId: s.box.id, + gameId: s.gameId, + linkedAccountId: s.owner.linkedAccountId + }); + return { ...s, session }; +} + describe('Session', () => { test('a session starts requested, with no ticket and no times', async () => { const { owner, box, gameId } = await scene('ses-defaults', 5400); @@ -414,3 +426,107 @@ describe('Session claim', () => { expect((await Session.fromID(session.id))?.ticket).toBeNull(); }); }); + +describe('Session one active run per box', () => { + test('the database refuses a second unstopped run, not just the caller', async () => { + const { owner, machineId, box, gameId } = await scene('ses-one-active', 5450); + const mk = () => + Session.request({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + const first = await mk(); + expect(first.state).toBe('requested'); + + // The interleaving `POST /session` permits: both callers read + // `activeForBox` and see nothing, then both insert. The read is a + // message; the unique index is the invariant, so the second insert is + // refused rather than producing a row. + const before = await Session.activeForBox(box.id); + expect(before?.id).toBe(first.id); + await expect(mk()).rejects.toThrow(Session.BOX_BUSY); + + expect(await Session.listByBox(box.id)).toHaveLength(1); + // The point of all of it: the host is handed one launch, not two. + expect(await Session.listJobsForMachine(machineId)).toHaveLength(1); + }); + + test('a box that has stopped running is free to run again', async () => { + const { owner, machineId, box, gameId } = await scene('ses-one-active-reuse', 5451); + const mk = () => + Session.request({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + const first = await mk(); + await Session.transition({ id: first.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ id: first.id, machineId, state: 'live', errorMessage: null }); + await Session.transition({ id: first.id, machineId, state: 'ended', errorMessage: null }); + + // The index is partial for exactly this reason: a box is a durable + // thing and playing twice is the ordinary case, so a stopped run must + // not occupy the slot forever. + const second = await mk(); + expect(second.id).not.toBe(first.id); + expect(await Session.listByBox(box.id)).toHaveLength(2); + }); +}); + +describe('Session tickets and the end of a run', () => { + test('stopping a run takes its address away', async () => { + const { machineId, session } = await requestedRun('ses-ticket-cleared', 5452); + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null }); + expect( + (await Session.publishTicket({ id: session.id, machineId, ticket: 'live-address' })).session + ?.ticket + ).toBe('live-address'); + + const ended = await Session.transition({ + id: session.id, + machineId, + state: 'ended', + errorMessage: null + }); + // Publishing an address for a stopped run is already refused, so a + // kept one would be the only ticket a client can read for a dead run + // and the one nothing is allowed to replace. It would be dialled. + expect(ended.outcome).toBe('moved'); + expect(ended.session?.ticket).toBeNull(); + expect((await Session.fromID(session.id))?.ticket).toBeNull(); + }); + + test('a run that failed does not keep an address either', async () => { + const { machineId, session } = await requestedRun('ses-ticket-cleared-fail', 5453); + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.publishTicket({ id: session.id, machineId, ticket: 'starting-address' }); + + const failed = await Session.transition({ + id: session.id, + machineId, + state: 'failed', + errorMessage: 'the guest never came up' + }); + expect(failed.session?.ticket).toBeNull(); + // The reason survives; only the address goes. + expect(failed.session?.errorMessage).toBe('the guest never came up'); + }); + + test('the unscoped primitive clears it too, whichever writer stops a run', async () => { + const { session } = await requestedRun('ses-ticket-cleared-setstate', 5454); + await Session.setTicket({ id: session.id, ticket: 'an-address' }); + + const ended = await Session.setState({ + id: session.id, + state: 'ended', + errorMessage: null + }); + expect(ended?.ticket).toBeNull(); + }); +}); From 51dabddbd8a011c9f25fb24e388de11e59318f4a Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 4 Sep 2026 22:12:31 +0300 Subject: [PATCH 3/4] fix(api): a run drives the box under it, and needs a game and a claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/app/routes/session.ts | 26 ++++- apps/api/test/session.test.ts | 91 ++++++++++++++- .../0008_session_one_active_run_per_box.sql | 8 +- packages/core/src/session/index.ts | 108 ++++++++++++++---- packages/core/src/session/session.test.ts | 108 ++++++++++++++++++ 5 files changed, 316 insertions(+), 25 deletions(-) diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index e8fb696d..afb1065f 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -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 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. A run that has stopped has no address, so that is 409.', + '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) } }, @@ -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: diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index de58463c..b151e32d 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -8,6 +8,7 @@ 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 { app } from '../app/index'; import './setup'; @@ -64,11 +65,24 @@ async function scene(label: string, steamAppId: number) { 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: await newGame(steamAppId), + gameId, user: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' @@ -230,6 +244,27 @@ describe('POST /session', () => { 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('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', { @@ -526,6 +561,33 @@ describe('POST /session/:id/ticket', () => { 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 else’s run', async () => { const mine = await scene('route-ticket-mine', 5541); const theirs = await scene('route-ticket-theirs', 5542); @@ -603,6 +665,33 @@ describe('POST /session/:id/ticket', () => { }); }); +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'); diff --git a/packages/core/migrations/0008_session_one_active_run_per_box.sql b/packages/core/migrations/0008_session_one_active_run_per_box.sql index fc391067..da2b4aa4 100644 --- a/packages/core/migrations/0008_session_one_active_run_per_box.sql +++ b/packages/core/migrations/0008_session_one_active_run_per_box.sql @@ -15,8 +15,14 @@ -- -- `ended` and not `failed`: nothing about these runs failed. They were work -- nobody picked up, and `failed` carries a reason there is none of. +-- +-- The ticket goes with the state, exactly as the terminal transitions in +-- `Session` do it. A duplicate that reached `starting` or `live` may have +-- published an address, and a stopped run that still answers with one is an +-- address a polling client would dial — with publishing a replacement already +-- refused, it would also be the last word. UPDATE "session" s -SET "state" = 'ended', "time_stopped" = now() +SET "state" = 'ended', "time_stopped" = now(), "ticket" = NULL WHERE s."time_stopped" IS NULL AND s."time_deleted" IS NULL AND EXISTS ( diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 893da4f1..82f94d4c 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1,7 +1,8 @@ -import { and, desc, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; import z from 'zod'; import { BoxTable, BoxTier } from '../box/box.sql.js'; +import { Box } from '../box/index.js'; import { Database } from '../db/index.js'; import { ErrorCodes, VisibleError } from '../error.js'; import { Examples } from '../examples.js'; @@ -497,6 +498,39 @@ export namespace Session { session: Info | null; } + /** + * What a run reaching a state means for the box underneath it. + * + * The box has its own three states and nothing was writing them, so a box + * read `created` while a run on it was `live` — the screens that show a + * person what their hardware is doing would all have been wrong. The two + * state machines are not the same shape and should not be: a box has no + * `starting`, deliberately, because that transition is synchronous from + * the agent's side and a state nobody sets is a state that lies. So only + * the states that mean something to the box are mapped, and `requested` + * and `starting` map to nothing at all. + * + * `failed` is a `stopped` box that did not stop cleanly, which is the + * distinction `stopClean` exists for: "it is not running" and "it faulted" + * are different facts and the difference lives in the reason. + */ + function boxStateFor( + run: Info + ): { state: 'running' | 'stopped'; stopReason: string | null; stopClean: boolean | null } | null { + switch (run.state) { + case 'live': + return { state: 'running', stopReason: null, stopClean: null }; + case 'ended': + return { state: 'stopped', stopReason: null, stopClean: true }; + case 'failed': + // The run's own reason, so a box explains its stop in the words + // the agent used rather than in a second wording of one event. + return { state: 'stopped', stopReason: run.errorMessage ?? null, stopClean: false }; + default: + return null; + } + } + export const transition = fn( z.object({ id: Info.shape.id, @@ -505,40 +539,66 @@ export namespace Session { errorMessage: Info.shape.errorMessage }), async (input): Promise => { - const current = await forMachine({ id: input.id, machineId: input.machineId }); - if (!current) return { outcome: 'forbidden', session: null }; - if (current.state === input.state) return { outcome: 'unchanged', session: current }; - if (!NEXT_STATES[current.state].includes(input.state)) { - return { outcome: 'illegal', session: current }; - } + // One transaction, because "this run is live" and "the box under it + // is running" are one fact written to two tables. Committing the + // first without the second is how a box gets stuck `running` with + // nothing running on it, and nothing here would ever correct it. + return Database.transaction(async (): Promise => { + const current = await forMachine({ id: input.id, machineId: input.machineId }); + if (!current) return { outcome: 'forbidden', session: null }; + if (current.state === input.state) return { outcome: 'unchanged', session: current }; + if (!NEXT_STATES[current.state].includes(input.state)) { + return { outcome: 'illegal', session: current }; + } - const moved = await compareAndSetState({ - id: input.id, - machineId: input.machineId, - from: current.state, - to: input.state, - errorMessage: input.errorMessage + const moved = await compareAndSetState({ + id: input.id, + machineId: input.machineId, + from: current.state, + to: input.state, + errorMessage: input.errorMessage + }); + // The state read above is not the state written below, and the + // gap is where two agents race. Nothing moved means somebody + // else did — and then the box is that caller's to update, not + // this one's. + if (!moved) return { outcome: 'lost', session: current }; + + const box = boxStateFor(moved); + if (box) { + await Box.setState({ id: moved.boxId, ...box }); + } + + return { outcome: 'moved', session: moved }; }); - // The state read above is not the state written below, and the gap - // is where two agents race. Nothing moved means somebody else did. - if (!moved) return { outcome: 'lost', session: current }; - return { outcome: 'moved', session: moved }; } ); export interface TicketResult { - outcome: 'forbidden' | 'closed' | 'published'; + outcome: 'forbidden' | 'unclaimed' | 'closed' | 'published'; session: Info | null; } + /** + * The states a run can have an address in. + * + * `starting` is in and `requested` is out, which is the whole distinction: + * a ticket is the address of something being brought up, so publishing one + * means the host has taken the work. It cannot have an address for a run it + * has not claimed, and the terminal states are out because a run that is + * not there has no address at all. + */ + const ADDRESSABLE = ['starting', 'live'] as const; + /** * Publish a ticket for a run, on behalf of the host it is placed on. * * A ticket may appear while the state is still `starting` — it is * republished as addresses are discovered, so the client polls and re-reads - * rather than keeping the first one. A run that has stopped is refused: an - * address for something that is not there can only mislead whoever is - * still polling. + * rather than keeping the first one. Outside {@link ADDRESSABLE} it is + * refused, and the two refusals are separate answers because they are + * different mistakes: a run not yet claimed is an agent that skipped a + * step, and a run that stopped is one that has nothing left to reach. */ export const publishTicket = fn( z.object({ @@ -549,6 +609,7 @@ export namespace Session { async (input): Promise => { const current = await forMachine({ id: input.id, machineId: input.machineId }); if (!current) return { outcome: 'forbidden', session: null }; + if (current.state === 'requested') return { outcome: 'unclaimed', session: current }; return Database.use(async (tx) => { return tx @@ -557,7 +618,10 @@ export namespace Session { .where( and( eq(SessionTable.id, input.id), - notInArray(SessionTable.state, ['ended', 'failed']), + // The state is in the write and not only in the check + // above it, so a run that stops underneath this call + // does not acquire an address on the way out. + inArray(SessionTable.state, [...ADDRESSABLE]), isNull(SessionTable.timeDeleted), inArray(SessionTable.boxId, boxesOn(tx, input.machineId)) ) diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts index 4da9c9d5..84b16347 100644 --- a/packages/core/src/session/session.test.ts +++ b/packages/core/src/session/session.test.ts @@ -530,3 +530,111 @@ describe('Session tickets and the end of a run', () => { expect(ended?.ticket).toBeNull(); }); }); + +describe('Session and the box underneath it', () => { + test('a live run is what makes its box running', async () => { + const { machineId, box, session } = await requestedRun('ses-box-live', 5460); + // A box starts out `created` and nothing had ever moved it, so it read + // `created` while a run on it was `live`. + expect((await Box.fromID(box.id))?.state).toBe('created'); + + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + // `starting` is deliberately not a box state: that transition is + // synchronous from the agent's side, so nothing would ever write it. + expect((await Box.fromID(box.id))?.state).toBe('created'); + + await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null }); + const running = await Box.fromID(box.id); + expect(running?.state).toBe('running'); + expect(running?.stopReason).toBeNull(); + expect(running?.stopClean).toBeNull(); + }); + + test('a run that ends stops its box, cleanly', async () => { + const { machineId, box, session } = await requestedRun('ses-box-ended', 5461); + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'ended', errorMessage: null }); + + const stopped = await Box.fromID(box.id); + expect(stopped?.state).toBe('stopped'); + expect(stopped?.stopClean).toBe(true); + expect(stopped?.stopReason).toBeNull(); + }); + + test('a run that fails stops its box in the words the agent used', async () => { + const { machineId, box, session } = await requestedRun('ses-box-failed', 5462); + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ + id: session.id, + machineId, + state: 'failed', + errorMessage: 'the guest never came up' + }); + + const stopped = await Box.fromID(box.id); + expect(stopped?.state).toBe('stopped'); + // "It is not running" and "it faulted" are different facts, and the + // difference lives in the reason rather than in a fourth state. + expect(stopped?.stopClean).toBe(false); + expect(stopped?.stopReason).toBe('the guest never came up'); + }); + + test('a refused report leaves the box alone', async () => { + const { machineId, box, session } = await requestedRun('ses-box-untouched', 5463); + const other = await scene('ses-box-otherhost', 5464); + + const refused = await Session.transition({ + id: session.id, + machineId: other.machineId, + state: 'starting', + errorMessage: null + }); + expect(refused.outcome).toBe('forbidden'); + + // An illegal transition does not move the run, so it must not move the + // box either — otherwise the box records a run that never happened. + const illegal = await Session.transition({ + id: session.id, + machineId, + state: 'live', + errorMessage: null + }); + expect(illegal.outcome).toBe('illegal'); + expect((await Box.fromID(box.id))?.state).toBe('created'); + }); +}); + +describe('Session tickets need a claim first', () => { + test('a run nobody has claimed has no address to publish', async () => { + const { machineId, session } = await requestedRun('ses-ticket-unclaimed', 5465); + + // A ticket is the address of something being brought up, so publishing + // one for a `requested` run means the agent skipped the claim — the + // step that is the only mutual exclusion in the design. + const early = await Session.publishTicket({ id: session.id, machineId, ticket: 'too-soon' }); + expect(early.outcome).toBe('unclaimed'); + expect((await Session.fromID(session.id))?.ticket).toBeNull(); + + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + const now = await Session.publishTicket({ id: session.id, machineId, ticket: 'in-time' }); + expect(now.outcome).toBe('published'); + expect(now.session?.ticket).toBe('in-time'); + }); + + test('the two refusals are different answers, because they are different mistakes', async () => { + const { machineId, session } = await requestedRun('ses-ticket-refusals', 5466); + const unclaimed = await Session.publishTicket({ id: session.id, machineId, ticket: 'a' }); + + await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null }); + await Session.transition({ id: session.id, machineId, state: 'ended', errorMessage: null }); + const closed = await Session.publishTicket({ id: session.id, machineId, ticket: 'b' }); + + // One is an agent that has not claimed the work; the other is a run + // with nothing left to reach. Collapsing them would tell an agent + // retrying the wrong thing. + expect(unclaimed.outcome).toBe('unclaimed'); + expect(closed.outcome).toBe('closed'); + }); +}); From 7bdca1240f3e3644e87686a4be3546374c461800 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 4 Sep 2026 22:27:20 +0300 Subject: [PATCH 4/4] fix(api): say what the library check actually proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library entry records the person, not the account the games were synced from, and `POST /library/sync` is not even told which account a list came from. So the ownership check added for session requests asks "has somebody this person linked got this game?" and not "does the account about to play own it?" — for the one Steam account most people have those are the same sentence, and for two they are not. Confirmed rather than reasoned about: a person with two Steam links, a game synced at person level, and a request naming the second account is accepted today. The check stays, because it still turns a box that boots, tries to launch and fails minutes later into an immediate refusal, and it never refuses on account grounds that the data cannot support. What changes is the comment, which claimed the stronger property, and a test that pins the gap so it is found deliberately rather than by surprise. Closing it properly means recording the linked account on a library entry: a column, a sync contract that says which account a list belongs to, a uniqueness rule per account rather than per person, and a backfill with no correct answer for rows already written. That is a decision about what a library is, and inferring it here would be the kind of modelling taken by accident that this branch refuses elsewhere. --- apps/api/app/routes/session.ts | 25 +++++++++++++++++-------- apps/api/test/session.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index afb1065f..eed46f48 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -137,18 +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. + // 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 the sentence a person can act on. + // 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 and - // not a reason to launch runs that cannot work. + // **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( diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index b151e32d..c63e12a2 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -9,6 +9,7 @@ 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'; @@ -265,6 +266,39 @@ describe('POST /session', () => { 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', {