mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
34
packages/core/.gitignore
vendored
Normal file
34
packages/core/.gitignore
vendored
Normal 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
|
||||
19
packages/core/drizzle.config.ts
Normal file
19
packages/core/drizzle.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
57
packages/core/migrations/0000_quick_dark_phoenix.sql
Normal file
57
packages/core/migrations/0000_quick_dark_phoenix.sql
Normal 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");
|
||||
29
packages/core/migrations/0001_opposite_senator_kelly.sql
Normal file
29
packages/core/migrations/0001_opposite_senator_kelly.sql
Normal 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");
|
||||
102
packages/core/migrations/0002_light_mesmero.sql
Normal file
102
packages/core/migrations/0002_light_mesmero.sql
Normal 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");
|
||||
2
packages/core/migrations/0003_many_pyro.sql
Normal file
2
packages/core/migrations/0003_many_pyro.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "game" ADD COLUMN "client_icon" text;--> statement-breakpoint
|
||||
ALTER TABLE "game" ADD COLUMN "icon" text;
|
||||
@@ -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";
|
||||
34
packages/core/migrations/0005_flaky_may_parker.sql
Normal file
34
packages/core/migrations/0005_flaky_may_parker.sql
Normal 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");
|
||||
429
packages/core/migrations/meta/0000_snapshot.json
Normal file
429
packages/core/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
640
packages/core/migrations/meta/0001_snapshot.json
Normal file
640
packages/core/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
1344
packages/core/migrations/meta/0002_snapshot.json
Normal file
1344
packages/core/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1356
packages/core/migrations/meta/0003_snapshot.json
Normal file
1356
packages/core/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1362
packages/core/migrations/meta/0004_snapshot.json
Normal file
1362
packages/core/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1699
packages/core/migrations/meta/0005_snapshot.json
Normal file
1699
packages/core/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/core/migrations/meta/_journal.json
Normal file
48
packages/core/migrations/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
34
packages/core/package.json
Normal file
34
packages/core/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
39
packages/core/src/access-token/access-token.sql.ts
Normal file
39
packages/core/src/access-token/access-token.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
185
packages/core/src/access-token/index.ts
Normal file
185
packages/core/src/access-token/index.ts
Normal 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
180
packages/core/src/actor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
10
packages/core/src/auth/subjects.ts
Normal file
10
packages/core/src/auth/subjects.ts
Normal 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()
|
||||
})
|
||||
});
|
||||
31
packages/core/src/context.ts
Normal file
31
packages/core/src/context.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
114
packages/core/src/db/index.ts
Normal file
114
packages/core/src/db/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/core/src/db/test.ts
Normal file
22
packages/core/src/db/test.ts
Normal 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 });
|
||||
}
|
||||
23
packages/core/src/db/types.ts
Normal file
23
packages/core/src/db/types.ts
Normal 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
41
packages/core/src/env.ts
Normal 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
124
packages/core/src/error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
141
packages/core/src/examples.ts
Normal file
141
packages/core/src/examples.ts
Normal 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
48
packages/core/src/fn.ts
Normal 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;
|
||||
}
|
||||
48
packages/core/src/game/depot.sql.ts
Normal file
48
packages/core/src/game/depot.sql.ts
Normal 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}`)
|
||||
]
|
||||
);
|
||||
266
packages/core/src/game/depot.ts
Normal file
266
packages/core/src/game/depot.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
38
packages/core/src/game/download.sql.ts
Normal file
38
packages/core/src/game/download.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
230
packages/core/src/game/download.test.ts
Normal file
230
packages/core/src/game/download.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
207
packages/core/src/game/download.ts
Normal file
207
packages/core/src/game/download.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
48
packages/core/src/game/game.sql.ts
Normal file
48
packages/core/src/game/game.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
388
packages/core/src/game/index.ts
Normal file
388
packages/core/src/game/index.ts
Normal 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 (0–100)',
|
||||
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
80
packages/core/src/id.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
261
packages/core/src/machine/index.ts
Normal file
261
packages/core/src/machine/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
38
packages/core/src/machine/machine.sql.ts
Normal file
38
packages/core/src/machine/machine.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
155
packages/core/src/pairing-code/index.ts
Normal file
155
packages/core/src/pairing-code/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
21
packages/core/src/pairing-code/pairing-code.sql.ts
Normal file
21
packages/core/src/pairing-code/pairing-code.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
231
packages/core/src/steam/index.ts
Normal file
231
packages/core/src/steam/index.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
184
packages/core/src/steam/resolve.test.ts
Normal file
184
packages/core/src/steam/resolve.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
143
packages/core/src/team/index.ts
Normal file
143
packages/core/src/team/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
27
packages/core/src/team/member.sql.ts
Normal file
27
packages/core/src/team/member.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
114
packages/core/src/team/member.ts
Normal file
114
packages/core/src/team/member.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
18
packages/core/src/team/team.sql.ts
Normal file
18
packages/core/src/team/team.sql.ts
Normal 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<{}>()
|
||||
});
|
||||
22
packages/core/src/user/fingerprint.sql.ts
Normal file
22
packages/core/src/user/fingerprint.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
167
packages/core/src/user/fingerprint.ts
Normal file
167
packages/core/src/user/fingerprint.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
124
packages/core/src/user/index.ts
Normal file
124
packages/core/src/user/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
27
packages/core/src/user/library.sql.ts
Normal file
27
packages/core/src/user/library.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
234
packages/core/src/user/library.ts
Normal file
234
packages/core/src/user/library.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
24
packages/core/src/user/linked-account.sql.ts
Normal file
24
packages/core/src/user/linked-account.sql.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
167
packages/core/src/user/linked-account.ts
Normal file
167
packages/core/src/user/linked-account.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
12
packages/core/src/user/user.sql.ts
Normal file
12
packages/core/src/user/user.sql.ts
Normal 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')
|
||||
});
|
||||
30
packages/core/src/utils/combine-promises.ts
Normal file
30
packages/core/src/utils/combine-promises.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
18
packages/core/src/utils/memo.ts
Normal file
18
packages/core/src/utils/memo.ts
Normal 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;
|
||||
}
|
||||
29
packages/core/tsconfig.json
Normal file
29
packages/core/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user