From f691b56c0cde27cf8eaddeb68eeb9e45406b7674 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 28 Sep 2026 08:36:09 +0300 Subject: [PATCH 1/3] feat(core): one-time install tokens that register a host to a team Installing on a host should be a command a person pastes, not a user session copied onto a machine. A token is issued for a team, spent by its first use, expires after an hour and is stored only as a digest, because it travels in that command and so lands in shell history. Redeeming is one conditional update inside the registration transaction: concurrent redemptions of one token register exactly one machine, and a registration that fails leaves the token unspent. --- .../core/migrations/0017_install_token.sql | 18 + .../core/migrations/meta/0017_snapshot.json | 3544 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 255 +- packages/core/src/id.ts | 1 + .../core/src/machine/install-token.sql.ts | 40 + .../core/src/machine/install-token.test.ts | 68 + packages/core/src/machine/install-token.ts | 108 + 7 files changed, 3910 insertions(+), 124 deletions(-) create mode 100644 packages/core/migrations/0017_install_token.sql create mode 100644 packages/core/migrations/meta/0017_snapshot.json create mode 100644 packages/core/src/machine/install-token.sql.ts create mode 100644 packages/core/src/machine/install-token.test.ts create mode 100644 packages/core/src/machine/install-token.ts diff --git a/packages/core/migrations/0017_install_token.sql b/packages/core/migrations/0017_install_token.sql new file mode 100644 index 00000000..02cbc221 --- /dev/null +++ b/packages/core/migrations/0017_install_token.sql @@ -0,0 +1,18 @@ +CREATE TABLE "install_token" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "team_id" char(30) NOT NULL, + "created_by_user_id" char(30) NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "redeemed_at" timestamp with time zone, + "machine_id" char(30) +); +--> statement-breakpoint +ALTER TABLE "install_token" ADD CONSTRAINT "install_token_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "install_token" ADD CONSTRAINT "install_token_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "install_token" ADD CONSTRAINT "install_token_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "install_token_hash_unique" ON "install_token" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "install_token_team_idx" ON "install_token" USING btree ("team_id"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0017_snapshot.json b/packages/core/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..b35cb5d3 --- /dev/null +++ b/packages/core/migrations/meta/0017_snapshot.json @@ -0,0 +1,3544 @@ +{ + "id": "25ef10b8-87f5-49f5-9b73-d8a8d02821f5", + "prevId": "1407e59d-170e-4a9f-85e1-eb69012ad99a", + "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.burn_counter": { + "name": "burn_counter", + "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 + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "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 + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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.install_token": { + "name": "install_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 + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "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 + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "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_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "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": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_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" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "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 + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "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": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "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 7ce99d9d..1552b3ae 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -1,125 +1,132 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1784801002476, - "tag": "0000_quick_dark_phoenix", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1785312635128, - "tag": "0001_opposite_senator_kelly", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1785379712946, - "tag": "0002_light_mesmero", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1785382013687, - "tag": "0003_many_pyro", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1785588097470, - "tag": "0004_remove_user_download_add_game_download", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1785909838801, - "tag": "0005_flaky_may_parker", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1786205230097, - "tag": "0006_waitlist_verification_game_aliases", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1788460224524, - "tag": "0007_box_session_team_notnull", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1788547836146, - "tag": "0008_session_one_active_run_per_box", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1788555252186, - "tag": "0009_email_is_the_root_identity", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1788590292860, - "tag": "0010_device_authorization_grant", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1788607804606, - "tag": "0011_auth_state_in_postgres", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1788691753961, - "tag": "0012_steam_enrolment_without_a_token", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1788725541386, - "tag": "0013_machine_endpoint_id", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1789680491539, - "tag": "0014_machine_public_label", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1789762221718, - "tag": "0015_organisation_owns_fleet_hardware", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1789765075037, - "tag": "0016_burn_counters_and_rate_segments", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1784801002476, + "tag": "0000_quick_dark_phoenix", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785312635128, + "tag": "0001_opposite_senator_kelly", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785379712946, + "tag": "0002_light_mesmero", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785382013687, + "tag": "0003_many_pyro", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785588097470, + "tag": "0004_remove_user_download_add_game_download", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1785909838801, + "tag": "0005_flaky_may_parker", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1786205230097, + "tag": "0006_waitlist_verification_game_aliases", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1788460224524, + "tag": "0007_box_session_team_notnull", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1788547836146, + "tag": "0008_session_one_active_run_per_box", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1788555252186, + "tag": "0009_email_is_the_root_identity", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1788590292860, + "tag": "0010_device_authorization_grant", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1788607804606, + "tag": "0011_auth_state_in_postgres", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1788691753961, + "tag": "0012_steam_enrolment_without_a_token", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1788725541386, + "tag": "0013_machine_endpoint_id", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1789680491539, + "tag": "0014_machine_public_label", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1789762221718, + "tag": "0015_organisation_owns_fleet_hardware", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1789765075037, + "tag": "0016_burn_counters_and_rate_segments", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1790573670492, + "tag": "0017_install_token", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 7d5019f4..4cf411d6 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -13,6 +13,7 @@ export namespace Identifier { userFingerprint: 'ufp', pairingCode: 'pai', machine: 'mch', + installToken: 'mit', box: 'box', session: 'ses', accessToken: 'pat', diff --git a/packages/core/src/machine/install-token.sql.ts b/packages/core/src/machine/install-token.sql.ts new file mode 100644 index 00000000..cd97e32d --- /dev/null +++ b/packages/core/src/machine/install-token.sql.ts @@ -0,0 +1,40 @@ +import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; + +import { id, timestamps, ulid, utc } from '../db/types.js'; +import { TeamTable } from '../team/team.sql.js'; +import { UserTable } from '../user/user.sql.js'; +import { MachineTable } from './machine.sql.js'; + +/** + * A one-time credential that registers exactly one machine to one team. + * + * It exists so that installing on a host is a command a person pastes, rather + * than a user session copied onto a machine. It travels in that command, so it + * lands in shell history: that is why it is single-use, expires in minutes, + * and is stored only as a digest. + */ +export const InstallTokenTable = pgTable( + 'install_token', + { + ...id, + ...timestamps, + teamId: ulid('team_id') + .notNull() + .references(() => TeamTable.id, { onDelete: 'cascade' }), + // Who asked for it. They become the machine's owner, as they would have + // by registering it with their own session. + createdByUserId: ulid('created_by_user_id') + .notNull() + .references(() => UserTable.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull(), + expiresAt: utc('expires_at').notNull(), + redeemedAt: utc('redeemed_at'), + // The machine it made. Null until redeemed; kept afterwards so a support + // conversation can say which command produced which host. + machineId: ulid('machine_id').references(() => MachineTable.id, { onDelete: 'set null' }) + }, + (t) => [ + uniqueIndex('install_token_hash_unique').on(t.tokenHash), + index('install_token_team_idx').on(t.teamId) + ] +); diff --git a/packages/core/src/machine/install-token.test.ts b/packages/core/src/machine/install-token.test.ts new file mode 100644 index 00000000..6af99f2f --- /dev/null +++ b/packages/core/src/machine/install-token.test.ts @@ -0,0 +1,68 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { Fixtures } from '../db/fixtures.js'; +import { testDb } from '../db/test.js'; +import { InstallToken } from './install-token.js'; + +const sql = testDb(); + +const createdUserIds: string[] = []; + +async function newOwner(label: string) { + const o = await Fixtures.owner(label); + createdUserIds.push(o.userId); + return o; +} + +afterAll(async () => { + if (createdUserIds.length > 0) { + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + createdUserIds.length = 0; + } +}); + +describe('Install tokens', () => { + test('a token registers one machine to its team, owned by whoever issued it', async () => { + const owner = await newOwner('nit-ok'); + const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + expect(token.startsWith('nit_')).toBe(true); + + const registered = await InstallToken.redeem({ token, label: 'host' }); + expect(registered?.secret.startsWith('msk_')).toBe(true); + + const rows = await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`; + expect(rows[0]!.team_id).toBe(owner.teamId); + expect(rows[0]!.owner_user_id).toBe(owner.userId); + + // Only the digest is kept, and the row records what it produced. + const tok = await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`; + expect(tok[0]!.token_hash).not.toBe(token); + expect(tok[0]!.machine_id).toBe(registered!.machineId); + }); + + test('a token is spent by its first use', async () => { + const owner = await newOwner('nit-once'); + const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + expect(await InstallToken.redeem({ token, label: 'a' })).not.toBeNull(); + expect(await InstallToken.redeem({ token, label: 'b' })).toBeNull(); + }); + + test('two hosts racing one token register exactly one machine', async () => { + const owner = await newOwner('nit-race'); + const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + const results = await Promise.all( + Array.from({ length: 5 }, (_, i) => InstallToken.redeem({ token, label: `h${i}` })) + ); + expect(results.filter(Boolean)).toHaveLength(1); + const machines = await sql`select id from machine where team_id = ${owner.teamId}`; + expect(machines).toHaveLength(1); + }); + + test('expired and unknown tokens are refused the same way', async () => { + const owner = await newOwner('nit-expired'); + const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + await sql`update install_token set expires_at = now() - interval '1 minute' where team_id = ${owner.teamId}`; + expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull(); + expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull(); + }); +}); diff --git a/packages/core/src/machine/install-token.ts b/packages/core/src/machine/install-token.ts new file mode 100644 index 00000000..61277811 --- /dev/null +++ b/packages/core/src/machine/install-token.ts @@ -0,0 +1,108 @@ +import { randomBytes } from 'node:crypto'; + +import { and, eq, gt, isNull, sql } from 'drizzle-orm'; +import { z } from 'zod'; + +import { Database } from '../db/index.js'; +import { fn } from '../fn.js'; +import { Identifier } from '../id.js'; +import { Machine } from './index.js'; +import { InstallTokenTable } from './install-token.sql.js'; + +export namespace InstallToken { + /** 128 bits: guessing one inside its lifetime is not a strategy. */ + const TOKEN_BYTES = 16; + + /** + * How long a token lives. Long enough to copy a command, open a terminal on + * another machine and run it; short enough that one found in shell history + * tomorrow is worthless. + */ + export const TTL_MINUTES = 60; + + function generate(): string { + return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`; + } + + async function digest(token: string): Promise { + const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)); + return Array.from(new Uint8Array(d)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + + /** Issue a token for `teamId`. The caller has already checked membership. */ + export const create = fn(z.object({ teamId: z.string(), userId: z.string() }), async (input) => { + const token = generate(); + const tokenHash = await digest(token); + const expiresAt = await Database.use(async (tx) => + tx + .insert(InstallTokenTable) + .values({ + id: Identifier.ascending('installToken'), + teamId: input.teamId, + createdByUserId: input.userId, + tokenHash, + expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'` + }) + .returning({ expiresAt: InstallTokenTable.expiresAt }) + .then((rows) => rows[0]!.expiresAt) + ); + return { token, expiresAt }; + }); + + /** + * Spend a token and register the machine it was issued for. + * + * Spending is one conditional UPDATE, so two hosts presenting the same token + * at the same instant cannot both win — the loser sees no row and is refused. + * Refusal is `null` for every reason (unknown, expired, already used), so the + * answer teaches a caller nothing about which tokens exist. + */ + export const redeem = fn( + z.object({ token: z.string(), label: z.string().min(1).max(64) }), + async (input) => { + const tokenHash = await digest(input.token); + // One transaction, so a registration that fails leaves the token + // unspent rather than burning the only copy the person has. + return Database.transaction(async () => { + const spent = await Database.use(async (tx) => + tx + .update(InstallTokenTable) + .set({ redeemedAt: sql`now()` }) + .where( + and( + eq(InstallTokenTable.tokenHash, tokenHash), + isNull(InstallTokenTable.redeemedAt), + isNull(InstallTokenTable.timeDeleted), + gt(InstallTokenTable.expiresAt, sql`now()`) + ) + ) + .returning({ + id: InstallTokenTable.id, + teamId: InstallTokenTable.teamId, + userId: InstallTokenTable.createdByUserId + }) + .then((rows) => rows.at(0) ?? null) + ); + if (!spent) return null; + + const machine = await Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: spent.userId, + teamId: spent.teamId, + label: input.label + }); + + await Database.use(async (tx) => + tx + .update(InstallTokenTable) + .set({ machineId: machine.id }) + .where(eq(InstallTokenTable.id, spent.id)) + ); + + return { machineId: machine.id, slug: machine.slug, secret: machine.secret }; + }); + } + ); +} From 54dbe8628ad1807e638a862af0232cf9f8d99429 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 28 Sep 2026 08:36:12 +0300 Subject: [PATCH 2/3] feat(api): issue and redeem install tokens POST /machine/install-token mints a token for a team the caller belongs to, with the same team resolution and membership rule as /register. POST /machine/install needs no session: it spends the token and returns the same id, slug and one-time secret as registering directly, refusing unknown, expired and used tokens identically. --- apps/api/app/routes/machine.ts | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 1960337e..d276a93c 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -4,6 +4,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; import { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/index'; +import { InstallToken } from '@nestri/core/machine/install-token'; import { Organisation } from '@nestri/core/organisation/index'; import { Team } from '@nestri/core/team/index'; import { Member } from '@nestri/core/team/member'; @@ -167,6 +168,127 @@ export namespace MachineApi { }); } ) + .post( + '/install-token', + notPublic, + describeRoute({ + tags: ['Machine'], + summary: 'Issue an install token', + description: + 'Mint a one-time token that registers one host to a team when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + token: z + .string() + .meta({ description: 'Shown once. Pass it to the installer.' }), + expiresAt: z.iso.datetime() + }) + ) + } + }, + description: 'A token for one host' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + validator( + 'json', + z.object({ + teamId: z.string().optional().meta({ + description: 'Team the host will belong to. Defaults to the caller\u2019s personal team' + }) + }) + ), + async (c) => { + const { teamId } = c.req.valid('json'); + const actor = Actor.use(); + if (actor.type !== 'user' && actor.type !== 'member') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Issuing an install token requires a user session' + ); + } + + // Same resolution and the same membership rule as `/register`: a + // token is a deferred registration, so it may not reach a team the + // caller could not register into directly. + const owningTeam = + teamId ?? + (actor.type === 'member' + ? actor.properties.teamID + : await Team.ensurePersonal({ displayName: Actor.userID })); + if (teamId) { + const membership = await Member.findByTeamAndUser({ teamId, userId: Actor.userID }); + if (!membership) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'You are not a member of that team' + ); + } + } + + const issued = await InstallToken.create({ teamId: owningTeam, userId: Actor.userID }); + return c.json({ + data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() } + }); + } + ) + .post( + '/install', + describeRoute({ + tags: ['Machine'], + summary: 'Register a host with an install token', + description: + 'Spend an install token and register the calling host to the team it was issued for. Needs no session: the token is the authority. The response is the same as registering directly, and the secret is likewise returned once.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + machineId: z.string().meta({ example: Examples.Machine.id }), + slug: z.string().meta({ example: Examples.Machine.slug }), + secret: z.string() + }) + ) + } + }, + description: 'The host is registered' + }, + 401: ErrorResponses[401] + } + }), + validator( + 'json', + z.object({ + token: z.string().min(1), + label: z.string().min(1).max(64).meta({ + description: 'Human-readable name for the host', + example: Examples.Machine.label + }) + }) + ), + async (c) => { + const { token, label } = c.req.valid('json'); + const registered = await InstallToken.redeem({ token, label }); + if (!registered) { + // One answer for unknown, expired and already used. + throw new VisibleError( + 'authentication', + ErrorCodes.Authentication.INVALID_TOKEN, + 'This install token is not valid. Copy a fresh command from your dashboard.' + ); + } + return c.json({ data: registered }); + } + ) .patch( '/:id', notPublic, From 065af689b3a8fbe2ccc4438d35bbd01349b04aaf Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 28 Sep 2026 09:12:23 +0300 Subject: [PATCH 3/3] feat(api): serve the host installer and its downloads GET /install.sh serves a POSIX script, embedded in the API at build time so the script and the routes that redeem its token ship together. It checks the platform, asks where box images should live, downloads the host agent at a pinned version, verifies it against SHA256SUMS, installs it for the calling user and hands over with the token in the environment rather than argv. GET /install/:component/:version/:asset redirects to a one-minute signed URL on a private S3-compatible bucket, so every download passes through a route that can be logged or switched off. The SigV4 signer is written against Web Crypto and checked against AWS's published example. --- apps/api/app/index.ts | 2 + apps/api/app/routes/install.ts | 81 ++++++++++++++++++++++++ apps/api/app/text-modules.d.ts | 5 ++ apps/api/app/utils/presign.ts | 94 ++++++++++++++++++++++++++++ apps/api/install/install.sh | 111 +++++++++++++++++++++++++++++++++ apps/api/test/presign.test.ts | 44 +++++++++++++ apps/api/wrangler.jsonc | 2 + packages/core/src/env.ts | 12 ++++ 8 files changed, 351 insertions(+) create mode 100644 apps/api/app/routes/install.ts create mode 100644 apps/api/app/text-modules.d.ts create mode 100644 apps/api/app/utils/presign.ts create mode 100755 apps/api/install/install.sh create mode 100644 apps/api/test/presign.test.ts diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 14f8710c..232f6651 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -14,6 +14,7 @@ import { BillingApi } from './routes/billing.js'; import { EnrolmentApi } from './routes/enrolment.js'; import { GameApi } from './routes/game.js'; import { IndexApi } from './routes/index.js'; +import { InstallApi } from './routes/install.js'; import { LibraryApi } from './routes/library.js'; import { MachineApi } from './routes/machine.js'; import { OrganisationApi } from './routes/organisation.js'; @@ -40,6 +41,7 @@ app const routes = app .route('/', IndexApi.route) + .route('/', InstallApi.route) .route('/user', UserApi.route) .route('/steam', SteamApi.route) .route('/library', LibraryApi.route) diff --git a/apps/api/app/routes/install.ts b/apps/api/app/routes/install.ts new file mode 100644 index 00000000..935bc3d0 --- /dev/null +++ b/apps/api/app/routes/install.ts @@ -0,0 +1,81 @@ +import { Env } from '@nestri/core/env'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; + +// The installer, embedded at build time so the script and the API that redeems +// its token always ship as one version. +import script from '../../install/install.sh' with { type: 'text' }; +import { presignGet } from '../utils/presign'; + +/** + * The host installer and the binaries it downloads. + * + * The bucket behind these is never public. A download is answered with a + * one-minute signed URL for exactly the object asked for, so every download + * passes through here, where it can be logged, rate-limited or switched off. + */ +export namespace InstallApi { + /** What may be downloaded: one component, versions and asset names by shape. */ + const COMPONENTS = new Set(['host']); + const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/; + const ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + + const SIGNED_SECONDS = 60; + + export const route = new Hono() + .get( + '/install.sh', + describeRoute({ + tags: ['Install'], + summary: 'The host installer', + description: + 'A POSIX shell script that installs the host agent for the calling user and registers the machine with a one-time install token. Pipe it to `sh -s -- `.', + responses: { 200: { description: 'The script' } } + }), + (c) => + c.body(script, 200, { + 'content-type': 'text/x-shellscript; charset=utf-8', + 'cache-control': 'no-cache' + }) + ) + .get( + '/install/:component/:version/:asset', + describeRoute({ + tags: ['Install'], + summary: 'Download an installable binary', + description: + 'Redirects to a short-lived signed URL for one release asset. Used by the installer.', + responses: { + 302: { description: 'Where to download it' }, + 404: { description: 'No such asset' } + } + }), + async (c) => { + const { component, version, asset } = c.req.param(); + if (!COMPONENTS.has(component) || !VERSION.test(version) || !ASSET.test(asset)) { + return c.notFound(); + } + const env = Env.get(); + if ( + !env.RELEASES_BUCKET || + !env.RELEASES_ENDPOINT || + !env.RELEASES_ACCESS_KEY_ID || + !env.RELEASES_SECRET_ACCESS_KEY + ) { + return c.json({ message: 'Downloads are not configured on this deployment.' }, 503); + } + const url = await presignGet( + { + endpoint: env.RELEASES_ENDPOINT, + bucket: env.RELEASES_BUCKET, + region: env.RELEASES_REGION, + accessKeyId: env.RELEASES_ACCESS_KEY_ID, + secretAccessKey: env.RELEASES_SECRET_ACCESS_KEY + }, + `${component}/${version}/${asset}`, + SIGNED_SECONDS + ); + return c.redirect(url, 302); + } + ); +} diff --git a/apps/api/app/text-modules.d.ts b/apps/api/app/text-modules.d.ts new file mode 100644 index 00000000..e6f356bd --- /dev/null +++ b/apps/api/app/text-modules.d.ts @@ -0,0 +1,5 @@ +// Files imported `with { type: 'text' }` arrive as their contents. +declare module '*.sh' { + const text: string; + export default text; +} diff --git a/apps/api/app/utils/presign.ts b/apps/api/app/utils/presign.ts new file mode 100644 index 00000000..d77fafd3 --- /dev/null +++ b/apps/api/app/utils/presign.ts @@ -0,0 +1,94 @@ +/** + * A presigned S3 GET, by hand: AWS Signature Version 4 in query-string form. + * + * Written against Web Crypto rather than an SDK so it runs the same under + * every runtime this API is deployed on, and because a GET presign is the whole + * of what is needed — a dependency the size of an S3 client for one signature + * is a dependency the size of an S3 client. + * + * Path-style URLs (`//`), which every S3-compatible + * store accepts and which need no DNS per bucket. + */ + +const enc = new TextEncoder(); + +function hex(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function sha256(s: string): Promise { + return hex(await crypto.subtle.digest('SHA-256', enc.encode(s))); +} + +async function hmac(key: ArrayBuffer | Uint8Array, s: string): Promise { + const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, [ + 'sign' + ]); + return crypto.subtle.sign('HMAC', k, enc.encode(s)); +} + +/** RFC 3986 encoding, which is what SigV4 means by "URI-encode". */ +function rfc3986(s: string): string { + return encodeURIComponent(s).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + +export type Bucket = { + endpoint: string; + bucket: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + /** `./` instead of `//`. */ + virtualHost?: boolean; +}; + +export async function presignGet( + b: Bucket, + key: string, + expiresSeconds: number, + now: Date = new Date() +): Promise { + const endpoint = new URL(b.endpoint); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); + const day = amzDate.slice(0, 8); + const scope = `${day}/${b.region}/s3/aws4_request`; + const host = b.virtualHost ? `${b.bucket}.${endpoint.host}` : endpoint.host; + const encodedKey = key.split('/').map(rfc3986).join('/'); + const path = b.virtualHost ? `/${encodedKey}` : `/${rfc3986(b.bucket)}/${encodedKey}`; + + const query: [string, string][] = [ + ['X-Amz-Algorithm', 'AWS4-HMAC-SHA256'], + ['X-Amz-Credential', `${b.accessKeyId}/${scope}`], + ['X-Amz-Date', amzDate], + ['X-Amz-Expires', String(expiresSeconds)], + ['X-Amz-SignedHeaders', 'host'] + ]; + const canonicalQuery = query + .map(([k, v]) => [rfc3986(k), rfc3986(v)] as const) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + + const canonicalRequest = [ + 'GET', + path, + canonicalQuery, + `host:${host}\n`, + 'host', + 'UNSIGNED-PAYLOAD' + ].join('\n'); + const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, await sha256(canonicalRequest)].join('\n'); + + let k = await hmac(enc.encode(`AWS4${b.secretAccessKey}`), day); + k = await hmac(k, b.region); + k = await hmac(k, 's3'); + k = await hmac(k, 'aws4_request'); + const signature = hex(await hmac(k, toSign)); + + return `${endpoint.protocol}//${host}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`; +} diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh new file mode 100755 index 00000000..9e47d752 --- /dev/null +++ b/apps/api/install/install.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env sh +# Nestri host installer — https://api.nestri.io/install.sh +# +# This file is the source of what that URL serves, kept in the public +# repository so anyone about to pipe it into a shell can read it first. +# +# curl -fsSL https://api.nestri.io/install.sh | sh -s -- +# +# What it does, in order: check this is 64-bit Linux, ask where box images +# should live, download the host agent for this platform, verify it against the +# published SHA256SUMS, install it to ~/.local/bin, and hand over to +# the agent's own onboarding, which checks the machine, registers it with the +# token and starts the agent as a systemd user service. It never asks for sudo: the agent +# runs as the user who ran this. +# +# The token comes from the dashboard's Installation page. It registers one +# machine, works once and lapses after an hour. + +set -eu + +API="${NESTRI_API:-https://api.nestri.io}" +# Pinned, not "latest", so the script and the binary it installs are a pair +# somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. +DEFAULT_VERSION="0.1.0" +VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" +BIN_DIR="${NESTRI_BIN_DIR:-$HOME/.local/bin}" + +say() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +TOKEN="${1:-${NESTRI_INSTALL_TOKEN:-}}" +[ -n "$TOKEN" ] || die "no install token. Copy the full command from the dashboard's Installation page." + +# --- platform --------------------------------------------------------------- +[ "$(uname -s)" = Linux ] || die "a host has to run Linux (with KVM); this is $(uname -s)." +case "$(uname -m)" in + x86_64|amd64) target=x86_64-unknown-linux-musl ;; + *) die "no host build for $(uname -m) yet." ;; +esac +[ "$(id -u)" -ne 0 ] || die "run this as the user that will run boxes, not as root." + +# --- fetch ------------------------------------------------------------------ +if command -v curl >/dev/null 2>&1; then + get() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + get() { wget -qO "$2" "$1"; } +else + die "need curl or wget" +fi + +# --- where box images go ---------------------------------------------------- +# Their own device, xfs or ext4, and never `/`: box images are large, and a +# filled root filesystem takes the whole machine down with it. The agent's +# preflight checks this again; asking here is so the default is a good guess. +tty_ok() { [ -e /dev/tty ] && (exec 3/dev/null; } +BOX_STORE="${NESTRI_BOX_STORE:-}" +if [ -z "$BOX_STORE" ]; then + guess="$(df -P -T -x tmpfs -x devtmpfs -x overlay 2>/dev/null \ + | awk 'NR>1 && ($2=="xfs"||$2=="ext4") && $7!="/" && $7!~/^\/(boot|efi)/ {print $5, $7}' \ + | sort -rn | awk 'NR==1 {print $2}')" + default="${guess:+$guess/nestri}" + if tty_ok; then + printf 'Where should box images go? (xfs or ext4, not /) [%s]: ' "${default:-none found}" >&2 + read -r answer /dev/null 2>&1; then + have="$(sha256sum "$TMP/$ASSET" | cut -d' ' -f1)" +else + have="$(shasum -a 256 "$TMP/$ASSET" | cut -d' ' -f1)" +fi +[ "$have" = "$want" ] || die "checksum mismatch — not installing + expected $want + got $have" +say "Checksum OK." + +mkdir -p "$BIN_DIR" +chmod +x "$TMP/$ASSET" +mv "$TMP/$ASSET" "$BIN_DIR/nestri-host" +say "Installed $BIN_DIR/nestri-host" +say "" + +# --- onboard ---------------------------------------------------------------- +# The token goes through the environment rather than argv, so it is not in +# `ps` for the length of the run. +export NESTRI_INSTALL_TOKEN="$TOKEN" NESTRI_BOX_STORE="$BOX_STORE" NESTRI_API="$API" +if tty_ok; then + exec "$BIN_DIR/nestri-host" onboard { + // The example in AWS's SigV4 query-string documentation, which publishes the + // signature it must produce. If this passes, every other signature is the + // same arithmetic with different inputs. + test('matches the published AWS example', async () => { + const url = await presignGet( + { + endpoint: 'https://s3.amazonaws.com', + bucket: 'examplebucket', + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + virtualHost: true + }, + 'test.txt', + 86400, + new Date('2013-05-24T00:00:00Z') + ); + expect(url).toContain('https://examplebucket.s3.amazonaws.com/test.txt?'); + expect(url).toEndWith( + 'X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404' + ); + }); + + test('path style puts the bucket in the path', async () => { + const url = await presignGet( + { + endpoint: 'https://objects.example.net', + bucket: 'releases', + region: 'europe-1', + accessKeyId: 'k', + secretAccessKey: 's' + }, + 'host/0.1.0/SHA256SUMS', + 60 + ); + expect(url).toStartWith('https://objects.example.net/releases/host/0.1.0/SHA256SUMS?'); + expect(url).toContain('X-Amz-Expires=60'); + }); +}); diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index c201ccce..97e4a40a 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -9,6 +9,8 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "nestri-api", "main": "app/index.ts", + // The installer is imported as text and served at /install.sh. + "rules": [{ "type": "Text", "globs": ["**/*.sh"], "fallthrough": false }], "compatibility_date": "2026-09-05", "compatibility_flags": ["nodejs_compat"], "workers_dev": false, diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 9e63ae84..35188a63 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -51,6 +51,18 @@ export namespace Env { POLAR_FREE_PRODUCT_ID: z.string().optional(), POLAR_SERVER: z.enum(['sandbox', 'production']).optional(), + /** + * Where installable binaries are kept: an S3-compatible bucket that is + * never public. Downloads are answered with a short-lived signed URL, + * so every one passes through a route that can be logged or turned off. + * Scope the key to this bucket and to reads. + */ + RELEASES_BUCKET: z.string().optional(), + RELEASES_ENDPOINT: z.string().optional(), + RELEASES_REGION: z.string().default('us-east-1'), + RELEASES_ACCESS_KEY_ID: z.string().optional(), + RELEASES_SECRET_ACCESS_KEY: z.string().optional(), + DATABASE_URL: z.string().optional() });