/** * cli:provision-external-app — execute.ts * * Creates an `ExternalApplication` through the platform's admin API and enables * its grants on the codes this project publishes, so the whole chain (token * exchange → route guard → catalogue → grant → tenant scope → permission) can * be proven end to end instead of clicked through a back-office. * * Two deliberate limits: * - the client secret is returned ONCE by the platform and is never written * to disk here — it is handed back on stdout and that is the only copy; * - granting a REAL partner stays a human decision. This CLI exists for a * test client, and says so. * * `fetch` is injected so the whole flow is testable without a running API. */ import type { ApiEndpointSummary, CreatedApplication, ProvisionExternalAppInput, ProvisionReport, } from './types.js' export type FetchLike = ( url: string, init: { method: string; headers: Record; body?: string }, ) => Promise<{ ok: boolean; status: number; text: () => Promise }> /** * The admin controller is routed from the DATABASE navigation registry * (`[NavRoute("api.accounts")]`), so its final path is not knowable statically. * Probing the candidates beats hardcoding one and failing with a bare 404. */ export const ADMIN_PATH_CANDIDATES = [ '/api/api/accounts', '/api/accounts', '/api/platform/api/accounts', ] function join(baseUrl: string, path: string): string { return `${baseUrl.replace(/\/+$/, '')}${path}` } async function readJson(res: { text: () => Promise }): Promise { const raw = await res.text() try { return JSON.parse(raw) } catch { return raw } } /** First candidate whose `api-endpoints` listing answers — that is the admin base. */ export async function discoverAdminPath( spec: ProvisionExternalAppInput, fetchImpl: FetchLike, ): Promise<{ path: string; endpoints: ApiEndpointSummary[] } | { error: string }> { const candidates = spec.adminPath ? [spec.adminPath] : ADMIN_PATH_CANDIDATES const tried: string[] = [] for (const candidate of candidates) { const url = join(spec.baseUrl, `${candidate}/api-endpoints`) let res try { res = await fetchImpl(url, { method: 'GET', headers: authHeaders(spec) }) } catch (err) { tried.push(`${candidate} → ${(err as Error).message}`) continue } if (res.ok) { const body = await readJson(res) if (Array.isArray(body)) { return { path: candidate, endpoints: body as ApiEndpointSummary[] } } tried.push(`${candidate} → 200 but the body is not an endpoint list`) continue } tried.push(`${candidate} → HTTP ${res.status}`) } return { error: `Could not reach the API-accounts admin surface. Tried: ${tried.join(' ; ')}. ` + `Pass adminPath explicitly, and check the admin token carries api.accounts.read.`, } } /** Match the requested codes against the live catalogue — an unknown code is fatal. */ export function resolveEndpointIds( endpoints: ApiEndpointSummary[], codes: readonly string[], ): { grants: Array<{ code: string; id: string }> } | { error: string } { const byCode = new Map(endpoints.map(e => [e.code, e])) const grants: Array<{ code: string; id: string }> = [] const missing: string[] = [] const inactive: string[] = [] for (const code of codes) { const found = byCode.get(code) if (!found) { missing.push(code) continue } if (found.isActive === false) { inactive.push(code) continue } grants.push({ code, id: found.id }) } if (missing.length > 0 || inactive.length > 0) { const parts: string[] = [] if (missing.length > 0) { parts.push( `not in the catalogue: ${missing.join(', ')} — the seed provider has not run (boot the API once) or the code is wrong`, ) } if (inactive.length > 0) parts.push(`inactive: ${inactive.join(', ')}`) return { error: `Cannot grant ${parts.join(' ; ')}.` } } return { grants } } function authHeaders(spec: ProvisionExternalAppInput): Record { return { Authorization: `Bearer ${spec.adminToken}`, 'Content-Type': 'application/json', Accept: 'application/json', } } export async function execute( spec: ProvisionExternalAppInput, fetchImpl: FetchLike, ): Promise<{ report: ProvisionReport | null; errors: string[]; warnings: string[] }> { const warnings: string[] = [] const discovered = await discoverAdminPath(spec, fetchImpl) if ('error' in discovered) return { report: null, errors: [discovered.error], warnings } const resolved = resolveEndpointIds(discovered.endpoints, spec.codes) if ('error' in resolved) return { report: null, errors: [resolved.error], warnings } if (spec.dryRun) { return { report: { adminPath: discovered.path, applicationId: null, clientId: null, clientSecret: null, granted: resolved.grants.map(g => g.code), dryRun: true, }, errors: [], warnings, } } const createRes = await fetchImpl(join(spec.baseUrl, discovered.path), { method: 'POST', headers: authHeaders(spec), body: JSON.stringify({ name: spec.name, description: spec.description ?? 'Provisioned by /external-api for integration testing.', allowedIpAddresses: spec.allowedIpAddresses ?? null, tokenExpirationMinutes: spec.tokenExpirationMinutes, }), }) if (!createRes.ok) { return { report: null, errors: [`Creating the external application failed: HTTP ${createRes.status} ${String(await createRes.text()).slice(0, 400)}`], warnings, } } const created = (await readJson(createRes)) as CreatedApplication if (!created?.id || !created?.clientId) { return { report: null, errors: ['The platform accepted the creation but returned no clientId — nothing to hand to the third party.'], warnings } } const granted: string[] = [] for (const grant of resolved.grants) { const res = await fetchImpl(join(spec.baseUrl, `${discovered.path}/${created.id}/api-access/${grant.id}`), { method: 'PUT', headers: authHeaders(spec), body: JSON.stringify({ isEnabled: true, rateLimitPerMinute: null, maxPageSize: null, allowedTenantIds: spec.allowedTenantIds ?? [], }), }) if (res.ok) { granted.push(grant.code) } else { warnings.push(`Grant on "${grant.code}" failed (HTTP ${res.status}) — the application exists but cannot call that endpoint yet.`) } } if (created.clientSecret) { warnings.push('The client secret is shown ONCE and is not stored anywhere by this CLI. Hand it to the third party over a secure channel now; if it is lost, rotate it rather than recreating the application.') } return { report: { adminPath: discovered.path, applicationId: created.id, clientId: created.clientId, clientSecret: created.clientSecret ?? null, granted, dryRun: false, }, errors: [], warnings, } }