File
Blob: scripts/seed-bootstrap-invite.ts
| 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 | */ |
| 22 | import { execFile as execFileCallback } from "node:child_process"; |
| 23 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 24 | import { tmpdir } from "node:os"; |
| 25 | import { dirname, join, resolve } from "node:path"; |
| 26 | import process from "node:process"; |
| 27 | import { promisify } from "node:util"; |
| 28 | import { fileURLToPath } from "node:url"; |
| 29 | |
| 30 | import { encodeBase64Url, sha256 } from "@/worker/services/crypto"; |
| 31 | |
| 32 | const execFile = promisify(execFileCallback); |
| 33 | |
| 34 | const DEFAULT_DATABASE = "tessera-prod"; |
| 35 | const DEFAULT_EXPIRY_DAYS = 7; |
| 36 | const MAX_EXPIRY_DAYS = 90; |
| 37 | const DEFAULT_REMOTE_ISSUER = "https://auth.limic.dev"; |
| 38 | const DEFAULT_LOCAL_ISSUER = "http://localhost:5174"; |
| 39 | const BOOTSTRAP_CREATED_BY = "bootstrap"; |
| 40 | const INVITE_TOKEN_BYTES = 32; |
| 41 | const INVITE_ID_BYTES = 12; |
| 42 | |
| 43 | const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); |
| 44 | const REPO_ROOT = resolve(SCRIPT_DIR, ".."); |
| 45 | |
| 46 | const HELP_TEXT = `Seed the first bootstrap invite for tessera. |
| 47 | |
| 48 | Usage: |
| 49 | npm run db:seed-initial-user -- --email <email> [options] |
| 50 | npm run db:seed-initial-user:local -- --email <email> [options] |
| 51 | |
| 52 | Options: |
| 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 | |
| 69 | interface 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 | |
| 81 | interface ParsedArguments { |
| 82 | options: Options | null; |
| 83 | requestedHelp: boolean; |
| 84 | } |
| 85 | |
| 86 | interface WranglerStatementResult { |
| 87 | success: boolean; |
| 88 | results?: Array<Record<string, unknown>>; |
| 89 | error?: string; |
| 90 | } |
| 91 | |
| 92 | interface 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 | |
| 105 | class CliError extends Error {} |
| 106 | |
| 107 | const printHelp = (): void => { |
| 108 | process.stdout.write(HELP_TEXT); |
| 109 | }; |
| 110 | |
| 111 | const escapeSqlString = (value: string): string => `'${value.replace(/'/g, "''")}'`; |
| 112 | |
| 113 | const 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 | |
| 121 | const 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 | |
| 235 | const 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 | |
| 262 | const 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 | |
| 272 | const 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 | |
| 310 | const 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 | |
| 329 | const 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 | |
| 339 | const 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 | |
| 362 | const 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 | |
| 473 | await main().catch((error: unknown) => { |
| 474 | process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); |
| 475 | process.exitCode = 1; |
| 476 | }); |
| 477 |