/** * Registry publish-token management (append-safe) * * `celilo module secret set celilo-registry publish_tokens ` OVERWRITES the * whole value. Because publish_tokens is a newline-separated list, that clobbers * every other holder (external CI, scoped bootstrap tokens) whenever you rotate * or add one token. These commands do a read-modify-write on the list instead. * * Runs on-mgr where the master key lives; never resurfaces the raw admin value * to a laptop. Runtime-minted scoped tokens are handled elsewhere — this is the * human-managed bootstrap/admin list only. */ import { and, eq } from 'drizzle-orm'; import { getDb } from '../../db/client'; import { modules, secrets } from '../../db/schema'; import { decryptSecret, encryptSecret } from '../../secrets/encryption'; import { getOrCreateMasterKey } from '../../secrets/master-key'; import { getArg, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; const REGISTRY_MODULE_ID = 'celilo-registry'; const PUBLISH_TOKENS_SECRET = 'publish_tokens'; type CommandError = Extract; /** Split a newline-separated token blob into trimmed, non-empty lines. */ function parseTokens(raw: string | null): string[] { if (!raw) return []; return raw .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); } interface TokenStore { masterKey: Buffer; /** Existing secrets row id, or null if publish_tokens isn't set yet. */ rowId: number | null; tokens: string[]; } /** * Read the current publish_tokens list (decrypted). Errors if celilo-registry * isn't installed locally — these commands must run where the module lives. */ async function readTokenStore(): Promise { const db = getDb(); const registryModule = db.select().from(modules).where(eq(modules.id, REGISTRY_MODULE_ID)).get(); if (!registryModule) { return { success: false, error: `Module not found: ${REGISTRY_MODULE_ID}\n\nRun these commands on the host where celilo-registry is deployed (where the master key lives).`, }; } let masterKey: Buffer; try { masterKey = await getOrCreateMasterKey(); } catch (err) { return { success: false, error: 'Failed to access master key', details: err }; } const row = db .select() .from(secrets) .where(and(eq(secrets.moduleId, REGISTRY_MODULE_ID), eq(secrets.name, PUBLISH_TOKENS_SECRET))) .get(); const tokens = row ? parseTokens( decryptSecret( { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag }, masterKey, ), ) : []; return { masterKey, rowId: row?.id ?? null, tokens }; } function isError(v: TokenStore | CommandError): v is CommandError { return 'success' in v && v.success === false; } /** Encrypt the list and upsert the publish_tokens row. */ function writeTokens(store: TokenStore, tokens: string[]): void { const db = getDb(); const encrypted = encryptSecret(tokens.join('\n'), store.masterKey); if (store.rowId !== null) { db.update(secrets) .set({ encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, updatedAt: new Date(), }) .where(eq(secrets.id, store.rowId)) .run(); return; } db.insert(secrets) .values({ moduleId: REGISTRY_MODULE_ID, name: PUBLISH_TOKENS_SECRET, encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, }) .run(); } /** * Resolve the local admin/bootstrap publish token (ce-1ch) — the first * bootstrap line with no package scope (a bare ``), which the admin * owner-management endpoints require. Runs on-mgr where publish_tokens lives. * Returns the raw token or a CommandError describing what's missing. */ export async function resolveAdminToken(): Promise { const store = await readTokenStore(); if (isError(store)) return store; // A scoped line is ` `; the admin/bootstrap token is a bare // single-field line. Pick the first one. const admin = store.tokens.find((line) => line.split(/\s+/).length === 1); if (!admin) { return { success: false, error: 'No admin publish token found in publish_tokens.\n\nAdd one with: celilo registry token add ', }; } return admin; } /** * Handle `celilo registry token add `. * Appends a token to publish_tokens without clobbering existing holders. * Idempotent — adding a token that's already present is a no-op. */ export async function handleRegistryTokenAdd(args: string[]): Promise { const err = validateRequiredArgs(args, 1); if (err) { return { success: false, error: `${err}\n\nUsage: celilo registry token add ` }; } const token = getArg(args, 0)?.trim(); if (!token) { return { success: false, error: 'Token value is required' }; } const store = await readTokenStore(); if (isError(store)) return store; if (store.tokens.includes(token)) { return { success: true, message: 'Token already present — no change' }; } writeTokens(store, [...store.tokens, token]); return { success: true, message: `Added publish token (${store.tokens.length + 1} total)`, }; } /** * Handle `celilo registry token rm `. * Removes one token from publish_tokens, leaving the others intact. */ export async function handleRegistryTokenRm(args: string[]): Promise { const err = validateRequiredArgs(args, 1); if (err) { return { success: false, error: `${err}\n\nUsage: celilo registry token rm ` }; } const token = getArg(args, 0)?.trim(); if (!token) { return { success: false, error: 'Token value is required' }; } const store = await readTokenStore(); if (isError(store)) return store; if (!store.tokens.includes(token)) { return { success: false, error: 'Token not found in publish_tokens' }; } const remaining = store.tokens.filter((t) => t !== token); writeTokens(store, remaining); return { success: true, message: `Removed publish token (${remaining.length} remaining)`, }; }