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/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, 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/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/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() }); 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 }; + }); + } + ); +}