diff --git a/apps/api/app/middleware/auth.ts b/apps/api/app/middleware/auth.ts index fbf64e5a..aab6062f 100644 --- a/apps/api/app/middleware/auth.ts +++ b/apps/api/app/middleware/auth.ts @@ -98,7 +98,7 @@ export const auth: MiddlewareHandler = async (c, next) => { if (machineId && machineSecret) { const machine = await Machine.authenticate({ id: machineId, secret: machineSecret }); if (machine) { - await Machine.touchLastSeen(machine.id); + await Machine.touchLastSeen({ id: machine.id }); return Actor.with( { type: 'machine', diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 09adae39..08054944 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -216,7 +216,7 @@ export namespace MachineApi { tags: ['Machine'], summary: 'Say the host is alive', description: - 'Records liveness for the calling machine and returns how often it should call back. The interval comes from the server on purpose: a fleet whose cadence can only change by shipping a new agent is a fleet whose cadence never changes. Takes no body — what a host is *running* is reported separately, and reporting a shape we cannot yet act on would be worse than reporting nothing.', + 'Records liveness for the calling machine and returns how often it should call back. The interval comes from the server on purpose: a fleet whose cadence can only change by shipping a new agent is a fleet whose cadence never changes. The body carries one optional fact — where this host can be reached — because only a host can say that about itself and this is the call it already makes as itself. What a host is *running* is reported separately.', responses: { 200: { content: { @@ -237,12 +237,32 @@ export namespace MachineApi { }, description: 'The beat was recorded' }, + 400: ErrorResponses[400], 403: ErrorResponses[403], - 404: ErrorResponses[404] + 404: ErrorResponses[404], + 409: ErrorResponses[409] } }), + validator( + 'json', + z + .object({ + endpointId: Machine.EndpointId.optional().meta({ + description: + 'Where this host can be reached, as its own endpoint id. Omit it and the stored value is left alone — a host that does not mention where it is has not moved, and an absent field must never read as "nowhere"', + example: Examples.Machine.endpointId + }) + }) + // A host that has nothing to add sends no body at all, which + // is what every agent shipped before this field did. + .optional() + ), async (c) => { - const lastSeen = await Machine.touchLastSeen(Actor.machineID); + const body = c.req.valid('json'); + const lastSeen = await Machine.touchLastSeen({ + id: Actor.machineID, + endpointId: body?.endpointId + }); if (!lastSeen) { // The credentials authenticated but the row is gone — a host // deleted mid-beat. It must re-register rather than keep diff --git a/apps/api/test/heartbeat.test.ts b/apps/api/test/heartbeat.test.ts index a3335428..0c85e1b0 100644 --- a/apps/api/test/heartbeat.test.ts +++ b/apps/api/test/heartbeat.test.ts @@ -94,6 +94,94 @@ describe('POST /machine/heartbeat', () => { expect((await Machine.fromID(host.id))?.lastSeen).toBeNull(); }); + test('a host says where it is on the beat it already sends', async () => { + const host = await registeredHost('beat-endpoint'); + const endpointId = 'd'.repeat(64); + + // A beat carrying no body is what every agent shipped before this field + // sends, and it must still be a beat. + const bare = await app.request('/machine/heartbeat', { + method: 'POST', + headers: host.headers + }); + expect(bare.status).toBe(200); + expect((await Machine.fromID(host.id))?.endpointId).toBeNull(); + + const res = await app.request('/machine/heartbeat', { + method: 'POST', + headers: { ...host.headers, 'content-type': 'application/json' }, + body: JSON.stringify({ endpointId }) + }); + expect(res.status).toBe(200); + expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId); + + // And a later beat that says nothing does not take the host off the map. + await app.request('/machine/heartbeat', { method: 'POST', headers: host.headers }); + expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId); + }); + + test('a host cannot report where somebody else is', async () => { + // The report is authenticated as the machine it is about, and there is + // no field naming a different one. This is the assertion that keeps it + // that way: a body that tries anyway changes nothing. + const host = await registeredHost('beat-endpoint-other'); + const victim = await registeredHost('beat-endpoint-victim'); + const endpointId = 'e'.repeat(64); + + const res = await app.request('/machine/heartbeat', { + method: 'POST', + headers: { ...host.headers, 'content-type': 'application/json' }, + body: JSON.stringify({ endpointId, machineId: victim.id, id: victim.id }) + }); + + expect(res.status).toBe(200); + expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId); + expect((await Machine.fromID(victim.id))?.endpointId).toBeNull(); + }); + + test('an endpoint id that cannot be one is refused', async () => { + const host = await registeredHost('beat-endpoint-shape'); + + const res = await app.request('/machine/heartbeat', { + method: 'POST', + headers: { ...host.headers, 'content-type': 'application/json' }, + body: JSON.stringify({ endpointId: 'not-an-endpoint-id' }) + }); + + expect(res.status).toBe(400); + expect((await Machine.fromID(host.id))?.endpointId).toBeNull(); + // Liveness is still recorded, and that is not a half-applied write: + // authenticating as this machine is itself proof it is alive, and the + // middleware records it before any route runs. What the refusal keeps + // out is the value that failed the check. + expect((await Machine.fromID(host.id))?.lastSeen).not.toBeNull(); + }); + + test('claiming another host’s endpoint id is a conflict, not a fault', async () => { + const first = await registeredHost('beat-endpoint-taken-a'); + const second = await registeredHost('beat-endpoint-taken-b'); + const endpointId = 'f'.repeat(64); + + await app.request('/machine/heartbeat', { + method: 'POST', + headers: { ...first.headers, 'content-type': 'application/json' }, + body: JSON.stringify({ endpointId }) + }); + + const res = await app.request('/machine/heartbeat', { + method: 'POST', + headers: { ...second.headers, 'content-type': 'application/json' }, + body: JSON.stringify({ endpointId }) + }); + + // The unique index is the invariant, so the database refusing is the + // expected way to find out — and an expected refusal reaching a host as + // a 500 tells it the server broke rather than that the id is taken. + expect(res.status).toBe(409); + expect((await res.json()) as any).toMatchObject({ type: 'already_exists' }); + expect((await Machine.fromID(second.id))?.endpointId).toBeNull(); + }); + test('a user session cannot beat on a host’s behalf', async () => { // A box holds credentials but is not its owner, and the reverse holds // too: `machineOnly` exists so a route written for a host cannot be diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 6b7bb3a9..045be5d0 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -2,6 +2,7 @@ import type { Hyperdrive } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; import { CodeProvider } from '@nestri/auth/provider/code'; import { CodeUI } from '@nestri/auth/ui/code'; +import { isDomainMatch } from '@nestri/auth/util'; import { Actor } from '@nestri/core/actor'; import { PostgresCodeStore } from '@nestri/core/auth/authorization-code'; import { PostgresDeviceStore } from '@nestri/core/auth/device-grant'; @@ -45,6 +46,72 @@ type Env = { */ const DEVICE_CLIENTS = new Set(['desktop']); +/** + * The zone every user-owned host is reached under, and the one path on it that + * may receive an authorization code. + * + * A host is reached at `.` through a proxy that authenticates + * browsers on its behalf. That proxy cannot be handed a session from here: a + * `__Host-` cookie is host-only by definition, so one set on this hostname is + * never sent to a different one, and a first request to a host's own name + * therefore arrives with no cookie whether or not the person is signed in. + * + * The proxy closes that by being an ordinary OAuth client — one per hostname — + * and exchanging a code for a session it can set on the hostname the browser is + * actually standing on. This is the rule that lets it: **the client id must be + * the hostname, and the redirect must be that same hostname at the reserved + * path below.** + * + * Making the client id the hostname is not a naming convention. A token is + * minted with its audience set to the client id, so it binds the session to the + * host it will live on — a cookie lifted off one host is not a credential on + * another, and it is not a credential here either. ref(d-0056) + */ +const HOST_ZONE = 'nestri.link'; +const HOST_CALLBACK_PATH = '/__nestri/callback'; + +/** + * Whether `clientID` names a single host under {@link HOST_ZONE} and + * `redirectURI` is that same host's reserved callback. + * + * Every clause is load-bearing, because what is being decided is where this + * issuer will send an authorization code: + * + * - **`https` only.** A code is a one-time credential and belongs on a channel + * that cannot be read. + * - **The host must equal the client id exactly**, so a client can only ever + * receive a code at its own name. + * - **One label under the zone.** `a.b.` is not a host id, and must not + * be treated as one because `b.` might be. + * - **The path must be exactly the reserved one**, with no query and no + * fragment. A caller-chosen return address on a wildcard of hostnames is an + * open redirector on every one of them, and this is the parameter that would + * be it. + */ +function isHostCallback(clientID: string, redirectURI: string): boolean { + let url: URL; + try { + url = new URL(redirectURI); + } catch { + return false; + } + + const label = clientID.toLowerCase().endsWith(`.${HOST_ZONE}`) + ? clientID.toLowerCase().slice(0, -`.${HOST_ZONE}`.length) + : null; + if (!label || label.length === 0 || label.includes('.')) { + return false; + } + + return ( + url.protocol === 'https:' && + url.host === clientID.toLowerCase() && + url.pathname === HOST_CALLBACK_PATH && + url.search === '' && + url.hash === '' + ); +} + /** * Enough of an address to be worth trying to deliver to. * @@ -68,6 +135,40 @@ async function firstSteamLink(userID: string): Promise { return link?.id ?? ''; } +/** + * Which clients may start a flow here. + * + * The default rule allows a redirect back to whatever hostname the request + * arrived on, which is right for a site served beside this one and refuses the + * proxy in front of user-owned hosts — it redirects to a different registrable + * domain on purpose, so that no host's cookie can ever reach this one. That + * case is named here; everything else keeps the behaviour it had. + * + * Exported so it can be tested against a real `/authorize` request rather than + * by reading it. + */ +export const allowClient = async ( + input: { clientID: string; redirectURI: string }, + req: Request +): Promise => { + if (isHostCallback(input.clientID, input.redirectURI)) { + return true; + } + + let redirect: string; + try { + redirect = new URL(input.redirectURI).hostname; + } catch { + return false; + } + if (redirect === 'localhost' || redirect === '127.0.0.1') { + return true; + } + const forwarded = req.headers.get('x-forwarded-host'); + const host = forwarded ? new URL(`https://${forwarded}`).hostname : new URL(req.url).hostname; + return isDomainMatch(redirect, host); +}; + export default { async fetch(request: Request, env: Env, ctx?: ExecutionContext) { Env.init(env as unknown as Record); @@ -90,6 +191,15 @@ export default { refreshStore: PostgresRefreshStore(), deviceStore: PostgresDeviceStore(), allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID), + // The default rule allows a redirect back to whatever hostname the + // request arrived on, which is right for a site served beside this + // one and refuses the proxy in front of user-owned hosts — it + // redirects to a different registrable domain on purpose, so that + // no host's cookie can ever reach this one. + // + // So that case is named, and everything else keeps the behaviour it + // had. + allow: allowClient, // One provider, on purpose. // // Verifying an email address is the only thing that brings an diff --git a/apps/auth/test/allow.test.ts b/apps/auth/test/allow.test.ts new file mode 100644 index 00000000..ed1e2ab1 --- /dev/null +++ b/apps/auth/test/allow.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'bun:test'; + +import { issuer } from '@nestri/auth/index'; +import { CodeProvider } from '@nestri/auth/provider/code'; +import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { CodeUI } from '@nestri/auth/ui/code'; +import { subjects } from '@nestri/core/auth/subjects'; + +import { allowClient } from '../src/index.js'; + +/** + * The same issuer the worker builds, with the database taken out and the real + * rule about which clients may start a flow left in. + * + * `allow` is the whole subject of this file, so unlike `worker.test.ts` it is + * not stubbed to `true`. + */ +const auth = issuer({ + subjects, + storage: MemoryStorage(), + allow: allowClient, + providers: { + code: CodeProvider({ + ...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }), + sendCode: async () => {} + }) + }, + async success(context) { + return context.subject('user', { userID: 'usr_test123', linkedAccountID: '' }); + } +}); + +/** + * Start an authorization and say only whether the client was allowed. + * + * An allowed client is redirected on towards the provider; a refused one is + * answered by the issuer itself. The distinction is the status, and nothing + * below cares about anything past it. + */ +async function allowed(clientID: string, redirectURI: string): Promise { + const url = new URL('https://auth.internal/authorize'); + url.searchParams.set('client_id', clientID); + url.searchParams.set('redirect_uri', redirectURI); + url.searchParams.set('response_type', 'code'); + const response = await auth.request(url.toString()); + if (response.status !== 302) { + return false; + } + // An allowed client is sent on to the provider, which is a path on this + // issuer. Anywhere else is not a sign-in beginning. + return (response.headers.get('location') ?? '').startsWith('/'); +} + +/** + * A browser that reaches one of these hostnames is standing on a different + * registrable domain from this issuer, and a session set here can never be + * sent there — a `__Host-` cookie has no `Domain` attribute and is host-only, + * which is exactly what it is for. The proxy in front of those hosts closes + * that by being an ordinary client and exchanging a code for a session it sets + * on the hostname the browser is actually on. + * + * Before this rule existed every case below was refused, including the first. + */ +describe('a host may receive a code at its own name', () => { + test('the reserved callback on the client id itself is allowed', async () => { + expect(await allowed('m123.nestri.link', 'https://m123.nestri.link/__nestri/callback')).toBe( + true + ); + }); + + test('a code is never sent anywhere but the client id', async () => { + // The attack this refuses: a client that names itself as one host and + // asks for the code at another. + expect(await allowed('m123.nestri.link', 'https://evil.nestri.link/__nestri/callback')).toBe( + false + ); + expect(await allowed('m123.nestri.link', 'https://evil.example/__nestri/callback')).toBe(false); + }); + + test('only the reserved path receives a code', async () => { + // Anything else under the hostname is served by the host itself, and a + // return address a caller chooses is an open redirector on every + // hostname in the zone. + expect(await allowed('m123.nestri.link', 'https://m123.nestri.link/')).toBe(false); + expect( + await allowed('m123.nestri.link', 'https://m123.nestri.link/__nestri/callback/../..') + ).toBe(false); + expect( + await allowed('m123.nestri.link', 'https://m123.nestri.link/__nestri/callback?next=x') + ).toBe(false); + }); + + test('a code goes over https or it does not go', async () => { + expect(await allowed('m123.nestri.link', 'http://m123.nestri.link/__nestri/callback')).toBe( + false + ); + }); + + test('one label, because a deeper name is not a host id', async () => { + // `a.b.zone` must not be treated as a host id just because `b.zone` + // might be one. + expect( + await allowed('a.m123.nestri.link', 'https://a.m123.nestri.link/__nestri/callback') + ).toBe(false); + expect(await allowed('nestri.link', 'https://nestri.link/__nestri/callback')).toBe(false); + }); + + test('another zone does not get in by using the path', async () => { + expect(await allowed('m123.example.com', 'https://m123.example.com/__nestri/callback')).toBe( + false + ); + }); +}); + +describe('everything else keeps the rule it had', () => { + test('a redirect back to where the request arrived is still allowed', async () => { + expect(await allowed('web', 'https://auth.internal/callback')).toBe(true); + }); + + test('local development is still allowed', async () => { + expect(await allowed('web', 'http://localhost:5173/callback')).toBe(true); + }); + + test('an unrelated domain is still refused', async () => { + expect(await allowed('web', 'https://somewhere.example/callback')).toBe(false); + }); +}); + +/** + * A refusal is delivered here, not wherever the refused client asked. + * + * The issuer reports an error by redirecting to the caller's `redirect_uri`, + * which is right once that URI has been approved. This is the case where it has + * just been rejected — and honouring it there made `/authorize` an open + * redirector to anywhere at all, reachable without signing in, on the hostname + * people are asked to type a password into. + */ +describe('a refused client does not choose where the refusal goes', () => { + test('the refusal is a page here, not a redirect to the caller', async () => { + const url = new URL('https://auth.internal/authorize'); + url.searchParams.set('client_id', 'web'); + url.searchParams.set('redirect_uri', 'https://somewhere.example/callback'); + url.searchParams.set('response_type', 'code'); + + const response = await auth.request(url.toString()); + + expect(response.status).toBe(400); + expect(response.headers.get('location')).toBeNull(); + }); +}); diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index 50f4348e..ab59f241 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -1702,6 +1702,16 @@ export function issuer< if (err instanceof UnknownStateError) { return auth.forward(c, await error(err, c.req.raw)); } + // A refused client does not get to choose where the refusal is delivered. + // Everything below reports an error by redirecting to the `redirect_uri` + // the caller supplied, which is correct once that URI has been approved + // and is an open redirector before it has: the check that approves it is + // the one that just failed, so honouring it here would turn every + // refusal into a redirect to anywhere at all — no sign-in required, on + // the hostname people are told to trust with a password. + if (err instanceof UnauthorizedClientError) { + return c.text(err.description || err.error, 400); + } const authorization = await getAuthorization(c); // A device grant has no redirect to carry the error back on, so it is // said here instead. Without this the reporting path throws on a URL diff --git a/packages/core/migrations/0013_machine_endpoint_id.sql b/packages/core/migrations/0013_machine_endpoint_id.sql new file mode 100644 index 00000000..7ac58b7e --- /dev/null +++ b/packages/core/migrations/0013_machine_endpoint_id.sql @@ -0,0 +1,26 @@ +-- Where a host actually is, so that a request authorised for it has somewhere +-- to go. +-- +-- Until now this table said who owns a host, which team it belongs to and when +-- it was last seen, and nothing at all about how to reach it. A proxy that +-- authenticates a browser on a host's behalf can therefore authorise a request +-- perfectly and then have nowhere to send it. +-- +-- **Reported, never assigned.** A host holds the secret half of this identity +-- and is the only thing that can know the public half first, so this column +-- records what a host says about itself on a call it already makes as itself. +-- Nothing here mints one. ref(d-0010) +-- +-- Nullable, because "has never reported one" is a real state rather than an +-- error: every host registered before this column existed is in it, and null +-- reads as "not reachable yet". A default would read as an address and route +-- somewhere wrong. +-- +-- Unique, because an endpoint id belongs to exactly one host. Two rows claiming +-- the same one would send a request addressed to one machine to another +-- machine's agent, which is the one mistake this column can make that the +-- authorisation in front of it cannot catch. Postgres allows many nulls under a +-- unique index, so the hosts that have never reported are unaffected. + +ALTER TABLE "machine" ADD COLUMN "endpoint_id" text;--> statement-breakpoint +CREATE UNIQUE INDEX "machine_endpoint_id_unique" ON "machine" USING btree ("endpoint_id"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0013_snapshot.json b/packages/core/migrations/meta/0013_snapshot.json new file mode 100644 index 00000000..41037a9b --- /dev/null +++ b/packages/core/migrations/meta/0013_snapshot.json @@ -0,0 +1,2951 @@ +{ + "id": "7510a518-9e7d-4017-a3ec-b28f8d29a853", + "prevId": "d519bc2f-25f8-46e1-b7df-67bf6b5a927a", + "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 + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "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": { + "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", + "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 dfae72e2..b6be8921 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1788691753961, "tag": "0012_steam_enrolment_without_a_token", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1788725541386, + "tag": "0013_machine_endpoint_id", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index d4ac76f7..ac06e99b 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -129,7 +129,8 @@ export namespace Examples { ownerUserId: Id('user'), teamId: Id('team'), label: 'living-room-box', - lastSeen: '2026-07-28T12:00:00.000Z' + lastSeen: '2026-07-28T12:00:00.000Z', + endpointId: 'a'.repeat(64) }; export const SteamEnrolment = { diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index b37ece6c..465c22fd 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -4,6 +4,7 @@ import { and, eq, isNull, sql } 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 { Member } from '../team/member.js'; @@ -22,6 +23,19 @@ export namespace Machine { /** Length in bytes before base64url encoding. */ const SECRET_BYTES = 32; + /** + * A host's endpoint id: 32 bytes of public key, lowercase hex. + * + * Checked for shape and nothing else. What it addresses is meaningless + * here — this is a string the control plane stores and hands back — so the + * only thing worth refusing is a value that cannot possibly be one, which + * is what keeps a truncated or double-encoded id from being written and + * then failing far away, at whoever tries to dial it. + */ + export const EndpointId = z.string().regex(/^[0-9a-f]{64}$/, { + message: 'An endpoint id is 64 lowercase hex characters' + }); + export const Info = z .object({ id: z.string().meta({ @@ -44,6 +58,11 @@ export namespace Machine { lastSeen: z.iso.datetime().optional().nullable().meta({ description: 'When this machine last authenticated', example: Examples.Machine.lastSeen + }), + endpointId: EndpointId.optional().nullable().meta({ + description: + 'Where this host can be reached, as its own endpoint id. Null until the host has reported one — it holds the secret half of this identity, so it is the only thing that can say what the public half is', + example: Examples.Machine.endpointId }) }) .meta({ @@ -65,6 +84,12 @@ export namespace Machine { .join(''); } + /** Postgres refusing a second row for the same key. */ + function isUniqueViolation(err: unknown): boolean { + const e = err as { code?: string; cause?: { code?: string } }; + return e?.code === '23505' || e?.cause?.code === '23505'; + } + /** Length-independent, content-constant comparison of two hex digests. */ function secureEquals(a: string, b: string): boolean { if (a.length !== b.length) { @@ -195,17 +220,56 @@ export namespace Machine { * Returns the stored timestamp rather than void so a caller can hand it * straight back to the host — which is what lets a heartbeat be one round * trip instead of a write followed by a read. + * + * `endpointId` rides along for the same reason. Where a host is reachable + * is a fact about the host, it changes when the agent's identity does, and + * a caller that has just proved it is that host is the only one who can + * report it — so it is written by the call that already says "still here", + * in the same statement, rather than by a second one that could succeed + * alone and leave the two facts disagreeing. */ - export const touchLastSeen = fn(Info.shape.id, async (id) => { - return Database.use(async (tx) => { - return tx - .update(MachineTable) - .set({ lastSeen: sql`now()` }) - .where(eq(MachineTable.id, id)) - .returning({ lastSeen: MachineTable.lastSeen }) - .then((rows) => rows.at(0)?.lastSeen ?? null); - }); - }); + export const touchLastSeen = fn( + Info.pick({ id: true }).extend({ endpointId: EndpointId.optional() }), + async (input) => { + try { + return await Database.use(async (tx) => { + return tx + .update(MachineTable) + .set({ + lastSeen: sql`now()`, + // Omitted rather than nulled when it is absent: a caller + // that does not mention where it is has not moved, and + // clearing the column would deregister a working host + // from every route that reads it. + ...(input.endpointId ? { endpointId: input.endpointId } : {}) + }) + .where(eq(MachineTable.id, input.id)) + .returning({ lastSeen: MachineTable.lastSeen }) + .then((rows) => rows.at(0)?.lastSeen ?? null); + }); + } catch (err) { + // Another host already holds this endpoint id. That is a + // conflict rather than a fault: the unique index is the + // invariant, so the database refusing is the expected way to + // find out, and letting it surface as a 500 would tell a host + // its beat broke the server. + // + // Checked-then-written would be worse rather than better. Two + // hosts reporting the same id in the same instant both read + // "nobody holds it" and both write, which is precisely what the + // index is for — so the read would add a query and remove + // nothing. + if (isUniqueViolation(err)) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'Another machine is already reachable at that endpoint id' + ); + } + throw err; + } + } + ); /** * Whether a host has beaten recently enough to place work on. @@ -306,7 +370,8 @@ export namespace Machine { ownerUserId: input.ownerUserId, teamId: input.teamId, label: input.label, - lastSeen: input.lastSeen?.toISOString() ?? null + lastSeen: input.lastSeen?.toISOString() ?? null, + endpointId: input.endpointId }; } } diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts index f565aad4..bca25416 100644 --- a/packages/core/src/machine/machine.sql.ts +++ b/packages/core/src/machine/machine.sql.ts @@ -29,6 +29,21 @@ export const MachineTable = pgTable( .notNull() .references(() => TeamTable.id, { onDelete: 'restrict' }), label: text('label').notNull(), + // Where this host can actually be reached: its own endpoint id, as + // hex. A row can be authorised perfectly and still have nowhere to + // send the request without it, which is what this column fixes. + // + // **Reported, never assigned.** A host holds the secret half and is + // the only thing that can know the public one first, so the control + // plane records what it is told rather than handing one out. ref(d-0010) + // + // Nullable because a host that has never reported one is a real state + // — every host registered before this column existed is in it — and + // the honest reading of null is "not reachable yet" rather than a + // default that would route somewhere wrong. Unique because an endpoint + // id belongs to one host: two rows claiming the same one would send a + // request addressed to one machine to a different machine's agent. + endpointId: text('endpoint_id'), // The secret itself is returned exactly once, at registration, and never // stored: a leaked database must not yield working box credentials. secretHash: text('secret_hash').notNull(), @@ -36,6 +51,7 @@ export const MachineTable = pgTable( }, (t) => [ uniqueIndex('machine_secret_hash_unique').on(t.secretHash), + uniqueIndex('machine_endpoint_id_unique').on(t.endpointId), index('machine_owner_idx').on(t.ownerUserId), index('machine_team_idx').on(t.teamId) ] diff --git a/packages/core/src/machine/machine.test.ts b/packages/core/src/machine/machine.test.ts index ed8c7489..97cfcf5f 100644 --- a/packages/core/src/machine/machine.test.ts +++ b/packages/core/src/machine/machine.test.ts @@ -87,17 +87,68 @@ describe('Machine heartbeat', () => { expect((await Machine.fromID(machineId))?.lastSeen).toBeNull(); - const first = await Machine.touchLastSeen(machineId); + const first = await Machine.touchLastSeen({ id: machineId }); expect(first).not.toBeNull(); - const second = await Machine.touchLastSeen(machineId); + const second = await Machine.touchLastSeen({ id: machineId }); expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()); }); test('beating for a machine that is gone reports nothing rather than pretending', async () => { // A host deleted mid-beat must be told to re-register, so this returns // null and the route turns that into a 404. - expect(await Machine.touchLastSeen('mch_deletedmiddeletedmid___')).toBeNull(); + expect(await Machine.touchLastSeen({ id: 'mch_deletedmiddeletedmid___' })).toBeNull(); + }); + + test('a host reports where it is, and a beat that says nothing leaves it alone', async () => { + const owner = await newOwner('mch-endpoint'); + const machineId = await Fixtures.machine(owner); + const endpointId = 'b'.repeat(64); + + // Nothing assigns this. Until the host says so, there is nowhere to + // send a request that was authorised for it. + expect((await Machine.fromID(machineId))?.endpointId).toBeNull(); + + await Machine.touchLastSeen({ id: machineId, endpointId }); + expect((await Machine.fromID(machineId))?.endpointId).toBe(endpointId); + + // The regression this guards: an agent that beats without the field — + // every agent shipped before it existed — must not clear the column and + // take a working host off every route that reads it. + await Machine.touchLastSeen({ id: machineId }); + expect((await Machine.fromID(machineId))?.endpointId).toBe(endpointId); + }); + + test('an endpoint id that cannot be one is refused before it is stored', async () => { + const owner = await newOwner('mch-endpoint-shape'); + const machineId = await Fixtures.machine(owner); + + // Truncated, upper-cased, and carrying an encoding that is not this + // one. Each would be written happily by a text column and would fail + // far away, at whoever tried to dial it. + for (const bad of ['abc', 'A'.repeat(64), `${'a'.repeat(63)}z`, `0x${'a'.repeat(64)}`]) { + expect(() => Machine.touchLastSeen({ id: machineId, endpointId: bad })).toThrow(); + } + }); + + test('two machines cannot claim the same endpoint id', async () => { + const owner = await newOwner('mch-endpoint-unique'); + const first = await Fixtures.machine(owner); + const second = await Fixtures.machine(owner); + const endpointId = 'c'.repeat(64); + + await Machine.touchLastSeen({ id: first, endpointId }); + + // Two rows claiming one endpoint id would send a request addressed to + // one machine to another machine's agent, and the authorisation in + // front of it cannot catch that. + // + // A conflict rather than a fault, and that distinction is the test: the + // database refusing is the *expected* way to find out, so it must not + // reach a host as "your beat broke the server". + await expect(Machine.touchLastSeen({ id: second, endpointId })).rejects.toMatchObject({ + type: 'already_exists' + }); }); test('online is derived from the last beat, not stored', async () => {