From 6429ec4ff77be366c09e4f11ad6c9e7851cc58c1 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 6 Sep 2026 13:27:51 +0300 Subject: [PATCH 1/3] feat(api): record which host holds a Steam token for whom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/app/index.ts | 2 + apps/api/app/routes/enrolment.ts | 127 + apps/api/test/enrolment.test.ts | 342 ++ packages/core/CLAUDE.md | 3 +- .../0012_steam_enrolment_without_a_token.sql | 47 + .../core/migrations/meta/0012_snapshot.json | 2914 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/examples.ts | 10 + packages/core/src/steam/enrolment.sql.ts | 65 + packages/core/src/steam/enrolment.test.ts | 145 + packages/core/src/steam/enrolment.ts | 176 + packages/core/src/steam/index.ts | 3 +- 12 files changed, 3839 insertions(+), 2 deletions(-) create mode 100644 apps/api/app/routes/enrolment.ts create mode 100644 apps/api/test/enrolment.test.ts create mode 100644 packages/core/migrations/0012_steam_enrolment_without_a_token.sql create mode 100644 packages/core/migrations/meta/0012_snapshot.json create mode 100644 packages/core/src/steam/enrolment.sql.ts create mode 100644 packages/core/src/steam/enrolment.test.ts create mode 100644 packages/core/src/steam/enrolment.ts diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 9a0d211b..065c2a44 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -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) diff --git a/apps/api/app/routes/enrolment.ts b/apps/api/app/routes/enrolment.ts new file mode 100644 index 00000000..39828205 --- /dev/null +++ b/apps/api/app/routes/enrolment.ts @@ -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 host’s 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) }); + } + ); +} diff --git a/apps/api/test/enrolment.test.ts b/apps/api/test/enrolment.test.ts new file mode 100644 index 00000000..f4a3c8e2 --- /dev/null +++ b/apps/api/test/enrolment.test.ts @@ -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 }, body: unknown) { + return app.request('/machine/enrolment', { + method: 'POST', + headers: host.headers, + body: JSON.stringify(body) + }); +} + +function markStale(host: { headers: Record }, body: unknown) { + return app.request('/machine/enrolment/stale', { + method: 'POST', + headers: host.headers, + body: JSON.stringify(body) + }); +} + +function list(host: { headers: Record }) { + 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 host’s 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 host’s', 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(); + for (const path of paths) { + for (const operation of Object.values(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']); + }); +}); diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 6d059f1b..14043086 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -22,10 +22,11 @@ src// | `user/library.*` | `Library` | User's owned games with playtime | | `team/member.*` | `Member` | Team membership with role | | `game/depot.*` | `Depot` | Platform-specific game content depots | +| `steam/enrolment.*` | `Enrolment` | Which host holds a Steam token for whom | Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`. -Modules that don't own their own table (like `steam/`) only need a single `index.ts` exposing reusable `fn()` functions — no `.sql.ts` file. +A parent may own no table of its own and still have sub-modules that do: `steam/index.ts` is reusable `fn()` functions with no `.sql.ts` beside it, while `steam/enrolment.*` is a full pair. ## Pattern: `.sql.ts` (Drizzle Table) diff --git a/packages/core/migrations/0012_steam_enrolment_without_a_token.sql b/packages/core/migrations/0012_steam_enrolment_without_a_token.sql new file mode 100644 index 00000000..38648778 --- /dev/null +++ b/packages/core/migrations/0012_steam_enrolment_without_a_token.sql @@ -0,0 +1,47 @@ +-- That a host holds a Steam refresh token for a user — and never the token. +-- +-- The auth session begins on the machine that will use the credential, so the +-- token is written on that host, encrypted, under that host's own account, and +-- it never travels back. What travels back is the outcome, and this table is +-- where the outcome is kept. ref(d-0004) +-- +-- **There is no token column and there must never be one**, including a +-- nullable "encrypted token" that looks harmless while empty. The protection +-- here is not that the column is guarded; it is that the credential is never +-- sent to this database at all, and a column able to hold one is the first step +-- in undoing that. The same applies to the challenge URL and client id the +-- sign-in flow uses: they live for about two minutes inside one process and +-- nothing outside it needs them. +-- +-- `steam_id` is not unique, on purpose. One Steam account signed in on two +-- hosts is two rows and two tokens, because each token is bound to the address +-- that asked for it — that binding is the anti-theft signal, and sharing one +-- token between hosts is the thing it fires on. A unique index here would read +-- as hygiene and would refuse a person their second box. +-- +-- The key is the pair. An enrolment is a fact about this user on this host and +-- there is exactly one such fact, so the row carries no surrogate id. It also +-- carries no `time_deleted`: the three states are the lifecycle, and the row +-- itself only goes away when the machine or the user does, which the foreign +-- keys already do. +-- +-- `last_ok_at` has no writer yet. A successful logon happens inside the +-- workload, which holds no control-plane credential, so the report has to come +-- back out through the host and nothing carries it today. 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. + +CREATE TYPE "public"."steam_enrolment_state" AS ENUM('enrolled', 'stale', 'revoked');--> statement-breakpoint +CREATE TABLE "steam_enrolment" ( + "machine_id" char(30) NOT NULL, + "user_id" char(30) NOT NULL, + "steam_id" text NOT NULL, + "state" "steam_enrolment_state" NOT NULL, + "enrolled_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_ok_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + CONSTRAINT "steam_enrolment_machine_id_user_id_pk" PRIMARY KEY("machine_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/core/migrations/meta/0012_snapshot.json b/packages/core/migrations/meta/0012_snapshot.json new file mode 100644 index 00000000..abecf8dc --- /dev/null +++ b/packages/core/migrations/meta/0012_snapshot.json @@ -0,0 +1,2914 @@ +{ + "id": "5c6f5a6c-74ff-4b9d-95ac-bef2c2603273", + "prevId": "6fdda454-c7ee-42c1-931b-fa595385aac0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 8dc73c15..b7a37435 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1788607804606, "tag": "0011_auth_state_in_postgres", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1788690115352, + "tag": "0012_steam_enrolment_without_a_token", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 3eb6eb8f..78234323 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -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'), diff --git a/packages/core/src/steam/enrolment.sql.ts b/packages/core/src/steam/enrolment.sql.ts new file mode 100644 index 00000000..27e9ce7d --- /dev/null +++ b/packages/core/src/steam/enrolment.sql.ts @@ -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] })] +); diff --git a/packages/core/src/steam/enrolment.test.ts b/packages/core/src/steam/enrolment.test.ts new file mode 100644 index 00000000..b1185752 --- /dev/null +++ b/packages/core/src/steam/enrolment.test.ts @@ -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([]); + }); +}); diff --git a/packages/core/src/steam/enrolment.ts b/packages/core/src/steam/enrolment.ts new file mode 100644 index 00000000..e79c667e --- /dev/null +++ b/packages/core/src/steam/enrolment.ts @@ -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; + + /** + * 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 + }; + } +} diff --git a/packages/core/src/steam/index.ts b/packages/core/src/steam/index.ts index 811cdf47..8f6f4607 100644 --- a/packages/core/src/steam/index.ts +++ b/packages/core/src/steam/index.ts @@ -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 } }; From 64a90abf755c31fc537cfc0a4d84fc67e560d572 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 6 Sep 2026 13:51:20 +0300 Subject: [PATCH 2/3] fix(api): a misshapen id is bad input, not a server fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ids are stored in a fixed-width column, so an overlong one is refused by Postgres rather than simply matching nothing. That refusal is not a foreign-key violation, so it fell through to the global error boundary and reached the caller as a 500 — telling a host to retry something that can never succeed. Measured: a 44-character user id returned 500, where an absent but well-formed one correctly returned 404. `Identifier.schema` is the natural place for the check and had no callers yet, so it now asserts the exact width an id has as well as its prefix — including the separator, without which `usrsomething` reads as a user id. The enrolment schema uses it for both foreign keys, so the refusal happens where the input arrives and names the field. Also index `steam_enrolment.user_id`. The primary key begins with the machine, which answers what one host holds and nothing else, so neither of the two things that read by user alone can use it: the cascade behind deleting a user, and asking which hosts hold a token for one person. The table's migration has not been released, so this is folded into it rather than following it with a correction. --- apps/api/test/enrolment.test.ts | 27 ++++++++++++++++--- .../0012_steam_enrolment_without_a_token.sql | 9 ++++++- .../core/migrations/meta/0012_snapshot.json | 20 ++++++++++++-- packages/core/migrations/meta/_journal.json | 2 +- packages/core/src/id.ts | 18 ++++++++++++- packages/core/src/steam/enrolment.sql.ts | 11 ++++++-- packages/core/src/steam/enrolment.test.ts | 19 ++++++++++++- packages/core/src/steam/enrolment.ts | 9 +++++-- 8 files changed, 102 insertions(+), 13 deletions(-) diff --git a/apps/api/test/enrolment.test.ts b/apps/api/test/enrolment.test.ts index f4a3c8e2..a201f68b 100644 --- a/apps/api/test/enrolment.test.ts +++ b/apps/api/test/enrolment.test.ts @@ -149,12 +149,33 @@ describe('POST /machine/enrolment', () => { 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) }); + // Well-formed and simply absent, which is the case the foreign key + // catches. A malformed one never reaches the database at all. + const res = await enrol(host, { + userId: Identifier.ascending('user'), + steamId: steamId(6) + }); expect(res.status).toBe(404); const body = (await res.json()) as any; expect(body.type).toBe('not_found'); }); + test('a userId of the wrong shape is bad input, not a server fault', async () => { + // Ids live in a fixed-width column, so an overlong one is refused by + // the database rather than merely not found — and that refusal used to + // reach the host as a 500, which tells it to retry something that can + // never succeed. The width is checked where the input arrives. + const host = await registeredHost('enrol-misshapen'); + const malformed = [`usr_${'a'.repeat(40)}`, 'usr_short', `mch_${'a'.repeat(26)}`, 'nonsense']; + for (const userId of malformed) { + // eslint-disable-next-line no-await-in-loop + const res = await enrol(host, { userId, steamId: steamId(15) }); + expect(res.status).toBe(400); + // eslint-disable-next-line no-await-in-loop + expect(((await res.json()) as any).type).toBe('validation'); + } + }); + 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' }); @@ -165,7 +186,7 @@ describe('POST /machine/enrolment', () => { 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) }) + body: JSON.stringify({ userId: Identifier.ascending('user'), steamId: steamId(7) }) }); expect(res.status).toBe(403); expect(((await res.json()) as any).message).toContain('Machine credentials'); @@ -220,7 +241,7 @@ describe('POST /machine/enrolment/stale', () => { 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' }) + body: JSON.stringify({ userId: Identifier.ascending('user') }) }); expect(res.status).toBe(403); }); diff --git a/packages/core/migrations/0012_steam_enrolment_without_a_token.sql b/packages/core/migrations/0012_steam_enrolment_without_a_token.sql index 38648778..f55c1964 100644 --- a/packages/core/migrations/0012_steam_enrolment_without_a_token.sql +++ b/packages/core/migrations/0012_steam_enrolment_without_a_token.sql @@ -25,6 +25,12 @@ -- itself only goes away when the machine or the user does, which the foreign -- keys already do. -- +-- The key begins with the machine, so it answers "what does this host hold" and +-- nothing else. `user_id` gets its own index because the two things that read +-- by user cannot use the key: deleting a user cascades into this table by that +-- column alone, and asking which hosts hold a token for one person is the +-- obvious next reader. +-- -- `last_ok_at` has no writer yet. A successful logon happens inside the -- workload, which holds no control-plane credential, so the report has to come -- back out through the host and nothing carries it today. The column exists @@ -44,4 +50,5 @@ CREATE TABLE "steam_enrolment" ( ); --> statement-breakpoint ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file +ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "steam_enrolment_user_idx" ON "steam_enrolment" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0012_snapshot.json b/packages/core/migrations/meta/0012_snapshot.json index abecf8dc..31297a46 100644 --- a/packages/core/migrations/meta/0012_snapshot.json +++ b/packages/core/migrations/meta/0012_snapshot.json @@ -1,5 +1,5 @@ { - "id": "5c6f5a6c-74ff-4b9d-95ac-bef2c2603273", + "id": "d519bc2f-25f8-46e1-b7df-67bf6b5a927a", "prevId": "6fdda454-c7ee-42c1-931b-fa595385aac0", "version": "7", "dialect": "postgresql", @@ -1860,7 +1860,23 @@ "notNull": false } }, - "indexes": {}, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "steam_enrolment_machine_id_machine_id_fk": { "name": "steam_enrolment_machine_id_machine_id_fk", diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index b7a37435..dfae72e2 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -89,7 +89,7 @@ { "idx": 12, "version": "7", - "when": 1788690115352, + "when": 1788691753961, "tag": "0012_steam_enrolment_without_a_token", "breakpoints": true } diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 83d000e7..9773885f 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -27,8 +27,24 @@ export namespace Identifier { refreshToken: 'rft' } as const; + /** + * An id as this control plane issues them: the right prefix, and the exact + * width the column has. + * + * The width is the half that matters at an API boundary. Ids are stored in + * a fixed-width column, so an overlong string is *refused by the database* + * rather than simply not matching anything — which surfaces to the caller + * as a server fault instead of the validation error it actually is. + * Checking it where the input arrives is what keeps the two apart. + * + * The separator is part of the prefix check for the same reason: without + * it, `usrsomething` reads as a user id. + */ export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]); + return z + .string() + .startsWith(`${prefixes[prefix]}_`) + .length(prefixes[prefix].length + 1 + LENGTH); } const LENGTH = 26; diff --git a/packages/core/src/steam/enrolment.sql.ts b/packages/core/src/steam/enrolment.sql.ts index 27e9ce7d..afd6d4f8 100644 --- a/packages/core/src/steam/enrolment.sql.ts +++ b/packages/core/src/steam/enrolment.sql.ts @@ -1,4 +1,4 @@ -import { pgEnum, pgTable, primaryKey, text } from 'drizzle-orm/pg-core'; +import { index, pgEnum, pgTable, primaryKey, text } from 'drizzle-orm/pg-core'; import { ulid, utc } from '../db/types.js'; import { MachineTable } from '../machine/machine.sql.js'; @@ -61,5 +61,12 @@ export const SteamEnrolmentTable = pgTable( lastOkAt: utc('last_ok_at'), revokedAt: utc('revoked_at') }, - (t) => [primaryKey({ columns: [t.machineId, t.userId] })] + (t) => [ + primaryKey({ columns: [t.machineId, t.userId] }), + // The key starts with the machine, which answers "what does this host + // hold" and nothing else. Deleting a user cascades into this table by + // `user_id` alone, and asking which hosts hold a token for one person + // is the obvious next reader — neither can use the key. + index('steam_enrolment_user_idx').on(t.userId) + ] ); diff --git a/packages/core/src/steam/enrolment.test.ts b/packages/core/src/steam/enrolment.test.ts index b1185752..2af5b031 100644 --- a/packages/core/src/steam/enrolment.test.ts +++ b/packages/core/src/steam/enrolment.test.ts @@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; +import { Identifier } from '../id.js'; import { Enrolment } from './enrolment.js'; const sql = testDb(); @@ -121,6 +122,22 @@ describe('Enrolment.markStale', () => { }); }); +describe('the user foreign key has its own index', () => { + test('deleting a user, and asking by user, do not scan the table', async () => { + // The primary key starts with the machine, so neither of the two things + // that read by user alone can use it: the cascade behind a user + // deletion, and the question "which hosts hold a token for me". + const indexes = await sql<{ indexdef: string }[]>` + select indexdef from pg_indexes + where schemaname = 'public' + and tablename = 'steam_enrolment' + and indexname = 'steam_enrolment_user_idx' + `; + expect(indexes).toHaveLength(1); + expect(indexes[0]!.indexdef).toContain('user_id'); + }); +}); + describe('Enrolment.listByMachine', () => { test('every enrolment for one host, oldest first', async () => { const h = await host('core-list'); @@ -140,6 +157,6 @@ describe('Enrolment.listByMachine', () => { }); test('an unknown host has no enrolments rather than an error', async () => { - expect(await Enrolment.listByMachine('mch_nosuchmachine')).toEqual([]); + expect(await Enrolment.listByMachine(Identifier.ascending('machine'))).toEqual([]); }); }); diff --git a/packages/core/src/steam/enrolment.ts b/packages/core/src/steam/enrolment.ts index e79c667e..f46b224e 100644 --- a/packages/core/src/steam/enrolment.ts +++ b/packages/core/src/steam/enrolment.ts @@ -5,6 +5,7 @@ import { Database } from '../db/index.js'; import { ErrorCodes, VisibleError } from '../error.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; +import { Identifier } from '../id.js'; import { SteamEnrolmentState, SteamEnrolmentTable } from './enrolment.sql.js'; import { STEAM_ID_RE } from './index.js'; @@ -31,11 +32,15 @@ function isForeignKeyViolation(err: unknown): boolean { export namespace Enrolment { export const Info = z .object({ - machineId: z.string().meta({ + // Shaped, not merely non-empty. Both are foreign keys into + // fixed-width columns, so a string of the wrong width is rejected + // by the database itself — and a database refusal reaches a caller + // as a server fault rather than as the bad input it is. + machineId: Identifier.schema('machine').meta({ description: 'The host that holds a token for this user', example: Examples.SteamEnrolment.machineId }), - userId: z.string().meta({ + userId: Identifier.schema('user').meta({ description: 'The person the host signed in as', example: Examples.SteamEnrolment.userId }), From fe5297acbdbc9e2ce63c87f1e720b1ade426a920 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 6 Sep 2026 13:57:12 +0300 Subject: [PATCH 3/3] fix(core): document an id that is actually a valid id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example generator emitted twenty-five payload characters where an id has twenty-six, so every documented id was twenty-nine characters — one short of the width the column holds and, since last commit, one short of what the schema publishing it will accept. Nothing caught it because an example is never parsed: it is copied into documentation and read by people. The width now comes from the generator's own constant instead of being typed out, in the two places that had counted it by hand. Counting twenty-six of anything by eye is a thing people get right once and never re-check. A test pins the three together — a generated id, the schema for one, and the documented example must all agree, for every prefix. It fails on the off-by-one that prompted this, and on a prefix without its separator, which would otherwise read as an id of that type because it starts with the same three letters. --- apps/api/app/routes/steam.ts | 153 +++++++++++++++++----------------- packages/core/CLAUDE.md | 2 +- packages/core/src/examples.ts | 6 +- packages/core/src/id.test.ts | 51 ++++++++++++ packages/core/src/id.ts | 9 +- 5 files changed, 141 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/id.test.ts diff --git a/apps/api/app/routes/steam.ts b/apps/api/app/routes/steam.ts index 5833a571..2dbb7626 100644 --- a/apps/api/app/routes/steam.ts +++ b/apps/api/app/routes/steam.ts @@ -23,12 +23,10 @@ export namespace SteamApi { content: { 'application/json': { schema: Result( - z - .union([LinkedAccount.Info, z.null()]) - .meta({ - description: 'The linked Steam account, or null', - example: Examples.LinkedAccount - }) + z.union([LinkedAccount.Info, z.null()]).meta({ + description: 'The linked Steam account, or null', + example: Examples.LinkedAccount + }) ) } }, @@ -53,9 +51,7 @@ export namespace SteamApi { 200: { content: { 'application/json': { - schema: Result( - z.object({ unlinked: z.boolean() }) - ) + schema: Result(z.object({ unlinked: z.boolean() })) } }, description: 'Steam account unlinked' @@ -80,76 +76,79 @@ export namespace SteamApi { ) .post( '/link', - describeRoute({ - tags: ['Steam'], - summary: 'Link a Steam account', - description: 'Link a Steam account to a user (admin) or yourself (user)', - responses: { - 200: { - content: { - 'application/json': { - schema: Result( - z.object({ - linkedAccountId: z.string().meta({ - description: 'The ID of the linked account', - example: Examples.LinkedAccount.id - }), - steamId: z.string().meta({ - description: 'The Steam ID that was linked', - example: '76561197960287930' + describeRoute({ + tags: ['Steam'], + summary: 'Link a Steam account', + description: 'Link a Steam account to a user (admin) or yourself (user)', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + linkedAccountId: z.string().meta({ + description: 'The ID of the linked account', + example: Examples.LinkedAccount.id + }), + steamId: z.string().meta({ + description: 'The Steam ID that was linked', + example: '76561197960287930' + }) }) - }) - ) - } + ) + } + }, + description: 'Steam account linked' }, - description: 'Steam account linked' - }, - 400: ErrorResponses[400], - 401: ErrorResponses[401], - 403: ErrorResponses[403], - 429: ErrorResponses[429] - } - }), - validator( - 'json', - z.object({ - steamId: z.string().min(1).meta({ - description: 'Steam ID to link', - example: '76561197960287930' - }), - userId: z.string().optional().meta({ - description: 'User ID to link to (admin only; omitted when linking your own account)', - example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX' - }), - profile: z - .record(z.string(), z.unknown()) - .optional() - .meta({ - description: 'Steam profile data', - example: { personaname: 'Player', avatarfull: 'https://...' } - }) - }) - ), - async (c) => { - const body = c.req.valid('json'); - const actor = Actor.use(); + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 429: ErrorResponses[429] + } + }), + validator( + 'json', + z.object({ + steamId: z.string().min(1).meta({ + description: 'Steam ID to link', + example: '76561197960287930' + }), + userId: z + .string() + .optional() + .meta({ + description: 'User ID to link to (admin only; omitted when linking your own account)', + example: Examples.Id('user') + }), + profile: z + .record(z.string(), z.unknown()) + .optional() + .meta({ + description: 'Steam profile data', + example: { personaname: 'Player', avatarfull: 'https://...' } + }) + }) + ), + async (c) => { + const body = c.req.valid('json'); + const actor = Actor.use(); - if (body.userId && actor.type !== 'admin') { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, - 'Only admin can link a Steam account for another user' - ); - } + if (body.userId && actor.type !== 'admin') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Only admin can link a Steam account for another user' + ); + } - const linkedAccountID = await Steam.link({ - steamId: body.steamId, - profile: body.profile, - userId: body.userId - }); - return c.json({ - data: { linkedAccountId: linkedAccountID, steamId: body.steamId } - }); - } - ); + const linkedAccountID = await Steam.link({ + steamId: body.steamId, + profile: body.profile, + userId: body.userId + }); + return c.json({ + data: { linkedAccountId: linkedAccountID, steamId: body.steamId } + }); + } + ); } diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 14043086..a7ab9612 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -378,7 +378,7 @@ The IDs are 30-char strings: `{prefix}_{26 base62 chars}`. They are monotonicall ```ts export namespace Examples { export const Id = (prefix: keyof typeof Identifier.prefixes) => - `${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`; + `${Identifier.prefixes[prefix]}_${'X'.repeat(Identifier.LENGTH)}`; export const User = { id: Id('user'), name: '…', email: '…', … }; export const LinkedAccount = { id: Id('linkedAccount'), provider: 'steam', … }; diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 78234323..d4ac76f7 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -1,8 +1,12 @@ import { Identifier } from './id.js'; export namespace Examples { + // The width is taken from the generator rather than typed out. Counting + // twenty-six of anything by eye is a thing people get wrong once and then + // never look at again — this was one short, which made every documented id + // a value the schema that published it would reject. export const Id = (prefix: keyof typeof Identifier.prefixes) => - `${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`; + `${Identifier.prefixes[prefix]}_${'X'.repeat(Identifier.LENGTH)}`; export const User = { id: Id('user'), diff --git a/packages/core/src/id.test.ts b/packages/core/src/id.test.ts new file mode 100644 index 00000000..17ea4cd7 --- /dev/null +++ b/packages/core/src/id.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; + +import { Examples } from './examples.js'; +import { Identifier } from './id.js'; + +const prefixes = Object.keys(Identifier.prefixes) as (keyof typeof Identifier.prefixes)[]; + +describe('an id, the rule for one, and the documented example agree', () => { + // Three things have to say the same thing and only one of them is the + // generator. They drifted once already: the example was twenty-nine + // characters against a rule demanding thirty, so every id in the published + // documentation was a value the schema beside it would reject. Nothing + // noticed, because an example is never parsed. + + test('every generated id satisfies its own schema', () => { + for (const prefix of prefixes) { + const parsed = Identifier.schema(prefix).safeParse(Identifier.ascending(prefix)); + expect(parsed.success).toBe(true); + } + }); + + test('every documented example satisfies the schema that publishes it', () => { + for (const prefix of prefixes) { + const parsed = Identifier.schema(prefix).safeParse(Examples.Id(prefix)); + expect(parsed.success).toBe(true); + } + }); + + test('an id is the width the column holds', () => { + // `ulid()` is `char(26 + 4)`, and a char column refuses an overlong + // value rather than truncating — so a generator that drifted wider + // would fail every insert, not merely look wrong. + for (const prefix of prefixes) { + expect(Identifier.ascending(prefix)).toHaveLength(30); + expect(Examples.Id(prefix)).toHaveLength(30); + } + }); + + test('the schema refuses the near misses, not just the obvious ones', () => { + const schema = Identifier.schema('user'); + const body = 'a'.repeat(Identifier.LENGTH); + expect(schema.safeParse(`usr_${body}`).success).toBe(true); + // One short, one long, right length with the wrong prefix, and the + // prefix without its separator — which would otherwise read as a user + // id because it starts with the same three letters. + expect(schema.safeParse(`usr_${body.slice(1)}`).success).toBe(false); + expect(schema.safeParse(`usr_${body}a`).success).toBe(false); + expect(schema.safeParse(`mch_${body}`).success).toBe(false); + expect(schema.safeParse(`usr${body}a`).success).toBe(false); + }); +}); diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 9773885f..1fcce66b 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -47,7 +47,14 @@ export namespace Identifier { .length(prefixes[prefix].length + 1 + LENGTH); } - const LENGTH = 26; + /** + * How many characters follow the prefix and separator. + * + * Exported because three things have to agree on it and two of them are + * not the generator: the column is fixed-width, {@link schema} refuses + * anything else, and the documented examples have to be values that pass. + */ + export const LENGTH = 26; let lastTimestamp = 0; let counter = 0;