From 7fa902139dedaf3401a2e1dbecb5792afda4e9ba Mon Sep 17 00:00:00 2001 From: "Li, Amazing Ang" Date: Fri, 11 Sep 2026 21:06:06 +0800 Subject: [PATCH] feat(provider): import API contracts and wait for publication --- README.md | 107 ++++++++++ examples/provider/openapi.json | 34 ++++ src/commands/provider-onboarding.ts | 236 ++++++++++++++++++++++ src/commands/provider.ts | 14 +- src/provider-client.ts | 80 ++++++++ src/tests/provider-onboarding.test.ts | 273 ++++++++++++++++++++++++++ 6 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 examples/provider/openapi.json create mode 100644 src/commands/provider-onboarding.ts create mode 100644 src/provider-client.ts create mode 100644 src/tests/provider-onboarding.test.ts diff --git a/README.md b/README.md index 1ee80fc..1042e34 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,113 @@ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 # wait un xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 1s --timeout 10m ``` +### Provider: Import → Configure → Submit → Wait + +`provider` manages APIs owned by your account. It uses `XAPI_API_HOST` +(default `api.xapi.to`) and the same saved or environment API key as other +commands. In the xAPI Console API Keys settings, grant `service:create`, +`service:read`, `service:update`, and `service:publish`. Legacy `allowRegister` +only grants creation, not the remaining lifecycle permissions. Missing +permissions return a nonzero exit with the required scope. + +Start from [examples/provider/openapi.json](examples/provider/openapi.json), +replace its upstream URL, service details, and endpoint contract, then run: + +```bash +# Inspect current rules; no API key required +xapi-to provider spec-rules --format pretty + +# Import a raw OpenAPI 3.0.3 JSON object (not a {openApiSpec: ...} envelope) +xapi-to provider import --file openapi.json > imported.json + +# Use the serviceId and revisionId from imported.json (jq is optional) +PROVIDER_SERVICE_ID=$(jq -er '.serviceId' imported.json) +PROVIDER_REVISION_ID=$(jq -er '.revisionId' imported.json) + +# Save version configuration to move the draft revision to SANDBOX. +# config.json can be {"description":"Initial release"} when the imported +# endpoints, authentication, and pricing are already complete. +xapi-to provider update "$PROVIDER_SERVICE_ID" \ + --revision "$PROVIDER_REVISION_ID" --file config.json + +# Submit the specified revision, then wait for the actual publication result +xapi-to provider submit "$PROVIDER_SERVICE_ID" \ + --revision "$PROVIDER_REVISION_ID" --changelog "Initial release" +xapi-to provider wait "$PROVIDER_SERVICE_ID" \ + --revision "$PROVIDER_REVISION_ID" --interval 2s --timeout 10m + +# Inspect owned services, configuration, version overview, or review reports +xapi-to provider list --format table +xapi-to provider get "$PROVIDER_SERVICE_ID" --format pretty +xapi-to provider versions "$PROVIDER_SERVICE_ID" --format pretty +xapi-to provider review "$PROVIDER_SERVICE_ID" --revision "$PROVIDER_REVISION_ID" +``` + +When scripting these steps, stop on nonzero exit (for example, use `set -e`). +Import returns the backend validation/preview plus `serviceId`, `revisionId`, +and `state`. An HTTP 201 with `success: false` is a validation failure and exits +nonzero; its structured validation errors are preserved. Registration creates +a new service each time. If a response is lost, inspect `provider list` before +retrying to avoid duplicate services. + +For an authenticated upstream, store credentials in a local JSON object such +as `{"Authorization":"Bearer YOUR_UPSTREAM_KEY"}` and pass +`--private-headers-file private-headers.json` to `provider import`. Keep this +file out of version control. `--file -` and `--private-headers-file -` accept +stdin, but only one input can consume stdin per command. Files must be JSON; +YAML and URL imports are not supported in this command group. + +`provider update --revision ` reads a version configuration object, using the backend +fields `description`, `baseUrl`, `baseUrls`, `authType`, `privateHeaders`, +`authConfig`, `openApiSpec`, `endpoints`, and `status`. Prefer structured +`privateHeaders` for upstream credentials. Endpoint fields include billing +configuration such as `billingType` and `costPerCall`. Update does not accept +a raw OpenAPI document; the nested backend field is `openApiSpec: {spec: ...}`. +Saving that field alone does not re-import endpoint definitions; configure +`endpoints` explicitly when changing the contract. + +- `--mode merge` (default) sends PATCH and preserves omitted fields/endpoints. + Existing endpoint edits require `id`, e.g. + `{"endpoints":[{"id":"ENDPOINT_ID","costPerCall":"0.002"}]}`. +- `--allow-new-endpoints` explicitly permits ID-less merge entries to create + endpoints. Repeating such a merge can create duplicates. +- `--mode replace` sends PUT. If `endpoints` is provided, it replaces the + endpoint list; include every endpoint you intend to keep. Omitted fields + otherwise follow backend PUT semantics. Use full configuration for replacement. + +`get` retains its existing service response; `--version v1.0` selects the +configuration returned by the backend. Find endpoint IDs in +`currentVersion.endpoints`; `provider versions` returns working revision IDs +in `majors[].working.id`. For an already-published API, use the existing +`provider revision start ` command to create a working revision. +`provider update` without `--revision` continues to update service metadata +and rate limits. Existing `version update`, `publish`, and positional `review` +commands remain available. The `submit` and `review --revision` forms are +additional onboarding commands. + +Updates to `IN_REVIEW`, `PUBLISHED`, or `SUSPENDED` revisions return a conflict; +the backend enforces this check under a transaction lock. When updating +`privateHeaders`, send the complete desired map: it replaces the old map and +rebuilds the derived authentication configuration. An empty map without an +explicit `authConfig` clears those credentials. Omitting both fields preserves them. + +`submit` returns `{serviceId, revisionId, submission}`. A successful submission +does not guarantee publication. `wait` checks the requested revision, succeeds +only for `PUBLISHED`, and outputs the review report with `success` and `reason`. +Rejection, a draft/sandbox/suspended revision, or a legacy manual-review hold +exit nonzero. Pending review continues until publication, the timeout (default +10 minutes), or optional `--max-attempts`. Timeout and attempt-limit results +include the last received report; polling can be resumed with the same IDs. +The deadline also bounds in-flight HTTP requests and retry delays. + +Reads retry transient errors; writes are never automatically retried. The CLI +redacts credential fields and known credential values from provider output. +Redacted reads are for inspection and must not be submitted unchanged as +configuration. After an ambiguous write failure, use `list`, `get`, or `review` +to inspect the result before repeating the operation. No npm release is implied +by a local source checkout; use `bun run src/index.ts provider ...` or build and +run `node dist/index.js provider ...` while testing unreleased changes. + ### Sandbox Commands Sandbox commands provide an AI-friendly cloud computer lifecycle. The fastest diff --git a/examples/provider/openapi.json b/examples/provider/openapi.json new file mode 100644 index 0000000..5ddf5bb --- /dev/null +++ b/examples/provider/openapi.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Example Provider API", + "version": "1.0.0", + "description": "Replace this example with your own upstream API contract.", + "x-xapi-category": "Public-Utils", + "x-xapi-host": "example-provider-api" + }, + "servers": [{ "url": "https://upstream.example.com" }], + "paths": { + "/status": { + "get": { + "summary": "Get status", + "description": "Return the upstream service status.", + "x-xapi-billing": { "type": "PER_CALL", "costPerCall": 0.001 }, + "responses": { + "200": { + "description": "Service status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "status": { "type": "string" } } + }, + "example": { "status": "ok" } + } + } + } + } + } + } + } +} diff --git a/src/commands/provider-onboarding.ts b/src/commands/provider-onboarding.ts new file mode 100644 index 0000000..c2bb6e1 --- /dev/null +++ b/src/commands/provider-onboarding.ts @@ -0,0 +1,236 @@ +import { readFile } from 'node:fs/promises'; +import { getConfig, requireApiKey } from '../config.ts'; +import { HttpError, isRetryableRequestError } from '../client.ts'; +import { output, err, type OutputFormat } from '../format.ts'; +import { providerRequest, object, redactProvider, collectProviderSecrets, type ProviderObject } from '../provider-client.ts'; + +export const PROVIDER_ONBOARDING_HELP = `xapi-to provider - Import and publish your API services + +COMMANDS + spec-rules Read current OpenAPI rules (public) + import --file openapi.json Create a DRAFT service from OpenAPI JSON + --private-headers-file Upstream credentials as a JSON object + update --revision --file config.json + --mode merge|replace PATCH merge (default) or PUT replacement + --allow-new-endpoints Allow merge entries without IDs to create endpoints + submit --revision + --changelog Submit for review; this alone is not publication + review --revision + Read latest review and previous attempts + wait --revision + --interval Poll interval (default: 2s; ms/s/m/h) + --timeout Overall deadline (default: 10m; ms/s/m/h) + --max-attempts Optional cap, including transient failures + +COMMON FLAGS + --format json|pretty|table + --help + +Files must contain JSON objects; --file - reads stdin. Import reads a raw +OpenAPI spec; update reads version configuration (not a raw OpenAPI spec). +Merge updates to existing endpoints require their IDs. Use --mode replace +to replace the endpoint list, or --allow-new-endpoints to intentionally add. +Saving a draft configuration moves it to SANDBOX, ready for submit. + +PERMISSIONS + import: service:create (legacy allowRegister also accepted) + list/get/review/wait: service:read; update: service:update + submit: service:publish. Grant scopes in the xAPI Console API Keys settings. + +wait succeeds only for PUBLISHED. Rejection, unpublished terminal states, +manual review, invalid responses, and timeouts exit nonzero with details. +Writes are not retried automatically. Credentials are redacted from output. +`; + +const FLAGS: Record = { + 'spec-rules': [], import: ['file', 'private-headers-file'], + update: ['revision', 'file', 'mode', 'allow-new-endpoints'], + submit: ['revision', 'changelog'], review: ['revision'], + wait: ['revision', 'interval', 'timeout', 'max-attempts'], +}; +const SCOPES: Record = { + import: 'service:create', + update: 'service:update', submit: 'service:publish', review: 'service:read', wait: 'service:read', +}; + +function flag(flags: Record, name: string, required = false): string | undefined { + const value = flags[name]; + if ((required && value === undefined) || value === '' || value === 'true') { + throw new Error(`--${name} requires a value`); + } + return value; +} + +function duration(raw: string, name: string): number { + const match = /^(\d+)(ms|s|m|h)?$/.exec(raw); + const units: Record = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; + const ms = match ? Number(match[1]) * units[match[2] || 'ms'] : NaN; + if (!Number.isSafeInteger(ms) || ms <= 0 || ms > 2_147_483_647) { + throw new Error(`--${name} must be a positive duration (ms/s/m/h), at most 2147483647ms`); + } + return ms; +} + +async function jsonFile(path: string): Promise { + let text: string; + try { + if (path === '-') { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + text = Buffer.concat(chunks).toString('utf8'); + } else text = await readFile(path, 'utf8'); + } catch { throw new Error('Could not read JSON input file'); } + let value: unknown; + try { value = JSON.parse(text); } + // JSON.parse error messages can quote input credentials. + catch { throw new Error('Input must be valid JSON (JSON objects only; YAML is not supported)'); } + const result = object(value); + if (!result) throw new Error('Input must be a JSON object'); + return result; +} + +function segment(value: string): string { + if (value === '.' || value === '..') throw new Error('Invalid service or revision ID'); + return encodeURIComponent(value); +} + +export async function providerOnboarding(args: string[], flags: Record): Promise { + const [command, ...rest] = args; + if (flags.help || !command) { console.log(PROVIDER_ONBOARDING_HELP); return; } + const secrets: string[] = []; + const emit = (value: unknown) => output(redactProvider(value, secrets), flags.format as OutputFormat | undefined); + try { + if (!Object.hasOwn(FLAGS, command)) throw new Error(`Unknown provider command: ${command}`); + for (const key of Object.keys(flags)) { + if (!['format', ...FLAGS[command]].includes(key)) throw new Error(`Unknown flag for provider ${command}: --${key}`); + } + const needsService = !['spec-rules', 'import'].includes(command); + if (rest.length !== (needsService ? 1 : 0)) throw new Error(`provider ${command} expects ${needsService ? 'one service ID' : 'no positional arguments'}`); + const serviceId = rest[0]; + const base = serviceId ? `services/${segment(serviceId)}` : 'services'; + const revisionId = flag(flags, 'revision', ['update', 'submit', 'review', 'wait'].includes(command)); + const revisionPath = revisionId ? `${base}/revisions/${segment(revisionId)}` : ''; + const cfg = getConfig(); + if (cfg.apiKey) secrets.push(cfg.apiKey); + if (command !== 'spec-rules') requireApiKey(cfg); + const read = (path: string) => providerRequest(path, cfg.apiKey); + + if (command === 'spec-rules') { emit(await providerRequest('spec-rules', undefined)); return; } + if (command === 'import') { + const file = flag(flags, 'file', true)!; + const headersFile = flag(flags, 'private-headers-file'); + if (file === '-' && headersFile === '-') throw new Error('Only one input may read stdin'); + const spec = await jsonFile(file); + const body: ProviderObject = { openApiSpec: spec }; + if (headersFile) { + body.privateHeaders = await jsonFile(headersFile); + if (Object.values(body.privateHeaders as ProviderObject).some(v => typeof v !== 'string')) { + throw new Error('Private header values must be strings'); + } + } + secrets.push(...collectProviderSecrets(body)); + const result = await providerRequest('register-api-service', cfg.apiKey, 'POST', body); + if (result?.success === false) { emit(result); process.exitCode = 1; return; } + const service = object(result?.apiService); + if (result?.success !== true || typeof service?.id !== 'string') { + throw new Error('Unexpected import response; creation may have succeeded. Check provider list before retrying'); + } + const active = object(service.activeVersion); + const revision = active ?? (Array.isArray(service.versions) ? object(service.versions[0]) : undefined); + emit({ ...result, serviceId: service.id, revisionId: revision?.id ?? service.activeVersionId ?? null, + state: revision?.state ?? service.status ?? null }); + return; + } + if (command === 'update') { + const mode = flag(flags, 'mode') ?? 'merge'; + if (!['merge', 'replace'].includes(mode)) throw new Error('--mode must be merge or replace'); + if (flags['allow-new-endpoints'] !== undefined && flags['allow-new-endpoints'] !== 'true') { + throw new Error('--allow-new-endpoints is a boolean flag'); + } + const body = await jsonFile(flag(flags, 'file', true)!); + secrets.push(...collectProviderSecrets(body)); + if ('openapi' in body) throw new Error('update expects version configuration, not a raw OpenAPI spec'); + if (body.endpoints !== undefined) { + if (!Array.isArray(body.endpoints) || body.endpoints.some(ep => !object(ep))) throw new Error('endpoints must be an array of objects'); + if (mode === 'merge' && !flags['allow-new-endpoints'] && body.endpoints.some(ep => typeof ep.id !== 'string' || !ep.id.trim())) { + throw new Error('Merge endpoints require IDs. Use --mode replace for a full list, or --allow-new-endpoints to intentionally create endpoints'); + } + } + const revision = await providerRequest(`${base}/versions/${segment(revisionId!)}`, cfg.apiKey, mode === 'merge' ? 'PATCH' : 'PUT', body); + emit({ serviceId, revisionId, state: revision?.state ?? null, revision }); + return; + } + if (command === 'submit') { + const changelog = flag(flags, 'changelog'); + const submission = await providerRequest(`${revisionPath}/submit`, cfg.apiKey, 'POST', changelog ? { changelog } : {}); + emit({ serviceId, revisionId, submission }); + return; + } + if (command === 'review') { emit(await read(`${revisionPath}/review`)); return; } + + const intervalMs = duration(flag(flags, 'interval') ?? '2s', 'interval'); + const timeoutMs = duration(flag(flags, 'timeout') ?? '10m', 'timeout'); + const attemptsFlag = flag(flags, 'max-attempts'); + const maxAttempts = attemptsFlag === undefined ? Infinity : Number(attemptsFlag); + if (attemptsFlag !== undefined && (!/^\d+$/.test(attemptsFlag) || !Number.isSafeInteger(maxAttempts) || maxAttempts <= 0)) { + throw new Error('--max-attempts must be a positive integer'); + } + const deadline = Date.now() + timeoutMs; + let attempts = 0; + let last: ProviderObject | undefined; + while (true) { + if (Date.now() >= deadline) { + emit({ serviceId, revisionId, success: false, reason: 'timeout', attempts, last }); + process.exitCode = 1; return; + } + let delay = intervalMs; + let report: ProviderObject | undefined; + let received = false; + attempts++; + try { + report = await providerRequest(`${revisionPath}/review`, cfg.apiKey, 'GET', undefined, Math.max(1, deadline - Date.now()), 0); + received = true; + } catch (e) { + if (!isRetryableRequestError(e)) throw e; + if (e instanceof HttpError && e.retryAfterMs !== undefined) delay = Math.max(intervalMs, e.retryAfterMs); + } + if (Date.now() >= deadline) continue; + if (received) { + const revision = object(report?.revision); + const state = revision?.state; + if (revision?.id !== revisionId || !['DRAFT', 'SANDBOX', 'IN_REVIEW', 'PUBLISHED', 'SUSPENDED'].includes(String(state))) { + throw new Error('Invalid review response: expected requested revision ID and known state'); + } + last = report; + const review = object(report?.review); + const manual = review?.outcome === 'pending_human' || review?.status === 'PENDING_HUMAN'; + const rejected = review?.outcome === 'rejected' || ['REJECTED', 'AUTO_FAILED'].includes(String(review?.status)); + if (state === 'PUBLISHED' || state !== 'IN_REVIEW' || manual || rejected) { + const success = state === 'PUBLISHED'; + emit({ ...report, serviceId, revisionId, success, reason: success ? 'published' : manual ? 'manual_review_required' : rejected ? 'rejected' : 'not_published' }); + if (!success) process.exitCode = 1; + return; + } + } + if (attempts >= maxAttempts) { + emit({ serviceId, revisionId, success: false, reason: 'max_attempts', attempts, last }); + process.exitCode = 1; return; + } + await new Promise(resolve => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now())))); + } + } catch (e) { + let message: string; + if (e instanceof HttpError) { + // Server errors may echo request bodies (including truncated credentials). + message = `HTTP ${e.status}`; + if (e.status === 403) message += `: requires ${SCOPES[command]}; check key permissions, service ownership, and IP restrictions in the xAPI Console`; + else if (e.status === 401) message += ': invalid or expired API key'; + else if (e.status === 400) message += ': request rejected; check spec-rules, configuration, and revision state'; + else if (e.status === 409 && /"code"\s*:\s*"REVISION_NOT_EDITABLE"/.test(e.message)) { + message += ': revision is not editable. Only DRAFT or SANDBOX can be updated; use a working revision for changes to a published API'; + } + if (['import', 'update', 'submit'].includes(command)) message += '. No automatic retry was made; inspect provider list/get/review before retrying'; + } else message = e instanceof SyntaxError ? 'Invalid JSON response from provider API' : e instanceof Error ? e.message : 'Unknown error'; + err(`provider ${command} failed`, redactProvider(message, secrets)); + } +} diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 5469b97..032aca8 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -3,6 +3,8 @@ import { mkdir, open, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { apiKeyApiRequest } from '../client.ts'; +import { providerOnboarding, PROVIDER_ONBOARDING_HELP } from './provider-onboarding.ts'; +import { redactProvider } from '../provider-client.ts'; import { getConfig, requireApiKey, @@ -16,6 +18,11 @@ const BASE = '/api/api-services/agent'; export const PROVIDER_HELP = `xapi-to provider - Manage provider services and their content USAGE + xapi-to provider spec-rules + xapi-to provider import --file [--private-headers-file ] + xapi-to provider update --revision --file + xapi-to provider submit --revision [--changelog ] + xapi-to provider wait --revision [--interval 2s] [--timeout 10m] xapi-to provider list xapi-to provider get [--version ] xapi-to provider create --file [rate-limit flags] @@ -215,9 +222,12 @@ async function writeExclusive(path: string, content: string, force: boolean) { export async function provider(args: string[], flags: Record) { if (flags.help || args.length === 0) { - console.log(PROVIDER_HELP); + console.log(PROVIDER_HELP + "\n" + PROVIDER_ONBOARDING_HELP); return; } + const onboardingCommand = ['spec-rules', 'import', 'submit', 'wait'].includes(args[0]); + const revisionAlias = ['update', 'review'].includes(args[0]) && flags.revision !== undefined; + if (onboardingCommand || revisionAlias) return providerOnboarding(args, flags); const cfg = getConfig(); requireApiKey(cfg); const apiKey = cfg.apiKey!; @@ -407,7 +417,7 @@ export async function provider(args: string[], flags: Record) { err(`unknown provider command: ${command}`, 'Run "xapi-to provider --help".'); } - output(result, flags.format as any); + output(redactProvider(result, [apiKey]), flags.format as any); } catch (error: any) { err('provider request failed', error.message); } diff --git a/src/provider-client.ts b/src/provider-client.ts new file mode 100644 index 0000000..4133a8d --- /dev/null +++ b/src/provider-client.ts @@ -0,0 +1,80 @@ +import { request } from './client.ts'; +import { scheme, XAPI_API_HOST } from './config.ts'; + +export type ProviderObject = Record; + +export function providerRequest( + path: string, + apiKey: string | undefined, + method: 'GET' | 'POST' | 'PATCH' | 'PUT' = 'GET', + body?: ProviderObject, + timeoutMs = 30_000, + retries = method === 'GET' ? 2 : 0, +): Promise { + return request(`${scheme(XAPI_API_HOST)}://${XAPI_API_HOST}/api/api-services/agent/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'XAPI-KEY': apiKey } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, Math.min(timeoutMs, 30_000), retries); +} + +export function object(value: unknown): ProviderObject | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as ProviderObject : undefined; +} + +// Owner reads can contain upstream credentials, even when write responses are scrubbed. +const SECRET_FIELD = /^(authConfig|privateHeaders|authorization|proxy-authorization|api[-_]?key|xapi[-_]?key|x-api-key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|token|cookie|set-cookie)$/i; + +// These fields contain API definitions, not credential maps. A property named +// "token" in a schema must retain its type/description and must not make the +// word "string" a secret everywhere else in the response. +const CONTRACT_FIELD = new Set([ + 'openApiSpec', 'bodySchema', 'schema', 'schemas', 'properties', + 'definitions', '$defs', 'params', 'pathParams', 'responses', 'securitySchemes', +]); + +function isContract(key: string, value: unknown, parent?: string): boolean { + if (CONTRACT_FIELD.has(key)) return true; + const obj = object(value); + if (typeof obj?.openapi === 'string') return true; + // Endpoint headers may be parameter definitions or literal header values. + return parent === 'headers' && !!obj && ('type' in obj || 'schema' in obj || '$ref' in obj); +} + +export function collectProviderSecrets(value: unknown): string[] { + const secrets: string[] = []; + function visit(item: unknown, sensitive = false, contract = false, parent?: string) { + if (typeof item === 'string' && sensitive && item) secrets.push(item); + else if (Array.isArray(item)) item.forEach(v => visit(v, sensitive, contract, parent)); + else if (object(item)) { + for (const [key, val] of Object.entries(item as ProviderObject)) { + const definition = !sensitive && (contract || isContract(key, val, parent)); + visit(val, sensitive || (!definition && SECRET_FIELD.test(key)), definition, key); + } + } + } + visit(value); + return secrets; +} + +export function redactProvider(value: unknown, knownSecrets: string[] = []): unknown { + const secrets = [...new Set([...knownSecrets, ...collectProviderSecrets(value)])] + .filter(Boolean).sort((a, b) => b.length - a.length); + function visit(item: unknown, contract = false, parent?: string): unknown { + if (typeof item === 'string') { + return secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), item); + } + if (Array.isArray(item)) return item.map(val => visit(val, contract, parent)); + if (object(item)) return Object.fromEntries(Object.entries(item as ProviderObject) + .map(([key, val]) => { + const definition = contract || isContract(key, val, parent); + return [key, !definition && SECRET_FIELD.test(key) ? '[REDACTED]' : visit(val, definition, key)]; + })); + return item; + } + return visit(value); +} diff --git a/src/tests/provider-onboarding.test.ts b/src/tests/provider-onboarding.test.ts new file mode 100644 index 0000000..2045a36 --- /dev/null +++ b/src/tests/provider-onboarding.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as config from '../config.ts'; +import * as format from '../format.ts'; +import { provider } from '../commands/provider.ts'; +import { providerRequest, redactProvider } from '../provider-client.ts'; + +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); +const report = (state: string, review: unknown = null) => ({ revision: { id: 'rev-1', state }, review }); + +describe('provider lifecycle commands', () => { + let dir: string; + let fetchSpy: ReturnType; + let outputSpy: ReturnType; + let errSpy: ReturnType; + let configSpy: ReturnType; + let oldExit: typeof process.exitCode; + let oldRetry: string | undefined; + const calls: Array<{ url: string; method: string; headers: Headers; body: any; signal?: AbortSignal }> = []; + let respond: (call: typeof calls[number]) => Response | Promise; + const file = async (value: unknown, name = 'input.json') => { + const path = join(dir, name); + await writeFile(path, JSON.stringify(value)); + return path; + }; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'xapi-provider-')); + calls.length = 0; + oldExit = process.exitCode; + process.exitCode = 0; + oldRetry = process.env.XAPI_RETRY_BASE_MS; + process.env.XAPI_RETRY_BASE_MS = '1'; + respond = () => json({}); + configSpy = spyOn(config, 'getConfig').mockReturnValue({ actionHost: 'action.xapi.to', apiKey: 'sk-cli-secret' }); + outputSpy = spyOn(format, 'output').mockImplementation(() => {}); + errSpy = spyOn(format, 'err').mockImplementation((() => { throw new Error('cli error'); }) as any); + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async (url: any, options: RequestInit) => { + const call = { url: String(url), method: options.method!, headers: new Headers(options.headers), + body: options.body ? JSON.parse(String(options.body)) : undefined, signal: options.signal ?? undefined }; + calls.push(call); + expect(options.redirect).toBe('manual'); + return respond(call); + }) as any); + }); + + afterEach(async () => { + fetchSpy.mockRestore(); outputSpy.mockRestore(); errSpy.mockRestore(); configSpy.mockRestore(); + process.exitCode = oldExit; + if (oldRetry === undefined) delete process.env.XAPI_RETRY_BASE_MS; + else process.env.XAPI_RETRY_BASE_MS = oldRetry; + await rm(dir, { recursive: true, force: true }); + }); + + it('completes import, configuration, submission, and polling on scoped management routes', async () => { + let polls = 0; + respond = call => { + expect(call.headers.get('XAPI-KEY')).toBe('sk-cli-secret'); + expect(call.url).toStartWith('https://api.xapi.to/api/api-services/agent/'); + if (call.url.endsWith('register-api-service')) return json({ success: true, + apiService: { id: 'svc-1', activeVersionId: 'rev-1', activeVersion: { id: 'rev-1', state: 'DRAFT' } }, validation: { warnings: [] } }, 201); + if (call.method === 'PATCH') return json({ id: 'rev-1', state: 'SANDBOX', ...call.body }); + if (call.url.endsWith('/submit')) return json({ state: 'IN_REVIEW', willReview: true }); + return json(report(++polls === 1 ? 'IN_REVIEW' : 'PUBLISHED')); + }; + const spec = { openapi: '3.0.3', info: { title: 'Example', version: '1.0' }, paths: {} }; + await provider(['import'], { file: await file(spec), 'private-headers-file': await file({ Authorization: 'Bearer upstream-secret' }, 'headers.json') }); + expect(calls[0].body).toEqual({ openApiSpec: spec, privateHeaders: { Authorization: 'Bearer upstream-secret' } }); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ serviceId: 'svc-1', revisionId: 'rev-1', state: 'DRAFT' }); + await provider(['update', 'svc-1'], { revision: 'rev-1', file: await file({ privateHeaders: { Authorization: 'Bearer upstream-secret' } }) }); + expect(calls[1].method).toBe('PATCH'); + expect(calls[1].url).toEndWith('/services/svc-1/versions/rev-1'); + expect(JSON.stringify(outputSpy.mock.calls)).not.toContain('upstream-secret'); + await provider(['submit', 'svc-1'], { revision: 'rev-1', changelog: 'Initial release' }); + expect(calls[2].body).toEqual({ changelog: 'Initial release' }); + await provider(['wait', 'svc-1'], { revision: 'rev-1', interval: '1ms', timeout: '1s' }); + expect(outputSpy.mock.calls.at(-1)?.[0]).toMatchObject({ success: true, reason: 'published' }); + expect(process.exitCode).toBe(0); + }); + + it('treats HTTP 201 application validation failures as failures with structured diagnostics', async () => { + respond = () => json({ success: false, validation: { errors: [{ field: 'openapi', message: 'Must be 3.0.3' }] } }, 201); + await provider(['import'], { file: await file({ openapi: '2.0' }) }); + expect(process.exitCode).toBe(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ success: false, validation: { errors: [{ field: 'openapi', message: 'Must be 3.0.3' }] } }); + expect(calls.length).toBe(1); + }); + + it('rejects malformed and non-object files without exposing input or making requests', async () => { + const path = join(dir, 'bad.json'); + await writeFile(path, '{"privateHeaders": "upstream-secret"'); + await expect(provider(['import'], { file: path })).rejects.toThrow('cli error'); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('upstream-secret'); + await expect(provider(['import'], { file: await file([]) })).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + }); + + it('guards endpoint merges while allowing explicit additions and full replacements', async () => { + respond = () => json({ state: 'SANDBOX' }); + const path = await file({ endpoints: [{ name: 'search', method: 'GET', path: '/search' }] }); + await expect(provider(['update', 'svc'], { file: path, revision: 'rev-1' })).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + await provider(['update', 'svc'], { file: path, revision: 'rev-1', mode: 'replace' }); + expect(calls[0].method).toBe('PUT'); + await provider(['update', 'svc'], { file: path, revision: 'rev-1', 'allow-new-endpoints': 'true' }); + expect(calls[1].method).toBe('PATCH'); + await provider(['update', 'svc'], { file: await file({ endpoints: [{ id: 'ep-1', costPerCall: '0.002' }] }), revision: 'rev-1' }); + expect(calls[2].body.endpoints[0].id).toBe('ep-1'); + }); + + it('gets owner configuration and overview with credentials redacted', async () => { + respond = call => call.url.endsWith('version-overview') ? json({ currentMajor: 1, majors: [] }) + : json({ id: 'svc', activeVersion: { id: 'rev-1', version: 'v1.0' }, + currentVersion: { id: 'rev-1', authConfig: 'raw-secret', privateHeaders: { Custom: 'custom-secret' }, + endpoints: [{ id: 'ep-1', bodySchema: { type: 'object', properties: { token: { type: 'string' }, name: { type: 'string' } } } }] }, + description: 'raw-secret custom-secret' }); + await provider(['get', 'svc'], { version: 'v1.0' }); + expect(calls[0].url).toEndWith('/services/svc?version=v1.0'); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ id: 'svc', currentVersion: { id: 'rev-1' } }); + expect(JSON.stringify(outputSpy.mock.calls)).not.toContain('raw-secret'); + expect(JSON.stringify(outputSpy.mock.calls)).not.toContain('custom-secret'); + expect(outputSpy.mock.calls[0][0].currentVersion.endpoints).toEqual([ + { id: 'ep-1', bodySchema: { type: 'object', properties: { token: { type: 'string' }, name: { type: 'string' } } } }, + ]); + }); + + it('uses public rules without credentials and owner list with scoped key', async () => { + await provider(['spec-rules'], {}); + expect(calls[0].headers.has('XAPI-KEY')).toBe(false); + await provider(['list'], {}); + expect(calls[1].url).toEndWith('/agent/services'); + expect(calls[1].headers.get('XAPI-KEY')).toBe('sk-cli-secret'); + }); + + it('never retries import, update, or submit, and does not print server-echoed secrets', async () => { + respond = () => new Response('upstream-secret sk-cli-secret truncated-upstream', { status: 503 }); + const path = await file({}); + for (const [args, flags] of [ + [['import'], { file: path }], + [['update', 'svc'], { revision: 'rev-1', file: path }], + [['submit', 'svc'], { revision: 'rev-1' }], + ] as [string[], Record][]) { + await expect(provider(args, flags)).rejects.toThrow('cli error'); + } + expect(calls).toHaveLength(3); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('secret'); + expect(errSpy.mock.calls[0][1]).toContain('No automatic retry'); + }); + + it('reports scope requirements on 403 and fails immediately', async () => { + respond = () => new Response('private error body', { status: 403 }); + await expect(provider(['submit', 'svc'], { revision: 'rev-1' })).rejects.toThrow('cli error'); + expect(errSpy.mock.calls[0][1]).toContain('service:publish'); + expect(calls).toHaveLength(1); + }); + + it('explains the backend immutable-revision conflict without echoing server input', async () => { + respond = () => json({ code: 'REVISION_NOT_EDITABLE', message: 'private-secret', state: 'PUBLISHED' }, 409); + await expect(provider(['update', 'svc'], { revision: 'rev-1', file: await file({ description: 'changed' }) })).rejects.toThrow('cli error'); + expect(errSpy.mock.calls[0][1]).toContain('Only DRAFT or SANDBOX'); + expect(errSpy.mock.calls[0][1]).not.toContain('private-secret'); + expect(calls).toHaveLength(1); + }); + + it('does not accept approval as publication, and waits through approved IN_REVIEW', async () => { + respond = () => json(report(calls.length === 1 ? 'IN_REVIEW' : 'PUBLISHED', { status: 'APPROVED', outcome: 'passed' })); + await provider(['wait', 'svc'], { revision: 'rev-1', interval: '1ms' }); + expect(calls).toHaveLength(2); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ success: true }); + }); + + for (const [state, review, reason] of [ + ['SANDBOX', { status: 'AUTO_FAILED', outcome: 'rejected' }, 'rejected'], + ['IN_REVIEW', { status: 'PENDING_HUMAN', outcome: 'pending_human' }, 'manual_review_required'], + ['DRAFT', null, 'not_published'], ['SUSPENDED', null, 'not_published'], + ] as const) { + it(`returns nonzero for ${state}/${reason}`, async () => { + respond = () => json(report(state, review)); + await provider(['wait', 'svc'], { revision: 'rev-1' }); + expect(process.exitCode).toBe(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ success: false, reason }); + expect(calls).toHaveLength(1); + }); + } + + it('recovers from transient poll errors within the attempt cap', async () => { + respond = () => calls.length === 1 ? new Response('busy', { status: 503 }) : json(report('PUBLISHED')); + await provider(['wait', 'svc'], { revision: 'rev-1', interval: '1ms', 'max-attempts': '2' }); + expect(calls).toHaveLength(2); + expect(process.exitCode).toBe(0); + }); + + it('caps transient errors and honors Retry-After without exceeding the deadline', async () => { + respond = () => new Response('busy', { status: 429, headers: { 'Retry-After': '120' } }); + const start = Date.now(); + await provider(['wait', 'svc'], { revision: 'rev-1', interval: '1ms', timeout: '30ms' }); + expect(Date.now() - start).toBeLessThan(1000); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ reason: 'timeout', success: false }); + calls.length = 0; + await provider(['wait', 'svc'], { revision: 'rev-1', 'max-attempts': '1' }); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls.at(-1)?.[0]).toMatchObject({ reason: 'max_attempts' }); + }); + + it('aborts an in-flight poll at the overall timeout', async () => { + respond = call => new Promise((_, reject) => call.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))); + const start = Date.now(); + await provider(['wait', 'svc'], { revision: 'rev-1', timeout: '30ms' }); + expect(Date.now() - start).toBeLessThan(1000); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ reason: 'timeout' }); + }); + + it('rejects empty, mismatched, and unknown-state review responses', async () => { + for (const value of [null, { revision: { id: 'different', state: 'PUBLISHED' } }, report('NEW_STATE')]) { + respond = () => value === null ? new Response(null, { status: 204 }) : json(value); + await expect(provider(['wait', 'svc'], { revision: 'rev-1' })).rejects.toThrow('cli error'); + } + expect(calls).toHaveLength(3); + }); + + it('validates command flags and IDs before HTTP requests', async () => { + for (const [args, flags] of [ + [['wait', 'svc'], { revision: 'rev-1', timeout: '0s' }], + [['wait', 'svc'], { revision: 'rev-1', interval: 'true' }], + [['wait', 'svc'], { revision: 'rev-1', 'max-attempts': '0' }], + [['submit', 'svc'], {}], [['wait', '..'], { revision: 'rev-1' }], + [['import'], { file: 'true' }], [['update', 'svc'], { revision: 'rev-1', mode: 'typo' }], + ] as [string[], Record][]) await expect(provider(args, flags)).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + }); + + it('refuses redirects and does not forward credentials', async () => { + respond = () => new Response(null, { status: 302, headers: { location: 'https://evil.example' } }); + await expect(providerRequest('services', 'key')).rejects.toThrow('refusing to follow redirect'); + expect(calls).toHaveLength(1); + }); + + it('redacts nested secret fields and known credential echoes', () => { + const value = redactProvider({ versions: [{ privateHeaders: { 'X-Custom': 'value-secret' }, authConfig: 'cipher-secret' }], + message: 'value-secret cipher-secret sk-cli-secret', headers: { Authorization: 'Bearer abc' } }, ['sk-cli-secret']); + expect(JSON.stringify(value)).not.toContain('secret'); + expect(JSON.stringify(value)).not.toContain('Bearer abc'); + }); + + it('preserves token/password/auth schema definitions and unrelated types', () => { + const properties = { token: { type: 'string', description: 'Authentication token' }, password: { type: 'string' }, authConfig: { type: 'object' } }; + const definitions = { + bodySchema: { type: 'object', properties }, + params: { token: { type: 'string' } }, + pathParams: { secret: { type: 'string' } }, + responses: [{ status: 200, schema: { type: 'object', properties } }], + headers: { Authorization: { type: 'string', description: 'Caller authorization' } }, + openApiSpec: { openapi: '3.0.3', components: { schemas: { Credentials: { type: 'object', properties } } } }, + description: 'string object Authentication token', + }; + expect(redactProvider(definitions)).toEqual(definitions); + }); + + it('still redacts real credentials and their echoes inside preserved schemas', () => { + const value = redactProvider({ + privateHeaders: { 'X-Custom': 'actual-upstream-key' }, + headers: { Authorization: 'Bearer another-key' }, + bodySchema: { properties: { token: { type: 'string', example: 'actual-upstream-key' } } }, + }); + expect(value).toEqual({ privateHeaders: '[REDACTED]', headers: { Authorization: '[REDACTED]' }, + bodySchema: { properties: { token: { type: 'string', example: '[REDACTED]' } } } }); + }); +});