From 6603383ad1e37987a2e13eaf321c660a83ac3341 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 6 Sep 2026 23:44:37 +0300 Subject: [PATCH 1/3] feat(machine): record where a host can be reached, as the host reports it The machine table said who owns a host, which team it belongs to and when it was last seen, and nothing about how to reach it. Anything standing in front of a host and authenticating browsers on its behalf could 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 it rides on the beat it already sends as itself. Omitting the field leaves the stored value alone -- an agent that does not mention where it is has not moved, and an absent field must never read as "nowhere", which would take every host shipped before this field off the map on its next beat. Nullable, because "has never reported one" is a real state that every host registered before today is in. Unique, because an endpoint id belongs to 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 here that the authorisation in front of it cannot catch. --- apps/api/app/middleware/auth.ts | 2 +- apps/api/app/routes/machine.ts | 23 +- apps/api/test/heartbeat.test.ts | 63 + .../migrations/0013_machine_endpoint_id.sql | 26 + .../core/migrations/meta/0013_snapshot.json | 2951 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/examples.ts | 3 +- packages/core/src/machine/index.ts | 58 +- packages/core/src/machine/machine.sql.ts | 16 + packages/core/src/machine/machine.test.ts | 51 +- 10 files changed, 3182 insertions(+), 18 deletions(-) create mode 100644 packages/core/migrations/0013_machine_endpoint_id.sql create mode 100644 packages/core/migrations/meta/0013_snapshot.json 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..0698b9ce 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,31 @@ export namespace MachineApi { }, description: 'The beat was recorded' }, + 400: ErrorResponses[400], 403: ErrorResponses[403], 404: ErrorResponses[404] } }), + 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..59119c3d 100644 --- a/apps/api/test/heartbeat.test.ts +++ b/apps/api/test/heartbeat.test.ts @@ -94,6 +94,69 @@ 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('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/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..15019ba1 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -22,6 +22,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 +57,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({ @@ -195,17 +213,34 @@ 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) => { + return 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); + }); + } + ); /** * Whether a host has beaten recently enough to place work on. @@ -306,7 +341,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..09a8a67f 100644 --- a/packages/core/src/machine/machine.test.ts +++ b/packages/core/src/machine/machine.test.ts @@ -87,17 +87,62 @@ 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. + expect(Machine.touchLastSeen({ id: second, endpointId })).rejects.toThrow(); }); test('online is derived from the last beat, not stored', async () => { From 49ae45624e7fd4dc48d68c3442f1e0be91def42d Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 6 Sep 2026 23:44:51 +0300 Subject: [PATCH 2/3] fix(auth): a host may receive a code at its own name, and a refusal is not a redirect Two changes to who may start a flow here, and where a refusal is delivered. A host reached at its own hostname sits on a different registrable domain from this issuer, deliberately: that is what stops a cookie set there from ever reaching this one. The default rule allows a redirect back to whatever hostname the request arrived on, so it refused exactly the case the separation created. Which is a real problem rather than a theoretical one, because a session cookie without a Domain attribute is host-only, so a browser arriving at one of those hostnames for the first time carries no cookie whether or not it is signed in, and sending it here to sign in again changes nothing. So a client id that is a single hostname under that zone, whose redirect_uri is https and that same hostname at one reserved path, is allowed. Making the client id the hostname is the load-bearing part: a token's audience is its client id, so the session that comes back is bound to the host it will live on and is not a credential anywhere else. Separately, and worth its own paragraph: a refused client's redirect_uri was still used to report the refusal. The check that approves that URI is the one that just failed, so /authorize was an open redirector to anywhere at all -- no sign-in required, on the hostname people are asked to type a password into. It is now a page here. Before: GET /authorize?client_id=web&redirect_uri=https://somewhere.example/callback -> 302 https://somewhere.example/callback?error=unauthorized_client --- apps/auth/src/index.ts | 110 +++++++++++++++++++++++++ apps/auth/test/allow.test.ts | 150 +++++++++++++++++++++++++++++++++++ packages/auth/src/issuer.ts | 10 +++ 3 files changed, 270 insertions(+) create mode 100644 apps/auth/test/allow.test.ts 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 From 51d25f3e8ce48d4eadef868736334d5dfbd5c05c Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 7 Sep 2026 00:19:34 +0300 Subject: [PATCH 3/3] fix(machine): a taken endpoint id is a conflict, not a server fault A host reporting an endpoint id another machine already holds hit the unique index, and the raw refusal reached the global handler as a 500 -- telling a host its beat broke the server rather than that the id is taken. It is now the 409 every other conflict here gives, and the route documents it. 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. The read would add a query and remove nothing. Before: expect(res.status).toBe(409) Received: 500 --- apps/api/app/routes/machine.ts | 3 +- apps/api/test/heartbeat.test.ts | 25 ++++++++++ packages/core/src/machine/index.ts | 59 +++++++++++++++++------ packages/core/src/machine/machine.test.ts | 8 ++- 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 0698b9ce..08054944 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -239,7 +239,8 @@ export namespace MachineApi { }, 400: ErrorResponses[400], 403: ErrorResponses[403], - 404: ErrorResponses[404] + 404: ErrorResponses[404], + 409: ErrorResponses[409] } }), validator( diff --git a/apps/api/test/heartbeat.test.ts b/apps/api/test/heartbeat.test.ts index 59119c3d..0c85e1b0 100644 --- a/apps/api/test/heartbeat.test.ts +++ b/apps/api/test/heartbeat.test.ts @@ -157,6 +157,31 @@ describe('POST /machine/heartbeat', () => { 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/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 15019ba1..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'; @@ -83,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) { @@ -224,21 +231,43 @@ export namespace Machine { export const touchLastSeen = fn( Info.pick({ id: true }).extend({ endpointId: EndpointId.optional() }), async (input) => { - return 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); - }); + 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; + } } ); diff --git a/packages/core/src/machine/machine.test.ts b/packages/core/src/machine/machine.test.ts index 09a8a67f..97cfcf5f 100644 --- a/packages/core/src/machine/machine.test.ts +++ b/packages/core/src/machine/machine.test.ts @@ -142,7 +142,13 @@ describe('Machine heartbeat', () => { // 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. - expect(Machine.touchLastSeen({ id: second, endpointId })).rejects.toThrow(); + // + // 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 () => {