Files
netris-nestri/apps/api/app/routes/session.ts
Wanjohi b4776fad2f feat(core,api): record burn as rate segments, and refuse the next run when spent
The counters this fills are the ones the windows already knew how to read.
What was missing was anything that put a number in them.

Burn is recorded as segments: one stretch of one run at one unchanging rate,
opened when the rate becomes true and closed when it stops being. Not a row per
session, because a session's rate does not survive its own lifetime — a second
run changes what the account spends per second while the first is still going,
and a rate that applied from that moment must not be backdated over the time
before it. Not a row per event either, because burn accrues against an envelope
that is held rather than per thing consumed.

Closing a segment is what moves burn into the counters, so a long run lands
incrementally instead of all at the end. Burn that only arrives when a session
stops is burn that cannot refuse the next one, and a bar that does not move
while something is running is a bar nobody believes.

The counters are written with the staleness rule as a single statement: add to
the total if its stamp is still inside the window, otherwise start again from
this amount. Reading and then deciding would be two statements with a gap, and
the gap is where a concurrent tick doubles or vanishes. The first tick for a
team and the thousandth are the same call, for the same reason.

The gate sits at the one moment it is allowed to speak — before a run starts,
never again. A limit refuses the next run and never interrupts one already
going; someone losing a session mid-game to a meter does not come back. Every
window is checked rather than the shortest, because they protect different
things over different spans.

The answer comes back with the created run rather than being thrown away: the
response carries where each window stands, what the account spends per second
now, and what one more run would cost. Every surface that can start a run has
to show that before the click, and a second call for it is a call nobody makes.
Asking twice would also let the number shown and the number billed disagree.

Accrual is wired to the run's own state transition, in the same transaction
that moves it. A session that went live without its meter starting is free
hardware; one that ended without its meter stopping bills forever. Both are
silent, so neither may be a second write that might not happen.
2026-09-19 00:19:39 +03:00

421 lines
15 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 { Billing } from '@nestri/core/billing/index';
import { Box } from '@nestri/core/box/index';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Game } from '@nestri/core/game/index';
import { Identifier } from '@nestri/core/id';
import { Session } from '@nestri/core/session/index';
import { Library } from '@nestri/core/user/library';
import { LinkedAccount } from '@nestri/core/user/linked-account';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import {
ErrorResponses,
machineOnly,
notPublic,
Result,
ResultWithBilling,
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;
}
/**
* The value that says which attempt is speaking.
*
* Described rather than explained. This description is served publicly, so
* it says what to send and not what it defends against.
*/
const ClaimTokenField = Session.ClaimToken.meta({
description: 'The token this attempt claimed the run with',
example: Examples.Session.claimToken
});
const StateReport = z
.object({
state: Session.ReportableState.meta({
description: 'Where the run has got to',
example: 'starting'
}),
claimToken: ClaimTokenField,
errorMessage: z.string().max(1024).nullable().optional().meta({
description: 'Why it failed. Kept only for a run that did',
example: Examples.Session.errorMessage
})
})
.strict();
export const route = new Hono()
.post(
'/',
notPublic,
describeRoute({
tags: ['Session'],
summary: 'Ask for a run of a box',
description:
'Creates the run in state `requested`, which is the work order the boxs host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one. The response carries where the accounts allowance stands and what it is now spending per second, including what one more run would cost — a spent allowance refuses this call with a 429 and never interrupts a run already going.',
responses: {
201: {
content: {
'application/json': {
schema: ResultWithBilling(Session.Info, Billing.State.nullable())
}
},
description: 'The run has been requested'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404],
409: ErrorResponses[409],
429: ErrorResponses[429]
}
}),
validator(
'json',
z
.object({
boxId: z.string().min(1).meta({
description: 'The box to run',
example: Examples.Session.boxId
}),
gameId: z.string().min(1).meta({
description: 'The game to launch',
example: Examples.Session.gameId
}),
linkedAccountId: z.string().min(1).optional().meta({
description:
'Which linked account is playing. Defaults to the one the caller signed in with',
example: Examples.Session.linkedAccountId
})
})
// Strict, so that naming hardware is a validation error rather
// than a field quietly ignored. There is nothing to choose:
// asking for a run is not where a box is placed.
.strict()
),
async (c) => {
const body = c.req.valid('json');
const userId = actingPerson();
const box = await Box.fromID(body.boxId);
if (!box || box.userId !== userId) {
// Somebody else's box and a box that was never created are the
// same answer, so ids cannot be probed for.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such box, or it is not yours'
);
}
const game = await Game.fromID(body.gameId);
if (!game) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such game'
);
}
// A game nobody has synced for this person is a box that starts,
// tries to launch and fails minutes later with nothing to point
// at. Refusing here is the same answer sooner.
//
// Told apart from a game that does not exist rather than hidden:
// the catalog is public, so there is nothing to hide, and "you do
// not own this" is a sentence a person can act on.
//
// **This is a weaker check than the one that matters.** A run
// launches as one account, but a library entry records only the
// person, so what is verified is "somebody this person has linked
// owns it" and not "the account playing owns it". For the one
// linked account most people have those are the same sentence;
// for two they are not, and the second account can ask for a game
// only the first owns. Answering the real question needs the
// account recorded on the library entry, which is a decision
// about what a library *is* and not something to infer here.
//
// The library is also a synced copy, so this refuses a game
// bought since the last sync. Both gaps let a launch fail late;
// neither is a reason to start runs already known to fail.
const owned = await Library.findByUserAndGame({ userId, gameId: game.id });
if (!owned) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'That game is not in your library'
);
}
const actor = Actor.use();
const linkedAccountId =
body.linkedAccountId ||
(actor.type === 'user' ? actor.properties.linkedAccountID : '') ||
'';
if (!linkedAccountId) {
// Which account is playing is the question the "who's playing?"
// screen asks, and some credentials carry no answer to it. Then
// the caller has to say.
throw new VisibleError(
'validation',
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
'Say which linked account is playing',
'linkedAccountId'
);
}
const linked = await LinkedAccount.fromID(linkedAccountId);
if (!linked || linked.userId !== userId) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'That account is not linked to you'
);
}
// A box runs one thing at a time. Refusing is the honest answer;
// starting a second run would leave two rows that both think they
// own the same hardware.
//
// This read is the message, not the guarantee — two callers can
// both pass it. `Session.request` is refused by a unique index on
// the same predicate, and answers with the same 409 in the same
// words, so which one caught it is not visible from here.
const active = await Session.activeForBox(box.id);
if (active) {
conflict(Session.BOX_BUSY);
}
// The allowance is checked here and nowhere later. A limit refuses
// the next run; it never stops one already going, so this is the
// only moment it may speak. The answer comes back rather than
// being discarded, because the caller has to be told what it will
// cost and what remains — and asking a second time would let the
// number shown and the number billed disagree.
const team = await Billing.teamForBox(box.id);
const billing = team ? await Billing.assertMayStart(team) : null;
const session = await Session.request({
id: Identifier.ascending('session'),
boxId: box.id,
gameId: game.id,
linkedAccountId
});
return c.json({ data: session, billing }, 201);
}
)
.get(
'/:id',
notPublic,
describeRoute({
tags: ['Session'],
summary: 'Read a run you asked for',
description:
'Poll this while a run starts. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else. Once the run reaches a terminal state the ticket is null: stop polling and stop dialling it.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
description: 'The run as it stands'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'param',
z.object({
id: z.string().meta({ description: 'The run to read', example: Examples.Session.id })
})
),
async (c) => {
const session = await Session.forOwner({
id: c.req.valid('param').id,
userId: actingPerson()
});
if (!session) {
// Owner-scoped in the query, so somebody else's run and one that
// never existed answer the same way.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such session, or it is not yours'
);
}
return c.json({ data: session });
}
)
.post(
'/:id/state',
machineOnly,
describeRoute({
tags: ['Session'],
summary: 'Report where a run has got to',
description:
'For the host the runs box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
description: 'The run as it stands after the report'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
409: ErrorResponses[409]
}
}),
validator('param', z.object({ id: z.string() })),
validator('json', StateReport),
async (c) => {
const body = c.req.valid('json');
const result = await Session.transition({
id: c.req.valid('param').id,
machineId: Actor.machineID,
state: body.state,
claimToken: body.claimToken,
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 'notHolder':
// The same answer whatever the state, including the state the
// run is already in. A report from an attempt that does not
// hold the run is that attempt losing a race, and telling
// that apart from a retry is the entire job of the token.
conflict('Another attempt holds this run');
case 'lost':
conflict('Another caller moved this run first');
default:
// `moved` and `unchanged` are both success. An agent retrying
// after a lost response must not be told it broke something.
return c.json({ data: result.session });
}
}
)
.post(
'/:id/ticket',
machineOnly,
describeRoute({
tags: ['Session'],
summary: 'Publish the address a client should connect to',
description:
'For the host the runs box is placed on, and no other. Republish freely: a later ticket is a better address for the same run, not a second run, and the address changes as more of them are discovered. Only a run being brought up has an address: claim it by reporting `starting` first, and expect 409 both before that and once it has stopped.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
description: 'The ticket is published'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
409: ErrorResponses[409]
}
}),
validator('param', z.object({ id: z.string() })),
validator(
'json',
z
.object({
ticket: z.string().min(1).meta({
description: 'The current connect ticket',
example: Examples.Session.ticket
}),
claimToken: ClaimTokenField
})
.strict()
),
async (c) => {
const result = await Session.publishTicket({
id: c.req.valid('param').id,
machineId: Actor.machineID,
claimToken: c.req.valid('json').claimToken,
ticket: c.req.valid('json').ticket
});
switch (result.outcome) {
case 'forbidden':
notYours();
case 'unclaimed':
conflict('Claim this run by reporting `starting` before publishing an address');
case 'notHolder':
conflict('Another attempt holds this run');
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) });
}
);
}