Files
netris-nestri/apps/api/app/routes/machine.ts
T
Wanjohi 8054e62bd9 feat(machine): let the owner read what their machine has downloaded
GET /machine/:id/downloads lists every game the machine is downloading,
has ready or failed to download, with progress and each game's name.
The library already carries a download per game, but it is the most
recent one across every host, which is not what a person watching their
own machine set up wants to see.

Owner-scoped in the query: a machine that is not yours answers 404, the
same as one that does not exist.
2026-10-09 12:50:44 +03:00

662 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]
}
}),
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.',
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]
}
}),
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]
}
}),
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
})
})
// 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
});
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) });
}
);
}