mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: bring the control plane up to date
Squashes the current state of the internal working tree onto this history. The two trees had grown apart with no common ancestor, so this is a content sync rather than a merge, and the published history is preserved rather than rewritten — a force-push here would break every existing fork and clone to no benefit. What lands: - Waitlist: API route, core module, and migration 0006 alongside game aliases. - User verification. - CI, oxfmt config, editor settings. - Assorted fixes across the API routes and core modules. The repository's own README, the wordmark and the per-package READMEs are kept from this side; the internal tree had dropped them and they are what a stranger arriving here reads first. The marketing site in the internal tree is deliberately not here. It is a separate product with its own repo and its own licence, and this repo is the open one — a closed component does not belong in it regardless of how convenient the directory looked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
CREATE TYPE "public"."verification_kind" AS ENUM('email');--> statement-breakpoint
|
||||
CREATE TABLE "verification" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"user_id" char(30) NOT NULL,
|
||||
"kind" "verification_kind" NOT NULL,
|
||||
"code_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"consumed_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "waitlist_entry" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"email" text NOT NULL,
|
||||
"source" text DEFAULT 'machines' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "game" ADD COLUMN "aliases" text;--> statement-breakpoint
|
||||
ALTER TABLE "verification" ADD CONSTRAINT "verification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "verification_user_kind_idx" ON "verification" USING btree ("user_id","kind");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "waitlist_entry_email_unique" ON "waitlist_entry" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "waitlist_entry_source_idx" ON "waitlist_entry" USING btree ("source");
|
||||
1908
packages/core/migrations/meta/0006_snapshot.json
Normal file
1908
packages/core/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1785909838801,
|
||||
"tag": "0005_flaky_may_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786205230097,
|
||||
"tag": "0006_waitlist_verification_game_aliases",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export namespace Examples {
|
||||
steamAppId: 730,
|
||||
slug: 'counter-strike-2',
|
||||
name: 'Counter-Strike 2',
|
||||
aliases: 'cs2 csgo',
|
||||
type: 'game',
|
||||
clientIcon: '5aad412d01a9b91ba0379f0b35f4eb0b69d9db08',
|
||||
icon: 'f92b09dab91f1d1738f72fe0dd9be18dcc2901f9',
|
||||
@@ -138,4 +139,11 @@ export namespace Examples {
|
||||
timeCompleted: null,
|
||||
errorMessage: null
|
||||
};
|
||||
|
||||
export const WaitlistEntry = {
|
||||
id: Id('waitlistEntry'),
|
||||
email: 'johndoe@example.com',
|
||||
source: 'machines',
|
||||
timeCreated: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const GameTable = pgTable(
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type'),
|
||||
// Space-separated nicknames ("tf2", "pubg") so search can match them.
|
||||
aliases: text('aliases'),
|
||||
|
||||
clientIcon: text('client_icon'),
|
||||
icon: text('icon'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import { eq, and, isNull, sql, inArray, or } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
@@ -28,6 +28,10 @@ export namespace Game {
|
||||
description: 'Game title',
|
||||
example: Examples.Game.name
|
||||
}),
|
||||
aliases: z.string().nullable().optional().meta({
|
||||
description: 'Space-separated nicknames ("tf2", "pubg") for search',
|
||||
example: Examples.Game.aliases
|
||||
}),
|
||||
type: z.string().nullable().optional().meta({
|
||||
description: 'Content type (game, dlc, demo, tool)',
|
||||
example: Examples.Game.type
|
||||
@@ -131,6 +135,7 @@ export namespace Game {
|
||||
steamAppId: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
aliases: true,
|
||||
type: true,
|
||||
clientIcon: true,
|
||||
icon: true,
|
||||
@@ -161,6 +166,7 @@ export namespace Game {
|
||||
steamAppId: input.steamAppId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
aliases: input.aliases ?? null,
|
||||
type: input.type ?? null,
|
||||
clientIcon: input.clientIcon ?? null,
|
||||
icon: input.icon ?? null,
|
||||
@@ -215,6 +221,7 @@ export namespace Game {
|
||||
steamAppId: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
aliases: true,
|
||||
type: true,
|
||||
clientIcon: true,
|
||||
icon: true,
|
||||
@@ -247,6 +254,7 @@ export namespace Game {
|
||||
steamAppId: input.steamAppId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
aliases: input.aliases ?? null,
|
||||
type: input.type ?? null,
|
||||
clientIcon: input.clientIcon ?? null,
|
||||
icon: input.icon ?? null,
|
||||
@@ -275,6 +283,7 @@ export namespace Game {
|
||||
set: {
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
aliases: input.aliases ?? null,
|
||||
type: input.type ?? null,
|
||||
clientIcon: input.clientIcon ?? null,
|
||||
icon: input.icon ?? null,
|
||||
@@ -305,11 +314,15 @@ export namespace Game {
|
||||
|
||||
export const searchByName = fn(z.string(), async (query) => {
|
||||
return Database.use(async (tx) => {
|
||||
const pattern = '%' + query + '%';
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(
|
||||
and(isNull(GameTable.timeDeleted), sql`${GameTable.name} ILIKE ${'%' + query + '%'}`)
|
||||
and(
|
||||
isNull(GameTable.timeDeleted),
|
||||
or(sql`${GameTable.name} ILIKE ${pattern}`, sql`${GameTable.aliases} ILIKE ${pattern}`)
|
||||
)
|
||||
)
|
||||
.orderBy(GameTable.name)
|
||||
.limit(50);
|
||||
@@ -361,6 +374,7 @@ export namespace Game {
|
||||
steamAppId: input.steamAppId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
aliases: input.aliases,
|
||||
type: input.type,
|
||||
clientIcon: input.clientIcon,
|
||||
icon: input.icon,
|
||||
|
||||
@@ -16,7 +16,8 @@ export namespace Identifier {
|
||||
game: 'gam',
|
||||
userLibrary: 'ulb',
|
||||
gameDepot: 'gdp',
|
||||
gameDownload: 'gdl'
|
||||
gameDownload: 'gdl',
|
||||
waitlistEntry: 'wle'
|
||||
} as const;
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -56,6 +56,16 @@ export namespace Fingerprint {
|
||||
}
|
||||
);
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserFingerprintTable)
|
||||
.where(and(eq(UserFingerprintTable.id, id), isNull(UserFingerprintTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const findByFingerprint = fn(Info.shape.fingerprint, async (fingerprint) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
|
||||
@@ -103,6 +103,23 @@ export namespace User {
|
||||
}
|
||||
);
|
||||
|
||||
export const setEmail = fn(
|
||||
Info.pick({ id: true }).extend({ email: z.email(), emailVerified: z.boolean() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
email: input.email,
|
||||
emailVerified: input.emailVerified
|
||||
})
|
||||
.where(eq(UserTable.id, input.id))
|
||||
.returning();
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
|
||||
30
packages/core/src/user/verification.sql.ts
Normal file
30
packages/core/src/user/verification.sql.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { index, integer, pgEnum, pgTable, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { UserTable } from './user.sql.js';
|
||||
|
||||
export const VerificationKindEnum = pgEnum('verification_kind', ['email']);
|
||||
|
||||
/**
|
||||
* A short-lived code proving the owner of an email address.
|
||||
*
|
||||
* The `ver` id prefix was reserved for this before the table existed. Codes
|
||||
* are hashed at rest so a leaked database cannot be used to verify on behalf
|
||||
* of its users.
|
||||
*/
|
||||
export const VerificationTable = pgTable(
|
||||
'verification',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
kind: VerificationKindEnum('kind').notNull(),
|
||||
codeHash: text('code_hash').notNull(),
|
||||
expiresAt: utc('expires_at').notNull(),
|
||||
attempts: integer('attempts').notNull().default(0),
|
||||
consumedAt: utc('consumed_at')
|
||||
},
|
||||
(t) => [index('verification_user_kind_idx').on(t.userId, t.kind)]
|
||||
);
|
||||
146
packages/core/src/user/verification.ts
Normal file
146
packages/core/src/user/verification.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import { and, desc, eq, gt, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { UserTable } from './user.sql.js';
|
||||
import { VerificationKindEnum, VerificationTable } from './verification.sql.js';
|
||||
|
||||
export function hashCode(code: string): string {
|
||||
return createHash('sha256').update(code).digest('hex');
|
||||
}
|
||||
|
||||
function generateCode(): string {
|
||||
const bytes = randomBytes(3);
|
||||
return String((bytes[0]! << 16) | (bytes[1]! << 8) | bytes[2]!)
|
||||
.padStart(6, '0')
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
export const MAX_VERIFICATION_ATTEMPTS = 5;
|
||||
export const VERIFICATION_TTL_MINUTES = 10;
|
||||
|
||||
export namespace Verification {
|
||||
export const create = fn(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
kind: z.enum(VerificationKindEnum.enumValues),
|
||||
code: z.string().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
const code = input.code ?? generateCode();
|
||||
// Old codes must not stay valid once a fresh one exists.
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(VerificationTable)
|
||||
.set({ consumedAt: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(VerificationTable.userId, input.userId),
|
||||
eq(VerificationTable.kind, input.kind),
|
||||
isNull(VerificationTable.consumedAt)
|
||||
)
|
||||
);
|
||||
await tx.insert(VerificationTable).values({
|
||||
id: Identifier.ascending('verification'),
|
||||
userId: input.userId,
|
||||
kind: input.kind,
|
||||
codeHash: hashCode(code),
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(VERIFICATION_TTL_MINUTES))} minutes'`,
|
||||
attempts: 0,
|
||||
consumedAt: null
|
||||
});
|
||||
});
|
||||
return code;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Redeem a code for the user's email.
|
||||
*
|
||||
* The whole flow — find the active code, check the hash, burn the code,
|
||||
* flip the flag — happens in one transaction so a code cannot be raced
|
||||
* into double use.
|
||||
*/
|
||||
export const verifyEmail = fn(
|
||||
z.object({ userId: z.string(), code: z.string() }),
|
||||
async (input) => {
|
||||
return Database.transaction(async (tx) => {
|
||||
const active = await tx
|
||||
.select()
|
||||
.from(VerificationTable)
|
||||
.where(
|
||||
and(
|
||||
eq(VerificationTable.userId, input.userId),
|
||||
eq(VerificationTable.kind, 'email'),
|
||||
isNull(VerificationTable.consumedAt),
|
||||
gt(VerificationTable.expiresAt, sql`now()`)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(VerificationTable.timeCreated))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
|
||||
if (!active) {
|
||||
return { ok: false as const, reason: 'no_active_code' as const };
|
||||
}
|
||||
|
||||
if (hashCode(input.code) !== active.codeHash) {
|
||||
const burn = active.attempts + 1 >= MAX_VERIFICATION_ATTEMPTS;
|
||||
await tx
|
||||
.update(VerificationTable)
|
||||
.set({
|
||||
attempts: sql`${VerificationTable.attempts} + 1`,
|
||||
// Exhausted codes are consumed so the next attempt
|
||||
// asks for a fresh one instead of counting forever.
|
||||
consumedAt: burn ? sql`now()` : undefined
|
||||
})
|
||||
.where(eq(VerificationTable.id, active.id));
|
||||
return { ok: false as const, reason: 'wrong_code' as const };
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(VerificationTable)
|
||||
.set({ consumedAt: sql`now()` })
|
||||
.where(eq(VerificationTable.id, active.id));
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({ emailVerified: true })
|
||||
.where(eq(UserTable.id, input.userId));
|
||||
return { ok: true as const };
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const findActiveByUserAndKind = fn(
|
||||
z.object({ userId: z.string(), kind: z.enum(VerificationKindEnum.enumValues) }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(VerificationTable)
|
||||
.where(
|
||||
and(
|
||||
eq(VerificationTable.userId, input.userId),
|
||||
eq(VerificationTable.kind, input.kind),
|
||||
isNull(VerificationTable.consumedAt),
|
||||
gt(VerificationTable.expiresAt, sql`now()`)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(VerificationTable.timeCreated))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const consume = fn(z.string(), async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(VerificationTable)
|
||||
.set({ consumedAt: sql`now()` })
|
||||
.where(eq(VerificationTable.id, id));
|
||||
});
|
||||
});
|
||||
}
|
||||
83
packages/core/src/waitlist/index.ts
Normal file
83
packages/core/src/waitlist/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { and, eq, isNull } 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 { WaitlistEntryTable } from './waitlist.sql.js';
|
||||
|
||||
export namespace Waitlist {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the waitlist entry',
|
||||
example: Examples.WaitlistEntry.id
|
||||
}),
|
||||
email: z.email().meta({
|
||||
description: 'The email to notify',
|
||||
example: Examples.WaitlistEntry.email
|
||||
}),
|
||||
source: z.string().meta({
|
||||
description: 'What the signup was for',
|
||||
example: Examples.WaitlistEntry.source
|
||||
}),
|
||||
timeCreated: z.iso.datetime().meta({
|
||||
description: 'When the signup happened',
|
||||
example: Examples.WaitlistEntry.timeCreated
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'WaitlistEntry',
|
||||
description: 'A waitlist signup for a not-yet-launched feature',
|
||||
example: Examples.WaitlistEntry
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const join = fn(Info.pick({ email: true, source: true }), async (input) => {
|
||||
const existing = await Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(WaitlistEntryTable)
|
||||
.where(and(eq(WaitlistEntryTable.email, input.email), isNull(WaitlistEntryTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
if (existing) {
|
||||
// Joining twice changes nothing; the entry stays as it was.
|
||||
return serialize(existing);
|
||||
}
|
||||
|
||||
const row = await Database.use(async (tx) => {
|
||||
return tx
|
||||
.insert(WaitlistEntryTable)
|
||||
.values({
|
||||
id: Identifier.ascending('waitlistEntry'),
|
||||
email: input.email,
|
||||
source: input.source
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows.at(0));
|
||||
});
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
|
||||
export async function list() {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(WaitlistEntryTable)
|
||||
.where(isNull(WaitlistEntryTable.timeDeleted))
|
||||
.orderBy(WaitlistEntryTable.timeCreated);
|
||||
});
|
||||
}
|
||||
|
||||
export function serialize(input: typeof WaitlistEntryTable.$inferSelect): Info {
|
||||
return {
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
source: input.source,
|
||||
timeCreated: input.timeCreated.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
25
packages/core/src/waitlist/waitlist.sql.ts
Normal file
25
packages/core/src/waitlist/waitlist.sql.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps } from '../db/types.js';
|
||||
|
||||
/**
|
||||
* Someone asking to be told when a self-hosted machines feature launches.
|
||||
*
|
||||
* Kept deliberately dumb: an email and where it came from. No auth — the whole
|
||||
* point is that a visitor without an account can leave one.
|
||||
*/
|
||||
export const WaitlistEntryTable = pgTable(
|
||||
'waitlist_entry',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
email: text('email').notNull(),
|
||||
// What the signup was for (e.g. "machines"), so one form can grow
|
||||
// into several without a schema change.
|
||||
source: text('source').notNull().default('machines')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('waitlist_entry_email_unique').on(t.email),
|
||||
index('waitlist_entry_source_idx').on(t.source)
|
||||
]
|
||||
);
|
||||
Reference in New Issue
Block a user