/** * Gateway-native guardian bootstrap — mints credentials using the * gateway's own SQLite database for token persistence and mirror-writes * contact bindings to the assistant's database via IPC proxy. * * Uses the gateway's own signing key for JWT minting. */ import { createHash, randomBytes } from "node:crypto"; import { and, desc, eq, inArray, ne, sql } from "drizzle-orm"; import { getGatewayDb } from "../db/connection.js"; import { actorRefreshTokenRecords, actorTokenRecords, contacts as gwContacts, contactChannels as gwContactChannels, } from "../db/schema.js"; import { readCredential } from "../credential-reader.js"; import { credentialKey } from "../credential-key.js"; import { arePlatformFeaturesEnabled } from "../feature-flag-resolver.js"; import { reportGuardianMintRefused } from "../guardian-integrity-reporter.js"; import { ipcCallAssistant } from "../ipc/assistant-client.js"; import { getLogger } from "../logger.js"; import { deleteContactIfOrphaned } from "../verification/contact-helpers.js"; import { bustGuardianIntegrityCache, guardianIntegrityState, hasEvidenceOfPriorGuardian, hasGuardianContactWithPrincipal, } from "./guardian-integrity.js"; import { CURRENT_POLICY_EPOCH } from "./policy.js"; import { mintToken } from "./token-service.js"; const log = getLogger("guardian-bootstrap"); // --------------------------------------------------------------------------- // Constants — canonical values for token TTLs and refresh thresholds. // --------------------------------------------------------------------------- /** Access token TTL: 30 days in seconds. */ export const ACCESS_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; /** Access token TTL in ms. */ export const ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL_SECONDS * 1000; /** Refresh token absolute expiry: 365 days. */ export const REFRESH_ABSOLUTE_TTL_MS = 365 * 24 * 60 * 60 * 1000; /** Refresh token inactivity expiry: 90 days. */ export const REFRESH_INACTIVITY_TTL_MS = 90 * 24 * 60 * 60 * 1000; /** Suggest refresh at 80% of access token TTL. */ export const REFRESH_AFTER_FRACTION = 0.8; /** The daemon's internal assistant scope identifier. */ const DAEMON_INTERNAL_ASSISTANT_ID = "self"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface GuardianBootstrapResult { guardianPrincipalId: string; accessToken: string; accessTokenExpiresAt: number; refreshToken: string; refreshTokenExpiresAt: number; refreshAfter: number; isNew: boolean; /** True when the mint overrode evidence of a prior guardian (re-pair). */ mintedOverPriorEvidence: boolean; } /** * Thrown when a vellum guardian mint is refused: the gateway DB has no active * vellum guardian binding but carries evidence of prior onboarding, so a mint * would permanently diverge from prior clients' tokens. Recovery is the * explicit /v1/guardian/init flow. */ export class VellumGuardianMintRefusedError extends Error { constructor() { super( "refusing to mint a vellum guardian principal: the gateway DB has " + "evidence of a prior guardian — re-pair via guardian init to recover", ); this.name = "VellumGuardianMintRefusedError"; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- export function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } function uuid(): string { return crypto.randomUUID(); } export function getExternalAssistantId(): string { return ( process.env.VELLUM_ASSISTANT_NAME?.trim() || DAEMON_INTERNAL_ASSISTANT_ID ); } // --------------------------------------------------------------------------- // Contact operations (via IPC proxy to assistant's DB) // --------------------------------------------------------------------------- /** * Find the existing guardian contact for the "vellum" channel. * Mirrors assistant's `findGuardianForChannel("vellum")`. */ export async function findVellumGuardian(): Promise<{ principalId: string; } | null> { const row = getGatewayDb() .select({ principalId: gwContacts.principalId }) .from(gwContacts) .innerJoin( gwContactChannels, eq(gwContactChannels.contactId, gwContacts.id), ) .where( and( eq(gwContacts.role, "guardian"), eq(gwContactChannels.type, "vellum"), eq(gwContactChannels.status, "active"), ), ) .orderBy(desc(gwContactChannels.verifiedAt)) .limit(1) .get(); return row?.principalId ? { principalId: row.principalId } : null; } /** * Look up the guardian binding for a given external user on a specific * channel type (e.g. `"slack"`, `"telegram"`, `"whatsapp"`). Returns the * guardian's principal ID when the actor is bound as a guardian on an * active channel of that type, or `null` otherwise. * * Used by channel ingress paths to decide whether an inbound message * came from the assistant's owner — see `index.ts` Slack upload flow. */ export async function findGuardianForChannelActor( channelType: string, externalUserId: string, ): Promise<{ principalId: string } | null> { if (!channelType || !externalUserId) return null; const row = getGatewayDb() .select({ principalId: gwContacts.principalId }) .from(gwContacts) .innerJoin( gwContactChannels, eq(gwContactChannels.contactId, gwContacts.id), ) .where( and( eq(gwContacts.role, "guardian"), eq(gwContactChannels.type, channelType), eq(gwContactChannels.status, "active"), sql`${gwContactChannels.address} = ${externalUserId} COLLATE NOCASE`, ), ) .limit(1) .get(); return row?.principalId ? { principalId: row.principalId } : null; } /** * Recover the guardian principal id from the gateway's own actor-token records. * * Actor tokens are minted exclusively for the guardian principal (device * pairing), so an active row names the exact principal the client's JWTs still * carry. This recovers a lost vellum guardian binding on an install whose * contact reconcile could not run: the assistant DB's contact ACL columns were * already dropped (assistant migration 305) by the time the gateway's data * migrations read them, so `contacts` stays empty while the actor tokens * migrated into the gateway DB (m0002) survive. * * Reads only the gateway DB. Prefers the most recently issued ACTIVE token so a * properly offboarded (revoked) guardian is never resurrected, and falls back * to an active refresh token when no access token survives. Returns null when * no active token names a principal. */ export function recoverGuardianPrincipalFromActorTokens(): string | null { const db = getGatewayDb(); const activeAccess = db .select({ guardianPrincipalId: actorTokenRecords.guardianPrincipalId }) .from(actorTokenRecords) .where(eq(actorTokenRecords.status, "active")) .orderBy(desc(actorTokenRecords.issuedAt)) .limit(1) .get(); if (activeAccess?.guardianPrincipalId) { return activeAccess.guardianPrincipalId; } const activeRefresh = db .select({ guardianPrincipalId: actorRefreshTokenRecords.guardianPrincipalId, }) .from(actorRefreshTokenRecords) .where(eq(actorRefreshTokenRecords.status, "active")) .orderBy(desc(actorRefreshTokenRecords.issuedAt)) .limit(1) .get(); return activeRefresh?.guardianPrincipalId ?? null; } // --------------------------------------------------------------------------- // Guardian binding creation — writes to both assistant + gateway DBs // --------------------------------------------------------------------------- export interface CreateGuardianBindingParams { /** Channel type (e.g. "vellum", "telegram", "slack", "phone", "whatsapp"). */ channel: string; /** Canonical external user ID for this channel (pre-canonicalized by caller). */ externalUserId: string; /** Delivery chat/conversation ID for this channel. */ deliveryChatId: string; /** Guardian's principal ID — links all channel bindings to one identity. */ guardianPrincipalId: string; /** Display name for the contact. Defaults to externalUserId. */ displayName?: string; /** How this binding was verified. Defaults to "challenge". */ verifiedVia?: string; /** * Whether this write is backed by a fresh verification/authentication act and * may therefore bring a deliberately-REVOKED binding at the same * (type, address) back to active. Default false (fail-closed): a derived * rebind with no fresh proof — e.g. boot-time token recovery — must never * silently undo a revoke. `blocked` bindings stay terminal regardless. * * Every caller that reaches here from a real verification act (code/voice * challenge, verification-session consume, platform channel-create, * provider-validated email, guardian bootstrap) sets this true; new callers * inherit the safe default until they prove they have one. */ reactivateRevoked?: boolean; } export interface CreateGuardianBindingResult { contactId: string; channelId: string; guardianPrincipalId: string; channel: string; } /** Result of the sync gateway-authoritative writes, consumed by the mirror. */ export interface GuardianBindingGatewayWrites { contactId: string; channelId: string; channel: string; address: string; deliveryChatId: string; displayName: string; guardianPrincipalId: string; /** Contact a claimed channel was re-parented away from (orphan-GC input). */ claimedFromContactId: string | null; } /** * Gateway-authoritative writes for a guardian binding — fully synchronous so * callers can compose it inside a single SQLite transaction (e.g. atomically * with a verification-session consume). Runs the id resolution and the * contact + channel upserts as plain statements; the caller owns the * transaction boundary. */ export function applyGuardianBindingGatewayWrites( params: CreateGuardianBindingParams, ): GuardianBindingGatewayWrites { const now = Date.now(); const displayName = params.displayName ?? params.externalUserId; const verifiedVia = params.verifiedVia ?? "challenge"; // The gateway DB is the source of truth for contact ids; resolve them // directly from it. const gwReadDb = getGatewayDb(); const existingGuardianContact = gwReadDb .select({ id: gwContacts.id }) .from(gwContacts) .where( and( eq(gwContacts.role, "guardian"), eq(gwContacts.principalId, params.guardianPrincipalId), ), ) .limit(1) .get(); const claimableChannel = gwReadDb .select({ id: gwContactChannels.id, contactId: gwContactChannels.contactId, }) .from(gwContactChannels) .where( and( eq(gwContactChannels.type, params.channel), ne(gwContactChannels.status, "blocked"), sql`${gwContactChannels.address} = ${params.externalUserId} COLLATE NOCASE`, ), ) .orderBy( sql`CASE WHEN ${gwContactChannels.contactId} = ${existingGuardianContact?.id ?? ""} THEN 0 ELSE 1 END`, sql`CASE ${gwContactChannels.status} WHEN 'active' THEN 0 WHEN 'unverified' THEN 1 ELSE 2 END`, desc(gwContactChannels.updatedAt), ) .limit(1) .get(); const contactId = existingGuardianContact?.id ?? claimableChannel?.contactId ?? uuid(); const existingChannel = !claimableChannel && existingGuardianContact ? gwReadDb .select({ id: gwContactChannels.id }) .from(gwContactChannels) .where( and( eq(gwContactChannels.contactId, contactId), eq(gwContactChannels.type, params.channel), ), ) .limit(1) .get() : undefined; const channelId = claimableChannel?.id ?? existingChannel?.id ?? uuid(); // --- Gateway DB write (authoritative) --- // The gateway owns the guardian ACL; a failure here means the binding failed, // so let it propagate (rolling back the caller's transaction). const gwDb = getGatewayDb(); gwDb .insert(gwContacts) .values({ id: contactId, displayName, role: "guardian", principalId: params.guardianPrincipalId, createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: gwContacts.id, set: { displayName, role: "guardian", principalId: params.guardianPrincipalId, updatedAt: now, }, }) .run(); const channelSet = { contactId, address: params.externalUserId, externalChatId: params.deliveryChatId, isPrimary: true, status: "active", policy: "allow", verifiedAt: now, verifiedVia, revokedReason: null, blockedReason: null, updatedAt: now, }; // Heal a divergent (type,address) row (m0006): adopt it by its own id // rather than insert and throw on idx_contact_channels_type_address_unique. const existingGw = gwDb .select({ id: gwContactChannels.id, status: gwContactChannels.status, }) .from(gwContactChannels) .where( and( eq(gwContactChannels.type, params.channel), sql`${gwContactChannels.address} = ${params.externalUserId} COLLATE NOCASE`, ), ) .get(); if (existingGw) { // A governance-terminal binding must not be silently resurrected by a // (type, address) code-match. `blocked` is always terminal; `revoked` is a // deliberate downgrade that only a caller with a fresh verification act may // reverse (`reactivateRevoked`). Without that proof — e.g. a derived rebind // from token recovery — the revoke stays intact, fail-closed. This is the // single enforcement point, so no caller (present or future) can turn a // revoke back on without asserting a verification act. const isProtectedFromReactivation = existingGw.status === "blocked" || (existingGw.status === "revoked" && !params.reactivateRevoked); if (!isProtectedFromReactivation) { gwDb .update(gwContactChannels) .set(channelSet) .where(eq(gwContactChannels.id, existingGw.id)) .run(); } } else { gwDb .insert(gwContactChannels) .values({ id: channelId, type: params.channel, interactionCount: 0, createdAt: now, ...channelSet, }) .onConflictDoUpdate({ target: gwContactChannels.id, set: channelSet, }) .run(); } // The guardian row just written supersedes any cached missing-guardian // state. Busting here covers every binding-commit path (createGuardianBinding // and the outbound phone rebind in verification/session-service.ts). bustGuardianIntegrityCache(); return { contactId, channelId, channel: params.channel, address: params.externalUserId, deliveryChatId: params.deliveryChatId, displayName, guardianPrincipalId: params.guardianPrincipalId, claimedFromContactId: claimableChannel && claimableChannel.contactId !== contactId ? claimableChannel.contactId : null, }; } /** * Post-commit assistant-side effects for a committed guardian binding: * identity mirror, orphaned-stub GC, daemon cache invalidation. All * best-effort — the gateway binding is already authoritative. */ export async function mirrorGuardianBinding( writes: GuardianBindingGatewayWrites, ): Promise { const { contactId, channelId, channel, address, deliveryChatId, displayName, } = writes; // --- Assistant DB identity mirror (best-effort, via typed transactional IPC) --- // A non-authoritative convenience copy; its failure must not undo or abort // the committed gateway binding. One atomic daemon-side transaction upserts // the guardian contact + its primary channel under the gateway-minted ids, // reusing the same channel-id alignment the gateway wrote. The upsert // reparents a claimable channel that inbound seeding attached elsewhere and // refreshes the display name to match this binding. try { await ipcCallAssistant("contacts_mirror_apply", { body: { ops: [ { op: "upsert_channel", contactId, channelId, type: channel, address, externalChatId: deliveryChatId, displayName, isPrimary: true, refreshDisplayName: true, reassignConflictingChannels: true, }, ], }, }); } catch (mirrorErr) { log.warn( { err: mirrorErr }, "Failed to mirror guardian binding identity to assistant DB", ); } // The claim above can re-parent a channel that inbound seeding attached to // a stub contact (first message from a then-unbound guardian identity). // Garbage-collect that stub when the claim stripped its last channel, so // the guardian doesn't end up with a duplicate of themselves in the // Contacts pane (LUM-2672). Best-effort — never fails the binding. if (writes.claimedFromContactId) { await deleteContactIfOrphaned(writes.claimedFromContactId); } log.info( { contactId, channelId, channel, guardianPrincipalId: writes.guardianPrincipalId, }, "Created guardian binding", ); // Invalidate the daemon guardian-id/role caches after a gateway-owned // guardian rebind. void ipcCallAssistant("emit_event", { body: { kind: "contacts_changed" }, } as unknown as Record).catch(() => {}); } /** * Create or update a guardian contact + channel binding. * * Writes the gateway DB (authoritative) first in its own transaction, then * mirrors identity to the assistant DB (best-effort, via IPC proxy). Uses * upsert semantics: looks up an existing contact by principalId, then claims * any preseeded channel for the same actor before falling back to an existing * guardian channel by (contactId, type). */ export async function createGuardianBinding( params: CreateGuardianBindingParams, ): Promise { const writes = getGatewayDb().transaction(() => applyGuardianBindingGatewayWrites(params), ); await mirrorGuardianBinding(writes); return { contactId: writes.contactId, channelId: writes.channelId, guardianPrincipalId: writes.guardianPrincipalId, channel: writes.channel, }; } // --------------------------------------------------------------------------- // Token operations (against the gateway's own DB — no cross-container issue) // --------------------------------------------------------------------------- export interface RefreshableTokenPair { accessToken: string; accessTokenExpiresAt: number; refreshToken: string; refreshTokenExpiresAt: number; refreshAfter: number; } /** * A freshly minted, DB-recorded access + refresh token pair bound to a device. */ export type DeviceBoundTokenPair = RefreshableTokenPair; /** * Revoke active actor tokens for a device binding. */ export function revokeActorTokensByDevice( guardianPrincipalId: string, hashedDeviceId: string, ): void { const now = Date.now(); getGatewayDb() .update(actorTokenRecords) .set({ status: "revoked", updatedAt: now }) .where( and( eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId), eq(actorTokenRecords.hashedDeviceId, hashedDeviceId), inArray(actorTokenRecords.status, ["active", "derived"]), ), ) .run(); } /** * Revoke active refresh tokens for a device binding. */ export function revokeRefreshTokensByDevice( guardianPrincipalId: string, hashedDeviceId: string, ): void { const now = Date.now(); getGatewayDb() .update(actorRefreshTokenRecords) .set({ status: "revoked", updatedAt: now }) .where( and( eq(actorRefreshTokenRecords.guardianPrincipalId, guardianPrincipalId), eq(actorRefreshTokenRecords.hashedDeviceId, hashedDeviceId), eq(actorRefreshTokenRecords.status, "active"), ), ) .run(); } /** * Mint a JWT access token and persist its hash in the gateway DB. */ function mintAccessToken( guardianPrincipalId: string, hashedDeviceId: string, platform: string, ttlSeconds: number = ACCESS_TOKEN_TTL_SECONDS, ): { token: string; expiresAt: number } { const externalAssistantId = getExternalAssistantId(); const sub = `actor:${externalAssistantId}:${guardianPrincipalId}`; const token = mintToken({ aud: "vellum-gateway", sub, scope_profile: "actor_client_v1", policy_epoch: CURRENT_POLICY_EPOCH, ttlSeconds, }); const now = Date.now(); const expiresAt = now + ttlSeconds * 1000; const tokenHash = hashToken(token); getGatewayDb() .insert(actorTokenRecords) .values({ id: uuid(), tokenHash, guardianPrincipalId, hashedDeviceId, platform, status: "active", issuedAt: now, expiresAt, createdAt: now, updatedAt: now, }) .run(); return { token, expiresAt }; } /** * Mint an opaque refresh token and persist its hash in the gateway DB. */ function mintRefreshToken( guardianPrincipalId: string, hashedDeviceId: string, platform: string, options: { browserRefreshCookiePath?: string } = {}, ): { refreshToken: string; refreshTokenExpiresAt: number; refreshAfter: number; } { const now = Date.now(); const refreshToken = randomBytes(32).toString("base64url"); const refreshTokenHash = hashToken(refreshToken); const familyId = randomBytes(16).toString("hex"); const absoluteExpiresAt = now + REFRESH_ABSOLUTE_TTL_MS; const inactivityExpiresAt = now + REFRESH_INACTIVITY_TTL_MS; getGatewayDb() .insert(actorRefreshTokenRecords) .values({ id: uuid(), tokenHash: refreshTokenHash, familyId, guardianPrincipalId, hashedDeviceId, platform, status: "active", issuedAt: now, absoluteExpiresAt, inactivityExpiresAt, lastUsedAt: null, browserRefreshCookiePath: options.browserRefreshCookiePath, createdAt: now, updatedAt: now, }) .run(); return { refreshToken, refreshTokenExpiresAt: Math.min(absoluteExpiresAt, inactivityExpiresAt), refreshAfter: now + Math.floor(ACCESS_TOKEN_TTL_MS * REFRESH_AFTER_FRACTION), }; } /** * Revoke any existing credentials for a (guardian, device) pair and mint a * fresh, DB-recorded access + refresh token pair bound to that device. * * This is the shared core used by guardian bootstrap (and any other flow that * needs a full refreshable credential). The device binding enforces one active * token per (guardianPrincipalId, hashedDeviceId) via a unique index, so * re-minting for the same device first revokes the prior tokens. */ export function mintAndRecordDeviceBoundTokenPair(params: { guardianPrincipalId: string; deviceId: string; platform: string; }): DeviceBoundTokenPair { const hashedDeviceId = hashToken(params.deviceId); revokeActorTokensByDevice(params.guardianPrincipalId, hashedDeviceId); revokeRefreshTokensByDevice(params.guardianPrincipalId, hashedDeviceId); const access = mintAccessToken( params.guardianPrincipalId, hashedDeviceId, params.platform, ); const refresh = mintRefreshToken( params.guardianPrincipalId, hashedDeviceId, params.platform, ); return { accessToken: access.token, accessTokenExpiresAt: access.expiresAt, refreshToken: refresh.refreshToken, refreshTokenExpiresAt: refresh.refreshTokenExpiresAt, refreshAfter: refresh.refreshAfter, }; } /** * Mint a refreshable browser credential without requiring the browser to track a * separate device id. The current token tables still require a binding column, * so remote web uses an internal random binding that never leaves the gateway. */ export function mintAndRecordBrowserTokenPair(params: { guardianPrincipalId: string; platform: string; browserRefreshCookiePath: string; }): RefreshableTokenPair { const internalBinding = randomBytes(32).toString("base64url"); const hashedDeviceId = hashToken(internalBinding); const access = mintAccessToken( params.guardianPrincipalId, hashedDeviceId, params.platform, ); const refresh = mintRefreshToken( params.guardianPrincipalId, hashedDeviceId, params.platform, { browserRefreshCookiePath: params.browserRefreshCookiePath }, ); return { accessToken: access.token, accessTokenExpiresAt: access.expiresAt, refreshToken: refresh.refreshToken, refreshTokenExpiresAt: refresh.refreshTokenExpiresAt, refreshAfter: refresh.refreshAfter, }; } // --------------------------------------------------------------------------- // Public: guardian bootstrap // --------------------------------------------------------------------------- /** * Attempt to fetch the assistant owner's display name from the platform. * * Only runs when IS_PLATFORM=true. Reads platform_base_url and * assistant_api_key from the credential store, then calls * GET /v1/internal/gateway/guardian/ with a 5-second timeout. * Returns null on any missing credential, timeout, or network/parse failure — * callers fall back to a generated principal ID in that case. */ async function fetchPlatformOwnerDisplayName(): Promise { if (!arePlatformFeaturesEnabled()) { log.debug( "Platform features disabled — skipping platform owner display name fetch", ); return null; } const isPlatform = process.env.IS_PLATFORM?.trim().toLowerCase() === "true" || process.env.IS_PLATFORM?.trim() === "1"; if (!isPlatform) return null; const [platformBaseUrl, assistantApiKey] = await Promise.all([ readCredential(credentialKey("vellum", "platform_base_url")), readCredential(credentialKey("vellum", "assistant_api_key")), ]); if (!platformBaseUrl || !assistantApiKey) { return null; } try { const url = `${platformBaseUrl.replace(/\/+$/, "")}/v1/internal/gateway/guardian/`; const response = await fetch(url, { headers: { Authorization: `Api-Key ${assistantApiKey}` }, signal: AbortSignal.timeout(5_000), }); if (!response.ok) { log.warn( { status: response.status }, "Failed to fetch platform owner display name", ); return null; } const data = (await response.json()) as { display_name?: string | null }; return data.display_name?.trim() || null; } catch (err) { log.warn({ err }, "Failed to fetch platform owner display name"); return null; } } /** * Boot-time self-heal: rebuild an ABSENT vellum guardian binding from the * gateway's own active actor tokens, returning the recovered principal (or * null when nothing is recoverable). Single home for the recovery decision so * its safety is co-located rather than re-derived at the call site. * * Safety contract — recovery is sound because, together: * - Actor tokens are minted EXCLUSIVELY for the guardian principal (every mint * site funnels through a guardianPrincipalId), so the token names the real * guardian, never some other actor. * - It fires only when NO *bootstrapped* guardian exists * (`!hasGuardianContactWithPrincipal()`) — genuine data loss, never a * guardian with a real identity whose binding was deliberately * revoked/blocked (that keeps its principal, so it stays fail-closed). A * principal-less contact-prompt stub is NOT a guardian, so an install * carrying only a stub plus surviving tokens is still self-healed. * - It rebinds the token's EXISTING principal — no divergent mint — and passes * `reactivateRevoked: false`, so the write path itself refuses to resurrect a * revoked binding: a second, enforced layer beneath the absent-guardian gate. * - Only ACTIVE tokens are recovered, so a properly offboarded (revoked) * guardian is never brought back. */ async function recoverAbsentVellumGuardianFromActorTokens(): Promise< string | null > { if (hasGuardianContactWithPrincipal()) { return null; } const recoveredPrincipalId = recoverGuardianPrincipalFromActorTokens(); if (!recoveredPrincipalId) { return null; } await createGuardianBinding({ channel: "vellum", externalUserId: recoveredPrincipalId, deliveryChatId: "local", guardianPrincipalId: recoveredPrincipalId, verifiedVia: "bootstrap", // Derived rebind with no fresh verification act: must not resurrect a // revoked binding. The absent-guardian gate above already means there is // none to resurrect; passing false keeps that true even if the gate moves. reactivateRevoked: false, }); log.info( { guardianPrincipalId: recoveredPrincipalId }, "Recovered the vellum guardian binding from gateway actor tokens (empty contacts table on an upgraded install)", ); return recoveredPrincipalId; } /** * Resolve the vellum guardian principal: (a) gateway fast path, then * (b) mint a fresh principal when the gateway has no guardian. * * Shared by ensureVellumGuardianBinding + bootstrapGuardian. `isNew` is true * only on the mint path. * * A gateway miss with evidence of a prior guardian (guardian-integrity.ts) * means the guardian rows were lost, not that this is a fresh install — a * mint there would permanently diverge from prior clients' tokens. Only the * explicit operator-driven guardian init may mint over evidence * (`allowMintWithEvidence`); implicit paths throw * {@link VellumGuardianMintRefusedError} instead. */ async function resolveOrCreateVellumGuardian(options: { allowMintWithEvidence: boolean; /** * Boot-time backfill only: when the gateway has no vellum guardian binding * but active actor tokens survive (an upgraded install whose contact * reconcile could not run), rebuild the binding from the token's principal * instead of refusing. Runtime callers leave this off so their fail-closed * repair path is unchanged. */ recoverFromActorTokens?: boolean; }): Promise<{ guardianPrincipalId: string; isNew: boolean; mintedOverPriorEvidence: boolean; }> { const gw = await findVellumGuardian(); if (gw) { log.debug( { guardianPrincipalId: gw.principalId }, "Vellum guardian binding already exists", ); return { guardianPrincipalId: gw.principalId, isNew: false, mintedOverPriorEvidence: false, }; } // Boot-time self-heal: recover an absent guardian from the gateway's own // active actor tokens before the evidence-based refusal below. The full // safety contract lives on recoverAbsentVellumGuardianFromActorTokens. // Runtime token/pairing callers leave recoverFromActorTokens off and stay // fail-closed (repairable 401/503), so the boot backfill is the single // self-heal seam. if (options.recoverFromActorTokens) { const recovered = await recoverAbsentVellumGuardianFromActorTokens(); if (recovered) { return { guardianPrincipalId: recovered, isNew: false, mintedOverPriorEvidence: false, }; } } const priorEvidence = hasEvidenceOfPriorGuardian(); if (priorEvidence && !options.allowMintWithEvidence) { // Fires the missing-guardian reporter (error log + telemetry, // rate-limited there) when the DB is truly guardian-less. The state is // `ok` when a guardian contact row survives with a lost/inactive vellum // binding, so the refusal reports under its own check_name either way — // clients see guardian_repair_required 401s and that must never be // telemetry-silent. Best-effort: the refusal itself must not depend on // the integrity check. let integrityState = "unavailable"; try { integrityState = guardianIntegrityState(); } catch { // Reported as "unavailable"; refusal proceeds regardless. } reportGuardianMintRefused({ integrity_state: integrityState }); log.error( "no active vellum guardian binding but the gateway DB carries evidence of prior onboarding — refusing to mint a divergent principal; re-pair via guardian init to recover", ); throw new VellumGuardianMintRefusedError(); } if (priorEvidence) { log.warn( "minting a fresh vellum guardian principal over evidence of a prior guardian — prior clients' tokens will not match", ); } // No gateway guardian — mint a fresh principal. const displayName = await fetchPlatformOwnerDisplayName(); const guardianPrincipalId = `vellum-principal-${uuid()}`; await createGuardianBinding({ channel: "vellum", externalUserId: guardianPrincipalId, deliveryChatId: "local", guardianPrincipalId, verifiedVia: "bootstrap", // Guardian bootstrap is a verified act (bootstrap secret / platform auth); // the principal is fresh so there is nothing to reactivate, but keep the // classification honest. reactivateRevoked: true, ...(displayName ? { displayName } : {}), }); return { guardianPrincipalId, isNew: true, mintedOverPriorEvidence: priorEvidence, }; } /** * Ensure a vellum guardian binding exists, returning its principalId. * Resolves from the gateway DB, minting a fresh principal on a miss — unless * the DB carries evidence of a prior guardian, in which case it throws * {@link VellumGuardianMintRefusedError}. Every caller must handle that * refusal explicitly: the startup backfill degrades boot non-fatally * (post-assistant-ready), /auth/token maps it to a repairable 401 * (`guardian_repair_required`), and the remote-web pairing route to a 503 * (its 401 is claimed by invalid device codes). * * Called during gateway startup to backfill existing installations. * * `recoverFromActorTokens` is the boot-time self-heal seam: the * post-assistant-ready backfill sets it so an install whose contact reconcile * could not run (empty contacts, surviving actor tokens) rebinds the guardian * from its own tokens instead of refusing. Runtime callers leave it off and * keep their fail-closed repair path. */ export async function ensureVellumGuardianBinding( opts: { recoverFromActorTokens?: boolean } = {}, ): Promise { const { guardianPrincipalId } = await resolveOrCreateVellumGuardian({ allowMintWithEvidence: false, recoverFromActorTokens: opts.recoverFromActorTokens ?? false, }); return guardianPrincipalId; } /** * Execute the full guardian bootstrap flow: * 1. Ensure a guardian principal exists for the vellum channel * 2. Revoke existing credentials for this device * 3. Mint new JWT access token + opaque refresh token * 4. Persist token hashes */ export async function bootstrapGuardian(params: { platform: string; deviceId: string; }): Promise { // 1. Resolve (or mint) the guardian principal. Guardian init is the // sanctioned operator-driven recovery path, so it may mint over evidence // of a prior guardian (loud warn inside). const { guardianPrincipalId, isNew, mintedOverPriorEvidence } = await resolveOrCreateVellumGuardian({ allowMintWithEvidence: true }); // 2. Revoke existing credentials for this device and mint a fresh pair. const pair = mintAndRecordDeviceBoundTokenPair({ guardianPrincipalId, deviceId: params.deviceId, platform: params.platform, }); log.info( { platform: params.platform, guardianPrincipalId, isNew, mintedOverPriorEvidence, }, "Guardian bootstrap completed", ); return { guardianPrincipalId, accessToken: pair.accessToken, accessTokenExpiresAt: pair.accessTokenExpiresAt, refreshToken: pair.refreshToken, refreshTokenExpiresAt: pair.refreshTokenExpiresAt, refreshAfter: pair.refreshAfter, isNew, mintedOverPriorEvidence, }; }