feat(api): record which host holds a Steam token for whom

A host that signs a person into Steam ends up holding a refresh token. The
control plane needs to know that happened — to show it, and so a host that
lost its disk can find out what it is expected to hold — but it must not know
the credential, because the token is bound to the address that obtained it and
a copy anywhere else is the account-theft signal Steam watches for.

So `steam_enrolment` stores the outcome and has no token column, no encrypted
token column, and no column that could hold one later. The safeguard is that
the credential is never sent here at all; a nullable column would be the first
step in undoing it, so a test asserts the column list exactly and fails if one
appears. Three machine-authenticated routes go with it: report a completed
sign-in, report that Steam refused the token, and list what this host should
have. All three take the host from its own credentials, so a box can neither
report onto nor read another box's hardware. Their bodies are strict, so a
host that sends a token is told it is wrong rather than quietly believed —
which also keeps the value out of the request log.

The Steam id is deliberately not unique. One account signed in on two hosts is
two rows and two tokens, and a unique index there would look like hygiene while
refusing somebody their second box.

There is no `pending` state: a sign-in challenge lives about two minutes inside
one process, and nothing outside it needs to know it exists. Nothing revokes
yet, and `last_ok_at` has no writer — a successful logon happens where there is
no credential to report it with — so the column exists with the shape it will
need and stays null rather than being filled with the nearest event that was
easy to observe.
This commit is contained in:
Wanjohi
2026-09-06 13:27:51 +03:00
parent 7f7e39de60
commit 6429ec4ff7
12 changed files with 3839 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status';
import { auth } from './middleware/auth.js';
import { AccessTokenApi } from './routes/access-token.js';
import { EnrolmentApi } from './routes/enrolment.js';
import { GameApi } from './routes/game.js';
import { IndexApi } from './routes/index.js';
import { LibraryApi } from './routes/library.js';
@@ -45,6 +46,7 @@ const routes = app
.route('/pairing-code', PairingCodeApi.route)
.route('/machine', MachineApi.route)
.route('/machine', SessionApi.machineRoute)
.route('/machine', EnrolmentApi.route)
.route('/session', SessionApi.route)
.route('/access-token', AccessTokenApi.route)
.route('/waitlist', WaitlistApi.route)

View File

@@ -0,0 +1,127 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Enrolment } from '@nestri/core/steam/enrolment';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, machineOnly, Result, validator } from '../utils';
/**
* What a host reports about the Steam sign-ins it holds.
*
* Mounted where a host looks for it — everything a box says about itself lives
* under one prefix — and machine-authenticated throughout, so the host is
* taken from its own credentials and never from a body. A box therefore cannot
* report an enrolment onto somebody else's hardware.
*
* **Nothing here accepts a credential**, and that is the point of the shape
* rather than a property of it. The refresh token, the challenge URL and the
* client id all stay inside the host process; the bodies below are strict, so
* a host that tried to send one is told it is wrong instead of being quietly
* believed. ref(d-0004)
*
* Whether a *person* may reach a given box is decided before a request gets
* here, at the edge, by comparing the team that owns the hardware against the
* teams they belong to. It is a different question from the one these routes
* ask, and none of them re-ask it.
*/
export namespace EnrolmentApi {
// Picked from the domain schema rather than restated, so the shape a host
// must send and the shape the record has cannot drift apart — including the
// Steam id's format, which is checked here at the boundary and therefore
// answers with a validation error rather than a server fault.
//
// `.strict()` on both is load-bearing. A body carrying a refresh token, a
// challenge URL or a client id is a mistake worth refusing loudly:
// accepting and ignoring it would mean the credential reached this process,
// was written to the request log, and nobody found out.
const Reported = Enrolment.Info.pick({ userId: true, steamId: true }).strict();
const ForOneUser = Enrolment.Info.pick({ userId: true }).strict();
export const route = new Hono()
.post(
'/enrolment',
machineOnly,
describeRoute({
tags: ['Enrolment'],
summary: 'Say a Steam sign-in completed',
description:
'Records that the calling host now holds a Steam refresh token for this user. The host comes from its own credentials. Repeating it is the same fact restated — the Steam account is updated, a previous refusal is cleared, and the time the pairing began is left alone. The token itself is never sent: it belongs on the host that obtained it, and there is no field here that would carry one.',
responses: {
200: {
content: { 'application/json': { schema: Result(Enrolment.Info) } },
description: 'The enrolment, as it now stands'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator('json', Reported),
async (c) => {
const body = c.req.valid('json');
return c.json({
data: await Enrolment.record({
machineId: Actor.machineID,
userId: body.userId,
steamId: body.steamId
})
});
}
)
.post(
'/enrolment/stale',
machineOnly,
describeRoute({
tags: ['Enrolment'],
summary: 'Say Steam refused the token this host holds',
description:
'Marks the calling hosts enrolment for this user as stale. Scoped to the caller, so an enrolment belonging to another host is simply not found. An enrolment that was never recorded is a 404 rather than a new stale row — inventing one would make the record claim a sign-in that never happened.',
responses: {
200: {
content: { 'application/json': { schema: Result(Enrolment.Info) } },
description: 'The enrolment, now stale'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator('json', ForOneUser),
async (c) => {
const enrolment = await Enrolment.markStale({
machineId: Actor.machineID,
userId: c.req.valid('json').userId
});
if (!enrolment) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'This machine has no enrolment for that user'
);
}
return c.json({ data: enrolment });
}
)
.get(
'/enrolment',
machineOnly,
describeRoute({
tags: ['Enrolment'],
summary: 'Ask what this host is expected to hold',
description:
'Every enrolment recorded against the calling host, oldest first. A host that lost its disk asks this to find out which sign-ins it is believed to have, and can then report the ones it does not. Nothing reconciles the answer yet; the shape is fixed now so it does not change once something depends on it.',
responses: {
200: {
content: { 'application/json': { schema: Result(z.array(Enrolment.Info)) } },
description: 'Enrolments this host is expected to hold'
},
403: ErrorResponses[403]
}
}),
async (c) => {
return c.json({ data: await Enrolment.listByMachine(Actor.machineID) });
}
);
}

View File

@@ -0,0 +1,342 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
import { app } from '../app/index';
import { TEST_ADMIN_SECRET } from './setup';
import './setup';
const sql = testDb();
const createdUserIds: string[] = [];
/** A Steam ID is 17 digits; these are distinct and obviously not real. */
function steamId(n: number) {
return `765611980000${String(n).padStart(5, '0')}`;
}
async function registeredHost(label: string) {
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
});
return {
id: registered.id,
userId: owner.userId,
headers: {
'x-nestri-machine-id': registered.id,
'x-nestri-machine-secret': registered.secret,
'content-type': 'application/json'
}
};
}
function enrol(host: { headers: Record<string, string> }, body: unknown) {
return app.request('/machine/enrolment', {
method: 'POST',
headers: host.headers,
body: JSON.stringify(body)
});
}
function markStale(host: { headers: Record<string, string> }, body: unknown) {
return app.request('/machine/enrolment/stale', {
method: 'POST',
headers: host.headers,
body: JSON.stringify(body)
});
}
function list(host: { headers: Record<string, string> }) {
return app.request('/machine/enrolment', { headers: host.headers });
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
});
describe('POST /machine/enrolment', () => {
test('the outcome is recorded and `data` is the enrolment itself', async () => {
const host = await registeredHost('enrol-shape');
const res = await enrol(host, { userId: host.userId, steamId: steamId(1) });
expect(res.status).toBe(200);
const body = (await res.json()) as any;
// `data` is the object, not `{"data": {"enrolment": …}}`. A host written
// against the wrapped form parses nothing, and finds out on first
// contact rather than in review.
expect(body.data).toMatchObject({
machineId: host.id,
userId: host.userId,
steamId: steamId(1),
state: 'enrolled'
});
expect(Object.keys(body.data).sort()).toEqual(
['enrolledAt', 'lastOkAt', 'machineId', 'revokedAt', 'state', 'steamId', 'userId'].sort()
);
expect(body.data.enrolment).toBeUndefined();
// camelCase on the wire, always. The snake-to-camel seam is where a
// host and a control plane silently stop understanding each other.
for (const key of Object.keys(body.data)) {
expect(key).not.toContain('_');
}
expect(typeof body.data.enrolledAt).toBe('string');
expect(body.data.lastOkAt).toBeNull();
expect(body.data.revokedAt).toBeNull();
});
test('the machine is taken from the credentials, never the body', async () => {
const host = await registeredHost('enrol-self');
const other = await registeredHost('enrol-other');
// A host naming another host would be a host enrolling somebody else's
// hardware. There is no field for it, so this is a validation error.
const res = await enrol(host, {
userId: host.userId,
steamId: steamId(2),
machineId: other.id
});
expect(res.status).toBe(400);
const still = await list(other);
expect(((await still.json()) as any).data).toEqual([]);
});
test('re-enrolling keeps the first `enrolledAt` and adopts the new Steam account', async () => {
const host = await registeredHost('enrol-again');
const first = (await (
await enrol(host, { userId: host.userId, steamId: steamId(3) })
).json()) as any;
const second = (await (
await enrol(host, { userId: host.userId, steamId: steamId(4) })
).json()) as any;
expect(second.data.enrolledAt).toBe(first.data.enrolledAt);
expect(second.data.steamId).toBe(steamId(4));
expect(second.data.state).toBe('enrolled');
});
test('one Steam account on two hosts is two enrolments', async () => {
// Two hosts, two tokens, two rows — the whole reason the Steam id is
// not unique across machines. A unique index there would read as
// hygiene and would refuse the second host.
const first = await registeredHost('enrol-two-a');
const second = await registeredHost('enrol-two-b');
const shared = steamId(5);
expect((await enrol(first, { userId: first.userId, steamId: shared })).status).toBe(200);
expect((await enrol(second, { userId: second.userId, steamId: shared })).status).toBe(200);
const a = ((await (await list(first)).json()) as any).data;
const b = ((await (await list(second)).json()) as any).data;
expect(a).toHaveLength(1);
expect(b).toHaveLength(1);
expect(a[0].machineId).toBe(first.id);
expect(b[0].machineId).toBe(second.id);
});
test('a user nobody has heard of is refused rather than crashing', async () => {
const host = await registeredHost('enrol-ghost');
const res = await enrol(host, { userId: 'usr_nosuchuseratall', steamId: steamId(6) });
expect(res.status).toBe(404);
const body = (await res.json()) as any;
expect(body.type).toBe('not_found');
});
test('a Steam id has to look like one', async () => {
const host = await registeredHost('enrol-badsteam');
const res = await enrol(host, { userId: host.userId, steamId: 'not-a-steam-id' });
expect(res.status).toBe(400);
});
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment', {
method: 'POST',
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET, 'content-type': 'application/json' },
body: JSON.stringify({ userId: 'usr_x', steamId: steamId(7) })
});
expect(res.status).toBe(403);
expect(((await res.json()) as any).message).toContain('Machine credentials');
});
});
describe('POST /machine/enrolment/stale', () => {
test('a refused token moves the enrolment to stale', async () => {
const host = await registeredHost('stale-happy');
await enrol(host, { userId: host.userId, steamId: steamId(8) });
const res = await markStale(host, { userId: host.userId });
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body.data.state).toBe('stale');
expect(body.data.userId).toBe(host.userId);
});
test('re-enrolling after a refusal returns the row to enrolled', async () => {
const host = await registeredHost('stale-recover');
await enrol(host, { userId: host.userId, steamId: steamId(9) });
await markStale(host, { userId: host.userId });
const again = (await (
await enrol(host, { userId: host.userId, steamId: steamId(9) })
).json()) as any;
expect(again.data.state).toBe('enrolled');
});
test('an enrolment this host does not have is a 404', async () => {
const host = await registeredHost('stale-missing');
const res = await markStale(host, { userId: host.userId });
expect(res.status).toBe(404);
expect(((await res.json()) as any).type).toBe('not_found');
});
test('a host cannot mark another hosts enrolment stale', async () => {
const owner = await registeredHost('stale-owner');
const stranger = await registeredHost('stale-stranger');
await enrol(owner, { userId: owner.userId, steamId: steamId(10) });
// Scoped to the calling machine, so somebody else's row is simply not
// there — a miss, not a permission check that could be forgotten.
const res = await markStale(stranger, { userId: owner.userId });
expect(res.status).toBe(404);
const untouched = ((await (await list(owner)).json()) as any).data;
expect(untouched[0].state).toBe('enrolled');
});
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment/stale', {
method: 'POST',
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET, 'content-type': 'application/json' },
body: JSON.stringify({ userId: 'usr_x' })
});
expect(res.status).toBe(403);
});
});
describe('GET /machine/enrolment', () => {
test('a host with no enrolments gets an empty list, not a 404', async () => {
const host = await registeredHost('list-empty');
const res = await list(host);
expect(res.status).toBe(200);
expect(((await res.json()) as any).data).toEqual([]);
});
test('every enrolment this host is expected to hold, and no other hosts', async () => {
const host = await registeredHost('list-mine');
const other = await registeredHost('list-theirs');
await enrol(host, { userId: host.userId, steamId: steamId(11) });
await enrol(other, { userId: other.userId, steamId: steamId(12) });
const res = await list(host);
expect(res.status).toBe(200);
const body = (await res.json()) as any;
// `data` is the list itself.
expect(Array.isArray(body.data)).toBe(true);
expect(body.data).toHaveLength(1);
expect(body.data[0].machineId).toBe(host.id);
});
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment', {
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET }
});
expect(res.status).toBe(403);
});
});
describe('The enrolment surface refuses a token', () => {
// The token lives on the host and nowhere else. There is no endpoint that
// accepts a refresh token, a challenge URL or a client id, and the way that
// stays true is a test that fails the moment somebody adds one.
const forbidden = [
{ refreshToken: 'eyJ.not.a.real.one' },
{ token: 'anything' },
{ accessToken: 'anything' },
{ challengeUrl: 'https://s.team/q/1/2' },
{ clientId: '1234567890' }
];
test('POST /machine/enrolment rejects every credential-shaped field', async () => {
const host = await registeredHost('refuse-token-enrol');
for (const extra of forbidden) {
// eslint-disable-next-line no-await-in-loop
const res = await enrol(host, {
userId: host.userId,
steamId: steamId(13),
...extra
});
expect(res.status).toBe(400);
// eslint-disable-next-line no-await-in-loop
expect(((await res.json()) as any).type).toBe('validation');
}
});
test('POST /machine/enrolment/stale rejects every credential-shaped field', async () => {
const host = await registeredHost('refuse-token-stale');
await enrol(host, { userId: host.userId, steamId: steamId(14) });
for (const extra of forbidden) {
// eslint-disable-next-line no-await-in-loop
const res = await markStale(host, { userId: host.userId, ...extra });
expect(res.status).toBe(400);
}
});
test('the published surface has exactly three enrolment routes and no field for a credential', async () => {
const res = await app.request('/doc');
const doc = (await res.json()) as any;
function resolve(schema: any): any {
if (schema?.$ref) {
const name = String(schema.$ref).split('/').pop()!;
return resolve(doc.components?.schemas?.[name]);
}
return schema;
}
function propertyNames(schema: any): string[] {
const s = resolve(schema);
if (!s) return [];
const own = Object.keys(s.properties ?? {});
const composed = [...(s.allOf ?? []), ...(s.anyOf ?? []), ...(s.oneOf ?? [])].flatMap(
propertyNames
);
return [...own, ...composed];
}
const paths = Object.keys(doc.paths).filter((p) => p.startsWith('/machine/enrolment'));
expect(paths.sort()).toEqual(['/machine/enrolment', '/machine/enrolment/stale']);
const accepted = new Set<string>();
for (const path of paths) {
for (const operation of Object.values<any>(doc.paths[path])) {
for (const parameter of operation.parameters ?? []) {
accepted.add(parameter.name);
}
const schema = operation.requestBody?.content?.['application/json']?.schema;
if (schema) {
for (const name of propertyNames(schema)) {
accepted.add(name);
}
}
}
}
// Not "contains no token" — an exact set. Anything new on this surface
// has to be argued for here, which is the point.
expect([...accepted].sort()).toEqual(['steamId', 'userId']);
});
});