Files
netris-nestri/apps/api/app/routes/machine.ts
T
Wanjohi 7209e910d9 feat(api): launches under a session (#422)
Phase 1 of decision 0085, the api piece (see .nestri contract
window-streams).

A box run (session) now holds launches: one game or app each. Billing,
allowance and the free weekend still count sessions; nothing about
limits changes. session_box_active_unique stays.

- Migration 0027: launch table (id lch_..., session_id, game_id, state
requested|running|ended|failed, first, time_started, time_stopped,
time_stop_requested, end_reason, error_message). Backfills a first
launch for every run that has not stopped.
- POST /session: same request; the response data gains launches (the
first launch).
- POST /session/{id}/launch {gameId}: 201 with the launch. Same library
check as POST /session. 409 unless the run is starting or live and not
being stopped.
- POST /session/{id}/launch/{launchId}/stop: marks the launch; host gets
launch.stop until it reports it over. A launch in a run no host has
claimed ends at once.
- POST /session/{id}/launch/{launchId}/state (host, run's claimToken):
running | ended | failed, optional endReason/errorMessage.
- When the last launch is over, the run is stopped through the same path
as POST /session/{id}/stop. A run ending ends every launch in it.
- GET /session/{id} gains launches.
- GET /machine/jobs: session.start gains launchId (the first launch);
new kinds launch.start and launch.stop. Agents skip unknown kinds, so
old hosts behave as before.

Tests: apps/api 203 pass (new test/launch.test.ts), packages/core 256
pass, against the local test Postgres.
2026-10-17 13:16:50 +03:00

681 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Actor } from '@nestri/core/actor';
import { Box } from '@nestri/core/box/index';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { GameDownload } from '@nestri/core/game/download';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
import { InstallToken } from '@nestri/core/machine/install-token';
import { Organisation } from '@nestri/core/organisation/index';
import { Team } from '@nestri/core/team/index';
import { Member } from '@nestri/core/team/member';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
/**
* Host registration.
*
* A box does not get to say who it is. It registers once against its owner's
* session, is handed an id and a secret, and authenticates as itself from then
* on — so `hostId` on a download report is something the API assigned rather
* than a free-form string any holder of a shared secret could invent.
*/
export namespace MachineApi {
export const route = new Hono()
.post(
'/register',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Register a host',
description:
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest. Hardware belongs either to a team, which is the default and covers a host somebody brings, or to an organisation the caller belongs to, which is how hardware bought to serve other people is registered so that it is nobody\u2019s personal property.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
machineId: z.string().meta({ example: Examples.Machine.id }),
slug: z.string().meta({
description:
'The name this host is reached at. Its hostname is this label under the box zone, and the id never appears in one.',
example: Examples.Machine.slug
}),
secret: z.string().meta({
description: 'Shown once. Store it on the box; it cannot be retrieved.'
})
})
)
}
},
description: 'The box is registered'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
label: z.string().min(1).max(64).meta({
description: 'Human-readable name for the box',
example: Examples.Machine.label
}),
teamId: z.string().optional().meta({
description:
'Team to own this hardware. Defaults to the caller\u2019s personal team, which always exists. Mutually exclusive with organisationId'
}),
organisationId: z.string().optional().meta({
description:
'Organisation to own this hardware outright, for a host that serves other people rather than its registrant. The caller must belong to it, and no team is recorded'
})
})
),
async (c) => {
const { label, teamId, organisationId } = c.req.valid('json');
if (teamId && organisationId) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
'Hardware belongs to a team or to an organisation, not to both',
'organisationId'
);
}
// `notPublic` also admits a machine, which has no user to own a
// box. Registering is an act of ownership, so it needs a real one.
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Registering a machine requires a user session'
);
}
// Naming an organisation registers fleet hardware: owned outright,
// with no team and no person behind it, so that it survives the
// account of whoever happened to run the command.
if (organisationId) {
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
// Membership is the verified address, so this refuses the
// same way for an organisation that does not exist and one
// the caller simply is not in — there is nothing to learn
// from the difference.
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You do not belong to that organisation'
);
}
const fleet = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
organisationId,
label
});
return c.json({
data: { machineId: fleet.id, slug: fleet.slug, secret: fleet.secret }
});
}
// Otherwise a team has to be resolved rather than left null, since
// hardware with neither owner is hardware nothing can bill. The
// order is: what the caller asked for, then the team they are
// acting inside, then their personal team — which `ensurePersonal`
// makes if this is an older user who has none. ref(d-0048)
const owningTeam =
teamId ??
(actor.type === 'member'
? actor.properties.teamID
: await Team.ensurePersonal({ displayName: Actor.userID }));
// A caller naming a team must belong to it. Without this, `teamId`
// would be a way to park hardware in somebody else's team.
if (teamId) {
const membership = await Member.findByTeamAndUser({
teamId,
userId: Actor.userID
});
if (!membership) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You are not a member of that team'
);
}
}
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: Actor.userID,
teamId: owningTeam,
label
});
return c.json({
data: {
machineId: registered.id,
slug: registered.slug,
secret: registered.secret
}
});
}
)
.post(
'/install-token',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Issue an install token',
description:
'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history. One person may make five an hour and hold three unspent at once, and a team may own at most ten hosts; fleet hosts are not counted.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
token: z
.string()
.meta({ description: 'Shown once. Pass it to the installer.' }),
expiresAt: z.iso.datetime()
})
)
}
},
description: 'A token for one host'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
teamId: z.string().optional().meta({
description: 'Team the host will belong to. Defaults to the caller\u2019s personal team'
}),
organisationId: z.string().optional().meta({
description:
'Register the host to this organisation’s fleet instead of to a team. You must belong to the organisation'
})
})
),
async (c) => {
const { teamId, organisationId } = c.req.valid('json');
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Issuing an install token requires a user session'
);
}
if (organisationId) {
if (teamId) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
'A host belongs to a team or to an organisation, not both',
'organisationId'
);
}
// Fleet hardware is rented to strangers, so only the
// organisation's own people may add to it.
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You do not belong to that organisation'
);
}
const issued = await InstallToken.create({ organisationId, userId: Actor.userID });
return c.json({
data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() }
});
}
// Same resolution and the same membership rule as `/register`: a
// token is a deferred registration, so it may not reach a team the
// caller could not register into directly.
const owningTeam =
teamId ??
(actor.type === 'member'
? actor.properties.teamID
: await Team.ensurePersonal({ displayName: Actor.userID }));
if (teamId) {
const membership = await Member.findByTeamAndUser({ teamId, userId: Actor.userID });
if (!membership) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You are not a member of that team'
);
}
}
const issued = await InstallToken.create({ teamId: owningTeam, userId: Actor.userID });
return c.json({
data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() }
});
}
)
.post(
'/install',
describeRoute({
tags: ['Machine'],
summary: 'Register a host with an install token',
description:
'Spend an install token and register the calling host to the team it was issued for. Needs no session: the token is the authority. The response is the same as registering directly, and the secret is likewise returned once.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
machineId: z.string().meta({ example: Examples.Machine.id }),
slug: z.string().meta({ example: Examples.Machine.slug }),
secret: z.string()
})
)
}
},
description: 'The host is registered'
},
401: ErrorResponses[401],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
token: z.string().min(1),
label: z.string().min(1).max(64).meta({
description: 'Human-readable name for the host',
example: Examples.Machine.label
})
})
),
async (c) => {
const { token, label } = c.req.valid('json');
const registered = await InstallToken.redeem({ token, label });
if (!registered) {
// One answer for unknown, expired and already used.
throw new VisibleError(
'authentication',
ErrorCodes.Authentication.INVALID_TOKEN,
'This install token is not valid. Copy a fresh command from your dashboard.'
);
}
return c.json({ data: registered });
}
)
.patch(
'/:id',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Move a box to another team',
description:
'Move a machine you own to a team you belong to. Hardware always belongs to exactly one team, so there is no way to unscope — name your personal team instead. This is not ownership transfer: the owner does not change.',
responses: {
200: {
content: { 'application/json': { schema: Result(Machine.Info) } },
description: 'The machine, rescoped'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z.object({
teamId: z.string().meta({
description:
'Team to move the box to. There is no “no team” — to unscope, name your personal team'
})
})
),
async (c) => {
const { teamId } = c.req.valid('json');
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Rescoping a machine requires a user session'
);
}
// Verified before the write. `setTeam` scopes to the owner but
// knows nothing about who belongs to the target team, so this is
// the only place that check exists.
const membership = await Member.findByTeamAndUser({
teamId,
userId: Actor.userID
});
if (!membership) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You are not a member of that team'
);
}
const machine = await Machine.setTeam({
id: c.req.param('id'),
ownerUserId: Actor.userID,
teamId
});
if (!machine) {
// Owner-scoped in the query, so someone else's machine is a
// 404 rather than a 403 — no way to probe for ids.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such machine, or it is not yours'
);
}
return c.json({ data: machine });
}
)
.get(
'/entitlement',
machineOnly,
describeRoute({
tags: ['Machine'],
summary: 'Ask whether a user may use this box',
description:
'Answers for the calling machine only — the machine is taken from its credentials, never from the query, so a box cannot ask about another. Membership is read live, so removing someone from a team removes their access.',
responses: {
200: {
content: { 'application/json': { schema: Result(Machine.Entitlement) } },
description: 'Whether the user may use this machine, and why'
},
403: ErrorResponses[403]
}
}),
validator('query', z.object({ userId: z.string().min(1) })),
async (c) => {
const { userId } = c.req.valid('query');
return c.json({
data: await Machine.entitlement({ machineId: Actor.machineID, userId })
});
}
)
.post(
'/heartbeat',
machineOnly,
describeRoute({
tags: ['Machine'],
summary: 'Say the host is alive',
description:
'Records liveness for the calling machine and returns how often it should call back. The interval comes from the server on purpose: a fleet whose cadence can only change by shipping a new agent is a fleet whose cadence never changes. The body carries one optional fact — where this host can be reached — because only a host can say that about itself and this is the call it already makes as itself. What a host is *running* is reported separately.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
lastSeen: z.iso.datetime().meta({
description: 'When this beat was recorded, by the database’s clock',
example: Examples.Machine.lastSeen
}),
intervalSeconds: z.number().meta({
description: 'Call back this often',
example: Machine.HEARTBEAT_SECONDS
})
})
)
}
},
description: 'The beat was recorded'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404],
409: ErrorResponses[409]
}
}),
validator(
'json',
z
.object({
endpointId: Machine.EndpointId.optional().meta({
description:
'Where this host can be reached, as its own endpoint id. Omit it and the stored value is left alone — a host that does not mention where it is has not moved, and an absent field must never read as "nowhere"',
example: Examples.Machine.endpointId
}),
slots: z.number().int().min(0).max(64).optional().meta({
description:
'How many runs this host will hold at once. Omit it and the stored value is left alone. Runs beyond it wait their turn rather than being handed to a host that would refuse them',
example: 2
}),
capabilities: z
.array(z.string().min(1).max(64))
.max(32)
.optional()
.meta({
description:
'What this host can do beyond starting and stopping runs. Replaces the stored list when sent; omit it and the stored list is left alone. `launches`: it adds and ends launches in a running box (`launch.start`, `launch.stop`)',
example: ['launches']
})
})
// A host that has nothing to add sends no body at all, which
// is what every agent shipped before this field did.
.optional()
),
async (c) => {
const body = c.req.valid('json');
const lastSeen = await Machine.touchLastSeen({
id: Actor.machineID,
endpointId: body?.endpointId,
slots: body?.slots,
capabilities: body?.capabilities
});
if (!lastSeen) {
// The credentials authenticated but the row is gone — a host
// deleted mid-beat. It must re-register rather than keep
// beating into nothing, so this is a 404 and not a 200.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'This machine no longer exists'
);
}
return c.json({
data: {
lastSeen: lastSeen.toISOString(),
intervalSeconds: Machine.HEARTBEAT_SECONDS
}
});
}
)
.post(
'/report',
machineOnly,
describeRoute({
tags: ['Machine'],
summary: 'Say what the host is running',
description:
'Records one full snapshot of the boxes on the calling host. Separate from the beat because the two have different loss tolerance: a dropped report is corrected by the next one, where a dropped beat moves a host towards offline. Send one when a box changes lifecycle, and send one anyway every so often so a single lost snapshot cannot leave this record permanently wrong. Never send a delta — a retrying agent cannot promise ordering, and out-of-order deltas describe a host that never existed.',
responses: {
200: {
content: { 'application/json': { schema: Result(Box.ReportOutcome) } },
description: 'The snapshot was recorded'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z
.object({
agentPid: z.number().int().meta({
description: 'The reporting agent’s own process id, in its own namespace'
}),
boxesKnown: z.number().int().meta({ description: 'How many boxes the host holds' }),
boxesRunning: z.number().int().meta({
description: 'How many of them are running'
}),
boxes: z.array(Box.Reported).meta({
description: 'Every box the host holds. A full snapshot, never a delta'
}),
gpus: z.array(Machine.Gpu).max(64).optional().meta({
description:
'The cards in this host. The first measured part of its capacity; left off when none could be read'
})
})
// Strict, so a field this cannot act on is a validation error a
// host operator sees rather than one quietly dropped. Capacity
// belongs here, one measured field at a time: `gpus` is the
// first, and refusing the ones nobody measures keeps it honest.
.strict()
),
async (c) => {
const machine = await Machine.fromID(Actor.machineID);
if (!machine) {
// Same answer as the beat gives, for the same reason: the
// credentials authenticated but the row is gone, and a host
// must re-register rather than keep reporting into nothing.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'This machine no longer exists'
);
}
const { boxes, gpus } = c.req.valid('json');
const outcome = await Box.applyHostReport({ machineId: Actor.machineID, boxes });
if (gpus && gpus.length > 0) {
await Machine.setGpus({ id: Actor.machineID, gpus });
}
// `agentPid`, `boxesKnown` and `boxesRunning` are read and not
// stored. They are a summary of the list that follows them, and a
// stored copy is a second answer to a question the list already
// answers — one that goes stale the first time the two disagree.
// They stay on the wire because a host that cannot enumerate its
// boxes can still say how many it has.
if (outcome.unknown.length > 0) {
// Loudly, per the contract this endpoint is built to: a host
// holding boxes nobody placed there is a bug to surface, not a
// state to reconcile quietly. Nothing is created for them.
// eslint-disable-next-line no-console
console.warn(
'host report named boxes that are not placed here:',
Actor.machineID,
outcome.unknown.join(', ')
);
}
return c.json({ data: outcome });
}
)
.get(
'/me',
machineOnly,
describeRoute({
tags: ['Machine'],
summary: 'Describe the calling machine',
description:
'Returns the registration record for the credentials used. A box calls this at startup to confirm its credentials still work before relying on them.',
responses: {
200: {
content: { 'application/json': { schema: Result(Machine.Info) } },
description: 'The calling machine'
},
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
async (c) => {
const machine = await Machine.fromID(Actor.machineID);
if (!machine) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'This machine no longer exists'
);
}
return c.json({ data: machine });
}
)
.get(
'/:id/downloads',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'What a machine has downloaded',
description:
'Every game this machine is downloading, has ready, or failed to download, with how far each has got. Only the machine’s owner can read it.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(
GameDownload.Info.extend({
game: z.object({ name: z.string(), steamAppId: z.number() })
})
)
)
}
},
description: 'Downloads on this machine, least recently changed first'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
async (c) => {
const data = await GameDownload.listForOwnedMachine({
machineId: c.req.param('id'),
ownerUserId: Actor.userID
});
if (!data) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such machine, or it is not yours'
);
}
return c.json({ data });
}
)
.get(
'/',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'List your registered hosts',
responses: {
200: {
content: { 'application/json': { schema: Result(z.array(Machine.Info)) } },
description: 'Machines owned by the caller'
},
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
async (c) => {
return c.json({ data: await Machine.listByOwner(Actor.userID) });
}
);
}