/** * Contributor identity tokens (idp-issued, per-user) * * `celilo token obtain|list|revoke` mints, lists, and revokes per-user API * tokens from the idp (authentik) so a module contributor authenticates * publishes AS THEMSELVES — no admin/shared token ever reaches their machine * (SECURE_MODULE_PUBLISH.md). The token consumed by `celilo author init`. * * Distinct from `celilo registry token add/rm`, which manages the raw * bootstrap `publish_tokens` list — a different trust/storage model. Keep the * two families separate. * * Runs on-mgr where the idp provider + bootstrap token live (same model as * registry-token.ts). The token string is shown once at mint; the idp stores * it hashed and celilo persists nothing. */ import type { IdpCapability } from '@celilo/capabilities'; import { getDb } from '../../db/client'; import { loadCapabilityFunctions } from '../../hooks/capability-loader'; import { createCapturingLogger } from '../../hooks/logger'; import { getArg, getFlag } from '../parser'; import type { CommandError, CommandResult } from '../types'; /** Default identifier for a contributor's publish token — stable per user so obtain is idempotent. */ function defaultIdentifier(username: string): string { return `celilo-publish-${username}`; } function isError(v: unknown): v is CommandError { return typeof v === 'object' && v !== null && 'success' in v && v.success === false; } /** * Load the idp capability. Runs where the idp provider (authentik) is * deployed; errors clearly when it isn't. The capturing logger discards the * capability's own progress markers so command output stays clean. */ async function loadIdp(): Promise { const db = getDb(); const { logger } = createCapturingLogger(); const caps = await loadCapabilityFunctions('celilo-token', db, logger); const idp = caps.idp as IdpCapability | undefined; if (!idp) { return { success: false, error: 'No idp provider found. Run this on the host where the idp (authentik) is deployed (celilo-mgr).', }; } return idp; } /** Resolve the target username from `--user` (or first positional arg). */ function resolveUser(args: string[], flags: Record): string | undefined { const flag = getFlag(flags, 'user').trim(); if (flag) return flag; return getArg(args, 0)?.trim(); } /** * `celilo token obtain --user [--identifier ] [--description ]` * Mints (or returns the existing) per-user token. Idempotent on identifier. */ export async function handleTokenObtain( args: string[], flags: Record, ): Promise { const username = resolveUser(args, flags); if (!username) { return { success: false, error: 'Username required\n\nUsage: celilo token obtain --user [--identifier ]', }; } const identifier = getFlag(flags, 'identifier').trim() || defaultIdentifier(username); const description = getFlag(flags, 'description').trim() || undefined; const idp = await loadIdp(); if (isError(idp)) return idp; const result = await idp.create_token({ username, identifier, description }); const verb = result.created ? 'Minted new' : 'Returned existing'; return { success: true, message: `${verb} per-user token for '${username}' (identifier: ${identifier}) ${result.token} Treat as a secret — store it now (CELILO_PUBLISH_TOKEN, or \`celilo author init --token …\`). celilo persists nothing; the idp stores it hashed. Revoke with: celilo token revoke ${identifier}`, }; } /** * `celilo token list --user ` * Lists the user's API tokens (metadata only — never the bearer string). */ export async function handleTokenList( args: string[], flags: Record, ): Promise { const username = resolveUser(args, flags); if (!username) { return { success: false, error: 'Username required\n\nUsage: celilo token list --user ', }; } const idp = await loadIdp(); if (isError(idp)) return idp; const tokens = await idp.list_tokens({ username }); if (tokens.length === 0) { return { success: true, message: `No API tokens for '${username}'.` }; } const lines = tokens.map((t) => { const desc = t.description ? ` — ${t.description}` : ''; const exp = t.expiring && t.expires ? ` (expires ${t.expires})` : ''; return ` ${t.identifier}${desc}${exp}`; }); return { success: true, message: `API tokens for '${username}':\n${lines.join('\n')}`, }; } /** * `celilo token revoke ` (or `--identifier `) * Deletes a token at the idp; the next publish with it is denied. */ export async function handleTokenRevoke( args: string[], flags: Record, ): Promise { const identifier = getArg(args, 0)?.trim() || getFlag(flags, 'identifier').trim(); if (!identifier) { return { success: false, error: 'Identifier required\n\nUsage: celilo token revoke ', }; } const idp = await loadIdp(); if (isError(idp)) return idp; const { revoked } = await idp.revoke_token({ identifier }); if (!revoked) { return { success: false, error: `No token with identifier '${identifier}' found.` }; } return { success: true, message: `Revoked token '${identifier}'. The next publish using it will be denied.`, }; }