mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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:
@@ -128,6 +128,16 @@ export namespace Examples {
|
||||
lastSeen: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const SteamEnrolment = {
|
||||
machineId: Id('machine'),
|
||||
userId: Id('user'),
|
||||
steamId: '76561197960287930',
|
||||
state: 'enrolled' as const,
|
||||
enrolledAt: '2026-07-28T12:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
revokedAt: null
|
||||
};
|
||||
|
||||
export const Box = {
|
||||
id: Id('box'),
|
||||
userId: Id('user'),
|
||||
|
||||
65
packages/core/src/steam/enrolment.sql.ts
Normal file
65
packages/core/src/steam/enrolment.sql.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { pgEnum, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ulid, utc } from '../db/types.js';
|
||||
import { MachineTable } from '../machine/machine.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* Where an enrolment can be, and nowhere else.
|
||||
*
|
||||
* There is no `pending`. A challenge showing on a screen lives about two
|
||||
* minutes, rotates on a cadence the auth provider chooses, and nothing outside
|
||||
* the host process needs to know it exists — so it is not a fact about the
|
||||
* machine and recording it here would be a claim we cannot keep true.
|
||||
* ref(d-0004)
|
||||
*/
|
||||
export const SteamEnrolmentState = pgEnum('steam_enrolment_state', [
|
||||
'enrolled',
|
||||
'stale',
|
||||
'revoked'
|
||||
]);
|
||||
|
||||
/**
|
||||
* That a host holds a Steam refresh token for a user — and never the token.
|
||||
*
|
||||
* **This table has no token column and must not gain one.** The token is
|
||||
* written by the host, encrypted, under that host's own account, and it does
|
||||
* not cross back: not in a request body, not in a log, not in an error
|
||||
* message, not as a metric label. A nullable "encrypted token" column would be
|
||||
* an invitation rather than a safeguard, because the thing standing between a
|
||||
* database leak and somebody's game library is that the credential was never
|
||||
* sent here at all. ref(d-0004)
|
||||
*
|
||||
* `steam_id` is deliberately **not unique**. One Steam account signed in on two
|
||||
* hosts is two rows and two tokens, which is the entire point of doing the auth
|
||||
* session on the machine that will use it: each token is bound to the address
|
||||
* that asked for it, and a shared one would be the theft signal we are avoiding.
|
||||
* A unique index here would read as hygiene and would refuse a person their
|
||||
* second box.
|
||||
*
|
||||
* The key is the pair, because an enrolment is a fact about *this user on this
|
||||
* host* and there is only ever one such fact. That is also why the row carries
|
||||
* no surrogate id and no soft-delete: the states are the lifecycle, and the row
|
||||
* itself goes away only when the machine or the user does.
|
||||
*/
|
||||
export const SteamEnrolmentTable = pgTable(
|
||||
'steam_enrolment',
|
||||
{
|
||||
machineId: ulid('machine_id')
|
||||
.notNull()
|
||||
.references(() => MachineTable.id, { onDelete: 'cascade' }),
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
steamId: text('steam_id').notNull(),
|
||||
state: SteamEnrolmentState('state').notNull(),
|
||||
enrolledAt: utc('enrolled_at').notNull().defaultNow(),
|
||||
// Written when a logon actually succeeds, which happens inside the
|
||||
// workload and reports back through the host. Nothing writes it yet,
|
||||
// and it stays null rather than being filled with the time of the
|
||||
// nearest event that was easy to observe.
|
||||
lastOkAt: utc('last_ok_at'),
|
||||
revokedAt: utc('revoked_at')
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.machineId, t.userId] })]
|
||||
);
|
||||
145
packages/core/src/steam/enrolment.test.ts
Normal file
145
packages/core/src/steam/enrolment.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { Fixtures } from '../db/fixtures.js';
|
||||
import { testDb } from '../db/test.js';
|
||||
import { Enrolment } from './enrolment.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
function steamId(n: number) {
|
||||
return `765611980001${String(n).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
async function host(label: string) {
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
return { machineId: await Fixtures.machine(owner, label), userId: owner.userId };
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
describe('the schema holds no token, and cannot be made to', () => {
|
||||
test('the columns are exactly the facts about an enrolment', async () => {
|
||||
// The refresh token lives on the host, encrypted, and nowhere else. A
|
||||
// nullable column that could hold one is an invitation, so the guard is
|
||||
// the column list itself rather than a promise in a comment: adding
|
||||
// `refresh_token`, or an `encrypted_token`, or a `secret`, fails here.
|
||||
const columns = await sql<{ column_name: string }[]>`
|
||||
select column_name from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'steam_enrolment'
|
||||
order by column_name
|
||||
`;
|
||||
expect(columns.map((c) => c.column_name)).toEqual([
|
||||
'enrolled_at',
|
||||
'last_ok_at',
|
||||
'machine_id',
|
||||
'revoked_at',
|
||||
'state',
|
||||
'steam_id',
|
||||
'user_id'
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Steam id is not unique across machines', async () => {
|
||||
// One Steam account on two hosts is two rows and two tokens. A unique
|
||||
// index here would look like hygiene and would refuse the second host.
|
||||
const indexes = await sql<{ indexdef: string }[]>`
|
||||
select indexdef from pg_indexes
|
||||
where schemaname = 'public' and tablename = 'steam_enrolment'
|
||||
`;
|
||||
const uniqueOnSteamId = indexes.filter(
|
||||
(i) => i.indexdef.includes('UNIQUE') && i.indexdef.includes('steam_id')
|
||||
);
|
||||
expect(uniqueOnSteamId).toEqual([]);
|
||||
});
|
||||
|
||||
test('an enrolment is one fact per machine and user', async () => {
|
||||
const primary = await sql<{ indexdef: string }[]>`
|
||||
select indexdef from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and tablename = 'steam_enrolment'
|
||||
and indexname = 'steam_enrolment_machine_id_user_id_pk'
|
||||
`;
|
||||
expect(primary).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrolment.record', () => {
|
||||
test('a first report creates the row as enrolled', async () => {
|
||||
const h = await host('core-enrol-new');
|
||||
const row = await Enrolment.record({ ...h, steamId: steamId(1) });
|
||||
expect(row).toMatchObject({ ...h, steamId: steamId(1), state: 'enrolled' });
|
||||
expect(row.lastOkAt).toBeNull();
|
||||
expect(row.revokedAt).toBeNull();
|
||||
expect(() => new Date(row.enrolledAt).toISOString()).not.toThrow();
|
||||
});
|
||||
|
||||
test('a second report is an upsert, not a duplicate', async () => {
|
||||
const h = await host('core-enrol-upsert');
|
||||
const first = await Enrolment.record({ ...h, steamId: steamId(2) });
|
||||
const second = await Enrolment.record({ ...h, steamId: steamId(3) });
|
||||
|
||||
expect(second.enrolledAt).toBe(first.enrolledAt);
|
||||
expect(second.steamId).toBe(steamId(3));
|
||||
expect(await Enrolment.listByMachine(h.machineId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('re-enrolling clears the refusal', async () => {
|
||||
const h = await host('core-enrol-recover');
|
||||
await Enrolment.record({ ...h, steamId: steamId(4) });
|
||||
await Enrolment.markStale(h);
|
||||
const back = await Enrolment.record({ ...h, steamId: steamId(4) });
|
||||
expect(back.state).toBe('enrolled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrolment.markStale', () => {
|
||||
test('a refused token is recorded against that host alone', async () => {
|
||||
const mine = await host('core-stale-mine');
|
||||
const theirs = await host('core-stale-theirs');
|
||||
await Enrolment.record({ ...mine, steamId: steamId(5) });
|
||||
await Enrolment.record({ ...theirs, steamId: steamId(5) });
|
||||
|
||||
const marked = await Enrolment.markStale(mine);
|
||||
expect(marked?.state).toBe('stale');
|
||||
|
||||
const untouched = await Enrolment.listByMachine(theirs.machineId);
|
||||
expect(untouched[0]!.state).toBe('enrolled');
|
||||
});
|
||||
|
||||
test('nothing to mark is null rather than a write', async () => {
|
||||
const h = await host('core-stale-absent');
|
||||
expect(await Enrolment.markStale(h)).toBeNull();
|
||||
expect(await Enrolment.listByMachine(h.machineId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrolment.listByMachine', () => {
|
||||
test('every enrolment for one host, oldest first', async () => {
|
||||
const h = await host('core-list');
|
||||
const second = await Fixtures.owner('core-list-second');
|
||||
createdUserIds.push(second.userId);
|
||||
|
||||
await Enrolment.record({ ...h, steamId: steamId(6) });
|
||||
await Enrolment.record({
|
||||
machineId: h.machineId,
|
||||
userId: second.userId,
|
||||
steamId: steamId(7)
|
||||
});
|
||||
|
||||
const rows = await Enrolment.listByMachine(h.machineId);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.userId)).toEqual([h.userId, second.userId]);
|
||||
});
|
||||
|
||||
test('an unknown host has no enrolments rather than an error', async () => {
|
||||
expect(await Enrolment.listByMachine('mch_nosuchmachine')).toEqual([]);
|
||||
});
|
||||
});
|
||||
176
packages/core/src/steam/enrolment.ts
Normal file
176
packages/core/src/steam/enrolment.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { SteamEnrolmentState, SteamEnrolmentTable } from './enrolment.sql.js';
|
||||
import { STEAM_ID_RE } from './index.js';
|
||||
|
||||
/** A foreign key that names a row nobody has. */
|
||||
function isForeignKeyViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
return e?.code === '23503' || e?.cause?.code === '23503';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the control plane knows about a host's Steam sign-ins: that one
|
||||
* happened, for whom, and whether it is still working.
|
||||
*
|
||||
* It does not know the credential and is not able to. The auth session begins
|
||||
* on the machine that will use the token, so the token is written there and
|
||||
* stays there; what comes back here is the *outcome*. Everything in this
|
||||
* namespace is therefore a report being recorded rather than a secret being
|
||||
* stored, and the one place that is enforced is the table's column list.
|
||||
* ref(d-0004)
|
||||
*
|
||||
* This module is the sole writer of every state. A host says what happened; it
|
||||
* does not say what the record should become.
|
||||
*/
|
||||
export namespace Enrolment {
|
||||
export const Info = z
|
||||
.object({
|
||||
machineId: z.string().meta({
|
||||
description: 'The host that holds a token for this user',
|
||||
example: Examples.SteamEnrolment.machineId
|
||||
}),
|
||||
userId: z.string().meta({
|
||||
description: 'The person the host signed in as',
|
||||
example: Examples.SteamEnrolment.userId
|
||||
}),
|
||||
steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID').meta({
|
||||
description: 'The Steam account that was signed in',
|
||||
example: Examples.SteamEnrolment.steamId
|
||||
}),
|
||||
state: z.enum(SteamEnrolmentState.enumValues).meta({
|
||||
description:
|
||||
'`enrolled` — the host holds a working token. `stale` — Steam refused the one it holds. `revoked` — the enrolment was ended',
|
||||
example: Examples.SteamEnrolment.state
|
||||
}),
|
||||
enrolledAt: z.iso.datetime().meta({
|
||||
description: 'When this host first signed this user in. Unchanged by a re-enrolment',
|
||||
example: Examples.SteamEnrolment.enrolledAt
|
||||
}),
|
||||
lastOkAt: z.iso.datetime().nullable().meta({
|
||||
description: 'When a logon last succeeded. Nothing writes this yet, so it is null',
|
||||
example: Examples.SteamEnrolment.lastOkAt
|
||||
}),
|
||||
revokedAt: z.iso.datetime().nullable().meta({
|
||||
description: 'When the enrolment was ended',
|
||||
example: Examples.SteamEnrolment.revokedAt
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'SteamEnrolment',
|
||||
description: 'That a host holds a Steam token for a user — never the token itself',
|
||||
example: Examples.SteamEnrolment
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
/**
|
||||
* Record that a host completed a sign-in for a user.
|
||||
*
|
||||
* An upsert, because a host re-running the flow — a person signing in
|
||||
* again, a token replaced after a refusal — is the same fact restated, not
|
||||
* a second one. `enrolledAt` therefore survives: it says when this pairing
|
||||
* began, and a re-enrolment does not begin it again. `revokedAt` is cleared,
|
||||
* because a row that is `enrolled` and carries a revocation time is two
|
||||
* answers to one question.
|
||||
*/
|
||||
export const record = fn(
|
||||
Info.pick({ machineId: true, userId: true, steamId: true }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.insert(SteamEnrolmentTable)
|
||||
.values({
|
||||
machineId: input.machineId,
|
||||
userId: input.userId,
|
||||
steamId: input.steamId,
|
||||
state: 'enrolled'
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [SteamEnrolmentTable.machineId, SteamEnrolmentTable.userId],
|
||||
set: { steamId: input.steamId, state: 'enrolled', revokedAt: null }
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => serialize(rows[0]!))
|
||||
.catch((err) => {
|
||||
if (isForeignKeyViolation(err)) {
|
||||
// A host naming a user or a machine that is not there.
|
||||
// Said plainly rather than surfacing as a server fault,
|
||||
// because the host can neither retry nor fix it.
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such user or machine'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Record that Steam refused the token this host holds.
|
||||
*
|
||||
* Scoped to the machine in the query itself, so another host's enrolment is
|
||||
* a miss rather than a permission check somebody could forget to write.
|
||||
* Returns null when there is nothing to mark — a host reporting a refusal
|
||||
* for an enrolment that was never recorded is telling us something, and
|
||||
* inventing a `stale` row to hold it would make the record say a sign-in
|
||||
* happened that never did.
|
||||
*/
|
||||
export const markStale = fn(Info.pick({ machineId: true, userId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(SteamEnrolmentTable)
|
||||
.set({ state: 'stale' })
|
||||
.where(
|
||||
and(
|
||||
eq(SteamEnrolmentTable.machineId, input.machineId),
|
||||
eq(SteamEnrolmentTable.userId, input.userId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every enrolment the control plane believes this host has.
|
||||
*
|
||||
* A host that lost its disk asks this to find out what it is expected to
|
||||
* hold, and can then say it does not. Reconciling the answer is the
|
||||
* caller's business and nothing does it yet; the shape is fixed now so it
|
||||
* does not have to change once something depends on it.
|
||||
*/
|
||||
export const listByMachine = fn(Info.shape.machineId, async (machineId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(SteamEnrolmentTable)
|
||||
.where(eq(SteamEnrolmentTable.machineId, machineId))
|
||||
.orderBy(SteamEnrolmentTable.enrolledAt)
|
||||
.then((rows) => rows.map(serialize));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof SteamEnrolmentTable.$inferSelect): Info {
|
||||
return {
|
||||
machineId: input.machineId,
|
||||
userId: input.userId,
|
||||
steamId: input.steamId,
|
||||
state: input.state as Info['state'],
|
||||
enrolledAt: input.enrolledAt.toISOString(),
|
||||
lastOkAt: input.lastOkAt?.toISOString() ?? null,
|
||||
revokedAt: input.revokedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { Identity } from '../user/identity.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
|
||||
const STEAM_ID_RE = /^\d{17}$/;
|
||||
/** An individual Steam account id: 17 digits, always. */
|
||||
export const STEAM_ID_RE = /^\d{17}$/;
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
|
||||
Reference in New Issue
Block a user