feat: Sync to OSS repo

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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