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
JavaScript
/**
* 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 };