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 { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/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 nessh 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.', responses: { 200: { content: { 'application/json': { schema: Result( z.object({ machineId: z.string().meta({ example: Examples.Machine.id }), 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’s personal team, which always exists' }) }) ), async (c) => { const { label, teamId } = c.req.valid('json'); // `notPublic` also admits admin, which has no user to own the 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' ); } // A team has to be resolved rather than defaulted to null, because // `machine.teamId` is notNull. 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, secret: registered.secret } }); } ) .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' }) }) // Strict, so a field this cannot act on is a validation error a // host operator sees rather than one quietly dropped. Capacity // belongs here eventually and it has no honest fields yet; // refusing the ones nobody measures is how it stays that way. .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 } = c.req.valid('json'); const outcome = await Box.applyHostReport({ machineId: Actor.machineID, boxes }); // `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( '/', 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) }); } ); }