feat: Sync to OSS repo

This commit is contained in:
Wanjohi
2026-08-06 22:13:51 +03:00
parent 46d2a56180
commit 3faac3008f
144 changed files with 27561 additions and 0 deletions

34
packages/core/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
verbose: true,
strict: true,
out: './migrations',
dialect: 'postgresql',
schema: './src/**/*.sql.ts',
dbCredentials: {
host: process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).hostname : 'localhost',
port: process.env.DATABASE_URL ? Number(new URL(process.env.DATABASE_URL).port || 5432) : 5432,
user: process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).username : 'postgres',
password: process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).password : 'postgres',
database: process.env.DATABASE_URL
? new URL(process.env.DATABASE_URL).pathname.slice(1)
: 'nestri',
ssl: !!process.env.DATABASE_URL ? { rejectUnauthorized: false } : false
}
});

View File

@@ -0,0 +1,57 @@
CREATE TYPE "public"."linked_account_provider" AS ENUM('owner', 'admin', 'member');--> statement-breakpoint
CREATE TABLE "linked_account" (
"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,
"user_id" char(30) NOT NULL,
"provider" "linked_account_provider" NOT NULL,
"provider_account_id" text NOT NULL,
"profile" jsonb
);
--> statement-breakpoint
CREATE TABLE "team_member" (
"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,
"user_id" char(30) NOT NULL,
"role" "linked_account_provider" DEFAULT 'member' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "team" (
"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,
"name" text NOT NULL,
"slug" text NOT NULL,
"owner_id" char(30) NOT NULL,
"billing_email" text,
"plan" text DEFAULT 'free' NOT NULL,
"subscription_status" text DEFAULT 'active' NOT NULL,
"metadata" jsonb,
CONSTRAINT "team_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "user" (
"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,
"name" text NOT NULL,
"email" text,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text
);
--> statement-breakpoint
ALTER TABLE "linked_account" ADD CONSTRAINT "linked_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team" ADD CONSTRAINT "team_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "linked_account_provider_unique" ON "linked_account" USING btree ("provider","provider_account_id");--> statement-breakpoint
CREATE INDEX "linked_account_user_idx" ON "linked_account" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "team_member_team_user_unique" ON "team_member" USING btree ("team_id","user_id");--> statement-breakpoint
CREATE INDEX "team_member_team_idx" ON "team_member" USING btree ("team_id");--> statement-breakpoint
CREATE INDEX "team_member_user_idx" ON "team_member" USING btree ("user_id");

View File

@@ -0,0 +1,29 @@
CREATE TABLE "pairing_code" (
"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,
"code" text NOT NULL,
"target_user_id" text NOT NULL,
"new_fingerprint" text,
"expires_at" timestamp with time zone NOT NULL,
"claimed_at" timestamp with time zone,
"is_claimed" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_fingerprint" (
"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,
"user_id" char(30) NOT NULL,
"fingerprint" text NOT NULL,
"name" text,
"last_seen" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "user_fingerprint" ADD CONSTRAINT "user_fingerprint_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "pairing_code_code_unique" ON "pairing_code" USING btree ("code");--> statement-breakpoint
CREATE INDEX "pairing_code_target_user_idx" ON "pairing_code" USING btree ("target_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_fingerprint_fingerprint_unique" ON "user_fingerprint" USING btree ("fingerprint");--> statement-breakpoint
CREATE INDEX "user_fingerprint_user_idx" ON "user_fingerprint" USING btree ("user_id");

View File

@@ -0,0 +1,102 @@
CREATE TYPE "public"."depot_status" AS ENUM('pending', 'downloading', 'complete', 'error', 'deleted');--> statement-breakpoint
CREATE TYPE "public"."team_member_role" AS ENUM('owner', 'admin', 'member');--> statement-breakpoint
CREATE TYPE "public"."download_status" AS ENUM('pending', 'downloading', 'ready', 'failed', 'cancelled');--> statement-breakpoint
CREATE TABLE "game_depot" (
"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,
"game_id" char(30) NOT NULL,
"depot_id" integer NOT NULL,
"branch" text DEFAULT 'public' NOT NULL,
"steam_manifest_id" text,
"steam_build_id" integer,
"installed_manifest_id" text,
"installed_build_id" integer,
"size_download" bigint,
"size_on_disk" bigint,
"status" "depot_status" DEFAULT 'pending' NOT NULL,
"error_message" text,
"oslist" text
);
--> statement-breakpoint
CREATE TABLE "game" (
"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,
"steam_app_id" integer NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"type" text,
"short_description" text,
"description" text,
"developers" jsonb,
"publishers" jsonb,
"primary_genre" text,
"genres" jsonb,
"categories" jsonb,
"oslist" jsonb,
"size_download" bigint,
"size_on_disk" bigint,
"controller_support" text,
"steam_deck_compat" text,
"review_score_percent" smallint,
"review_count" integer,
"metacritic_score" smallint,
"steam_change_number" integer,
"public_build_id" integer,
"release_date_utc" timestamp with time zone,
"time_enriched" timestamp with time zone,
CONSTRAINT "game_steam_app_id_unique" UNIQUE("steam_app_id")
);
--> statement-breakpoint
CREATE TABLE "user_download" (
"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,
"user_id" char(30) NOT NULL,
"game_id" char(30) NOT NULL,
"status" "download_status" DEFAULT 'pending' NOT NULL,
"progress_bytes" bigint DEFAULT 0,
"total_bytes" bigint,
"time_started" timestamp with time zone,
"time_completed" timestamp with time zone,
"error_message" text
);
--> statement-breakpoint
CREATE TABLE "user_library" (
"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,
"user_id" char(30) NOT NULL,
"game_id" char(30) NOT NULL,
"playtime_2w" integer,
"playtime_forever" integer,
"last_played" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "linked_account" ALTER COLUMN "provider" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "team_member" ALTER COLUMN "role" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "team_member" ALTER COLUMN "role" SET DATA TYPE "public"."team_member_role" USING "role"::text::"public"."team_member_role";--> statement-breakpoint
ALTER TABLE "team_member" ALTER COLUMN "role" SET DEFAULT 'member';--> statement-breakpoint
DROP TYPE "public"."linked_account_provider";--> statement-breakpoint
CREATE TYPE "public"."linked_account_provider" AS ENUM('steam', 'ssh', 'discord');--> statement-breakpoint
ALTER TABLE "linked_account" ALTER COLUMN "provider" SET DATA TYPE "public"."linked_account_provider" USING "provider"::"public"."linked_account_provider";--> statement-breakpoint
ALTER TABLE "game_depot" ADD CONSTRAINT "game_depot_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_download" ADD CONSTRAINT "user_download_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_download" ADD CONSTRAINT "user_download_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_library" ADD CONSTRAINT "user_library_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_library" ADD CONSTRAINT "user_library_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "game_depot_unique" ON "game_depot" USING btree ("game_id","depot_id","branch");--> statement-breakpoint
CREATE INDEX "game_depot_game_idx" ON "game_depot" USING btree ("game_id");--> statement-breakpoint
CREATE INDEX "game_depot_updates_idx" ON "game_depot" USING btree ("game_id") WHERE "game_depot"."installed_manifest_id" is distinct from "game_depot"."steam_manifest_id";--> statement-breakpoint
CREATE UNIQUE INDEX "game_slug_unique" ON "game" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "game_app_id_unique" ON "game" USING btree ("steam_app_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_download_user_game_unique" ON "user_download" USING btree ("user_id","game_id");--> statement-breakpoint
CREATE INDEX "user_download_user_status_idx" ON "user_download" USING btree ("user_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "user_library_user_game_unique" ON "user_library" USING btree ("user_id","game_id");--> statement-breakpoint
CREATE INDEX "user_library_user_idx" ON "user_library" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "user_library_game_idx" ON "user_library" USING btree ("game_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "game" ADD COLUMN "client_icon" text;--> statement-breakpoint
ALTER TABLE "game" ADD COLUMN "icon" text;

View File

@@ -0,0 +1,21 @@
CREATE TYPE "public"."game_download_status" AS ENUM('pending', 'verifying', 'downloading', 'ready', 'failed');--> statement-breakpoint
CREATE TABLE "game_download" (
"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,
"host_id" text NOT NULL,
"game_id" char(30) NOT NULL,
"status" "game_download_status" DEFAULT 'pending' NOT NULL,
"progress_bytes" bigint DEFAULT 0,
"total_bytes" bigint,
"time_started" timestamp with time zone,
"time_completed" timestamp with time zone,
"error_message" text
);--> statement-breakpoint
ALTER TABLE "game_download" ADD CONSTRAINT "game_download_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "game_download_host_game_unique" ON "game_download" USING btree ("host_id","game_id");--> statement-breakpoint
CREATE INDEX "game_download_game_idx" ON "game_download" USING btree ("game_id");--> statement-breakpoint
CREATE INDEX "game_download_host_status_idx" ON "game_download" USING btree ("host_id","status");--> statement-breakpoint
DROP TABLE "user_download";--> statement-breakpoint
DROP TYPE "public"."download_status";

View File

@@ -0,0 +1,34 @@
CREATE TABLE "access_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,
"owner_user_id" char(30) NOT NULL,
"team_id" char(30),
"name" text NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone,
"last_used" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "machine" (
"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,
"owner_user_id" char(30) NOT NULL,
"team_id" char(30),
"label" text NOT NULL,
"secret_hash" text NOT NULL,
"last_seen" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "access_token" ADD CONSTRAINT "access_token_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "access_token" ADD CONSTRAINT "access_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 "machine" ADD CONSTRAINT "machine_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "access_token_hash_unique" ON "access_token" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "access_token_owner_idx" ON "access_token" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "access_token_team_idx" ON "access_token" USING btree ("team_id");--> statement-breakpoint
CREATE UNIQUE INDEX "machine_secret_hash_unique" ON "machine" USING btree ("secret_hash");--> statement-breakpoint
CREATE INDEX "machine_owner_idx" ON "machine" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "machine_team_idx" ON "machine" USING btree ("team_id");

View File

@@ -0,0 +1,429 @@
{
"id": "00b09079-b4e1-4781-848a-abbf2426e174",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"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.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": "linked_account_provider",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {
"team_member_team_user_unique": {
"name": "team_member_team_user_unique",
"columns": [
{
"expression": "team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"team_member_team_idx": {
"name": "team_member_team_idx",
"columns": [
{
"expression": "team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"team_member_user_idx": {
"name": "team_member_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"team_member_team_id_team_id_fk": {
"name": "team_member_team_id_team_id_fk",
"tableFrom": "team_member",
"tableTo": "team",
"columnsFrom": ["team_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"team_member_user_id_user_id_fk": {
"name": "team_member_user_id_user_id_fk",
"tableFrom": "team_member",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.team": {
"name": "team",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"billing_email": {
"name": "billing_email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"plan": {
"name": "plan",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'free'"
},
"subscription_status": {
"name": "subscription_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"team_owner_id_user_id_fk": {
"name": "team_owner_id_user_id_fk",
"tableFrom": "team",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"team_slug_unique": {
"name": "team_slug_unique",
"nullsNotDistinct": false,
"columns": ["slug"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"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": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.linked_account_provider": {
"name": "linked_account_provider",
"schema": "public",
"values": ["owner", "admin", "member"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,640 @@
{
"id": "e9c276ac-b054-44b5-ba34-1ea8efe17453",
"prevId": "00b09079-b4e1-4781-848a-abbf2426e174",
"version": "7",
"dialect": "postgresql",
"tables": {
"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.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.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": "linked_account_provider",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {
"team_member_team_user_unique": {
"name": "team_member_team_user_unique",
"columns": [
{
"expression": "team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"team_member_team_idx": {
"name": "team_member_team_idx",
"columns": [
{
"expression": "team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"team_member_user_idx": {
"name": "team_member_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"team_member_team_id_team_id_fk": {
"name": "team_member_team_id_team_id_fk",
"tableFrom": "team_member",
"tableTo": "team",
"columnsFrom": ["team_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"team_member_user_id_user_id_fk": {
"name": "team_member_user_id_user_id_fk",
"tableFrom": "team_member",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.team": {
"name": "team",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"billing_email": {
"name": "billing_email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"plan": {
"name": "plan",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'free'"
},
"subscription_status": {
"name": "subscription_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"team_owner_id_user_id_fk": {
"name": "team_owner_id_user_id_fk",
"tableFrom": "team",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"team_slug_unique": {
"name": "team_slug_unique",
"nullsNotDistinct": false,
"columns": ["slug"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_fingerprint": {
"name": "user_fingerprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "char(30)",
"primaryKey": true,
"notNull": true
},
"time_created": {
"name": "time_created",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "char(30)",
"primaryKey": false,
"notNull": true
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_seen": {
"name": "last_seen",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_fingerprint_fingerprint_unique": {
"name": "user_fingerprint_fingerprint_unique",
"columns": [
{
"expression": "fingerprint",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_fingerprint_user_idx": {
"name": "user_fingerprint_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_fingerprint_user_id_user_id_fk": {
"name": "user_fingerprint_user_id_user_id_fk",
"tableFrom": "user_fingerprint",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"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": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.linked_account_provider": {
"name": "linked_account_provider",
"schema": "public",
"values": ["owner", "admin", "member"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"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
}
]
}

View File

@@ -0,0 +1,34 @@
{
"name": "@nestri/core",
"type": "module",
"exports": {
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts"
},
"./**/*": {
"types": "./src/**/*.ts",
"import": "./src/**/*.ts"
}
},
"scripts": {
"db": "drizzle-kit",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@nestri/auth": "workspace:",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.9",
"postgresql": "^0.0.1",
"zod": "catalog:",
"zod-openapi": "^6.0.0"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "^0.31.10"
},
"peerDependencies": {
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,39 @@
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';
/**
* A personal access token: a long-lived, revocable credential a user creates
* for something that is not a browser — a nessh box registering itself, or a
* script driving the API.
*
* Deliberately not a session JWT. A JWT is short-lived and cannot be revoked
* without rotating signing keys for everyone, which makes it wrong for a
* credential that sits in a config file on a machine for months.
*/
export const AccessTokenTable = pgTable(
'access_token',
{
...id,
...timestamps,
ownerUserId: ulid('owner_user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
// Set to act within a team rather than as the user alone. The grant is
// re-checked against live membership on every use, so losing the
// membership disables the token without anyone remembering to revoke it.
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
// Only the digest. The token is shown once, at creation.
tokenHash: text('token_hash').notNull(),
expiresAt: utc('expires_at'),
lastUsed: utc('last_used')
},
(t) => [
uniqueIndex('access_token_hash_unique').on(t.tokenHash),
index('access_token_owner_idx').on(t.ownerUserId),
index('access_token_team_idx').on(t.teamId)
]
);

View File

@@ -0,0 +1,185 @@
import { randomBytes } from 'node:crypto';
import { and, eq, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { AccessTokenTable } from './access-token.sql.js';
/**
* Personal access tokens.
*
* The prefix is load-bearing: the API decides how to verify a bearer token by
* looking at it, so a PAT never reaches JWT verification and a JWT never
* reaches a database lookup. Without it every request would pay for both.
*/
export namespace AccessToken {
export const PREFIX = 'pat_';
const SECRET_BYTES = 32;
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the token record',
example: Examples.AccessToken.id
}),
ownerUserId: z.string().meta({
description: 'The user this token acts as',
example: Examples.AccessToken.ownerUserId
}),
teamId: z.string().optional().nullable().meta({
description: 'Team this token acts within, when it is team-scoped',
example: Examples.AccessToken.teamId
}),
name: z.string().meta({
description: 'What the token is for, so it can be recognised later',
example: Examples.AccessToken.name
}),
expiresAt: z.iso.datetime().optional().nullable().meta({
description: 'When the token stops working. Null means it does not expire.',
example: Examples.AccessToken.expiresAt
}),
lastUsed: z.iso.datetime().optional().nullable().meta({
description: 'When the token was last accepted',
example: Examples.AccessToken.lastUsed
})
})
.meta({
ref: 'AccessToken',
description: 'A long-lived, revocable credential for non-browser access',
example: Examples.AccessToken
});
export type Info = z.infer<typeof Info>;
function generateToken(): string {
return `${PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`;
}
async function hashToken(token: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Cheap check that routes a bearer token to the right verifier. */
export function looksLikeToken(bearer: string): boolean {
return bearer.startsWith(PREFIX);
}
/**
* Mint a token. The value is returned here and nowhere else — only its
* digest is stored, so a lost token is replaced rather than recovered.
*/
export const create = fn(
Info.pick({ id: true, ownerUserId: true, teamId: true, name: true }).extend({
expiresInDays: z.number().int().min(1).optional()
}),
async (input) => {
const token = generateToken();
await Database.use(async (tx) => {
await tx.insert(AccessTokenTable).values({
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId ?? null,
name: input.name,
tokenHash: await hashToken(token),
expiresAt: input.expiresInDays
? sql`now() + interval '${sql.raw(String(input.expiresInDays))} days'`
: null,
lastUsed: null
});
});
return { id: input.id, token };
}
);
/**
* Resolve a token to its record, or `null`.
*
* Expiry is part of the query rather than a check afterwards: an expired
* token and an unknown one are then indistinguishable to the caller, and
* there is no branch left where a stale row could be accepted by mistake.
*/
export const authenticate = fn(z.string(), async (token) => {
if (!looksLikeToken(token)) {
return null;
}
const tokenHash = await hashToken(token);
return Database.use(async (tx) => {
return tx
.select()
.from(AccessTokenTable)
.where(
and(
eq(AccessTokenTable.tokenHash, tokenHash),
isNull(AccessTokenTable.timeDeleted),
sql`(${AccessTokenTable.expiresAt} is null or ${AccessTokenTable.expiresAt} > now())`
)
)
.then((rows) => {
const row = rows.at(0);
// Serialized here so `tokenHash` never leaves this function.
return row ? serialize(row) : null;
});
});
});
export const touchLastUsed = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(AccessTokenTable)
.set({ lastUsed: sql`now()` })
.where(eq(AccessTokenTable.id, id));
});
});
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(AccessTokenTable)
.where(
and(eq(AccessTokenTable.ownerUserId, ownerUserId), isNull(AccessTokenTable.timeDeleted))
)
.orderBy(AccessTokenTable.timeCreated)
.then((rows) => rows.map(serialize));
});
});
/** Revoke by id, but only for its owner — ids are guessable in shape. */
export const revoke = fn(Info.pick({ id: true, ownerUserId: true }), async (input) => {
return Database.use(async (tx) => {
return tx
.update(AccessTokenTable)
.set({ timeDeleted: sql`now()` })
.where(
and(
eq(AccessTokenTable.id, input.id),
eq(AccessTokenTable.ownerUserId, input.ownerUserId),
isNull(AccessTokenTable.timeDeleted)
)
)
.returning()
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
export function serialize(input: typeof AccessTokenTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId,
name: input.name,
expiresAt: input.expiresAt?.toISOString() ?? null,
lastUsed: input.lastUsed?.toISOString() ?? null
};
}
}

180
packages/core/src/actor.ts Normal file
View File

@@ -0,0 +1,180 @@
import { z } from 'zod';
import { Context } from './context.js';
import { ErrorCodes, VisibleError } from './error.js';
const Public = z.object({
type: z.literal('public'),
properties: z.object({})
});
const User = z.object({
type: z.literal('user'),
properties: z.object({
userID: z.string(),
linkedAccountID: z.string(),
fingerprint: z.string().optional()
})
});
const Member = z.object({
type: z.literal('member'),
properties: z.object({
userID: z.string(),
teamID: z.string(),
role: z.enum(['owner', 'admin', 'member'])
})
});
const System = z.object({
type: z.literal('system'),
properties: z.object({
teamID: z.string()
})
});
const Admin = z.object({
type: z.literal('admin'),
properties: z.object({})
});
/**
* A registered nessh host, authenticated by its own credentials.
*
* Deliberately not a `user`: the box acts on behalf of whoever is logged into
* it, which is not the same authority as its owner. It carries `ownerUserID`
* for attribution, but `Actor.userID` refuses it, so a route written for a
* signed-in human cannot silently accept a box instead.
*/
const Machine = z.object({
type: z.literal('machine'),
properties: z.object({
machineID: z.string(),
ownerUserID: z.string(),
teamID: z.string().optional()
})
});
const ActorInfo = z.discriminatedUnion('type', [Public, User, Member, System, Admin, Machine]);
type ActorInfo = z.infer<typeof ActorInfo>;
const _context = Context.create<ActorInfo>();
function _use(): ActorInfo {
return _context.use();
}
function _with<T>(value: ActorInfo, fn: () => T): T {
return _context.provide(value, fn);
}
function _assert<T extends ActorInfo['type']>(type: T): Extract<ActorInfo, { type: T }> {
const actor = _use();
if (actor.type !== type) {
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`Expected actor type ${type}, got ${actor.type}`
);
}
return actor as Extract<ActorInfo, { type: T }>;
}
export const Actor = {
Info: ActorInfo,
use: _use,
with: _with,
assert: _assert,
get type(): ActorInfo['type'] {
return _use().type;
},
get userID(): string {
const actor = _use();
if (actor.type === 'user' || actor.type === 'member') {
return actor.properties.userID;
}
if (actor.type === 'machine') {
// A box holds credentials but is not its owner. Refusing here is
// what keeps a route written for a human from accepting a box —
// and it is a caller error, not a server fault, so it must not
// surface as a 500.
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'A machine cannot act as its owner; this route requires a user session'
);
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`Actor type ${actor.type} has no userID`
);
},
get machineID(): string {
const actor = _use();
if (actor.type === 'machine') {
return actor.properties.machineID;
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`Actor type ${actor.type} has no machineID`
);
},
get linkedAccountID(): string {
const actor = _use();
if (actor.type === 'user') {
return actor.properties.linkedAccountID;
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`Actor type ${actor.type} has no linkedAccountID`
);
},
get fingerprint(): string | undefined {
const actor = _use();
if (actor.type === 'user') {
return actor.properties.fingerprint;
}
return undefined;
},
get useTeam(): string {
const actor = _use();
if (actor.type === 'member' || actor.type === 'system') {
return actor.properties.teamID;
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`Actor type ${actor.type} has no team scope`
);
},
get role(): 'owner' | 'admin' | 'member' {
const actor = _use();
if (actor.type === 'member') {
return actor.properties.role;
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`Actor type ${actor.type} has no role`
);
},
get isSignedIn(): boolean {
try {
return _use().type !== 'public';
} catch {
return false;
}
}
};

View File

@@ -0,0 +1,10 @@
import { createSubjects } from '@nestri/auth/subject';
import { z } from 'zod';
export const subjects = createSubjects({
user: z.object({
userID: z.string(),
linkedAccountID: z.string(),
fingerprint: z.string().optional()
})
});

View File

@@ -0,0 +1,31 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { ErrorCodes, VisibleError } from './error.js';
export namespace Context {
export class NotFound extends VisibleError {
constructor() {
super(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
'No context available - actor-dependent code was called outside of Actor.with()'
);
}
}
export function create<T>() {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new NotFound();
}
return result;
},
provide<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
}
};
}
}

View File

@@ -0,0 +1,114 @@
import { type ExtractTablesWithRelations } from 'drizzle-orm';
import { PgTransaction, type PgTransactionConfig } from 'drizzle-orm/pg-core';
import { drizzle } from 'drizzle-orm/postgres-js';
import { type PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { Context } from '../context.js';
import { Env } from '../env.js';
export namespace Database {
export async function ping() {
const url = Env.get().DATABASE_URL || process.env.DATABASE_URL;
const sql = url
? postgres(url, { idle_timeout: 30, connect_timeout: 30 })
: postgres({
idle_timeout: 30,
connect_timeout: 30,
host: 'localhost',
database: 'nestri',
user: 'postgres',
password: 'postgres',
port: 5432
});
try {
const [result] = await sql`SELECT 1`;
return result ? true : false;
} finally {
await sql.end();
}
}
export function client() {
const url = Env.get().DATABASE_URL || process.env.DATABASE_URL;
const c = url
? postgres(url, { idle_timeout: 30, connect_timeout: 30 })
: postgres({
idle_timeout: 30,
connect_timeout: 30,
host: 'localhost',
database: 'nestri',
user: 'postgres',
password: 'postgres',
port: 5432
});
return drizzle({ client: c });
}
export type Transaction = PgTransaction<
PostgresJsQueryResultHKT,
Record<string, never>,
ExtractTablesWithRelations<Record<string, never>>
>;
export type TxOrDb = Transaction | ReturnType<typeof client>;
const TransactionContext = Context.create<{
tx: TxOrDb;
effects: (() => void | Promise<void>)[];
}>();
export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
try {
const { tx } = TransactionContext.use();
return tx.transaction(callback);
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = [];
const result = await TransactionContext.provide(
{
effects,
tx: client()
},
() => callback(client())
);
await Promise.all(effects.map((x) => x()));
return result;
}
throw err;
}
}
export async function fn<Input, T>(callback: (input: Input, trx: TxOrDb) => Promise<T>) {
return (input: Input) => use(async (tx) => callback(input, tx));
}
export async function effect(effect: () => any | Promise<any>) {
try {
const { effects } = TransactionContext.use();
effects.push(effect);
} catch {
await effect();
}
}
export async function transaction<T>(
callback: (tx: TxOrDb) => Promise<T>,
config?: PgTransactionConfig
) {
try {
const { tx } = TransactionContext.use();
return callback(tx);
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = [];
const result = await client().transaction(async (tx) => {
return TransactionContext.provide({ tx, effects }, () => callback(tx));
}, config);
await Promise.all(effects.map((x) => x()));
return result;
}
throw err;
}
}
}

View File

@@ -0,0 +1,22 @@
import postgres from 'postgres';
/**
* Fail-closed test database connection.
*
* Tests must never silently fall back to an ad-hoc localhost database, so
* this throws unless an explicit `TEST_DATABASE_URL` is set. Use an isolated
* database for tests, e.g.:
*
* TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/nestri
*/
export function testDb() {
const url = process.env.TEST_DATABASE_URL;
if (!url) {
throw new Error(
'TEST_DATABASE_URL is not set; refusing to run against an unspecified database. ' +
'Set it to an isolated test database, e.g. ' +
'TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/nestri'
);
}
return postgres(url, { idle_timeout: 30, connect_timeout: 30 });
}

View File

@@ -0,0 +1,23 @@
import { char, timestamp as rawTs } from 'drizzle-orm/pg-core';
export const ulid = (name: string) => char(name, { length: 26 + 4 });
export const id = {
get id() {
return ulid('id').primaryKey().notNull();
}
};
export const utc = (name: string) =>
rawTs(name, {
withTimezone: true
});
export const timestamps = {
timeCreated: utc('time_created').notNull().defaultNow(),
timeUpdated: utc('time_updated')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
timeDeleted: utc('time_deleted')
};

41
packages/core/src/env.ts Normal file
View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
import { memo } from './utils/memo.js';
let _overrides: Record<string, unknown> = {};
export namespace Env {
export const Info = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
STEAM_API_KEY: z.string().optional(),
AUTH_ISSUER_URL: z.string().optional(),
SSH_AUTH_KEY: z.string().optional(),
ADMIN_SHARED_SECRET: z.string().optional(),
DATABASE_URL: z.string().optional()
});
export type Info = z.infer<typeof Info>;
const _get = memo(() => Info.parse({ ...process.env, ..._overrides }));
export function get(): Info {
return _get();
}
export function init(bindings: Record<string, unknown>) {
_overrides = {
...bindings,
...(bindings.HYPERDRIVE
? {
DATABASE_URL: (bindings.HYPERDRIVE as { connectionString: string }).connectionString
}
: {})
};
_get.reset();
}
}

124
packages/core/src/error.ts Normal file
View File

@@ -0,0 +1,124 @@
import { z } from 'zod';
export const ErrorResponse = z
.object({
type: z
.enum([
'validation',
'authentication',
'forbidden',
'not_found',
'already_exists',
'rate_limit',
'internal'
])
.meta({
description: 'The error type category',
examples: ['validation', 'authentication']
}),
code: z.string().meta({
description: 'Machine-readable error code identifier',
examples: ['invalid_parameter', 'missing_required_field', 'unauthorized']
}),
message: z.string().meta({
description: 'Human-readable error message',
examples: ['The request was invalid', 'Authentication required']
}),
param: z
.string()
.optional()
.meta({
description: 'The parameter that caused the error (if applicable)',
examples: ['email', 'user_id', 'team_id']
}),
details: z.any().optional().meta({
description: 'Additional error context information'
})
})
.meta({ ref: 'ErrorResponse' });
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
export const ErrorCodes = {
Validation: {
MISSING_REQUIRED_FIELD: 'missing_required_field',
ALREADY_EXISTS: 'resource_already_exists',
TEAM_ALREADY_EXISTS: 'team_already_exists',
INVALID_PARAMETER: 'invalid_parameter',
INVALID_FORMAT: 'invalid_format',
INVALID_STATE: 'invalid_state',
IN_USE: 'resource_in_use'
},
Authentication: {
UNAUTHORIZED: 'unauthorized',
INVALID_TOKEN: 'invalid_token',
EXPIRED_TOKEN: 'expired_token',
INVALID_CREDENTIALS: 'invalid_credentials'
},
Permission: {
FORBIDDEN: 'forbidden',
INSUFFICIENT_PERMISSIONS: 'insufficient_permissions',
ACCOUNT_RESTRICTED: 'account_restricted'
},
NotFound: {
RESOURCE_NOT_FOUND: 'resource_not_found'
},
RateLimit: {
TOO_MANY_REQUESTS: 'too_many_requests',
QUOTA_EXCEEDED: 'quota_exceeded'
},
Server: {
INTERNAL_ERROR: 'internal_error',
SERVICE_UNAVAILABLE: 'service_unavailable',
DEPENDENCY_FAILURE: 'dependency_failure'
}
};
export class VisibleError extends Error {
constructor(
public type: ErrorResponseType['type'],
public code: string,
public override message: string,
public param?: string,
public details?: any
) {
super(message);
}
public statusCode(): number {
switch (this.type) {
case 'validation':
return 400;
case 'authentication':
return 401;
case 'forbidden':
return 403;
case 'not_found':
return 404;
case 'already_exists':
return 409;
case 'rate_limit':
return 429;
case 'internal':
return 500;
}
}
public toResponse(): ErrorResponseType {
const response: ErrorResponseType = {
type: this.type,
code: this.code,
message: this.message
};
if (this.param) response.param = this.param;
if (this.details) response.details = this.details;
return response;
}
}

View File

@@ -0,0 +1,141 @@
import { Identifier } from './id.js';
export namespace Examples {
export const Id = (prefix: keyof typeof Identifier.prefixes) =>
`${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
export const User = {
id: Id('user'),
name: 'John Doe',
email: 'johndoe@example.com',
emailVerified: true,
image: 'https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png'
};
export const LinkedAccount = {
id: Id('linkedAccount'),
userId: Id('user'),
provider: 'steam',
providerAccountId: '76561197960287930',
profile: { personaname: 'John Doe', avatarfull: 'https://avatars.steamstatic.com/xxxx.jpg' }
};
export const Team = {
id: Id('team'),
name: 'The A Team',
slug: 'the-a-team',
ownerId: Id('user'),
billingEmail: 'billing@example.com',
plan: 'free',
subscriptionStatus: 'active',
metadata: null
};
export const Member = {
id: Id('teamMember'),
teamId: Id('team'),
userId: Id('user'),
role: 'owner' as const
};
export const Fingerprint = {
id: Id('userFingerprint'),
userId: Id('user'),
fingerprint: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
name: 'MacBook Air',
lastSeen: '2026-07-28T12:00:00.000Z'
};
export const PairingCode = {
id: Id('pairingCode'),
code: 'NESSH-7F2Q',
targetUserId: Id('user'),
newFingerprint: null,
expiresAt: '2026-07-28T12:10:00.000Z',
claimedAt: null,
isClaimed: false
};
export const Game = {
id: Id('game'),
steamAppId: 730,
slug: 'counter-strike-2',
name: 'Counter-Strike 2',
type: 'game',
clientIcon: '5aad412d01a9b91ba0379f0b35f4eb0b69d9db08',
icon: 'f92b09dab91f1d1738f72fe0dd9be18dcc2901f9',
shortDescription: 'For 25 years...',
description: 'For over two decades...',
developers: ['Valve'],
publishers: ['Valve'],
primaryGenre: 'Action',
genres: ['Action', 'FPS'],
categories: ['Multi-player', 'Steam Achievements'],
oslist: ['windows', 'linux'],
sizeDownload: 35000000000,
sizeOnDisk: 40000000000,
controllerSupport: 'partial',
steamDeckCompat: 'perfect',
reviewScorePercent: 86,
reviewCount: 1500000,
metacriticScore: 89,
steamChangeNumber: 24605165,
publicBuildId: 12345678,
releaseDate: '2012-08-21T00:00:00.000Z',
timeEnriched: '2026-07-29T12:00:00.000Z'
};
export const Library = {
id: Id('userLibrary'),
userId: Id('user'),
gameId: Id('game'),
playtime2w: 3600,
playtimeForever: 150000,
lastPlayed: '2026-07-28T12:00:00.000Z'
};
export const Depot = {
id: Id('gameDepot'),
gameId: Id('game'),
depotId: 731,
branch: 'public',
steamManifestId: '3183503801510301321',
steamBuildId: 12345678,
installedManifestId: '3183503801510301321',
installedBuildId: 12345678,
sizeDownload: 1000,
sizeOnDisk: 2000,
status: 'complete' as const,
errorMessage: null,
oslist: 'linux'
};
export const AccessToken = {
id: Id('accessToken'),
ownerUserId: Id('user'),
teamId: null,
name: 'living-room-box',
expiresAt: null,
lastUsed: '2026-07-28T12:00:00.000Z'
};
export const Machine = {
id: Id('machine'),
ownerUserId: Id('user'),
teamId: null,
label: 'living-room-box',
lastSeen: '2026-07-28T12:00:00.000Z'
};
export const GameDownload = {
id: Id('gameDownload'),
hostId: Id('machine'),
gameId: Id('game'),
status: 'downloading' as const,
progressBytes: 1073741824,
totalBytes: 5000000000,
timeStarted: '2026-07-28T12:00:00.000Z',
timeCompleted: null,
errorMessage: null
};
}

48
packages/core/src/fn.ts Normal file
View File

@@ -0,0 +1,48 @@
import { z, type ZodType } from 'zod';
// Explicit return type structures capturing the .schema attachment
export type WrappedFn<Arg1 extends ZodType, Callback extends (...args: any[]) => any> = ((
input: z.input<Arg1>
) => ReturnType<Callback>) & { schema: Arg1 };
export type WrappedDoubleFn<
Arg1 extends ZodType,
Arg2 extends ZodType,
Callback extends (...args: any[]) => any
> = ((input1: z.input<Arg1>, input2: z.input<Arg2>) => ReturnType<Callback>) & {
schemas: [Arg1, Arg2];
};
// Single Argument Function
export function fn<Arg1 extends ZodType, Callback extends (arg: z.output<Arg1>) => any>(
arg1: Arg1,
cb: Callback
): WrappedFn<Arg1, Callback> {
const result = Object.assign(
function (input: z.input<Arg1>): ReturnType<Callback> {
const parsed = arg1.parse(input);
return cb(parsed);
},
{ schema: arg1 }
);
return result;
}
// Double Argument Function
export function doublefn<
Arg1 extends ZodType,
Arg2 extends ZodType,
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any
>(arg1: Arg1, arg2: Arg2, cb: Callback): WrappedDoubleFn<Arg1, Arg2, Callback> {
const result = Object.assign(
function (input: z.input<Arg1>, input2: z.input<Arg2>): ReturnType<Callback> {
const parsed = arg1.parse(input);
const parsed2 = arg2.parse(input2);
return cb(parsed, parsed2);
},
{ schemas: [arg1, arg2] as [Arg1, Arg2] }
);
return result;
}

View File

@@ -0,0 +1,48 @@
import { sql } from 'drizzle-orm';
import { bigint, index, integer, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
import { GameTable } from '../game/game.sql.js';
export const DepotStatus = pgEnum('depot_status', [
'pending',
'downloading',
'complete',
'error',
'deleted'
]);
export const GameDepotTable = pgTable(
'game_depot',
{
...id,
...timestamps,
gameId: ulid('game_id')
.notNull()
.references(() => GameTable.id, { onDelete: 'cascade' }),
depotId: integer('depot_id').notNull(),
branch: text('branch').notNull().default('public'),
steamManifestId: text('steam_manifest_id'),
steamBuildId: integer('steam_build_id'),
installedManifestId: text('installed_manifest_id'),
installedBuildId: integer('installed_build_id'),
sizeDownload: bigint('size_download', { mode: 'number' }),
sizeOnDisk: bigint('size_on_disk', { mode: 'number' }),
status: DepotStatus('status').notNull().default('pending'),
errorMessage: text('error_message'),
oslist: text('oslist')
},
(t) => [
uniqueIndex('game_depot_unique').on(t.gameId, t.depotId, t.branch),
index('game_depot_game_idx').on(t.gameId),
index('game_depot_updates_idx')
.on(t.gameId)
.where(sql`${t.installedManifestId} is distinct from ${t.steamManifestId}`)
]
);

View File

@@ -0,0 +1,266 @@
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { DepotStatus, GameDepotTable } from './depot.sql.js';
export namespace Depot {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the depot entry',
example: Examples.Depot.id
}),
gameId: z.string().meta({
description: 'The game this depot belongs to',
example: Examples.Depot.gameId
}),
depotId: z.number().int().meta({
description: 'Steam depot ID',
example: Examples.Depot.depotId
}),
branch: z.string().meta({
description: 'Depot branch (e.g. public)',
example: Examples.Depot.branch
}),
steamManifestId: z.string().nullable().optional().meta({
description: 'Current manifest ID from Steam',
example: Examples.Depot.steamManifestId
}),
steamBuildId: z.number().int().nullable().optional().meta({
description: 'Current build ID from Steam',
example: Examples.Depot.steamBuildId
}),
installedManifestId: z.string().nullable().optional().meta({
description: 'Installed manifest ID on this host',
example: Examples.Depot.installedManifestId
}),
installedBuildId: z.number().int().nullable().optional().meta({
description: 'Installed build ID on this host',
example: Examples.Depot.installedBuildId
}),
sizeDownload: z.number().nullable().optional().meta({
description: 'Compressed download size in bytes',
example: Examples.Depot.sizeDownload
}),
sizeOnDisk: z.number().nullable().optional().meta({
description: 'Uncompressed size in bytes',
example: Examples.Depot.sizeOnDisk
}),
status: z.enum(DepotStatus.enumValues).meta({
description: 'Current depot status',
example: Examples.Depot.status
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is error',
example: Examples.Depot.errorMessage
}),
oslist: z.string().nullable().optional().meta({
description: 'OS filter (windows, linux, mac)',
example: Examples.Depot.oslist
})
})
.meta({
ref: 'Depot',
description: 'A game depot (shared install/update tracking)',
example: Examples.Depot
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({
id: true,
gameId: true,
depotId: true,
branch: true,
steamManifestId: true,
steamBuildId: true,
installedManifestId: true,
installedBuildId: true,
sizeDownload: true,
sizeOnDisk: true,
status: true,
errorMessage: true,
oslist: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(GameDepotTable).values({
id: input.id,
gameId: input.gameId,
depotId: input.depotId,
branch: input.branch ?? 'public',
steamManifestId: input.steamManifestId ?? null,
steamBuildId: input.steamBuildId ?? null,
installedManifestId: input.installedManifestId ?? null,
installedBuildId: input.installedBuildId ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
status: input.status ?? 'pending',
errorMessage: input.errorMessage ?? null,
oslist: input.oslist ?? null
});
});
return input.id;
}
);
export const upsert = fn(
Info.pick({
id: true,
gameId: true,
depotId: true,
branch: true,
steamManifestId: true,
steamBuildId: true,
installedManifestId: true,
installedBuildId: true,
sizeDownload: true,
sizeOnDisk: true,
status: true,
errorMessage: true,
oslist: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx
.insert(GameDepotTable)
.values({
id: input.id,
gameId: input.gameId,
depotId: input.depotId,
branch: input.branch ?? 'public',
steamManifestId: input.steamManifestId ?? null,
steamBuildId: input.steamBuildId ?? null,
installedManifestId: input.installedManifestId ?? null,
installedBuildId: input.installedBuildId ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
status: input.status ?? 'pending',
errorMessage: input.errorMessage ?? null,
oslist: input.oslist ?? null
})
.onConflictDoUpdate({
target: [GameDepotTable.gameId, GameDepotTable.depotId, GameDepotTable.branch],
set: {
steamManifestId: sql`excluded.${GameDepotTable.steamManifestId.name}`,
steamBuildId: sql`excluded.${GameDepotTable.steamBuildId.name}`,
sizeDownload: sql`excluded.${GameDepotTable.sizeDownload.name}`,
sizeOnDisk: sql`excluded.${GameDepotTable.sizeOnDisk.name}`,
oslist: sql`excluded.${GameDepotTable.oslist.name}`
// Do not clobber installed_* fields — those are set by DepotJob
}
});
});
return input.id;
}
);
export const markInstalled = fn(
Info.pick({ id: true, installedManifestId: true, installedBuildId: true, status: true }),
async (input) => {
await Database.use(async (tx) => {
await tx
.update(GameDepotTable)
.set({
installedManifestId: input.installedManifestId,
installedBuildId: input.installedBuildId,
status: input.status ?? 'complete'
})
.where(eq(GameDepotTable.id, input.id));
});
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(and(eq(GameDepotTable.id, id), isNull(GameDepotTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(and(eq(GameDepotTable.gameId, gameId), isNull(GameDepotTable.timeDeleted)));
});
});
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
if (gameIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(and(inArray(GameDepotTable.gameId, gameIds), isNull(GameDepotTable.timeDeleted)));
});
});
export const listByGameAndDepotIDs = fn(
z.object({ gameIds: z.array(z.string()), depotIds: z.array(z.number().int()) }),
async (input) => {
if (input.gameIds.length === 0 || input.depotIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(
and(
inArray(GameDepotTable.gameId, input.gameIds),
inArray(GameDepotTable.depotId, input.depotIds),
isNull(GameDepotTable.timeDeleted)
)
);
});
}
);
export const getInstalled = fn(z.void(), async () => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDepotTable)
.where(
and(
isNull(GameDepotTable.timeDeleted),
sql`${GameDepotTable.installedManifestId} IS NOT NULL`
)
);
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(GameDepotTable)
.set({ timeDeleted: sql`now()` })
.where(eq(GameDepotTable.id, id));
});
});
export function serialize(input: typeof GameDepotTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
gameId: input.gameId,
depotId: input.depotId,
branch: input.branch,
steamManifestId: input.steamManifestId,
steamBuildId: input.steamBuildId,
installedManifestId: input.installedManifestId,
installedBuildId: input.installedBuildId,
sizeDownload: input.sizeDownload,
sizeOnDisk: input.sizeOnDisk,
status: input.status as Info['status'],
errorMessage: input.errorMessage,
oslist: input.oslist
};
}
}

View File

@@ -0,0 +1,38 @@
import { bigint, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { GameTable } from './game.sql.js';
export const GameDownloadStatus = pgEnum('game_download_status', [
'pending',
'verifying',
'downloading',
'ready',
'failed'
]);
export const GameDownloadTable = pgTable(
'game_download',
{
...id,
...timestamps,
hostId: text('host_id').notNull(),
gameId: ulid('game_id')
.notNull()
.references(() => GameTable.id, { onDelete: 'cascade' }),
status: GameDownloadStatus('status').notNull().default('pending'),
progressBytes: bigint('progress_bytes', { mode: 'number' }).default(0),
totalBytes: bigint('total_bytes', { mode: 'number' }),
timeStarted: utc('time_started'),
timeCompleted: utc('time_completed'),
errorMessage: text('error_message')
},
(t) => [
uniqueIndex('game_download_host_game_unique').on(t.hostId, t.gameId),
index('game_download_game_idx').on(t.gameId),
index('game_download_host_status_idx').on(t.hostId, t.status)
]
);

View File

@@ -0,0 +1,230 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { testDb } from '../db/test.js';
import { Game } from '../game/index.js';
import { Identifier } from '../id.js';
import { GameDownload } from './download.js';
const sql = testDb();
const HOST_A = 'hst_aaaaaaaaaaaaaaaaaaaaaaaaa';
const HOST_B = 'hst_bbbbbbbbbbbbbbbbbbbbbbbbb';
const createdGameIds: string[] = [];
const gameIdByApp = new Map<number, string>();
async function ensureGame(steamAppId: number): Promise<string> {
const existing = gameIdByApp.get(steamAppId);
if (existing) return existing;
const [row] = await Game.upsert({
id: Identifier.ascending('game'),
steamAppId,
slug: `test-game-${steamAppId}`,
name: `Test Game ${steamAppId}`
});
if (!row) throw new Error('expected a game row');
createdGameIds.push(row.id);
gameIdByApp.set(steamAppId, row.id);
return row.id;
}
beforeAll(async () => {
await ensureGame(4400);
await ensureGame(4401);
await ensureGame(4402);
});
afterAll(async () => {
if (createdGameIds.length > 0) {
// Deleting the games cascades to their game_download rows.
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
createdGameIds.length = 0;
}
});
describe('GameDownload', () => {
function expectRow<T>(row: T | null | undefined): T {
expect(row).not.toBeNull();
return row as T;
}
test('a new host/game creates one row', async () => {
const gameId = await ensureGame(4400);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 1000,
totalBytes: 5000
})
);
expect(row.id).toMatch(/^gdl_/);
expect(row.hostId).toBe(HOST_A);
expect(row.gameId).toBe(gameId);
expect(row.status).toBe('downloading');
expect(row.timeStarted).toBeInstanceOf(Date);
});
test('repeating the same host/game updates one row', async () => {
const gameId = await ensureGame(4400);
const first = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 1000,
totalBytes: 5000
})
);
const second = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 2000,
totalBytes: 5000
})
);
expect(second.id).toBe(first.id);
expect(second.progressBytes).toBe(2000);
expect(second.totalBytes).toBe(5000);
const count =
await sql`select count(*)::int as n from "game_download" where host_id = ${HOST_A} and game_id = ${gameId}`;
expect(count[0]?.n).toBe(1);
});
test('reports for other users do not create another row', async () => {
const gameId = await ensureGame(4401);
// No user dimension exists on the shared state row: reports for any
// user land on the single (host, game) row.
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 1
});
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading',
progressBytes: 2
});
const rows = await GameDownload.listByHost(HOST_A);
expect(rows.filter((r) => r.gameId === gameId).length).toBe(1);
});
test('two hosts create two independent rows', async () => {
const gameId = await ensureGame(4401);
const a = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'downloading'
})
);
const b = expectRow(
await GameDownload.upsertState({
hostId: HOST_B,
gameId,
status: 'downloading'
})
);
expect(a.id).not.toBe(b.id);
expect(a.hostId).toBe(HOST_A);
expect(b.hostId).toBe(HOST_B);
});
test('verifying is accepted', async () => {
const gameId = await ensureGame(4401);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'verifying',
progressBytes: 2048
})
);
expect(row.status).toBe('verifying');
expect(row.timeStarted).toBeInstanceOf(Date);
});
test('ready sets timeCompleted', async () => {
const gameId = await ensureGame(4400);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'ready',
progressBytes: 5000,
totalBytes: 5000
})
);
expect(row.status).toBe('ready');
expect(row.timeCompleted).toBeInstanceOf(Date);
expect(row.timeStarted).toBeInstanceOf(Date);
});
test('failed records an error', async () => {
const gameId = await ensureGame(4401);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_A,
gameId,
status: 'failed',
errorMessage: 'depot key missing'
})
);
expect(row.status).toBe('failed');
expect(row.errorMessage).toBe('depot key missing');
});
test('progress updates do not require a user ID', async () => {
const gameId = await ensureGame(4402);
const row = expectRow(
await GameDownload.upsertState({
hostId: HOST_B,
gameId,
status: 'downloading',
progressBytes: 12345
})
);
expect(row.progressBytes).toBe(12345);
expect(row).not.toHaveProperty('userId');
});
test('old user_download table is no longer referenced', async () => {
const rows =
await sql`select table_name from information_schema.tables where table_schema = 'public' and table_name = 'user_download'`;
expect(rows.length).toBe(0);
});
test('findByHostAndGame and listByGame', async () => {
const gameId = await ensureGame(4402);
const found = await GameDownload.findByHostAndGame({ hostId: HOST_B, gameId });
expect(found).not.toBeNull();
expect(found!.gameId).toBe(gameId);
const byGame = await GameDownload.listByGame(gameId);
expect(byGame.some((r) => r.hostId === HOST_B)).toBe(true);
});
test('markReady and markFailed update the row', async () => {
const gameId = await ensureGame(4402);
const ready = await GameDownload.markReady({ hostId: HOST_B, gameId });
expect(ready!.status).toBe('ready');
expect(ready!.timeCompleted).toBeInstanceOf(Date);
const failed = await GameDownload.markFailed({
hostId: HOST_B,
gameId,
errorMessage: 'disk full'
});
expect(failed!.status).toBe('failed');
expect(failed!.errorMessage).toBe('disk full');
});
});

View File

@@ -0,0 +1,207 @@
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { GameDownloadStatus, GameDownloadTable } from './download.sql.js';
export namespace GameDownload {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the download state record',
example: Examples.GameDownload.id
}),
hostId: z.string().meta({
description: 'The nessh host performing the download',
example: Examples.GameDownload.hostId
}),
gameId: z.string().meta({
description: 'The game being downloaded',
example: Examples.GameDownload.gameId
}),
status: z.enum(GameDownloadStatus.enumValues).meta({
description: 'Current download status',
example: Examples.GameDownload.status
}),
progressBytes: z.number().nullable().optional().meta({
description: 'Bytes downloaded so far',
example: Examples.GameDownload.progressBytes
}),
totalBytes: z.number().nullable().optional().meta({
description: 'Total bytes to download',
example: Examples.GameDownload.totalBytes
}),
timeStarted: z.string().nullable().optional().meta({
description: 'When the download started (ISO 8601)',
example: Examples.GameDownload.timeStarted
}),
timeCompleted: z.string().nullable().optional().meta({
description: 'When the download completed (ISO 8601)',
example: Examples.GameDownload.timeCompleted
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is failed',
example: Examples.GameDownload.errorMessage
})
})
.meta({
ref: 'GameDownload',
description: 'Per-host game download state, shared across users',
example: Examples.GameDownload
});
export type Info = z.infer<typeof Info>;
/**
* Atomically insert or update the state row for a (host, game) pair and
* return the actual database row. Timestamps are derived from status:
* `downloading`/`verifying` set `timeStarted` (preserving an existing
* start on resume), `ready` sets `timeCompleted`.
*/
export const upsertState = fn(
Info.pick({
hostId: true,
gameId: true,
status: true,
progressBytes: true,
totalBytes: true,
errorMessage: true
}),
async (input) => {
return Database.use(async (tx) => {
const started = input.status === 'downloading' || input.status === 'verifying';
const [row] = await tx
.insert(GameDownloadTable)
.values({
id: Identifier.ascending('gameDownload'),
hostId: input.hostId,
gameId: input.gameId,
status: input.status,
progressBytes: input.progressBytes ?? null,
totalBytes: input.totalBytes ?? null,
errorMessage: input.status === 'failed' ? (input.errorMessage ?? null) : null,
timeStarted: started ? new Date() : null,
timeCompleted: input.status === 'ready' ? new Date() : null
})
.onConflictDoUpdate({
target: [GameDownloadTable.hostId, GameDownloadTable.gameId],
set: {
status: sql`excluded.${sql.identifier(GameDownloadTable.status.name)}`,
progressBytes: sql`coalesce(excluded.${sql.identifier(GameDownloadTable.progressBytes.name)}, ${GameDownloadTable.progressBytes})`,
totalBytes: sql`coalesce(excluded.${sql.identifier(GameDownloadTable.totalBytes.name)}, ${GameDownloadTable.totalBytes})`,
errorMessage: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} = 'failed' then coalesce(excluded.${sql.identifier(GameDownloadTable.errorMessage.name)}, ${GameDownloadTable.errorMessage}) else null end`,
timeStarted: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} in ('downloading', 'verifying') then coalesce(${GameDownloadTable.timeStarted}, now()) else ${GameDownloadTable.timeStarted} end`,
timeCompleted: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} = 'ready' then now() else null end`
}
})
.returning();
return row;
});
}
);
export const findByHostAndGame = fn(Info.pick({ hostId: true, gameId: true }), async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(
and(
eq(GameDownloadTable.hostId, input.hostId),
eq(GameDownloadTable.gameId, input.gameId),
isNull(GameDownloadTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(and(eq(GameDownloadTable.gameId, gameId), isNull(GameDownloadTable.timeDeleted)))
.orderBy(GameDownloadTable.timeCreated);
});
});
export const listByHost = fn(Info.shape.hostId, async (hostId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(and(eq(GameDownloadTable.hostId, hostId), isNull(GameDownloadTable.timeDeleted)))
.orderBy(GameDownloadTable.timeCreated);
});
});
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
if (gameIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameDownloadTable)
.where(
and(inArray(GameDownloadTable.gameId, gameIds), isNull(GameDownloadTable.timeDeleted))
);
});
});
export const markReady = fn(Info.pick({ hostId: true, gameId: true }), async (input) => {
return Database.use(async (tx) => {
const [row] = await tx
.update(GameDownloadTable)
.set({ status: 'ready', timeCompleted: new Date() })
.where(
and(
eq(GameDownloadTable.hostId, input.hostId),
eq(GameDownloadTable.gameId, input.gameId),
isNull(GameDownloadTable.timeDeleted)
)
)
.returning();
return row ?? null;
});
});
export const markFailed = fn(
Info.pick({ hostId: true, gameId: true, errorMessage: true }),
async (input) => {
return Database.use(async (tx) => {
const [row] = await tx
.update(GameDownloadTable)
.set({
status: 'failed',
errorMessage: input.errorMessage ?? null
})
.where(
and(
eq(GameDownloadTable.hostId, input.hostId),
eq(GameDownloadTable.gameId, input.gameId),
isNull(GameDownloadTable.timeDeleted)
)
)
.returning();
return row ?? null;
});
}
);
export function serialize(input: typeof GameDownloadTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
hostId: input.hostId,
gameId: input.gameId,
status: input.status as Info['status'],
progressBytes: input.progressBytes,
totalBytes: input.totalBytes,
timeStarted: input.timeStarted?.toISOString() ?? null,
timeCompleted: input.timeCompleted?.toISOString() ?? null,
errorMessage: input.errorMessage
};
}
}

View File

@@ -0,0 +1,48 @@
import { bigint, integer, jsonb, pgTable, smallint, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
export const GameTable = pgTable(
'game',
{
...id,
...timestamps,
steamAppId: integer('steam_app_id').notNull().unique(),
slug: text('slug').notNull(),
name: text('name').notNull(),
type: text('type'),
clientIcon: text('client_icon'),
icon: text('icon'),
shortDescription: text('short_description'),
description: text('description'),
developers: jsonb('developers').$type<string[]>(),
publishers: jsonb('publishers').$type<string[]>(),
primaryGenre: text('primary_genre'),
genres: jsonb('genres').$type<string[]>(),
categories: jsonb('categories').$type<string[]>(),
oslist: jsonb('oslist').$type<string[]>(),
sizeDownload: bigint('size_download', { mode: 'number' }),
sizeOnDisk: bigint('size_on_disk', { mode: 'number' }),
controllerSupport: text('controller_support'),
steamDeckCompat: text('steam_deck_compat'),
reviewScorePercent: smallint('review_score_percent'),
reviewCount: integer('review_count'),
metacriticScore: smallint('metacritic_score'),
steamChangeNumber: integer('steam_change_number'),
publicBuildId: integer('public_build_id'),
releaseDate: utc('release_date_utc'),
timeEnriched: utc('time_enriched')
},
(t) => [
uniqueIndex('game_slug_unique').on(t.slug),
uniqueIndex('game_app_id_unique').on(t.steamAppId)
]
);

View File

@@ -0,0 +1,388 @@
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { GameDownload } from './download.js';
import { GameTable } from './game.sql.js';
export { GameDownload };
export namespace Game {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the game',
example: Examples.Game.id
}),
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
slug: z.string().meta({
description: 'URL-friendly slug',
example: Examples.Game.slug
}),
name: z.string().meta({
description: 'Game title',
example: Examples.Game.name
}),
type: z.string().nullable().optional().meta({
description: 'Content type (game, dlc, demo, tool)',
example: Examples.Game.type
}),
clientIcon: z.string().nullable().optional().meta({
description: 'Steam client icon hash (256×256 square)',
example: Examples.Game.clientIcon
}),
icon: z.string().nullable().optional().meta({
description: 'Steam icon hash (32×32)',
example: Examples.Game.icon
}),
shortDescription: z.string().nullable().optional().meta({
description: 'Short marketing description',
example: Examples.Game.shortDescription
}),
description: z.string().nullable().optional().meta({
description: 'Full game description',
example: Examples.Game.description
}),
developers: z.array(z.string()).nullable().optional().meta({
description: 'Game developers',
example: Examples.Game.developers
}),
publishers: z.array(z.string()).nullable().optional().meta({
description: 'Game publishers',
example: Examples.Game.publishers
}),
primaryGenre: z.string().nullable().optional().meta({
description: 'Primary genre label',
example: Examples.Game.primaryGenre
}),
genres: z.array(z.string()).nullable().optional().meta({
description: 'All genre labels',
example: Examples.Game.genres
}),
categories: z.array(z.string()).nullable().optional().meta({
description: 'Steam store categories (Multi-player, Achievements, etc.)',
example: Examples.Game.categories
}),
oslist: z.array(z.string()).nullable().optional().meta({
description: 'Supported operating systems',
example: Examples.Game.oslist
}),
sizeDownload: z.number().nullable().optional().meta({
description: 'Compressed download size in bytes',
example: Examples.Game.sizeDownload
}),
sizeOnDisk: z.number().nullable().optional().meta({
description: 'Uncompressed install size in bytes',
example: Examples.Game.sizeOnDisk
}),
controllerSupport: z.string().nullable().optional().meta({
description: 'Controller support level',
example: Examples.Game.controllerSupport
}),
steamDeckCompat: z.string().nullable().optional().meta({
description: 'Steam Deck compatibility rating',
example: Examples.Game.steamDeckCompat
}),
reviewScorePercent: z.number().int().nullable().optional().meta({
description: 'Review score percentage (0100)',
example: Examples.Game.reviewScorePercent
}),
reviewCount: z.number().int().nullable().optional().meta({
description: 'Total review count',
example: Examples.Game.reviewCount
}),
metacriticScore: z.number().int().nullable().optional().meta({
description: 'Metacritic score',
example: Examples.Game.metacriticScore
}),
steamChangeNumber: z.number().int().nullable().optional().meta({
description: 'PICS change number for current version',
example: Examples.Game.steamChangeNumber
}),
publicBuildId: z.number().int().nullable().optional().meta({
description: 'Public branch build ID',
example: Examples.Game.publicBuildId
}),
releaseDate: z.string().nullable().optional().meta({
description: 'Release date (ISO 8601)',
example: Examples.Game.releaseDate
}),
timeEnriched: z.string().nullable().optional().meta({
description: 'When full metadata was last enriched from PICS',
example: Examples.Game.timeEnriched
})
})
.meta({
ref: 'Game',
description: 'A game in the global catalog',
example: Examples.Game
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({
id: true,
steamAppId: true,
slug: true,
name: true,
type: true,
clientIcon: true,
icon: true,
shortDescription: true,
description: true,
developers: true,
publishers: true,
primaryGenre: true,
genres: true,
categories: true,
oslist: true,
sizeDownload: true,
sizeOnDisk: true,
controllerSupport: true,
steamDeckCompat: true,
reviewScorePercent: true,
reviewCount: true,
metacriticScore: true,
steamChangeNumber: true,
publicBuildId: true,
releaseDate: true,
timeEnriched: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(GameTable).values({
id: input.id,
steamAppId: input.steamAppId,
slug: input.slug,
name: input.name,
type: input.type ?? null,
clientIcon: input.clientIcon ?? null,
icon: input.icon ?? null,
shortDescription: input.shortDescription ?? null,
description: input.description ?? null,
developers: input.developers ?? null,
publishers: input.publishers ?? null,
primaryGenre: input.primaryGenre ?? null,
genres: input.genres ?? null,
categories: input.categories ?? null,
oslist: input.oslist ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
controllerSupport: input.controllerSupport ?? null,
steamDeckCompat: input.steamDeckCompat ?? null,
reviewScorePercent: input.reviewScorePercent ?? null,
reviewCount: input.reviewCount ?? null,
metacriticScore: input.metacriticScore ?? null,
steamChangeNumber: input.steamChangeNumber ?? null,
publicBuildId: input.publicBuildId ?? null,
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
});
});
return input.id;
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(eq(GameTable.id, id), isNull(GameTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const fromSteamAppID = fn(z.number().int(), async (steamAppId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(eq(GameTable.steamAppId, steamAppId), isNull(GameTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const upsert = fn(
Info.pick({
id: true,
steamAppId: true,
slug: true,
name: true,
type: true,
clientIcon: true,
icon: true,
shortDescription: true,
description: true,
developers: true,
publishers: true,
primaryGenre: true,
genres: true,
categories: true,
oslist: true,
sizeDownload: true,
sizeOnDisk: true,
controllerSupport: true,
steamDeckCompat: true,
reviewScorePercent: true,
reviewCount: true,
metacriticScore: true,
steamChangeNumber: true,
publicBuildId: true,
releaseDate: true,
timeEnriched: true
}),
async (input) =>
Database.use(async (tx) =>
tx
.insert(GameTable)
.values({
id: input.id,
steamAppId: input.steamAppId,
slug: input.slug,
name: input.name,
type: input.type ?? null,
clientIcon: input.clientIcon ?? null,
icon: input.icon ?? null,
shortDescription: input.shortDescription ?? null,
description: input.description ?? null,
developers: input.developers ?? null,
publishers: input.publishers ?? null,
primaryGenre: input.primaryGenre ?? null,
genres: input.genres ?? null,
categories: input.categories ?? null,
oslist: input.oslist ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
controllerSupport: input.controllerSupport ?? null,
steamDeckCompat: input.steamDeckCompat ?? null,
reviewScorePercent: input.reviewScorePercent ?? null,
reviewCount: input.reviewCount ?? null,
metacriticScore: input.metacriticScore ?? null,
steamChangeNumber: input.steamChangeNumber ?? null,
publicBuildId: input.publicBuildId ?? null,
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
})
.onConflictDoUpdate({
target: GameTable.steamAppId,
set: {
slug: input.slug,
name: input.name,
type: input.type ?? null,
clientIcon: input.clientIcon ?? null,
icon: input.icon ?? null,
shortDescription: input.shortDescription ?? null,
description: input.description ?? null,
developers: input.developers ?? null,
publishers: input.publishers ?? null,
primaryGenre: input.primaryGenre ?? null,
genres: input.genres ?? null,
categories: input.categories ?? null,
oslist: input.oslist ?? null,
sizeDownload: input.sizeDownload ?? null,
sizeOnDisk: input.sizeOnDisk ?? null,
controllerSupport: input.controllerSupport ?? null,
steamDeckCompat: input.steamDeckCompat ?? null,
reviewScorePercent: input.reviewScorePercent ?? null,
reviewCount: input.reviewCount ?? null,
metacriticScore: input.metacriticScore ?? null,
steamChangeNumber: input.steamChangeNumber ?? null,
publicBuildId: input.publicBuildId ?? null,
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
}
})
.returning()
)
);
export const searchByName = fn(z.string(), async (query) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(
and(isNull(GameTable.timeDeleted), sql`${GameTable.name} ILIKE ${'%' + query + '%'}`)
)
.orderBy(GameTable.name)
.limit(50);
});
});
export const listUnenriched = fn(z.number().int().default(50), async (limit) => {
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(isNull(GameTable.timeEnriched), isNull(GameTable.timeDeleted)))
.limit(limit);
});
});
export const listByIDs = fn(z.array(z.string()), async (ids) => {
if (ids.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(inArray(GameTable.id, ids), isNull(GameTable.timeDeleted)));
});
});
export const listByAppIDs = fn(z.array(z.number().int()), async (appIds) => {
if (appIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(GameTable)
.where(and(inArray(GameTable.steamAppId, appIds), isNull(GameTable.timeDeleted)));
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(GameTable)
.set({ timeDeleted: sql`now()` })
.where(eq(GameTable.id, id));
});
});
export function serialize(input: typeof GameTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
steamAppId: input.steamAppId,
slug: input.slug,
name: input.name,
type: input.type,
clientIcon: input.clientIcon,
icon: input.icon,
shortDescription: input.shortDescription,
description: input.description,
developers: input.developers,
publishers: input.publishers,
primaryGenre: input.primaryGenre,
genres: input.genres,
categories: input.categories,
oslist: input.oslist,
sizeDownload: input.sizeDownload,
sizeOnDisk: input.sizeOnDisk,
controllerSupport: input.controllerSupport,
steamDeckCompat: input.steamDeckCompat,
reviewScorePercent: input.reviewScorePercent,
reviewCount: input.reviewCount,
metacriticScore: input.metacriticScore,
steamChangeNumber: input.steamChangeNumber,
publicBuildId: input.publicBuildId,
releaseDate: input.releaseDate?.toISOString() ?? null,
timeEnriched: input.timeEnriched?.toISOString() ?? null
};
}
}

80
packages/core/src/id.ts Normal file
View File

@@ -0,0 +1,80 @@
import { randomBytes } from 'crypto';
import { z } from 'zod';
export namespace Identifier {
export const prefixes = {
user: 'usr',
linkedAccount: 'lac',
team: 'tem',
teamMember: 'mem',
verification: 'ver',
userFingerprint: 'ufp',
pairingCode: 'pai',
machine: 'mch',
accessToken: 'pat',
game: 'gam',
userLibrary: 'ulb',
gameDepot: 'gdp',
gameDownload: 'gdl'
} as const;
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix]);
}
const LENGTH = 26;
let lastTimestamp = 0;
let counter = 0;
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, false, given);
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, true, given);
}
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return generateNewID(prefix, descending);
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`);
}
return given;
}
function randomBase62(length: number): string {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let result = '';
const bytes = randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[bytes[i]! % 62];
}
return result;
}
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
const currentTimestamp = Date.now();
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp;
counter = 0;
}
counter++;
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter);
now = descending ? ~now : now;
const timeBytes = Buffer.alloc(6);
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff));
}
return prefixes[prefix] + '_' + timeBytes.toString('hex') + randomBase62(LENGTH - 12);
}
}

View File

@@ -0,0 +1,261 @@
import { randomBytes } from 'node:crypto';
import { and, eq, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { Member } from '../team/member.js';
import { MachineTable } from './machine.sql.js';
/**
* Registered host identity.
*
* A box trades an owner-supplied token for an assigned id and a secret, then
* authenticates as itself. The alternative — deriving an id from
* `/etc/machine-id` or a hardware fingerprint — was rejected: self-hosted
* boxes mean the operator is not automatically trusted, and every such input
* is operator-editable, so uniqueness would rest on nobody choosing to lie.
*/
export namespace Machine {
/** Length in bytes before base64url encoding. */
const SECRET_BYTES = 32;
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the machine',
example: Examples.Machine.id
}),
ownerUserId: z.string().meta({
description: 'The user who registered this machine',
example: Examples.Machine.ownerUserId
}),
teamId: z.string().optional().nullable().meta({
description: 'The team this machine belongs to, when registered inside one',
example: Examples.Machine.teamId
}),
label: z.string().meta({
description: 'Human-readable name for the box',
example: Examples.Machine.label
}),
lastSeen: z.iso.datetime().optional().nullable().meta({
description: 'When this machine last authenticated',
example: Examples.Machine.lastSeen
})
})
.meta({
ref: 'Machine',
description: 'A registered nessh host',
example: Examples.Machine
});
export type Info = z.infer<typeof Info>;
function generateSecret(): string {
return `msk_${randomBytes(SECRET_BYTES).toString('base64url')}`;
}
async function hashSecret(secret: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Length-independent, content-constant comparison of two hex digests. */
function secureEquals(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
/**
* Register a box. The secret is returned here and nowhere else — it is
* stored only as a digest, so a lost secret means re-registering rather
* than looking it up.
*/
export const register = fn(
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }),
async (input) => {
const secret = generateSecret();
await Database.use(async (tx) => {
await tx.insert(MachineTable).values({
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId ?? null,
label: input.label,
secretHash: await hashSecret(secret),
lastSeen: null
});
});
return { id: input.id, secret };
}
);
/**
* Resolve credentials to a machine, or `null`. Looks the row up by id and
* then compares digests, so a wrong id and a wrong secret are refused the
* same way and neither reveals which half was wrong.
*/
export const authenticate = fn(
z.object({ id: z.string(), secret: z.string() }),
async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.id, input.id), isNull(MachineTable.timeDeleted)))
.then(async (rows) => {
const row = rows.at(0);
if (!row) {
return null;
}
if (!secureEquals(row.secretHash, await hashSecret(input.secret))) {
return null;
}
// Serialized here, so `secretHash` never leaves this
// function even in memory — the caller cannot leak what
// it was never handed.
return serialize(row);
});
});
}
);
/**
* Move a box into a team, or back out of one with `teamId: null`.
*
* Scoped to the owner in the query itself, so a machine belonging to
* someone else is a miss rather than a permission check that could be
* forgotten. Membership of the *target* team is the caller's to verify —
* this function knows about machines, not about who belongs where.
*
* Deliberately not ownership transfer. Scoping keeps the same owner and
* should be easy; handing a box to a different person should not be, and
* is left to re-registration until renting makes it worth building.
*/
export const setTeam = fn(
Info.pick({ id: true, ownerUserId: true, teamId: true }),
async (input) => {
return Database.use(async (tx) => {
return tx
.update(MachineTable)
.set({ teamId: input.teamId ?? null })
.where(
and(
eq(MachineTable.id, input.id),
eq(MachineTable.ownerUserId, input.ownerUserId),
isNull(MachineTable.timeDeleted)
)
)
.returning()
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
}
);
export const touchLastSeen = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(MachineTable)
.set({ lastSeen: sql`now()` })
.where(eq(MachineTable.id, id));
});
});
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.id, id), isNull(MachineTable.timeDeleted)))
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
/** Why a user may — or may not — use a box. */
export const Entitlement = z.object({
entitled: z.boolean(),
/** `owner`, `team`, or `none`. Present so a refusal can explain itself. */
reason: z.enum(['owner', 'team', 'none'])
});
export type Entitlement = z.infer<typeof Entitlement>;
/**
* Whether a user may use a box.
*
* The whole access model in one function: a solo box (`teamId` null) is the
* owner's alone, and a team-scoped box is open to that team. Multi-user
* access is the paid tier, so this is the line the paywall sits on — worth
* having exactly one implementation of.
*
* Membership is read live rather than cached in the machine row, so
* removing someone from a team takes their box access with it and nobody
* has to remember to revoke anything.
*/
export const entitlement = fn(
z.object({ machineId: z.string(), userId: z.string() }),
async (input): Promise<Entitlement> => {
const machine = await fromID(input.machineId);
if (!machine) {
return { entitled: false, reason: 'none' };
}
if (machine.ownerUserId === input.userId) {
return { entitled: true, reason: 'owner' };
}
if (!machine.teamId) {
// A solo box. Nobody but the owner, whatever else is true.
return { entitled: false, reason: 'none' };
}
const membership = await Member.findByTeamAndUser({
teamId: machine.teamId,
userId: input.userId
});
return membership ? { entitled: true, reason: 'team' } : { entitled: false, reason: 'none' };
}
);
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.ownerUserId, ownerUserId), isNull(MachineTable.timeDeleted)))
.orderBy(MachineTable.timeCreated)
.then((rows) => rows.map(serialize));
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(MachineTable)
.set({ timeDeleted: sql`now()` })
.where(eq(MachineTable.id, id));
});
});
export function serialize(input: typeof MachineTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId,
label: input.label,
lastSeen: input.lastSeen?.toISOString() ?? null
};
}
}

View File

@@ -0,0 +1,38 @@
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { UserTable } from '../user/user.sql.js';
/**
* A registered nessh host — the *box* that runs downloads and serves SSH, not
* the laptop someone connects from. (`nessh-tui-redesign-guide.md` §7.2 uses
* "machine" for the other end of that connection; this table is the host end.)
*
* A box does not assert who it is. It registers once against an owner's token
* and is handed an id and a secret, so ids are unique because the API assigns
* them rather than because a self-reported string happened not to collide.
*/
export const MachineTable = pgTable(
'machine',
{
...id,
...timestamps,
ownerUserId: ulid('owner_user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
// Set only when the box was registered by someone acting inside a team.
// A personal box has no team, and requiring one would make registering
// impossible for the single-operator case that self-hosting is.
teamId: ulid('team_id'),
label: text('label').notNull(),
// The secret itself is returned exactly once, at registration, and never
// stored: a leaked database must not yield working box credentials.
secretHash: text('secret_hash').notNull(),
lastSeen: utc('last_seen')
},
(t) => [
uniqueIndex('machine_secret_hash_unique').on(t.secretHash),
index('machine_owner_idx').on(t.ownerUserId),
index('machine_team_idx').on(t.teamId)
]
);

View File

@@ -0,0 +1,155 @@
import { randomBytes } from 'node:crypto';
import { eq, and, isNull, sql, gt } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { PairingCodeTable } from './pairing-code.sql.js';
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
const bytes = randomBytes(4);
for (let i = 0; i < 4; i++) {
code += chars[bytes[i]! % chars.length];
}
return `NESSH-${code}`;
}
export namespace PairingCode {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the pairing code record',
example: Examples.PairingCode.id
}),
code: z.string().meta({
description: 'Human-readable pairing code (e.g. NESSH-7F2Q)',
example: Examples.PairingCode.code
}),
targetUserId: z.string().meta({
description: 'The user who generated this code',
example: Examples.PairingCode.targetUserId
}),
newFingerprint: z.string().optional().nullable().meta({
description: 'The fingerprint that was paired (set on claim)',
example: Examples.PairingCode.newFingerprint
}),
expiresAt: z.iso.datetime().meta({
description: 'When this code expires',
example: Examples.PairingCode.expiresAt
}),
claimedAt: z.iso.datetime().optional().nullable().meta({
description: 'When this code was claimed',
example: Examples.PairingCode.claimedAt
}),
isClaimed: z.boolean().meta({
description: 'Whether this code has been used',
example: Examples.PairingCode.isClaimed
})
})
.meta({
ref: 'PairingCode',
description: 'Ephemeral device pairing code for linking a new SSH key to an existing user',
example: Examples.PairingCode
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({ id: true, targetUserId: true }).extend({
ttlMinutes: z.number().default(10)
}),
async (input) => {
const code = generateCode();
await Database.use(async (tx) => {
await tx.insert(PairingCodeTable).values({
id: input.id,
code,
targetUserId: input.targetUserId,
expiresAt: sql`now() + interval '${sql.raw(String(input.ttlMinutes))} minutes'`,
isClaimed: false,
newFingerprint: null,
claimedAt: null
});
});
return code;
}
);
export const claim = fn(
Info.pick({ code: true }).extend({ fingerprint: z.string() }),
async (input) => {
return Database.use(async (tx) => {
const row = await tx
.select()
.from(PairingCodeTable)
.where(
and(
eq(PairingCodeTable.code, input.code),
eq(PairingCodeTable.isClaimed, false),
gt(PairingCodeTable.expiresAt, sql`now()`),
isNull(PairingCodeTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
if (!row) {
return null;
}
// `.returning()` rather than handing back the row read before
// the update: that row still says `isClaimed: false`, so a
// caller inspecting it would see a code that is not yet used.
return tx
.update(PairingCodeTable)
.set({
isClaimed: true,
newFingerprint: input.fingerprint,
claimedAt: sql`now()`
})
.where(eq(PairingCodeTable.id, row.id))
.returning()
.then((rows) => {
const claimed = rows.at(0);
return claimed ? serialize(claimed) : null;
});
});
}
);
export const listByUser = fn(Info.shape.targetUserId, async (targetUserId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(PairingCodeTable)
.where(
and(eq(PairingCodeTable.targetUserId, targetUserId), isNull(PairingCodeTable.timeDeleted))
)
.orderBy(PairingCodeTable.timeCreated);
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(PairingCodeTable)
.set({ timeDeleted: sql`now()` })
.where(eq(PairingCodeTable.id, id));
});
});
export function serialize(input: typeof PairingCodeTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
code: input.code,
targetUserId: input.targetUserId,
newFingerprint: input.newFingerprint,
expiresAt: input.expiresAt.toISOString(),
claimedAt: input.claimedAt?.toISOString() ?? null,
isClaimed: input.isClaimed
};
}
}

View File

@@ -0,0 +1,21 @@
import { boolean, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
export const PairingCodeTable = pgTable(
'pairing_code',
{
...id,
...timestamps,
code: text('code').notNull(),
targetUserId: text('target_user_id').notNull(),
newFingerprint: text('new_fingerprint'),
expiresAt: utc('expires_at').notNull(),
claimedAt: utc('claimed_at'),
isClaimed: boolean('is_claimed').notNull().default(false)
},
(t) => [
uniqueIndex('pairing_code_code_unique').on(t.code),
index('pairing_code_target_user_idx').on(t.targetUserId)
]
);

View File

@@ -0,0 +1,231 @@
import { z } from 'zod';
import { Actor } from '../actor.js';
import { Database } from '../db/index.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { Fingerprint } from '../user/fingerprint.js';
import { User } from '../user/index.js';
import { LinkedAccount } from '../user/linked-account.js';
const STEAM_ID_RE = /^\d{17}$/;
function isUniqueViolation(err: unknown): boolean {
const e = err as { code?: string; cause?: { code?: string } };
return e?.code === '23505' || e?.cause?.code === '23505';
}
type SshIdentityInput = {
fingerprint: string;
steamId: string;
username?: string;
profile?: Record<string, unknown> | null;
};
async function resolveSshIdentityOnce(
input: SshIdentityInput
): Promise<{ userID: string; linkedAccountID: string }> {
const steamLink = await LinkedAccount.findByProvider({
provider: 'steam',
providerAccountId: input.steamId
});
const fingerprintRow = await Fingerprint.findByFingerprint(input.fingerprint);
const sshLink = await LinkedAccount.findSshByFingerprint(input.fingerprint);
if (steamLink) {
const canonicalUserID = steamLink.userId;
if (input.profile) {
await LinkedAccount.updateProfile({ id: steamLink.id, profile: input.profile });
}
if (!fingerprintRow) {
// Case A: existing Steam account, new SSH fingerprint.
await Fingerprint.create({
id: Identifier.ascending('userFingerprint'),
userId: canonicalUserID,
fingerprint: input.fingerprint,
name: input.username ?? null
});
} else if (fingerprintRow.userId === canonicalUserID) {
// Case D: same canonical user.
await Fingerprint.touchLastSeen(fingerprintRow.id);
} else {
// Case E: fingerprint currently owned by a different user (device migration).
const oldSteam = await LinkedAccount.findSteamByUser(fingerprintRow.userId);
if (oldSteam) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
`SSH key is already linked to Steam account ${oldSteam.providerAccountId}; refusing to switch accounts`
);
}
const otherLinks = (await LinkedAccount.listByUser(fingerprintRow.userId)).filter(
(l) => l.provider !== 'ssh' || l.providerAccountId !== input.fingerprint
);
if (otherLinks.length > 0) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'SSH key belongs to a user with other identities; refusing to merge'
);
}
await Fingerprint.repoint({ fingerprint: input.fingerprint, userId: canonicalUserID });
if (sshLink) {
await LinkedAccount.repoint({ id: sshLink.id, userId: canonicalUserID });
}
}
if (!sshLink) {
await LinkedAccount.create({
id: Identifier.ascending('linkedAccount'),
userId: canonicalUserID,
provider: 'ssh',
providerAccountId: input.fingerprint,
profile: null
});
}
return { userID: canonicalUserID, linkedAccountID: steamLink.id };
}
if (fingerprintRow) {
// Case B: new Steam account, existing fingerprint.
const currentUserID = fingerprintRow.userId;
const existingSteam = await LinkedAccount.findSteamByUser(currentUserID);
if (existingSteam) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
`User is already linked to Steam account ${existingSteam.providerAccountId}`
);
}
await Fingerprint.touchLastSeen(fingerprintRow.id);
const newSteamLinkID = Identifier.ascending('linkedAccount');
await LinkedAccount.create({
id: newSteamLinkID,
userId: currentUserID,
provider: 'steam',
providerAccountId: input.steamId,
profile: input.profile ?? null
});
if (!sshLink) {
await LinkedAccount.create({
id: Identifier.ascending('linkedAccount'),
userId: currentUserID,
provider: 'ssh',
providerAccountId: input.fingerprint,
profile: null
});
}
return { userID: currentUserID, linkedAccountID: newSteamLinkID };
}
// Case C: new Steam account, new SSH fingerprint.
const newUserID = Identifier.ascending('user');
const displayName = input.username ?? `player_${input.fingerprint.slice(0, 8)}`;
await User.create({
id: newUserID,
name: displayName,
email: undefined,
emailVerified: false,
image: null
});
await Fingerprint.create({
id: Identifier.ascending('userFingerprint'),
userId: newUserID,
fingerprint: input.fingerprint,
name: input.username ?? null
});
await LinkedAccount.create({
id: Identifier.ascending('linkedAccount'),
userId: newUserID,
provider: 'ssh',
providerAccountId: input.fingerprint,
profile: null
});
const newSteamLinkID = Identifier.ascending('linkedAccount');
await LinkedAccount.create({
id: newSteamLinkID,
userId: newUserID,
provider: 'steam',
providerAccountId: input.steamId,
profile: input.profile ?? null
});
return { userID: newUserID, linkedAccountID: newSteamLinkID };
}
export namespace Steam {
export const link = fn(
z.object({
steamId: z.string(),
profile: z.record(z.string(), z.unknown()).nullable().optional(),
userId: z.string().optional()
}),
async (input) => {
return Database.transaction(async () => {
const existing = await LinkedAccount.findByProvider({
provider: 'steam',
providerAccountId: input.steamId
});
if (existing) {
return existing.id;
}
const actor = Actor.use();
const uid =
input.userId ??
(actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined);
if (!uid) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Cannot link Steam account without a user ID'
);
}
const id = Identifier.ascending('linkedAccount');
await LinkedAccount.create({
id,
userId: uid,
provider: 'steam',
providerAccountId: input.steamId,
profile: input.profile ?? null
});
return id;
});
}
);
export const resolveSshIdentity = fn(
z.object({
fingerprint: z.string().min(1),
steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID'),
username: z.string().optional(),
profile: z.record(z.string(), z.unknown()).nullable().optional()
}),
async (input) => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
// eslint-disable-next-line
return await Database.transaction(async () => resolveSshIdentityOnce(input));
} catch (err) {
if (isUniqueViolation(err) && attempt < 2) {
continue;
}
throw err;
}
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
'Failed to resolve SSH identity'
);
}
);
}

View File

@@ -0,0 +1,184 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Fingerprint } from '../user/fingerprint.js';
import { User } from '../user/index.js';
import { LinkedAccount } from '../user/linked-account.js';
import { Steam } from './index.js';
const sql = testDb();
const createdUserIDs: string[] = [];
function steamID(n: number): string {
return String(76561197960287930n + BigInt(n));
}
function fingerprint(n: number): string {
return `aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:${String(n).padStart(2, '0')}`;
}
async function resolve(fpr: string, sid: string) {
return Steam.resolveSshIdentity({ fingerprint: fpr, steamId: sid });
}
async function cleanup() {
if (createdUserIDs.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIDs)}`;
createdUserIDs.length = 0;
}
}
function track(userID: string) {
createdUserIDs.push(userID);
return userID;
}
async function countSteamLinks(steamId: string): Promise<number> {
const rows = await sql`
select count(*)::int as n from linked_account
where provider = 'steam' and provider_account_id = ${steamId}
`;
return rows[0]?.n;
}
async function countFingerprints(fpr: string): Promise<number> {
const rows =
await sql`select count(*)::int as n from user_fingerprint where fingerprint = ${fpr}`;
return rows[0]?.n;
}
describe('Steam.resolveSshIdentity', () => {
afterAll(async () => {
await cleanup();
await sql.end();
});
test('1. new fingerprint + new Steam ID creates one user', async () => {
await cleanup();
const result = await resolve(fingerprint(1), steamID(1));
expect(result.userID).toMatch(/^usr_/);
track(result.userID);
const user = await User.fromID(result.userID);
expect(user).not.toBeNull();
const links = await LinkedAccount.listByUser(result.userID);
expect(links.map((l) => l.provider).sort()).toEqual(['ssh', 'steam']);
expect(await countFingerprints(fingerprint(1))).toBe(1);
expect(await countSteamLinks(steamID(1))).toBe(1);
});
test('2. same fingerprint + same Steam ID returns the same user', async () => {
const first = await resolve(fingerprint(2), steamID(2));
track(first.userID);
const second = await resolve(fingerprint(2), steamID(2));
expect(second.userID).toBe(first.userID);
expect(second.linkedAccountID).toBe(first.linkedAccountID);
expect(await countFingerprints(fingerprint(2))).toBe(1);
expect(await countSteamLinks(steamID(2))).toBe(1);
});
test('3. new fingerprint + same Steam ID returns the first user', async () => {
const first = await resolve(fingerprint(3), steamID(3));
track(first.userID);
const second = await resolve(fingerprint(4), steamID(3));
expect(second.userID).toBe(first.userID);
expect(second.linkedAccountID).toBe(first.linkedAccountID);
track(second.userID);
expect(await countFingerprints(fingerprint(4))).toBe(1);
expect(await countSteamLinks(steamID(3))).toBe(1);
});
test('4. same fingerprint + different Steam ID is rejected', async () => {
await resolve(fingerprint(5), steamID(5));
expect(resolve(fingerprint(5), steamID(6))).rejects.toMatchObject({
type: 'forbidden'
});
});
test('5. existing Steam ID reassigns a provisional fingerprint user', async () => {
const canonical = await resolve(fingerprint(7), steamID(7));
track(canonical.userID);
const provisionalUserID = track(Identifier.ascending('user'));
await User.create({
id: provisionalUserID,
name: 'provisional',
email: undefined,
emailVerified: false,
image: null
});
const fpr = fingerprint(8);
await Fingerprint.create({
id: Identifier.ascending('userFingerprint'),
userId: provisionalUserID,
fingerprint: fpr,
name: null
});
await LinkedAccount.create({
id: Identifier.ascending('linkedAccount'),
userId: provisionalUserID,
provider: 'ssh',
providerAccountId: fpr,
profile: null
});
const result = await resolve(fpr, steamID(7));
expect(result.userID).toBe(canonical.userID);
const row = await Fingerprint.findByFingerprint(fpr);
expect(row?.userId).toBe(canonical.userID);
const sshLink = await LinkedAccount.findSshByFingerprint(fpr);
expect(sshLink?.userId).toBe(canonical.userID);
});
test('5b. reassignment is rejected when the provisional user has a different Steam account', async () => {
await resolve(fingerprint(9), steamID(9));
expect(resolve(fingerprint(9), steamID(10))).rejects.toMatchObject({
type: 'forbidden'
});
});
test('6. two concurrent new fingerprints for one Steam ID create one Steam link', async () => {
await cleanup();
const [a, b] = await Promise.all([
resolve(fingerprint(11), steamID(11)),
resolve(fingerprint(12), steamID(11))
]);
expect(a.userID).toBe(b.userID);
track(a.userID);
expect(await countSteamLinks(steamID(11))).toBe(1);
expect(await countFingerprints(fingerprint(11))).toBe(1);
expect(await countFingerprints(fingerprint(12))).toBe(1);
});
test('7. malformed request without Steam ID is rejected', () => {
expect(
Steam.resolveSshIdentity.schema.safeParse({ fingerprint: fingerprint(13) }).success
).toBe(false);
expect(
Steam.resolveSshIdentity.schema.safeParse({
fingerprint: fingerprint(13),
steamId: 'not-a-steam-id'
}).success
).toBe(false);
});
test('8. last-seen is updated on repeat login', async () => {
const first = await resolve(fingerprint(14), steamID(14));
track(first.userID);
const before = await Fingerprint.findByFingerprint(fingerprint(14));
expect(before?.lastSeen).toBeNull();
await new Promise((r) => setTimeout(r, 25));
await resolve(fingerprint(14), steamID(14));
const after = await Fingerprint.findByFingerprint(fingerprint(14));
expect(after?.lastSeen).not.toBeNull();
});
});

View File

@@ -0,0 +1,143 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Actor } from '../actor.js';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { TeamMemberTable } from './member.sql.js';
import { TeamTable } from './team.sql.js';
export namespace Team {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the team record',
example: Examples.Team.id
}),
name: z.string().meta({
description: 'Display name of the team',
example: Examples.Team.name
}),
slug: z.string().meta({
description: 'URL-friendly unique slug for the team',
example: Examples.Team.slug
}),
ownerId: z.string().meta({
description: 'The user who owns/created this team',
example: Examples.Team.ownerId
}),
billingEmail: z.email().nullable().optional().meta({
description: 'Email address used for billing and invoices',
example: Examples.Team.billingEmail
}),
plan: z.string().optional().meta({
description: 'Current billing plan (free, pro, team, enterprise)',
example: Examples.Team.plan
}),
subscriptionStatus: z.string().optional().meta({
description: 'Current subscription status (active, past_due, canceled, etc.)',
example: Examples.Team.subscriptionStatus
}),
metadata: z.record(z.string(), z.unknown()).nullable().optional().meta({
description: 'Arbitrary metadata attached to the team',
example: Examples.Team.metadata
})
})
.meta({
ref: 'Team',
description:
'A team/organization for collaboration and billing. Users join teams via memberships.',
example: Examples.Team
});
export type Info = z.infer<typeof Info>;
export const create = fn(Info.pick({ id: true, name: true, slug: true }), async (input) => {
const ownerId = Actor.userID;
await Database.use(async (tx) => {
await tx.insert(TeamTable).values({
id: input.id,
name: input.name,
slug: input.slug,
ownerId
});
await tx.insert(TeamMemberTable).values({
id: Identifier.ascending('teamMember'),
teamId: input.id,
userId: ownerId,
role: 'owner'
});
});
return input.id;
});
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(TeamTable)
.where(and(eq(TeamTable.id, id), isNull(TeamTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const fromSlug = fn(Info.shape.slug, async (slug) => {
return Database.use(async (tx) => {
return tx
.select()
.from(TeamTable)
.where(and(eq(TeamTable.slug, slug), isNull(TeamTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export async function list() {
return Database.use(async (tx) => {
return tx
.select()
.from(TeamTable)
.where(isNull(TeamTable.timeDeleted))
.orderBy(TeamTable.timeCreated);
});
}
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(TeamTable)
.set({ timeDeleted: sql`now()` })
.where(eq(TeamTable.id, id));
});
});
export const createPersonal = fn(z.object({ displayName: z.string() }), async (input) => {
const baseSlug = input.displayName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
const existing = await fromSlug(baseSlug);
const slug = existing
? `${baseSlug}-${String(Math.floor(Math.random() * 9999)).padStart(4, '0')}`
: baseSlug;
const id = Identifier.ascending('team');
return create({ id, name: `${input.displayName}'s Team`, slug });
});
export function serialize(input: typeof TeamTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
name: input.name,
slug: input.slug,
ownerId: input.ownerId,
billingEmail: input.billingEmail,
plan: input.plan,
subscriptionStatus: input.subscriptionStatus,
metadata: input.metadata
};
}
}

View File

@@ -0,0 +1,27 @@
import { index, pgTable, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
import { UserTable } from '../user/user.sql.js';
import { TeamTable } from './team.sql.js';
export const TeamMemberRole = pgEnum('team_member_role', ['owner', 'admin', 'member']);
export const TeamMemberTable = pgTable(
'team_member',
{
...id,
...timestamps,
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.id, { onDelete: 'cascade' }),
userId: ulid('user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
role: TeamMemberRole('role').notNull().default('member')
},
(t) => [
uniqueIndex('team_member_team_user_unique').on(t.teamId, t.userId),
index('team_member_team_idx').on(t.teamId),
index('team_member_user_idx').on(t.userId)
]
);

View File

@@ -0,0 +1,114 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { TeamMemberRole, TeamMemberTable } from './member.sql.js';
export namespace Member {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the membership record',
example: Examples.Member.id
}),
teamId: z.string().meta({
description: 'The team this membership belongs to',
example: Examples.Member.teamId
}),
userId: z.string().meta({
description: 'The user who is a member of the team',
example: Examples.Member.userId
}),
role: z.enum(TeamMemberRole.enumValues).meta({
description: 'Role within the team (owner, admin, member)',
example: Examples.Member.role
})
})
.meta({
ref: 'Member',
description: 'Links a user to a team with a specific role',
example: Examples.Member
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({ id: true, teamId: true, userId: true, role: true }),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(TeamMemberTable).values({
id: input.id,
teamId: input.teamId,
userId: input.userId,
role: input.role ?? 'member'
});
});
return input.id;
}
);
export const findByTeamAndUser = fn(Info.pick({ teamId: true, userId: true }), async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(TeamMemberTable)
.where(
and(
eq(TeamMemberTable.teamId, input.teamId),
eq(TeamMemberTable.userId, input.userId),
isNull(TeamMemberTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const listByTeam = fn(Info.shape.teamId, async (teamId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(TeamMemberTable)
.where(and(eq(TeamMemberTable.teamId, teamId), isNull(TeamMemberTable.timeDeleted)))
.orderBy(TeamMemberTable.timeCreated);
});
});
export const listByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(TeamMemberTable)
.where(and(eq(TeamMemberTable.userId, userId), isNull(TeamMemberTable.timeDeleted)))
.orderBy(TeamMemberTable.timeCreated);
});
});
export const updateRole = fn(Info.pick({ id: true, role: true }), async (input) => {
await Database.use(async (tx) => {
await tx
.update(TeamMemberTable)
.set({ role: input.role })
.where(eq(TeamMemberTable.id, input.id));
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(TeamMemberTable)
.set({ timeDeleted: sql`now()` })
.where(eq(TeamMemberTable.id, id));
});
});
export function serialize(input: typeof TeamMemberTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
teamId: input.teamId,
userId: input.userId,
role: input.role
};
}
}

View File

@@ -0,0 +1,18 @@
import { jsonb, pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
import { UserTable } from '../user/user.sql.js';
export const TeamTable = pgTable('team', {
...id,
...timestamps,
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
ownerId: ulid('owner_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
billingEmail: text('billing_email'),
plan: text('plan').notNull().default('free'),
subscriptionStatus: text('subscription_status').notNull().default('active'),
metadata: jsonb('metadata').$type<{}>()
});

View File

@@ -0,0 +1,22 @@
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { UserTable } from './user.sql.js';
export const UserFingerprintTable = pgTable(
'user_fingerprint',
{
...id,
...timestamps,
userId: ulid('user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
fingerprint: text('fingerprint').notNull(),
name: text('name'),
lastSeen: utc('last_seen')
},
(t) => [
uniqueIndex('user_fingerprint_fingerprint_unique').on(t.fingerprint),
index('user_fingerprint_user_idx').on(t.userId)
]
);

View File

@@ -0,0 +1,167 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { UserFingerprintTable } from './fingerprint.sql.js';
import { User } from './index.js';
import { LinkedAccount } from './linked-account.js';
export namespace Fingerprint {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the fingerprint record',
example: Examples.Fingerprint.id
}),
userId: z.string().meta({
description: 'The user this fingerprint belongs to',
example: Examples.Fingerprint.userId
}),
fingerprint: z.string().meta({
description: 'MD5 hex of the SSH public key',
example: Examples.Fingerprint.fingerprint
}),
name: z.string().optional().nullable().meta({
description: 'Human-readable label (e.g. "MacBook Air")',
example: Examples.Fingerprint.name
}),
lastSeen: z.iso.datetime().optional().nullable().meta({
description: 'Timestamp of last connection using this key',
example: Examples.Fingerprint.lastSeen
})
})
.meta({
ref: 'Fingerprint',
description: 'An SSH public key fingerprint linked to a user account',
example: Examples.Fingerprint
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({ id: true, userId: true, fingerprint: true, name: true }),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(UserFingerprintTable).values({
id: input.id,
userId: input.userId,
fingerprint: input.fingerprint,
name: input.name ?? null,
lastSeen: null
});
});
return input.id;
}
);
export const findByFingerprint = fn(Info.shape.fingerprint, async (fingerprint) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserFingerprintTable)
.where(
and(
eq(UserFingerprintTable.fingerprint, fingerprint),
isNull(UserFingerprintTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const listByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserFingerprintTable)
.where(
and(eq(UserFingerprintTable.userId, userId), isNull(UserFingerprintTable.timeDeleted))
)
.orderBy(UserFingerprintTable.timeCreated);
});
});
export const updateName = fn(Info.pick({ id: true, name: true }), async (input) => {
await Database.use(async (tx) => {
await tx
.update(UserFingerprintTable)
.set({ name: input.name ?? null })
.where(eq(UserFingerprintTable.id, input.id));
});
});
export const touchLastSeen = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(UserFingerprintTable)
.set({ lastSeen: sql`now()` })
.where(eq(UserFingerprintTable.id, id));
});
});
export const remove = fn(Info.shape.fingerprint, async (fingerprint) => {
await Database.use(async (tx) => {
await tx
.update(UserFingerprintTable)
.set({ timeDeleted: sql`now()` })
.where(eq(UserFingerprintTable.fingerprint, fingerprint));
});
});
export const repoint = fn(Info.pick({ fingerprint: true, userId: true }), async (input) => {
await Database.use(async (tx) => {
await tx
.update(UserFingerprintTable)
.set({
userId: input.userId,
timeUpdated: sql`now()`
})
.where(
and(
eq(UserFingerprintTable.fingerprint, input.fingerprint),
isNull(UserFingerprintTable.timeDeleted)
)
);
});
});
export const mergeFingerprint = fn(
Info.pick({ fingerprint: true }).extend({ targetUserId: z.string() }),
async (input) => {
const fp = await findByFingerprint(input.fingerprint);
if (!fp) {
throw new Error('Fingerprint not found');
}
if (fp.userId === input.targetUserId) {
return { merged: false as const, reason: 'already_owned' as const };
}
const orphanLinkedAccounts = await LinkedAccount.listByUser(fp.userId);
if (orphanLinkedAccounts.length > 0) {
throw new Error(
'This device already has linked accounts. Unlink them first before merging.'
);
}
await Database.transaction(async () => {
await repoint({ fingerprint: input.fingerprint, userId: input.targetUserId });
await User.remove(fp.userId);
});
return { merged: true as const, targetUserId: input.targetUserId };
}
);
export function serialize(input: typeof UserFingerprintTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
userId: input.userId,
fingerprint: input.fingerprint,
name: input.name,
lastSeen: input.lastSeen?.toISOString() ?? null
};
}
}

View File

@@ -0,0 +1,124 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { UserTable } from './user.sql.js';
export namespace User {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the user record',
example: Examples.User.id
}),
name: z.string().meta({
description: 'The display name associated with this account',
example: Examples.User.name
}),
email: z.email().optional().nullable().optional().meta({
description:
'Primary email address for account notifications and billing. May be null for gaming-only accounts.',
example: Examples.User.email
}),
emailVerified: z.boolean().meta({
description: 'Indicates whether the email address has been verified',
example: Examples.User.emailVerified
}),
image: z.string().nullable().optional().meta({
description: "URL pointing to the user's profile picture",
example: Examples.User.image
})
})
.meta({
ref: 'User',
description: 'User account entity with core identification details',
example: Examples.User
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({ id: true, name: true, email: true, emailVerified: true, image: true }),
async (input) => {
await Database.use(async (tx) => {
await tx
.insert(UserTable)
.values({
id: input.id,
name: input.name || 'Player',
email: input.email ?? null,
emailVerified: input.emailVerified ?? false,
image: input.image ?? null
})
.onConflictDoNothing({ target: UserTable.id });
});
return input.id;
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserTable)
.where(and(eq(UserTable.id, id), isNull(UserTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const fromEmail = fn(z.email(), async (email) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserTable)
.where(and(eq(UserTable.email, email), isNull(UserTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export async function list() {
return Database.use(async (tx) => {
return tx
.select()
.from(UserTable)
.where(isNull(UserTable.timeDeleted))
.orderBy(UserTable.timeCreated);
});
}
export const updateEmail = fn(
Info.pick({ id: true, emailVerified: true }).extend({ email: z.email() }),
async (input) => {
await Database.use(async (tx) => {
await tx
.update(UserTable)
.set({
email: input.email,
emailVerified: input.emailVerified ?? false
})
.where(eq(UserTable.id, input.id));
});
}
);
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(UserTable)
.set({ timeDeleted: sql`now()` })
.where(eq(UserTable.id, id));
});
});
export function serialize(input: typeof UserTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
name: input.name,
email: input.email,
emailVerified: input.emailVerified,
image: input.image
};
}
}

View File

@@ -0,0 +1,27 @@
import { index, integer, pgTable, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { GameTable } from '../game/game.sql.js';
import { UserTable } from '../user/user.sql.js';
export const UserLibraryTable = pgTable(
'user_library',
{
...id,
...timestamps,
userId: ulid('user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
gameId: ulid('game_id')
.notNull()
.references(() => GameTable.id, { onDelete: 'cascade' }),
playtime2w: integer('playtime_2w'),
playtimeForever: integer('playtime_forever'),
lastPlayed: utc('last_played')
},
(t) => [
uniqueIndex('user_library_user_game_unique').on(t.userId, t.gameId),
index('user_library_user_idx').on(t.userId),
index('user_library_game_idx').on(t.gameId)
]
);

View File

@@ -0,0 +1,234 @@
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { GameDownload } from '../game/download.js';
import { GameDownloadTable } from '../game/download.sql.js';
import { GameTable } from '../game/game.sql.js';
import { Game } from '../game/index.js';
import { UserLibraryTable } from './library.sql.js';
export namespace Library {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the library entry',
example: Examples.Library.id
}),
userId: z.string().meta({
description: 'The user who owns this game',
example: Examples.Library.userId
}),
gameId: z.string().meta({
description: 'The game in the library',
example: Examples.Library.gameId
}),
playtime2w: z.number().int().nullable().optional().meta({
description: 'Playtime in seconds over the last 2 weeks',
example: Examples.Library.playtime2w
}),
playtimeForever: z.number().int().nullable().optional().meta({
description: 'Total playtime in seconds',
example: Examples.Library.playtimeForever
}),
lastPlayed: z.string().nullable().optional().meta({
description: 'Last time the game was played (ISO 8601)',
example: Examples.Library.lastPlayed
})
})
.meta({
ref: 'Library',
description: 'Links a user to a game in their library with playtime info',
example: Examples.Library
});
export type Info = z.infer<typeof Info>;
export const create = fn(Info, async (input) => {
await Database.use(async (tx) => {
await tx.insert(UserLibraryTable).values({
id: input.id,
userId: input.userId,
gameId: input.gameId,
playtime2w: input.playtime2w ?? null,
playtimeForever: input.playtimeForever ?? null,
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
});
});
return input.id;
});
export const upsert = fn(
Info.pick({
id: true,
userId: true,
gameId: true,
playtime2w: true,
playtimeForever: true,
lastPlayed: true
}),
async (input) => {
await Database.use(async (tx) => {
await tx
.insert(UserLibraryTable)
.values({
id: input.id,
userId: input.userId,
gameId: input.gameId,
playtime2w: input.playtime2w ?? null,
playtimeForever: input.playtimeForever ?? null,
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
})
.onConflictDoUpdate({
target: [UserLibraryTable.userId, UserLibraryTable.gameId],
set: {
playtime2w: input.playtime2w ?? null,
playtimeForever: input.playtimeForever ?? null,
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
}
});
});
return input.id;
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserLibraryTable)
.where(and(eq(UserLibraryTable.id, id), isNull(UserLibraryTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const findByUserAndGame = fn(Info.pick({ userId: true, gameId: true }), async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserLibraryTable)
.where(
and(
eq(UserLibraryTable.userId, input.userId),
eq(UserLibraryTable.gameId, input.gameId),
isNull(UserLibraryTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const listByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserLibraryTable)
.where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted)))
.orderBy(UserLibraryTable.timeCreated);
});
});
export const listByUserWithGames = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
const rows = await tx
.select({
library: UserLibraryTable,
game: GameTable
})
.from(UserLibraryTable)
.leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id))
.where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted)))
.orderBy(UserLibraryTable.timeCreated);
const gameIds = [
...new Set(rows.filter((row) => row.game !== null).map((row) => row.library.gameId))
];
const downloadRows =
gameIds.length > 0
? await tx
.select()
.from(GameDownloadTable)
.where(
and(
inArray(GameDownloadTable.gameId, gameIds),
isNull(GameDownloadTable.timeDeleted)
)
)
: [];
// A game may have one state row per host; surface the most recently
// updated state for the library listing.
const downloadByGame = new Map<string, (typeof downloadRows)[number]>();
for (const row of downloadRows) {
const existing = downloadByGame.get(row.gameId);
if (!existing || row.timeUpdated > existing.timeUpdated) {
downloadByGame.set(row.gameId, row);
}
}
return rows
.filter((row) => row.game !== null)
.map((row) => {
const download = downloadByGame.get(row.library.gameId);
return {
id: row.library.id,
game: Game.serialize(row.game!),
playtime2w: row.library.playtime2w,
playtimeForever: row.library.playtimeForever,
lastPlayed: row.library.lastPlayed?.toISOString() ?? null,
download: download ? GameDownload.serialize(download) : null
};
});
});
});
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(UserLibraryTable)
.where(and(eq(UserLibraryTable.gameId, gameId), isNull(UserLibraryTable.timeDeleted)));
});
});
export const listByUserAndGameIDs = fn(
z.object({ userId: z.string(), gameIds: z.array(z.string()) }),
async (input) => {
if (input.gameIds.length === 0) return [];
return Database.use(async (tx) => {
return tx
.select()
.from(UserLibraryTable)
.where(
and(
eq(UserLibraryTable.userId, input.userId),
inArray(UserLibraryTable.gameId, input.gameIds),
isNull(UserLibraryTable.timeDeleted)
)
);
});
}
);
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(UserLibraryTable)
.set({ timeDeleted: sql`now()` })
.where(eq(UserLibraryTable.id, id));
});
});
export function serialize(input: typeof UserLibraryTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
userId: input.userId,
gameId: input.gameId,
playtime2w: input.playtime2w,
playtimeForever: input.playtimeForever,
lastPlayed: input.lastPlayed?.toISOString() ?? null
};
}
}

View File

@@ -0,0 +1,24 @@
import { index, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
import { UserTable } from '../user/user.sql.js';
export const ProviderEnum = pgEnum('linked_account_provider', ['steam', 'ssh', 'discord']);
export const LinkedAccountTable = pgTable(
'linked_account',
{
...id,
...timestamps,
userId: ulid('user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
provider: ProviderEnum('provider').notNull(),
providerAccountId: text('provider_account_id').notNull(),
profile: jsonb('profile').$type<{}>()
},
(t) => [
uniqueIndex('linked_account_provider_unique').on(t.provider, t.providerAccountId),
index('linked_account_user_idx').on(t.userId)
]
);

View File

@@ -0,0 +1,167 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { LinkedAccountTable, ProviderEnum } from './linked-account.sql.js';
export namespace LinkedAccount {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the linked account record',
example: Examples.LinkedAccount.id
}),
userId: z.string().meta({
description: 'The user this account belongs to',
example: Examples.LinkedAccount.userId
}),
provider: z.enum(ProviderEnum.enumValues).meta({
description: 'Authentication provider',
example: Examples.LinkedAccount.provider
}),
providerAccountId: z.string().meta({
description: 'The account ID from the provider',
example: Examples.LinkedAccount.providerAccountId
}),
profile: z.record(z.string(), z.unknown()).nullable().optional().meta({
description: 'Platform-specific profile data (name, avatar, etc.)',
example: Examples.LinkedAccount.profile
})
})
.meta({
ref: 'LinkedAccount',
description: 'A linked gaming or OAuth identity (Steam, Epic Games, GitHub, Discord, etc.)',
example: Examples.LinkedAccount
});
export type Info = z.infer<typeof Info>;
export const create = fn(Info, async (input) => {
await Database.use(async (tx) => {
await tx.insert(LinkedAccountTable).values({
id: input.id,
userId: input.userId,
provider: input.provider,
providerAccountId: input.providerAccountId,
profile: input.profile ?? null
});
});
return input.id;
});
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(LinkedAccountTable)
.where(and(eq(LinkedAccountTable.id, id), isNull(LinkedAccountTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const findByProvider = fn(
Info.pick({ provider: true, providerAccountId: true }),
async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(LinkedAccountTable)
.where(
and(
eq(LinkedAccountTable.provider, input.provider),
eq(LinkedAccountTable.providerAccountId, input.providerAccountId),
isNull(LinkedAccountTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
}
);
export const findSshByFingerprint = fn(Info.shape.providerAccountId, async (fingerprint) => {
return Database.use(async (tx) => {
return tx
.select()
.from(LinkedAccountTable)
.where(
and(
eq(LinkedAccountTable.provider, 'ssh'),
eq(LinkedAccountTable.providerAccountId, fingerprint),
isNull(LinkedAccountTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const findSteamByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(LinkedAccountTable)
.where(
and(
eq(LinkedAccountTable.userId, userId),
eq(LinkedAccountTable.provider, 'steam'),
isNull(LinkedAccountTable.timeDeleted)
)
)
.then((rows) => rows.at(0) ?? null);
});
});
export const repoint = fn(Info.pick({ id: true, userId: true }), async (input) => {
await Database.use(async (tx) => {
await tx
.update(LinkedAccountTable)
.set({
userId: input.userId,
timeUpdated: sql`now()`
})
.where(and(eq(LinkedAccountTable.id, input.id), isNull(LinkedAccountTable.timeDeleted)));
});
});
export const updateProfile = fn(Info.pick({ id: true, profile: true }), async (input) => {
await Database.use(async (tx) => {
await tx
.update(LinkedAccountTable)
.set({
profile: input.profile ?? null,
timeUpdated: sql`now()`
})
.where(and(eq(LinkedAccountTable.id, input.id), isNull(LinkedAccountTable.timeDeleted)));
});
});
export const listByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(LinkedAccountTable)
.where(and(eq(LinkedAccountTable.userId, userId), isNull(LinkedAccountTable.timeDeleted)))
.orderBy(LinkedAccountTable.timeCreated);
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(LinkedAccountTable)
.set({ timeDeleted: sql`now()` })
.where(eq(LinkedAccountTable.id, id));
});
});
export function serialize(input: typeof LinkedAccountTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
userId: input.userId,
provider: input.provider,
providerAccountId: input.providerAccountId,
profile: input.profile
};
}
}

View File

@@ -0,0 +1,12 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps } from '../db/types.js';
export const UserTable = pgTable('user', {
...id,
...timestamps,
name: text('name').notNull(),
email: text('email'),
emailVerified: boolean('email_verified').notNull().default(false),
image: text('image')
});

View File

@@ -0,0 +1,30 @@
type UnwrapPromise<P extends unknown> = P extends PromiseLike<infer V> ? V : P;
type Input = Record<string | number | symbol, unknown>;
type Result<Obj extends Input> = {
[P in keyof Obj]: UnwrapPromise<Obj[P]>;
};
export default function combinePromises<Obj extends Input>(obj: Obj): Promise<Result<Obj>> {
if (obj === null) {
return Promise.reject(new Error('combinePromises does not handle null argument'));
}
if (typeof obj !== 'object') {
return Promise.reject(
new Error(`combinePromises does not handle argument of type ${typeof obj}`)
);
}
const keys = Object.keys(obj);
// not using async/await on purpose, otherwise lib outputs large _asyncToGenerator code in dist
return Promise.all(Object.values(obj)).then((values) => {
const result: any = {};
values.forEach((v, i) => {
//@ts-expect-error this is NEVER undefined
result[keys[i]] = v;
});
return result;
});
}

View File

@@ -0,0 +1,18 @@
export function memo<T>(fn: () => T, cleanup?: (input: T) => Promise<void>) {
let value: T | undefined;
let loaded = false;
const result = (): T => {
if (loaded) return value as T;
loaded = true;
value = fn();
return value as T;
};
result.reset = async () => {
if (cleanup && value) await cleanup(value);
loaded = false;
value = undefined;
};
return result;
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"allowJs": false,
"types": ["node", "bun"],
// Strict resolution mode
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}