SPB Git

Repository Public MIT

auth.ts 23.8 KB 396 lines typescript

1import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";2import {3  ALL_SCOPES, DEFAULT_TOKEN_TTL_DAYS, ELEVATED_MAX_TTL_HOURS, ELEVATED_SCOPES, MAX_TOKEN_TTL_DAYS, NAMESPACE_RE, RESERVED_NAMESPACES, TOKEN_RE, type Scope,4} from "@skills/registry-core";5import { audit } from "./audit";6import type { Ctx, Principal, UserRow } from "./context";7import { generateApiToken, generateSessionToken, safeEqualHex, verifier } from "./crypto";8import { ApiError, badRequest, conflict, forbidden, notFound, unauthorized } from "./errors";9import { isLimited, rateLimit } from "./ratelimit";1011const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 } as const;12export const SESSION_TTL_HOURS = 12;13export const REAUTH_WINDOW_MIN = 15;14export const SESSION_COOKIE = "skills_session";1516const USER_COLS = ["id", "email", "display_name", "platform_role", "status", "must_change_password", "email_verified_at"] as const;1718export function validatePassword(pw: string) {19  if (typeof pw !== "string" || pw.length < 12 || pw.length > 200) throw badRequest("Password must be between 12 and 200 characters.");20}21export async function hashPassword(pw: string): Promise<string> {22  validatePassword(pw);23  return argonHash(pw, ARGON);24}2526export function isPlatformAdmin(u: UserRow | null): boolean {27  return !!u && u.status === "active" && (u.platform_role === "owner" || u.platform_role === "admin");28}2930/* ───────────────────────── sessions ───────────────────────── */3132export async function login(ctx: Ctx, email: string, password: string): Promise<{ sessionToken: string; user: UserRow; expiresAt: Date }> {33  const key = `${ctx.ip}:${String(email).toLowerCase().slice(0, 200)}`;34  rateLimit("login", key);35  const [row] = await ctx.sql<(UserRow & { password_hash: string | null })[]>`SELECT ${ctx.sql(USER_COLS as unknown as string[])}, password_hash FROM users WHERE email = ${String(email)}`;36  // Verify against a dummy hash when the user is unknown so timing does not reveal account existence.37  const DUMMY = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Q0nYbKQ6v3b7mQ2c3wZb8c8P4y5nJk1pE0o0m9Zr1uA";38  let ok = false;39  try {40    ok = await argonVerify(row?.password_hash ?? DUMMY, String(password));41  } catch {42    ok = false;43  }44  if (!row || !ok || row.status !== "active") {45    await audit({ ...ctx, principal: { kind: "anonymous" } }, { action: "auth.login", result: "denied", targetType: "user", targetLabel: String(email).slice(0, 200) });46    throw unauthorized("Invalid email or password.", "invalid_credentials");47  }48  const s = generateSessionToken();49  const expiresAt = new Date(Date.now() + SESSION_TTL_HOURS * 3600_000);50  const [sess] = await ctx.sql<{ id: string }[]>`51    INSERT INTO sessions (user_id, token_hash, csrf_secret, expires_at, ip, user_agent)52    VALUES (${row.id}, ${s.hash}, ${s.csrfSecret}, ${expiresAt}, ${ctx.ip}, ${ctx.userAgent.slice(0, 300)}) RETURNING id`;53  const { password_hash: _ph, ...user } = row;54  await audit({ ...ctx, principal: { kind: "session", user, sessionId: sess!.id, reauthAt: new Date(), csrfSecret: s.csrfSecret } }, { action: "auth.login", targetType: "user", targetId: user.id });55  return { sessionToken: s.token, user, expiresAt };56}5758export async function principalFromSession(ctx: Ctx, sessionToken: string | undefined | null): Promise<Principal> {59  if (!sessionToken || sessionToken.length < 20 || sessionToken.length > 100) return { kind: "anonymous" };60  const [r] = await ctx.sql<(UserRow & { sid: string; reauth_at: Date; csrf_secret: string })[]>`61    SELECT u.id, u.email, u.display_name, u.platform_role, u.status, u.must_change_password, u.email_verified_at,62           s.id AS sid, s.reauth_at, s.csrf_secret63    FROM sessions s JOIN users u ON u.id = s.user_id64    WHERE s.token_hash = ${verifier("session", sessionToken)} AND s.revoked_at IS NULL AND s.expires_at > now() AND u.status = 'active'`;65  if (!r) return { kind: "anonymous" };66  const { sid, reauth_at, csrf_secret, ...user } = r;67  return { kind: "session", user, sessionId: sid, reauthAt: reauth_at, csrfSecret: csrf_secret };68}6970export async function logout(ctx: Ctx) {71  if (ctx.principal.kind !== "session") return;72  await ctx.sql`UPDATE sessions SET revoked_at = now() WHERE id = ${ctx.principal.sessionId}`;73  await audit(ctx, { action: "auth.logout" });74}7576/** Step-up authentication for sensitive actions. */77export async function reauthenticate(ctx: Ctx, password: string) {78  if (ctx.principal.kind !== "session") throw unauthorized();79  rateLimit("login", `${ctx.ip}:reauth:${ctx.principal.user.id}`);80  const [row] = await ctx.sql<{ password_hash: string | null }[]>`SELECT password_hash FROM users WHERE id = ${ctx.principal.user.id}`;81  const ok = row?.password_hash ? await argonVerify(row.password_hash, String(password)).catch(() => false) : false;82  if (!ok) {83    await audit(ctx, { action: "auth.reauth", result: "denied" });84    throw unauthorized("Password is incorrect.", "invalid_credentials");85  }86  await ctx.sql`UPDATE sessions SET reauth_at = now() WHERE id = ${ctx.principal.sessionId}`;87  ctx.principal.reauthAt = new Date();88  await audit(ctx, { action: "auth.reauth" });89}9091export function hasRecentReauth(p: Principal): boolean {92  if (p.kind === "local") return true;93  return p.kind === "session" && Date.now() - p.reauthAt.getTime() < REAUTH_WINDOW_MIN * 60_000;94}95export function requireRecentReauth(p: Principal) {96  if (!hasRecentReauth(p)) throw new ApiError(403, "reauth_required", `This action requires re-entering your password (valid for ${REAUTH_WINDOW_MIN} minutes).`);97}9899export async function changePassword(ctx: Ctx, current: string, next: string) {100  if (ctx.principal.kind !== "session") throw unauthorized();101  await reauthenticate(ctx, current);102  if (current === next) throw badRequest("The new password must differ from the current one.");103  const h = await hashPassword(next);104  const uid = ctx.principal.user.id;105  await ctx.sql`UPDATE users SET password_hash = ${h}, must_change_password = false, updated_at = now() WHERE id = ${uid}`;106  await ctx.sql`UPDATE sessions SET revoked_at = now() WHERE user_id = ${uid} AND id <> ${ctx.principal.sessionId} AND revoked_at IS NULL`;107  await audit(ctx, { action: "auth.password_change", targetType: "user", targetId: uid });108}109110/* ───────────────────────── authorization ───────────────────────── */111112export function requireUser(p: Principal): UserRow {113  if (p.kind === "anonymous") throw unauthorized();114  if (p.kind === "local") {115    if (!p.user) throw unauthorized("This local command must act on behalf of a user.");116    return p.user;117  }118  return p.user;119}120121/** Sessions and the local operator carry the user's full rights; tokens carry only their scopes. */122export function hasScope(p: Principal, scope: Scope): boolean {123  if (p.kind === "anonymous") return false;124  if (p.kind === "token") return p.token.scopes.includes(scope);125  if (ELEVATED_SCOPES.includes(scope)) return isPlatformAdmin(p.user);126  return true;127}128export function requireScope(p: Principal, scope: Scope) {129  requireUser(p);130  if (!hasScope(p, scope)) throw forbidden(`This credential lacks the "${scope}" scope.`, "insufficient_scope", { required: scope });131  if (ELEVATED_SCOPES.includes(scope) && !isPlatformAdmin(requireUser(p))) throw forbidden("Platform administrator role required.", "admin_required");132}133134export interface NamespaceRow {135  id: string;136  name: string;137  display_name: string | null;138  owner_user_id: string | null;139  owner_org_id: string | null;140  identity_verified: boolean;141}142143export async function getNamespace(ctx: Ctx, name: string): Promise<NamespaceRow | null> {144  if (!NAMESPACE_RE.test(name)) return null;145  const [n] = await ctx.sql<NamespaceRow[]>`SELECT id, name::text AS name, display_name, owner_user_id, owner_org_id, identity_verified FROM namespaces WHERE name = ${name}`;146  return n ?? null;147}148149/** Ownership or organisation role. Platform admins do NOT get implicit publish rights in other namespaces. */150export async function userCanPublishTo(ctx: Ctx, user: UserRow, ns: NamespaceRow): Promise<boolean> {151  if (user.status !== "active") return false;152  if (ns.owner_user_id) return ns.owner_user_id === user.id;153  const [m] = await ctx.sql<{ role: string }[]>`SELECT role FROM memberships WHERE user_id = ${user.id} AND organization_id = ${ns.owner_org_id}`;154  return !!m && (m.role === "org_admin" || m.role === "publisher");155}156157export async function canWriteNamespace(ctx: Ctx, ns: NamespaceRow): Promise<boolean> {158  const p = ctx.principal;159  if (p.kind === "anonymous" || !p.user) return false;160  if (p.kind === "token" && !p.token.namespaces.includes(ns.name)) return false;161  return userCanPublishTo(ctx, p.user, ns);162}163export async function requireNamespaceWrite(ctx: Ctx, ns: NamespaceRow, scope: Scope) {164  requireScope(ctx.principal, scope);165  if (!(await canWriteNamespace(ctx, ns))) {166    await audit(ctx, { action: "authz.namespace_denied", result: "denied", targetType: "namespace", targetId: ns.id, targetLabel: ns.name, detail: { scope } });167    throw forbidden(`This credential is not allowed to modify the "${ns.name}" namespace.`, "namespace_forbidden");168  }169}170171export async function listWritableNamespaces(ctx: Ctx, user: UserRow): Promise<string[]> {172  const rows = await ctx.sql<{ name: string }[]>`173    SELECT n.name::text AS name FROM namespaces n174    WHERE n.owner_user_id = ${user.id}175       OR n.owner_org_id IN (SELECT organization_id FROM memberships WHERE user_id = ${user.id} AND role IN ('org_admin','publisher'))176    ORDER BY 1`;177  return rows.map((r) => r.name);178}179180export async function createNamespace(ctx: Ctx, name: string, ownerUserId: string, opts: { displayName?: string; identityVerified?: boolean; allowReserved?: boolean } = {}) {181  if (!NAMESPACE_RE.test(name)) throw badRequest("Namespace must be 1-39 lowercase letters, digits or single hyphens.");182  if (RESERVED_NAMESPACES.has(name) && !opts.allowReserved) throw conflict(`Namespace "${name}" is reserved.`, "namespace_reserved");183  const [exists] = await ctx.sql`SELECT 1 FROM namespaces WHERE name = ${name}`;184  if (exists) throw conflict(`Namespace "${name}" already exists.`, "namespace_taken");185  const [n] = await ctx.sql<NamespaceRow[]>`186    INSERT INTO namespaces (name, display_name, owner_user_id, identity_verified) VALUES (${name}, ${opts.displayName ?? null}, ${ownerUserId}, ${opts.identityVerified ?? false})187    RETURNING id, name::text AS name, display_name, owner_user_id, owner_org_id, identity_verified`;188  await audit(ctx, { action: "namespace.create", targetType: "namespace", targetId: n!.id, targetLabel: name, detail: { ownerUserId } });189  return n!;190}191192/* ───────────────────────── API tokens ───────────────────────── */193194export interface TokenRow {195  id: string;196  user_id: string;197  name: string;198  prefix: string;199  scopes: Scope[];200  namespaces: string[];201  ip_allowlist: string[];202  elevated: boolean;203  expires_at: Date;204  last_used_at: Date | null;205  last_used_ip: string | null;206  revoked_at: Date | null;207  revoked_reason: string | null;208  rotated_from: string | null;209  created_at: Date;210}211const TOKEN_COLS = ["id", "user_id", "name", "prefix", "scopes", "namespaces", "ip_allowlist", "elevated", "expires_at", "last_used_at", "last_used_ip", "revoked_at", "revoked_reason", "rotated_from", "created_at"];212213export function publicToken(t: TokenRow) {214  return {215    id: t.id, name: t.name, prefix: t.prefix, scopes: t.scopes, namespaces: t.namespaces, ipAllowlist: t.ip_allowlist, elevated: t.elevated,216    expiresAt: t.expires_at, lastUsedAt: t.last_used_at, lastUsedIp: t.last_used_ip, revokedAt: t.revoked_at, revokedReason: t.revoked_reason,217    rotatedFrom: t.rotated_from, createdAt: t.created_at,218    status: t.revoked_at ? "revoked" : t.expires_at.getTime() <= Date.now() ? "expired" : "active",219  };220}221222function ipv4ToInt(ip: string): number | null {223  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);224  if (!m) return null;225  const p = m.slice(1).map(Number);226  if (p.some((n) => n > 255)) return null;227  return ((p[0]! << 24) | (p[1]! << 16) | (p[2]! << 8) | p[3]!) >>> 0;228}229export function validIpRule(rule: string): boolean {230  const [ip, bits] = rule.split("/");231  if (ipv4ToInt(ip!) !== null) return bits === undefined || (/^\d{1,2}$/.test(bits) && Number(bits) <= 32);232  return bits === undefined && /^[0-9a-fA-F:]{2,45}$/.test(ip!) && ip!.includes(":");233}234export function ipAllowed(ip: string, rules: string[]): boolean {235  if (rules.length === 0) return true;236  const n = ipv4ToInt(ip.replace(/^::ffff:/, ""));237  return rules.some((rule) => {238    const [base, bits] = rule.split("/");239    const b = ipv4ToInt(base!);240    if (b === null || n === null) return base!.toLowerCase() === ip.toLowerCase();241    const k = bits === undefined ? 32 : Number(bits);242    const mask = k === 0 ? 0 : (~0 << (32 - k)) >>> 0;243    return (n & mask) === (b & mask);244  });245}246247export interface CreateTokenInput {248  name: string;249  scopes: string[];250  namespaces?: string[];251  expiresInHours?: number;252  ipAllowlist?: string[];253}254255export async function createToken(ctx: Ctx, input: CreateTokenInput, opts: { rotatedFrom?: string } = {}): Promise<{ token: string; row: TokenRow }> {256  const p = ctx.principal;257  const user = requireUser(p);258  const name = String(input.name ?? "").trim();259  if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{1,62}$/.test(name)) throw badRequest("Token name must be 2-63 characters: letters, digits, space, dot, underscore, hyphen.");260  const scopes = [...new Set(input.scopes ?? [])];261  if (scopes.length === 0) throw badRequest("At least one scope is required.");262  const unknown = scopes.filter((s) => !(ALL_SCOPES as string[]).includes(s));263  if (unknown.length) throw badRequest(`Unknown scopes: ${unknown.join(", ")}.`, { validScopes: ALL_SCOPES });264  const elevated = scopes.some((s) => (ELEVATED_SCOPES as string[]).includes(s));265  const namespaces = [...new Set(input.namespaces ?? [])];266  const ipAllowlist = [...new Set(input.ipAllowlist ?? [])];267  if (ipAllowlist.length > 16 || ipAllowlist.some((r) => !validIpRule(r))) throw badRequest("ipAllowlist accepts up to 16 IPv4 addresses, IPv4 CIDR ranges or exact IPv6 addresses.");268269  const maxHours = elevated ? ELEVATED_MAX_TTL_HOURS : MAX_TOKEN_TTL_DAYS * 24;270  let hours = input.expiresInHours ?? (elevated ? ELEVATED_MAX_TTL_HOURS : DEFAULT_TOKEN_TTL_DAYS * 24);271  if (!Number.isFinite(hours) || hours < 1 / 60) throw badRequest("expiresInHours must be positive.");272  if (hours > maxHours) throw badRequest(elevated ? `Elevated tokens expire within ${ELEVATED_MAX_TTL_HOURS} hours.` : `Tokens expire within ${MAX_TOKEN_TTL_DAYS} days.`);273  let expiresAt = new Date(Date.now() + hours * 3600_000);274275  if (p.kind === "token") {276    // A token can never mint something broader than itself.277    requireScope(p, "tokens:write");278    if (elevated) throw forbidden("Elevated tokens can only be issued from an interactive, re-authenticated session or the server console.", "elevation_forbidden");279    const extra = scopes.filter((s) => !p.token.scopes.includes(s as Scope));280    if (extra.length) throw forbidden(`A token cannot grant scopes it does not hold: ${extra.join(", ")}.`, "scope_escalation");281    const extraNs = namespaces.filter((n) => !p.token.namespaces.includes(n));282    if (extraNs.length) throw forbidden(`A token cannot grant namespaces it does not hold: ${extraNs.join(", ")}.`, "namespace_escalation");283    if (expiresAt > p.token.expiresAt && !opts.rotatedFrom) expiresAt = p.token.expiresAt;284  } else if (p.kind === "session") {285    requireRecentReauth(p);286  }287  if (elevated && !isPlatformAdmin(user)) throw forbidden("Only platform administrators can hold elevated scopes.", "admin_required");288289  const writable = await listWritableNamespaces(ctx, user);290  const bad = namespaces.filter((n) => !writable.includes(n));291  if (bad.length) throw forbidden(`You cannot publish to: ${bad.join(", ")}.`, "namespace_forbidden");292  const needsNs = scopes.some((s) => /^(artifacts|uploads|releases):/.test(s));293  if (needsNs && namespaces.length === 0) throw badRequest("Publishing scopes require at least one allowed namespace.");294295  const [cnt] = await ctx.sql<{ n: number }[]>`SELECT count(*)::int AS n FROM api_tokens WHERE user_id = ${user.id} AND revoked_at IS NULL AND expires_at > now()`;296  if (cnt!.n >= 50) throw conflict("Too many active tokens (limit 50). Revoke unused tokens first.", "token_quota");297298  const g = generateApiToken();299  const [row] = await ctx.sql<TokenRow[]>`300    INSERT INTO api_tokens (user_id, name, prefix, token_hash, scopes, namespaces, ip_allowlist, elevated, expires_at, rotated_from)301    VALUES (${user.id}, ${name}, ${g.prefix}, ${g.hash}, ${scopes}, ${namespaces}, ${ipAllowlist}, ${elevated}, ${expiresAt}, ${opts.rotatedFrom ?? null})302    RETURNING ${ctx.sql(TOKEN_COLS)}`;303  await audit(ctx, { action: opts.rotatedFrom ? "token.rotate" : "token.create", targetType: "api_token", targetId: row!.id, targetLabel: `${g.prefix} (${name})`, detail: { scopes, namespaces, elevated, expiresAt, ipAllowlist, rotatedFrom: opts.rotatedFrom } });304  return { token: g.token, row: row! };305}306307async function ownedToken(ctx: Ctx, id: string): Promise<TokenRow> {308  const user = requireUser(ctx.principal);309  if (!/^[0-9a-f-]{36}$/.test(id)) throw notFound("Token");310  const [t] = await ctx.sql<TokenRow[]>`SELECT ${ctx.sql(TOKEN_COLS)} FROM api_tokens WHERE id = ${id} AND user_id = ${user.id}`;311  if (!t) throw notFound("Token");312  return t;313}314315export async function listTokens(ctx: Ctx): Promise<TokenRow[]> {316  const user = requireUser(ctx.principal);317  requireScope(ctx.principal, "tokens:read");318  return ctx.sql<TokenRow[]>`SELECT ${ctx.sql(TOKEN_COLS)} FROM api_tokens WHERE user_id = ${user.id} ORDER BY created_at DESC LIMIT 200`;319}320321export async function revokeToken(ctx: Ctx, id: string, reason = "revoked by owner"): Promise<TokenRow> {322  const t = await ownedToken(ctx, id);323  const p = ctx.principal;324  // A token may always revoke itself; revoking others needs tokens:write.325  if (p.kind === "token" && p.token.id !== t.id) requireScope(p, "tokens:write");326  if (t.revoked_at) return t;327  const [r] = await ctx.sql<TokenRow[]>`UPDATE api_tokens SET revoked_at = now(), revoked_reason = ${reason.slice(0, 200)} WHERE id = ${t.id} RETURNING ${ctx.sql(TOKEN_COLS)}`;328  await audit(ctx, { action: "token.revoke", targetType: "api_token", targetId: t.id, targetLabel: `${t.prefix} (${t.name})`, detail: { reason } });329  return r!;330}331332/** Issue a replacement with the same grants and lifetime, then revoke the old token atomically. */333export async function rotateToken(ctx: Ctx, id: string): Promise<{ token: string; row: TokenRow }> {334  const old = await ownedToken(ctx, id);335  const p = ctx.principal;336  if (p.kind === "token" && p.token.id !== old.id) requireScope(p, "tokens:write");337  if (old.revoked_at) throw conflict("This token is revoked and cannot be rotated.", "token_revoked");338  if (old.expires_at.getTime() <= Date.now()) throw conflict("This token has expired; create a new one.", "token_expired");339  if (p.kind === "session") requireRecentReauth(p);340  if (old.elevated && p.kind === "token") throw forbidden("Elevated tokens cannot be rotated non-interactively.", "elevation_forbidden");341  const lifetimeH = Math.max(1, (old.expires_at.getTime() - old.created_at.getTime()) / 3600_000);342  return ctx.sql.begin(async (tx) => {343    const c2: Ctx = { ...ctx, sql: tx as never, principal: p.kind === "token" ? { kind: "local", user: p.user } : p };344    const created = await createToken(c2, { name: old.name, scopes: old.scopes, namespaces: old.namespaces, ipAllowlist: old.ip_allowlist, expiresInHours: Math.min(lifetimeH, (old.elevated ? ELEVATED_MAX_TTL_HOURS : MAX_TOKEN_TTL_DAYS * 24)) }, { rotatedFrom: old.id });345    await tx`UPDATE api_tokens SET revoked_at = now(), revoked_reason = 'rotated' WHERE id = ${old.id}`;346    return created;347  }) as Promise<{ token: string; row: TokenRow }>;348}349350/** Resolve a bearer token. Every failure is the same 401 to the caller; the reason goes to the audit log. */351export async function principalFromBearer(ctx: Ctx, bearer: string): Promise<Principal> {352  if (isLimited("auth-fail", ctx.ip)) throw new ApiError(429, "rate_limited", "Too many failed authentication attempts. Try again later.", undefined, { "Retry-After": "600" });353  const fail = async (reason: string, tokenId?: string, label?: string): Promise<never> => {354    try { rateLimit("auth-fail", ctx.ip); } catch { /* counted; the 401 below is the response */ }355    await audit({ ...ctx, principal: { kind: "anonymous" } }, { action: "auth.token", result: "denied", targetType: "api_token", targetId: tokenId, targetLabel: label, detail: { reason } });356    throw unauthorized(357      reason === "expired" ? "This API token has expired. Create or rotate a token." : reason === "revoked" ? "This API token has been revoked." : "Invalid API token.",358      reason === "expired" ? "token_expired" : reason === "revoked" ? "token_revoked" : "invalid_token",359    );360  };361  const m = TOKEN_RE.exec(bearer);362  if (!m) return fail("malformed");363  const [t] = await ctx.sql<(TokenRow & { token_hash: string })[]>`SELECT ${ctx.sql(TOKEN_COLS)}, token_hash FROM api_tokens WHERE prefix = ${m[1]!}`;364  if (!t || !safeEqualHex(t.token_hash, verifier("api-token", bearer))) return fail("unknown");365  const label = `${t.prefix} (${t.name})`;366  if (t.revoked_at) return fail("revoked", t.id, label);367  if (t.expires_at.getTime() <= Date.now()) return fail("expired", t.id, label);368  if (!ipAllowed(ctx.ip, t.ip_allowlist)) return fail("ip_not_allowed", t.id, label);369  const [user] = await ctx.sql<UserRow[]>`SELECT ${ctx.sql(USER_COLS as unknown as string[])} FROM users WHERE id = ${t.user_id}`;370  if (!user || user.status !== "active") return fail("user_inactive", t.id, label);371  await ctx.sql`UPDATE api_tokens SET last_used_at = now(), last_used_ip = ${ctx.ip} WHERE id = ${t.id} AND (last_used_at IS NULL OR last_used_at < now() - interval '1 minute')`;372  return { kind: "token", user, token: { id: t.id, name: t.name, prefix: t.prefix, scopes: t.scopes, namespaces: t.namespaces, elevated: t.elevated, expiresAt: t.expires_at } };373}374375/* ───────────────────────── bootstrap (local operator only) ───────────────────────── */376377export async function bootstrapOwner(ctx: Ctx, input: { email: string; displayName: string; password: string; namespace: string }) {378  if (ctx.principal.kind !== "local") throw forbidden("Bootstrap is only available from the server console.");379  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input.email)) throw badRequest("A valid owner email is required.");380  const passwordHash = await hashPassword(input.password);381  return ctx.sql.begin(async (tx) => {382    // Fail closed: serialise concurrent bootstraps and refuse when any administrator exists.383    await tx`SELECT pg_advisory_xact_lock(727001)`;384    const [cnt] = await tx<{ n: number }[]>`SELECT count(*)::int AS n FROM users WHERE platform_role IN ('owner','admin')`;385    if (cnt!.n > 0) throw conflict("An administrator already exists. Bootstrap refuses to run again; use `admin recover` from the server console.", "already_bootstrapped");386    const [user] = await tx<UserRow[]>`387      INSERT INTO users (email, email_verified_at, display_name, password_hash, platform_role, must_change_password)388      VALUES (${input.email}, now(), ${input.displayName}, ${passwordHash}, 'owner', true)389      RETURNING ${tx(USER_COLS as unknown as string[])}`;390    const c2: Ctx = { ...ctx, sql: tx as never, principal: { kind: "local", user: user! } };391    await audit(c2, { action: "admin.bootstrap", targetType: "user", targetId: user!.id, targetLabel: input.email, detail: { role: "owner", emailVerifiedBy: "operator-attested" } });392    const ns = await createNamespace(c2, input.namespace, user!.id, { identityVerified: true, displayName: input.displayName, allowReserved: false });393    return { user: user!, namespace: ns };394  });395}396
Files main
Menu

Theme