Skip to content
File

Blob: scripts/seed-bootstrap-invite.ts

typescript477 lines
1#!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json
2/**
3 * Seed the first bootstrap invite (OPERATOR.md § 7).
4 *
5 * Tessera is invite-only — no one can sign in until at least one invite has
6 * been minted *and* accepted. The admin UI is unreachable until that first
7 * sign-in, so the very first invite has to be inserted out-of-band. This
8 * script does that via `wrangler d1 execute` — no D1 binding required.
9 *
10 * Pair the run with `BOOTSTRAP_ADMIN_EMAIL` set to the same address in
11 * wrangler.jsonc `vars` (remote) or .dev.vars (local) so first signup
12 * matching that email gets auto-promoted to role=admin by the Better Auth
13 * databaseHooks.user.create.before hook.
14 *
15 * Usage:
16 * npm run db:seed-initial-user -- --email ops@limic.dev
17 * npm run db:seed-initial-user:local -- --email ops@example.com
18 *
19 * After the operator accepts the invite, remove BOOTSTRAP_ADMIN_EMAIL and
20 * redeploy — subsequent signups must not auto-promote.
21 */
22import { execFile as execFileCallback } from "node:child_process";
23import { mkdtemp, rm, writeFile } from "node:fs/promises";
24import { tmpdir } from "node:os";
25import { dirname, join, resolve } from "node:path";
26import process from "node:process";
27import { promisify } from "node:util";
28import { fileURLToPath } from "node:url";
29 
30import { encodeBase64Url, sha256 } from "@/worker/services/crypto";
31 
32const execFile = promisify(execFileCallback);
33 
34const DEFAULT_DATABASE = "tessera-prod";
35const DEFAULT_EXPIRY_DAYS = 7;
36const MAX_EXPIRY_DAYS = 90;
37const DEFAULT_REMOTE_ISSUER = "https://auth.limic.dev";
38const DEFAULT_LOCAL_ISSUER = "http://localhost:5174";
39const BOOTSTRAP_CREATED_BY = "bootstrap";
40const INVITE_TOKEN_BYTES = 32;
41const INVITE_ID_BYTES = 12;
42 
43const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
44const REPO_ROOT = resolve(SCRIPT_DIR, "..");
45 
46const HELP_TEXT = `Seed the first bootstrap invite for tessera.
47
48Usage:
49 npm run db:seed-initial-user -- --email <email> [options]
50 npm run db:seed-initial-user:local -- --email <email> [options]
51
52Options:
53 --local Seed the local D1 database (default for the :local script)
54 --remote Seed the remote D1 database (default for the bare script)
55 --email EMAIL Operator email; bound to the invite and matched by
56 BOOTSTRAP_ADMIN_EMAIL for auto-promotion (required)
57 --database NAME D1 database binding/name (default: ${DEFAULT_DATABASE})
58 --expires-in-days N Invite TTL in days (default: ${DEFAULT_EXPIRY_DAYS}, max: ${MAX_EXPIRY_DAYS})
59 --issuer URL Base URL used to print the invite link
60 (default: ${DEFAULT_REMOTE_ISSUER} for --remote,
61 ${DEFAULT_LOCAL_ISSUER} for --local)
62 --force Replace existing unused bootstrap invites
63 --dry-run Build the SQL but do not execute it
64 --print-sql Print the generated SQL
65 --json Emit a machine-readable JSON payload
66 --help Show this help text
67`;
68 
69interface Options {
70 mode: "local" | "remote";
71 email: string;
72 database: string;
73 expiresInDays: number;
74 issuer: string;
75 force: boolean;
76 dryRun: boolean;
77 printSql: boolean;
78 json: boolean;
79}
80 
81interface ParsedArguments {
82 options: Options | null;
83 requestedHelp: boolean;
84}
85 
86interface WranglerStatementResult {
87 success: boolean;
88 results?: Array<Record<string, unknown>>;
89 error?: string;
90}
91 
92interface SeedBootstrapInviteJsonOutput {
93 mode: Options["mode"];
94 database: string;
95 inviteId: string;
96 email: string;
97 token: string;
98 inviteUrl: string;
99 createdAt: string;
100 expiresAt: string;
101 createdBy: string;
102 dryRun: boolean;
103}
104 
105class CliError extends Error {}
106 
107const printHelp = (): void => {
108 process.stdout.write(HELP_TEXT);
109};
110 
111const escapeSqlString = (value: string): string => `'${value.replace(/'/g, "''")}'`;
112
113const parseNumberFlag = (name: string, rawValue: string): number => {
114 const value = Number(rawValue);
115 if (!Number.isFinite(value)) {
116 throw new CliError(`${name} must be a valid number.`);
117 }
118 return value;
119};
120
121const parseArguments = (argv: string[]): ParsedArguments => {
122 if (argv.length === 0) {
123 return { options: null, requestedHelp: false };
124 }
125
126 let mode: Options["mode"] | null = null;
127 let email: string | null = null;
128 let database = DEFAULT_DATABASE;
129 let expiresInDays = DEFAULT_EXPIRY_DAYS;
130 let issuer: string | null = null;
131 let force = false;
132 let dryRun = false;
133 let printSql = false;
134 let json = false;
135 let requestedHelp = false;
136
137 for (let index = 0; index < argv.length; index += 1) {
138 const argument = argv[index];
139
140 switch (argument) {
141 case "--help":
142 case "-h":
143 requestedHelp = true;
144 break;
145 case "--local":
146 if (mode && mode !== "local") {
147 throw new CliError("Specify exactly one of --local or --remote.");
148 }
149 mode = "local";
150 break;
151 case "--remote":
152 if (mode && mode !== "remote") {
153 throw new CliError("Specify exactly one of --local or --remote.");
154 }
155 mode = "remote";
156 break;
157 case "--force":
158 force = true;
159 break;
160 case "--dry-run":
161 dryRun = true;
162 break;
163 case "--print-sql":
164 printSql = true;
165 break;
166 case "--json":
167 json = true;
168 break;
169 case "--email":
170 case "--database":
171 case "--expires-in-days":
172 case "--issuer": {
173 const rawValue = argv[index + 1];
174 if (!rawValue || rawValue.startsWith("--")) {
175 throw new CliError(`${argument} requires a value.`);
176 }
177 index += 1;
178 if (argument === "--email") {
179 email = rawValue;
180 } else if (argument === "--database") {
181 database = rawValue;
182 } else if (argument === "--expires-in-days") {
183 expiresInDays = parseNumberFlag(argument, rawValue);
184 } else {
185 issuer = rawValue;
186 }
187 break;
188 }
189 default:
190 throw new CliError(`Unknown argument: ${argument}`);
191 }
192 }
193
194 if (requestedHelp) {
195 return { options: null, requestedHelp: true };
196 }
197
198 if (!mode) {
199 return { options: null, requestedHelp: false };
200 }
201
202 if (!email) {
203 throw new CliError("--email is required.");
204 }
205
206 const normalizedEmail = email.trim().toLowerCase();
207 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
208 throw new CliError(`--email "${email}" is not a valid email address.`);
209 }
210
211 if (!database.trim()) {
212 throw new CliError("--database cannot be empty.");
213 }
214
215 if (!Number.isFinite(expiresInDays) || expiresInDays <= 0 || expiresInDays > MAX_EXPIRY_DAYS) {
216 throw new CliError(`--expires-in-days must be between 1 and ${MAX_EXPIRY_DAYS}.`);
217 }
218
219 return {
220 options: {
221 mode,
222 email: normalizedEmail,
223 database: database.trim(),
224 expiresInDays,
225 issuer: issuer ?? (mode === "remote" ? DEFAULT_REMOTE_ISSUER : DEFAULT_LOCAL_ISSUER),
226 force,
227 dryRun,
228 printSql,
229 json,
230 },
231 requestedHelp: false,
232 };
233};
234
235const createWranglerArgs = (
236 options: Pick<Options, "database" | "mode">,
237 payload: { command: string; file?: never } | { command?: never; file: string },
238 json: boolean,
239): string[] => {
240 const args = [
241 "wrangler",
242 "d1",
243 "execute",
244 options.database,
245 options.mode === "local" ? "--local" : "--remote",
246 "--yes",
247 ];
248
249 if ("file" in payload && payload.file !== undefined) {
250 args.push("--file", payload.file);
251 } else {
252 args.push("--command", payload.command);
253 }
254
255 if (json) {
256 args.push("--json");
257 }
258
259 return args;
260};
261
262const formatCommandFailure = (error: unknown): string => {
263 if (error && typeof error === "object" && "stderr" in error) {
264 const stderr = String((error as { stderr?: unknown }).stderr ?? "").trim();
265 const stdout = String((error as { stdout?: unknown }).stdout ?? "").trim();
266 if (stderr) return stderr;
267 if (stdout) return stdout;
268 }
269 return error instanceof Error ? error.message : String(error);
270};
271
272const runWranglerJson = async (
273 options: Pick<Options, "database" | "mode">,
274 command: string,
275): Promise<WranglerStatementResult[]> => {
276 const args = createWranglerArgs(options, { command }, true);
277
278 let stdout: string;
279 try {
280 ({ stdout } = await execFile("npx", args, {
281 cwd: REPO_ROOT,
282 maxBuffer: 10 * 1024 * 1024,
283 }));
284 } catch (error) {
285 throw new CliError(formatCommandFailure(error));
286 }
287
288 let parsed: unknown;
289 try {
290 parsed = JSON.parse(stdout);
291 } catch (error) {
292 throw new CliError(
293 `Failed to parse Wrangler JSON output: ${error instanceof Error ? error.message : String(error)}`,
294 );
295 }
296
297 if (!Array.isArray(parsed)) {
298 throw new CliError("Wrangler returned an unexpected JSON payload.");
299 }
300
301 const statements = parsed as WranglerStatementResult[];
302 const failed = statements.find((statement) => !statement.success);
303 if (failed) {
304 throw new CliError(failed.error ?? "Wrangler reported a failed statement.");
305 }
306
307 return statements;
308};
309
310const runWranglerFile = async (
311 options: Pick<Options, "database" | "mode">,
312 file: string,
313 quiet: boolean,
314): Promise<void> => {
315 const args = createWranglerArgs(options, { file }, false);
316
317 try {
318 const { stdout, stderr } = await execFile("npx", args, {
319 cwd: REPO_ROOT,
320 maxBuffer: 10 * 1024 * 1024,
321 });
322 if (!quiet && stdout.trim()) process.stdout.write(`${stdout.trim()}\n`);
323 if (!quiet && stderr.trim()) process.stderr.write(`${stderr.trim()}\n`);
324 } catch (error) {
325 throw new CliError(formatCommandFailure(error));
326 }
327};
328
329const getNumericCell = (statements: WranglerStatementResult[], statementIndex: number, key: string): number => {
330 const rawValue = statements[statementIndex]?.results?.[0]?.[key];
331 if (typeof rawValue === "number") return rawValue;
332 if (typeof rawValue === "string" && rawValue.length > 0) {
333 const parsed = Number(rawValue);
334 if (Number.isFinite(parsed)) return parsed;
335 }
336 throw new CliError(`Wrangler response did not include a numeric ${key} value.`);
337};
338
339const buildBootstrapInviteSql = (params: {
340 inviteId: string;
341 tokenHash: string;
342 email: string;
343 createdAt: string;
344 expiresAt: string;
345 replaceExisting: boolean;
346}): string => {
347 const lines: string[] = [];
348
349 if (params.replaceExisting) {
350 lines.push(
351 `DELETE FROM invites WHERE created_by = ${escapeSqlString(BOOTSTRAP_CREATED_BY)} AND consumed_at IS NULL;`,
352 );
353 }
354
355 lines.push(
356 `INSERT INTO invites (id, token_hash, email, created_by, created_at, expires_at) VALUES (${escapeSqlString(params.inviteId)}, ${escapeSqlString(params.tokenHash)}, ${escapeSqlString(params.email)}, ${escapeSqlString(BOOTSTRAP_CREATED_BY)}, ${escapeSqlString(params.createdAt)}, ${escapeSqlString(params.expiresAt)});`,
357 );
358
359 return `${lines.join("\n")}\n`;
360};
361
362const main = async (): Promise<void> => {
363 const { options, requestedHelp } = parseArguments(process.argv.slice(2));
364
365 if (requestedHelp) {
366 printHelp();
367 return;
368 }
369
370 if (!options) {
371 printHelp();
372 throw new CliError("Missing required mode flag. Specify either --local or --remote.");
373 }
374
375 const nowIso = new Date().toISOString();
376 const preflight = await runWranglerJson(
377 options,
378 [
379 `SELECT COUNT(*) AS userCount FROM users`,
380 `SELECT COUNT(*) AS activeBootstrapInviteCount FROM invites WHERE created_by = ${escapeSqlString(BOOTSTRAP_CREATED_BY)} AND consumed_at IS NULL AND expires_at > ${escapeSqlString(nowIso)}`,
381 ].join("; "),
382 );
383
384 const userCount = getNumericCell(preflight, 0, "userCount");
385 const activeBootstrapInviteCount = getNumericCell(preflight, 1, "activeBootstrapInviteCount");
386
387 if (userCount > 0) {
388 throw new CliError(
389 `Refusing to seed a bootstrap invite because ${userCount} user row${userCount === 1 ? "" : "s"} already exist. Mint future invites through /admin/invites.`,
390 );
391 }
392
393 if (activeBootstrapInviteCount > 0 && !options.force) {
394 throw new CliError(
395 `Refusing to seed: ${activeBootstrapInviteCount} unused bootstrap invite${activeBootstrapInviteCount === 1 ? " already exists" : "s already exist"}. Re-run with --force to replace.`,
396 );
397 }
398
399 const tokenBytes = crypto.getRandomValues(new Uint8Array(INVITE_TOKEN_BYTES));
400 const token = encodeBase64Url(tokenBytes);
401 const tokenHash = await sha256(token);
402 const idBytes = crypto.getRandomValues(new Uint8Array(INVITE_ID_BYTES));
403 const inviteId = `inv_${encodeBase64Url(idBytes)}`;
404 const createdAtDate = new Date();
405 const expiresAtDate = new Date(createdAtDate.getTime() + options.expiresInDays * 86_400_000);
406 const createdAt = createdAtDate.toISOString();
407 const expiresAt = expiresAtDate.toISOString();
408
409 const sql = buildBootstrapInviteSql({
410 inviteId,
411 tokenHash,
412 email: options.email,
413 createdAt,
414 expiresAt,
415 replaceExisting: options.force && activeBootstrapInviteCount > 0,
416 });
417
418 if (options.printSql) {
419 process.stdout.write(sql);
420 }
421
422 if (!options.dryRun) {
423 const tempDirectory = await mkdtemp(join(tmpdir(), "tessera-bootstrap-invite-"));
424 const sqlFile = join(tempDirectory, "seed-bootstrap-invite.sql");
425 try {
426 await writeFile(sqlFile, sql, "utf8");
427 await runWranglerFile(options, sqlFile, options.json);
428 } finally {
429 await rm(tempDirectory, { recursive: true, force: true });
430 }
431 }
432
433 const inviteUrl = `${options.issuer.replace(/\/+$/, "")}/invite/${token}`;
434
435 if (options.json) {
436 const output: SeedBootstrapInviteJsonOutput = {
437 mode: options.mode,
438 database: options.database,
439 inviteId,
440 email: options.email,
441 token,
442 inviteUrl,
443 createdAt,
444 expiresAt,
445 createdBy: BOOTSTRAP_CREATED_BY,
446 dryRun: options.dryRun,
447 };
448 process.stdout.write(`${JSON.stringify(output)}\n`);
449 return;
450 }
451
452 const lines = [
453 options.dryRun
454 ? "Bootstrap invite dry run complete. No database changes were made."
455 : "Bootstrap invite seeded successfully.",
456 `Mode: ${options.mode}`,
457 `Database: ${options.database}`,
458 `Invite ID: ${inviteId}`,
459 `Email: ${options.email}`,
460 `Created at: ${createdAt}`,
461 `Expires at: ${expiresAt}`,
462 `Invite URL: ${inviteUrl}`,
463 "",
464 `Next: set BOOTSTRAP_ADMIN_EMAIL=${options.email} in ${options.mode === "remote" ? "wrangler.jsonc vars" : ".dev.vars"}, open the invite URL, then remove BOOTSTRAP_ADMIN_EMAIL and redeploy.`,
465 options.dryRun
466 ? "Warning: this dry-run token was not inserted and will not work."
467 : "Warning: treat this token as a one-time secret until it is redeemed or expires.",
468 ];
469
470 process.stdout.write(`${lines.join("\n")}\n`);
471};
472
473await main().catch((error: unknown) => {
474 process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
475 process.exitCode = 1;
476});
477