Repository Public MIT
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