UNPKG

spaps

Version:

Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware

692 lines (675 loc) 27 kB
/** * AI Tool Spec generator for SPAPS * - Produces OpenAI-style function schemas for common SPAPS actions * - Derives auth expectations from the running local server when available */ const { DEFAULT_PORT } = require('./config'); const fs = require('fs'); const path = require('path'); const { getServerRuntime } = require('./local-runtime'); const { buildDomainTools } = require('./domains'); function tryLoadManifest() { const candidates = [ path.resolve(process.cwd(), 'docs/manifest.json'), path.resolve(__dirname, '../../../docs/manifest.json') ]; for (const p of candidates) { try { if (fs.existsSync(p)) { const raw = fs.readFileSync(p, 'utf8'); return JSON.parse(raw); } } catch {} } return null; } function tryLoadOpenAPI() { const candidates = [ path.resolve(process.cwd(), 'docs/api-reference.yaml'), path.resolve(__dirname, '../../../docs/api-reference.yaml') ]; for (const p of candidates) { try { if (fs.existsSync(p)) { const yaml = require('js-yaml'); const raw = fs.readFileSync(p, 'utf8'); return yaml.load(raw); } } catch {} } return null; } function buildAuthSection(runtime) { if (!runtime.running) { return { local_mode: false, mode_source: '/health/local-mode', api_key_required: true, header: 'X-API-Key', env: 'SPAPS_API_KEY', status: 'server_unreachable', }; } return { local_mode: Boolean(runtime.local_mode.active), mode_source: '/health/local-mode', api_key_required: !runtime.local_mode.active, header: 'X-API-Key', env: 'SPAPS_API_KEY', environment: runtime.local_mode.environment, spaps_local_mode_env: runtime.local_mode.spaps_local_mode_env, ...(runtime.local_mode.active ? { test_users: runtime.local_mode.test_users, test_application: runtime.local_mode.test_application, hints: runtime.local_mode.hints, } : { self_service_password_env: 'SELF_SERVICE_PASSWORD', }), }; } const CORE_TOOL_ROUTES = [ { name: 'login', method: 'POST', path: '/api/auth/login', requestBody: true }, { name: 'register', method: 'POST', path: '/api/auth/register', requestBody: true }, { name: 'auth_methods', method: 'GET', path: '/api/auth/methods' }, { name: 'get_current_user', method: 'GET', path: '/api/auth/user' }, { name: 'create_checkout_session', method: 'POST', path: '/api/stripe/checkout-sessions', requestBody: true }, { name: 'list_products', method: 'GET', path: '/api/stripe/products' }, { name: 'request_magic_link', method: 'POST', path: '/api/auth/magic-link', requestBody: true }, { name: 'get_wallet_nonce', method: 'POST', path: '/api/auth/nonce', requestBody: true }, { name: 'wallet_sign_in', method: 'POST', path: '/api/auth/wallet-sign-in', requestBody: true }, { name: 'verify_magic_link', method: 'POST', path: '/api/auth/verify-magic-link', requestBody: true }, { name: 'oidc_nonce', method: 'POST', path: '/api/auth/oidc/nonce', requestBody: true }, { name: 'oidc_sign_in', method: 'POST', path: '/api/auth/oidc/sign-in', requestBody: true }, { name: 'sms_request', method: 'POST', path: '/api/auth/sms/request', requestBody: true }, { name: 'sms_verify', method: 'POST', path: '/api/auth/sms/verify', requestBody: true }, { name: 'webauthn_assertion_options', method: 'POST', path: '/api/auth/webauthn/assertion/options', requestBody: true }, { name: 'webauthn_assertion_verify', method: 'POST', path: '/api/auth/webauthn/assertion/verify', requestBody: true }, { name: 'webauthn_register_options', method: 'POST', path: '/api/auth/webauthn/register/options', requestBody: true }, { name: 'webauthn_register_verify', method: 'POST', path: '/api/auth/webauthn/register/verify', requestBody: true }, { name: 'mfa_verify', method: 'POST', path: '/api/auth/mfa/verify', requestBody: true }, { name: 'mfa_totp_enroll', method: 'POST', path: '/api/auth/mfa/totp/enroll', requestBody: true }, { name: 'mfa_totp_activate', method: 'POST', path: '/api/auth/mfa/totp/activate', requestBody: true }, { name: 'mfa_totp_disable', method: 'POST', path: '/api/auth/mfa/totp/disable', requestBody: true }, ]; const CLI_AUTH_TOOLS = [ { name: 'spaps_auth_methods', description: 'Run `spaps auth methods` to print the configured auth method matrix for an application.', command: 'spaps auth methods', cli: true, parameters: { type: 'object', properties: { server_url: { type: 'string', description: 'SPAPS server URL; maps to --server-url' }, origin: { type: 'string', description: 'Browser origin to send with publishable-key checks; maps to --origin' }, json: { type: 'boolean', default: true }, }, }, }, { name: 'spaps_auth_mfa_test', description: 'Run `spaps auth mfa-test` against a local SPAPS stack to exercise TOTP enrollment, activation, MFA challenge verification, and cleanup.', command: 'spaps auth mfa-test', cli: true, local_only_by_default: true, parameters: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string', description: 'Local test user email; maps to --email' }, password: { type: 'string', description: 'Local test user password; maps to --password' }, server_url: { type: 'string', description: 'SPAPS server URL; maps to --server-url' }, origin: { type: 'string', description: 'Browser origin to send with publishable-key checks; maps to --origin' }, allow_remote: { type: 'boolean', default: false, description: 'Maps to --allow-remote' }, json: { type: 'boolean', default: true }, }, }, }, { name: 'spaps_auth_sms_test', description: 'Run `spaps auth sms-test` against local console SMS to request a challenge, then verify it with a code read from local server logs.', command: 'spaps auth sms-test', cli: true, local_only_by_default: true, parameters: { type: 'object', required: ['phone_number'], properties: { phone_number: { type: 'string', description: 'E.164 phone number; maps to --phone-number' }, challenge_id: { type: 'string', description: 'Existing SMS challenge id; maps to --challenge-id' }, code: { type: 'string', description: 'SMS code from local server logs; maps to --code' }, server_url: { type: 'string', description: 'SPAPS server URL; maps to --server-url' }, origin: { type: 'string', description: 'Browser origin to send with publishable-key checks; maps to --origin' }, allow_remote: { type: 'boolean', default: false, description: 'Maps to --allow-remote' }, json: { type: 'boolean', default: true }, }, }, }, { name: 'spaps_auth_passkeys_doctor', description: 'Diagnose passkey readiness without starting a ceremony or mutating credentials.', command: 'spaps auth passkeys doctor', cli: true, idempotent: true, agent_guidance: { discovery: 'Use spaps auth passkeys show for the canonical profile, then spaps auth passkeys doctor --json with the exact RP, origin, scope, CORS, SDK, and environment evidence.', when_to_use: 'Before generating browser passkey code, launching a ceremony test, or diagnosing an RP/origin failure.', do: [ 'Use a publishable browser key with webauthn_ceremonies scope.', 'Treat every failed check and next_action as blocking evidence.', ], dont: [ 'Do not put spaps_sec_ keys in browser configuration.', 'Do not infer support from a successful deterministic model alone.', ], common_mistakes: [ 'Preview origin checked against a production profile.', 'CORS host is similar but not an exact origin match.', ], next_actions: ['Resolve failed checks, then run spaps auth passkeys test without --launch to inspect the safe packet.'], }, parameters: { type: 'object', properties: { application_id: { type: 'string', description: 'Application UUID; maps to --application-id' }, rp_id: { type: 'string', description: 'Exact expected RP hostname; maps to --rp-id' }, origin: { type: 'string', description: 'Exact expected browser origin; maps to --origin' }, environment: { type: 'string', enum: ['local', 'preview', 'production'] }, publishable_scopes: { type: 'array', items: { type: 'string' } }, cors_origins: { type: 'array', items: { type: 'string' } }, sdk_version: { type: 'string' }, json: { type: 'boolean', default: true }, }, }, }, { name: 'spaps_auth_passkeys_test', description: 'Emit or launch the isolated local Chromium virtual-authenticator harness; browser credential bodies are never terminal output.', command: 'spaps auth passkeys test', cli: true, local_only_by_default: true, preferred_client_api: 'client.auth.passkeys', idempotent: false, agent_guidance: { discovery: 'Run spaps auth passkeys doctor --json first. Use the returned exact RP/origin and next_actions.', when_to_use: 'After doctor is ready and you need safe browser/virtual-authenticator evidence.', do: [ 'Use client.auth.passkeys.register/signIn/createConditionalSignIn in browser code.', 'Prefer PublicKeyCredential parseCreationOptionsFromJSON/parseRequestOptionsFromJSON through spaps-sdk.', ], dont: [ 'Do not emulate CTAP or WebAuthn in the terminal.', 'Do not persist or log credential bodies.', 'Do not request remote mutation without explicit operator intent.', ], common_mistakes: [ 'Using raw DOM casts instead of the SDK JSON conversion path.', 'Treating mfa_required as a generic public step-up consume endpoint.', ], next_actions: ['Inspect the packet, launch locally, then handle authenticated or mfa_required results explicitly.'], }, parameters: { type: 'object', properties: { server_url: { type: 'string', description: 'Target SPAPS URL; maps to --server-url' }, rp_id: { type: 'string', description: 'Exact RP hostname; maps to --rp-id' }, origin: { type: 'string', description: 'Exact browser origin; maps to --origin' }, launch: { type: 'boolean', default: false }, allow_remote: { type: 'boolean', default: false }, json: { type: 'boolean', default: true }, }, }, }, ]; async function buildOpenAIToolSpec({ port = DEFAULT_PORT, version = '0.0.0', include_non_agent = false } = {}) { const baseUrl = `http://localhost:${port}`; const runtime = await getServerRuntime({ port }); const auth = buildAuthSection(runtime); const spec = { name: 'spaps', version, description: 'Auth + payments via SPAPS. Resolve local auth requirements from /health/local-mode.', base_url: baseUrl, auth, tools: [ { name: 'login', description: auth.local_mode ? 'Authenticate a local test user. API key validation is bypassed while local mode is active.' : 'Authenticate a user with email/password for a provisioned application. Send X-API-Key when local mode is disabled.', method: 'POST', path: '/api/auth/login', parameters: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string', description: 'Email address' }, password: { type: 'string', description: 'Plain text password' } } } }, { name: 'register', description: auth.local_mode ? 'Register a user while local mode is active.' : 'Register a new user with email/password for a provisioned application.', method: 'POST', path: '/api/auth/register', parameters: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string' }, password: { type: 'string' } } } }, { name: 'auth_methods', description: 'Discover the public auth methods configured for this application before rendering sign-in UI.', method: 'GET', path: '/api/auth/methods', parameters: { type: 'object', properties: {} } }, { name: 'get_current_user', description: 'Get the currently authenticated user. Uses the bearer token from a previous login.', method: 'GET', path: '/api/auth/user', parameters: { type: 'object', properties: { authorization: { type: 'string', description: 'Bearer <access_token>' } } } }, { name: 'create_checkout_session', description: 'Create a Stripe Checkout session against the configured SPAPS server.', method: 'POST', path: '/api/stripe/checkout-sessions', parameters: { type: 'object', required: ['success_url', 'cancel_url'], properties: { price_id: { type: 'string', description: 'Existing Stripe price ID (preferred)' }, product_name: { type: 'string', description: 'Used when price_id not provided' }, amount: { type: 'number', description: 'Amount in cents if creating ad-hoc price' }, currency: { type: 'string', default: 'usd' }, success_url: { type: 'string' }, cancel_url: { type: 'string' } } } }, { name: 'list_products', description: 'List products exposed by the SPAPS server.', method: 'GET', path: '/api/stripe/products', parameters: { type: 'object', properties: { active: { type: 'boolean' }, limit: { type: 'number' } } } }, { name: 'request_magic_link', description: 'Send a magic link for passwordless login.', method: 'POST', path: '/api/auth/magic-link', parameters: { type: 'object', required: ['email'], properties: { email: { type: 'string' } } } }, { name: 'get_wallet_nonce', description: 'Get a nonce to sign for wallet authentication.', method: 'POST', path: '/api/auth/nonce', parameters: { type: 'object', required: ['wallet_address'], properties: { wallet_address: { type: 'string' }, chain_type: { type: 'string', enum: ['solana', 'ethereum', 'bitcoin', 'base'] } } } }, { name: 'wallet_sign_in', description: 'Exchange a signed wallet nonce for SPAPS tokens or an mfa_required challenge.', method: 'POST', path: '/api/auth/wallet-sign-in', parameters: { type: 'object', required: ['wallet_address', 'signature', 'message'], properties: { wallet_address: { type: 'string' }, signature: { type: 'string' }, message: { type: 'string' }, chain_type: { type: 'string', enum: ['solana', 'ethereum', 'bitcoin', 'base'] }, username: { type: 'string' } } } }, { name: 'verify_magic_link', description: 'Exchange a magic-link token for SPAPS tokens or an mfa_required challenge.', method: 'POST', path: '/api/auth/verify-magic-link', parameters: { type: 'object', required: ['token'], properties: { token: { type: 'string' }, type: { type: 'string', default: 'magiclink' }, state: { type: 'string' } } } }, { name: 'oidc_nonce', description: 'Create a nonce challenge before exchanging an OIDC provider ID token.', method: 'POST', path: '/api/auth/oidc/nonce', parameters: { type: 'object', properties: {} } }, { name: 'oidc_sign_in', description: 'Exchange an OIDC ID token and challenge_id for SPAPS tokens or an mfa_required challenge.', method: 'POST', path: '/api/auth/oidc/sign-in', parameters: { type: 'object', required: ['provider', 'id_token', 'challenge_id'], properties: { provider: { type: 'string' }, id_token: { type: 'string' }, challenge_id: { type: 'string' }, username: { type: 'string' } } } }, { name: 'sms_request', description: 'Request an enumeration-safe SMS OTP challenge.', method: 'POST', path: '/api/auth/sms/request', parameters: { type: 'object', required: ['phone_number'], properties: { phone_number: { type: 'string' } } } }, { name: 'sms_verify', description: 'Verify an SMS OTP challenge for SPAPS tokens or an mfa_required challenge.', method: 'POST', path: '/api/auth/sms/verify', parameters: { type: 'object', required: ['phone_number', 'code', 'challenge_id'], properties: { phone_number: { type: 'string' }, code: { type: 'string' }, challenge_id: { type: 'string' } } } }, { name: 'webauthn_assertion_options', description: 'Advanced raw endpoint. Prefer client.auth.passkeys.signIn() or createConditionalSignIn(), which performs safe browser JSON conversion.', preferred_client_api: 'client.auth.passkeys.signIn', warning: 'Advanced agents only: do not hand-cast WebAuthn JSON or log ceremony data.', method: 'POST', path: '/api/auth/webauthn/assertion/options', parameters: { type: 'object', properties: { email: { type: 'string' } } } }, { name: 'webauthn_assertion_verify', description: 'Advanced raw endpoint. Prefer client.auth.passkeys.signIn(), which verifies the assertion and preserves the mfa_required branch.', preferred_client_api: 'client.auth.passkeys.signIn', warning: 'Advanced agents only: never persist or log credential bodies, and do not invent a public generic step-up consume endpoint.', method: 'POST', path: '/api/auth/webauthn/assertion/verify', parameters: { type: 'object', required: ['challenge_id', 'credential'], properties: { challenge_id: { type: 'string' }, credential: { type: 'object' } } } }, { name: 'webauthn_register_options', description: 'Advanced raw endpoint. Prefer client.auth.passkeys.register(), which uses native PublicKeyCredential JSON parsing.', preferred_client_api: 'client.auth.passkeys.register', warning: 'Advanced agents only: do not hand-cast browser ceremony options.', method: 'POST', path: '/api/auth/webauthn/register/options', parameters: { type: 'object', properties: {} } }, { name: 'webauthn_register_verify', description: 'Advanced raw endpoint. Prefer client.auth.passkeys.register(), which serializes and verifies the browser credential safely.', preferred_client_api: 'client.auth.passkeys.register', warning: 'Advanced agents only: never persist or log credential bodies.', method: 'POST', path: '/api/auth/webauthn/register/verify', parameters: { type: 'object', required: ['challenge_id', 'credential'], properties: { challenge_id: { type: 'string' }, credential: { type: 'object' } } } }, { name: 'mfa_verify', description: 'Complete an mfa_required challenge with a TOTP code or recovery code.', method: 'POST', path: '/api/auth/mfa/verify', parameters: { type: 'object', required: ['challenge_id', 'challenge'], properties: { challenge_id: { type: 'string' }, challenge: { type: 'string' }, code: { type: 'string' }, recovery_code: { type: 'string' } } } }, { name: 'mfa_totp_enroll', description: 'Enroll TOTP MFA for an authenticated user.', method: 'POST', path: '/api/auth/mfa/totp/enroll', parameters: { type: 'object', properties: {} } }, { name: 'mfa_totp_activate', description: 'Activate pending TOTP MFA enrollment with a current code.', method: 'POST', path: '/api/auth/mfa/totp/activate', parameters: { type: 'object', required: ['code'], properties: { code: { type: 'string' } } } }, { name: 'mfa_totp_disable', description: 'Disable TOTP MFA for an authenticated user with a current code.', method: 'POST', path: '/api/auth/mfa/totp/disable', parameters: { type: 'object', required: ['code'], properties: { code: { type: 'string' } } } } ] }; // Merge domain registry tools (dayrate, email, webhooks, policies, issue_reporting). // Admin routes carry admin_required:true; non-agent routes (e.g. mailgun inbound) // are excluded unless include_non_agent is set. spec.tools.push(...CLI_AUTH_TOOLS.map((tool) => ({ ...tool }))); const domainTools = buildDomainTools({ includeNonAgent: include_non_agent }); for (const dt of domainTools) { spec.tools.push(dt); } // Default error shapes used for enrichment/merging const defaultErrors = { '400': { type: 'object', properties: { success: { type: 'boolean' }, error: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } }, required: ['message'] } }, required: ['error'] }, '401': { type: 'object', properties: { error: { type: 'string', enum: ['unauthorized'] }, message: { type: 'string' } }, required: ['error'] }, '403': { type: 'object', properties: { error: { type: 'string', enum: ['forbidden'] }, message: { type: 'string' } }, required: ['error'] }, '429': { type: 'object', properties: { error: { type: 'string', enum: ['rate_limited'] }, message: { type: 'string' } }, required: ['error'] }, '500': { type: 'object', properties: { error: { type: 'string', enum: ['server_error'] }, message: { type: 'string' } }, required: ['error'] } }; // Attempt to align paths/methods with docs/manifest.json if available try { const manifest = tryLoadManifest(); if (manifest && Array.isArray(manifest.endpoints)) { const find = (method, pathStr) => manifest.endpoints.find(e => e.method === method && e.path === pathStr); const patchTool = (toolName, method, pathStr) => { const t = spec.tools.find(x => x.name === toolName); const ep = find(method, pathStr); if (t && ep) { t.method = ep.method; t.path = ep.path; if (ep.auth !== undefined) t.auth = ep.auth; if (ep.rate_tier !== undefined) t.rate_tier = ep.rate_tier; if (ep.publishable_scope !== undefined) t.publishable_scope = ep.publishable_scope; if (ep.entitlement_key !== undefined) t.entitlement_key = ep.entitlement_key; } }; for (const route of CORE_TOOL_ROUTES) { patchTool(route.name, route.method, route.path); } } } catch { // Best-effort alignment only } // Attempt to enrich parameter schemas from OpenAPI try { const openapi = tryLoadOpenAPI(); if (openapi && openapi.paths) { const findOp = (method, pathStr) => { const ops = openapi.paths[pathStr]; if (!ops) return null; return ops[String(method).toLowerCase()] || null; }; const setBodySchema = (toolName, method, pathStr) => { const t = spec.tools.find(x => x.name === toolName); const op = findOp(method, pathStr); const schema = op?.requestBody?.content?.['application/json']?.schema; if (t && schema) { t.parameters = schema; } }; const setResponses = (toolName, method, pathStr) => { const t = spec.tools.find(x => x.name === toolName); const op = findOp(method, pathStr); if (t && op && op.responses) { const responses = {}; const examples = {}; for (const [code, obj] of Object.entries(op.responses)) { const schema = obj?.content?.['application/json']?.schema; if (schema) responses[code] = schema; const content = obj?.content?.['application/json']; if (content?.example !== undefined) { examples[code] = content.example; } else if (content?.examples && typeof content.examples === 'object') { const ex = {}; for (const [name, val] of Object.entries(content.examples)) { if (val && typeof val === 'object') { if ('value' in val) ex[name] = val.value; } } if (Object.keys(ex).length) examples[code] = ex; } } if (Object.keys(responses).length) { const merged = { ...defaultErrors, ...responses }; t.responses = merged; } if (Object.keys(examples).length) t.examples = examples; } }; for (const route of CORE_TOOL_ROUTES) { if (route.requestBody) setBodySchema(route.name, route.method, route.path); setResponses(route.name, route.method, route.path); } // Enrich every domain-registry tool opportunistically. Skips silently // if the operation isn\u2019t in the OpenAPI doc. for (const t of spec.tools) { if (!t.domain) continue; setBodySchema(t.name, t.method, t.path); setResponses(t.name, t.method, t.path); } } } catch { // Ignore enrichment errors } // Add default error shapes if responses missing spec.tools.forEach(t => { if (!t.responses) t.responses = defaultErrors; }); return spec; } async function buildToolSpec({ format = 'openai', port = DEFAULT_PORT, version = '0.0.0', include_non_agent = false } = {}) { switch (format) { case 'openai': default: return buildOpenAIToolSpec({ port, version, include_non_agent }); } } module.exports = { buildToolSpec };