diff --git a/apps/web-next/.env.local.example b/apps/web-next/.env.local.example index 65789705f..f86fe72fb 100644 --- a/apps/web-next/.env.local.example +++ b/apps/web-next/.env.local.example @@ -167,6 +167,15 @@ FACADE_USE_STUBS=true # PYMTHOUSE_M2M_CLIENT_SECRET=... # PYMTHOUSE_BASE_URL=http://localhost:3001 # optional; marketplace link origin when unset elsewhere # +# Staging (pymthouse) reference — issuer origin https://staging.pymthouse.com. +# Issuer URL is the OIDC mount; the SDK resolves …/.well-known/openid-configuration from it. +# The public + M2M client id are the same public app_… id. The M2M client SECRET is +# provided ONLY via the PYMTHOUSE_M2M_CLIENT_SECRET env/secret store — NEVER commit it. +# PYMTHOUSE_ISSUER_URL=https://staging.pymthouse.com/api/v1/oidc +# PYMTHOUSE_PUBLIC_CLIENT_ID=app_2d89999406f9be57dd0233de +# PYMTHOUSE_M2M_CLIENT_ID=app_2d89999406f9be57dd0233de +# PYMTHOUSE_M2M_CLIENT_SECRET= # set in the secret store only — do not write the value here +# # Device-flow third-party initiate login: register initiate_login_uri in PymtHouse app settings as # {NEXT_PUBLIC_APP_URL}/login # PymtHouse redirects here with ?iss=&target_link_uri=&login_hint= ; NAAP validates iss/origin and diff --git a/apps/web-next/package.json b/apps/web-next/package.json index d8821a3e0..42b3a90fc 100644 --- a/apps/web-next/package.json +++ b/apps/web-next/package.json @@ -78,6 +78,8 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.0.18", + "ajv": "^8.18.0", + "ajv-formats": "^2.1.1", "autoprefixer": "^10.4.27", "eslint": "^9.16.0", "eslint-config-next": "15.5.12", diff --git a/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts new file mode 100644 index 000000000..9674048b9 --- /dev/null +++ b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts @@ -0,0 +1,188 @@ +/** @vitest-environment node */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import type { AuthUser } from '@naap/types'; + +import { GET, POST } from './route'; +import { AdapterNotImplementedError } from '@/lib/billing/adapter'; + +const isFeatureEnabled = vi.fn(); +vi.mock('@/lib/feature-flags', () => ({ + isFeatureEnabled: (...a: unknown[]) => isFeatureEnabled(...a), +})); + +const getBillingProviderAdapter = vi.fn(); +vi.mock('@/lib/billing/registry', () => ({ + getBillingProviderAdapter: (...a: unknown[]) => getBillingProviderAdapter(...a), +})); + +const validateSession = vi.fn(); +vi.mock('@/lib/api/auth', () => ({ + validateSession: (...a: unknown[]) => validateSession(...a), +})); + +vi.mock('@/lib/api/csrf', () => ({ validateCSRF: vi.fn(() => null) })); +vi.mock('@/lib/api/rate-limit', () => ({ enforceRateLimit: vi.fn(() => null) })); + +function authUser(overrides: Partial = {}): AuthUser { + return { + id: 'user-1', + email: 'u@example.com', + displayName: null, + avatarUrl: null, + address: null, + roles: [], + permissions: [], + ...overrides, + }; +} + +function makeAdapter(overrides: Record = {}) { + return { + slug: 'pymthouse', + isConfigured: vi.fn(() => true), + validate: vi.fn(), + getPlans: vi.fn(), + getUsageForExternalUser: vi.fn(async () => ({ requestCount: 1 })), + getAppUsage: vi.fn(async () => ({ totals: { requestCount: 0 } })), + mintSignerSession: vi.fn(async () => ({ + accessToken: 'tok-abc', + tokenType: 'Bearer', + expiresIn: 3600, + scope: 'sign:job', + })), + receiveCuratedOrchestrators: vi.fn(), + getCapabilityManifest: vi.fn(), + ...overrides, + }; +} + +function req( + url: string, + init?: { method?: string; headers?: Record }, +): NextRequest { + return new NextRequest(url, { + method: init?.method, + headers: { cookie: 'naap_auth_token=tok', ...(init?.headers ?? {}) }, + }); +} + +function params(provider: string, path: string[]) { + return { params: Promise.resolve({ provider, path }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + isFeatureEnabled.mockResolvedValue(true); + validateSession.mockResolvedValue(authUser()); + getBillingProviderAdapter.mockReturnValue(makeAdapter()); +}); + +describe('generic /api/v1/billing/{provider}/* — flag OFF (zero regression)', () => { + it('is a no-op 404 when provider_adapters is OFF', async () => { + isFeatureEnabled.mockResolvedValue(false); + const res = await GET( + req('http://localhost/api/v1/billing/pymthouse/usage'), + params('pymthouse', ['usage']), + ); + expect(res.status).toBe(404); + expect(getBillingProviderAdapter).not.toHaveBeenCalled(); + expect(validateSession).not.toHaveBeenCalled(); + }); +}); + +describe('generic billing route — flag ON', () => { + it('404s for an unknown provider', async () => { + getBillingProviderAdapter.mockReturnValue(undefined); + const res = await GET( + req('http://localhost/api/v1/billing/nope/usage'), + params('nope', ['usage']), + ); + expect(res.status).toBe(404); + }); + + it('404s for an invalid provider slug', async () => { + const res = await GET( + req('http://localhost/api/v1/billing/Bad_Slug/usage'), + params('Bad_Slug', ['usage']), + ); + expect(res.status).toBe(404); + expect(getBillingProviderAdapter).not.toHaveBeenCalled(); + }); + + it('401 without an auth token', async () => { + const res = await GET( + new NextRequest('http://localhost/api/v1/billing/pymthouse/usage'), + params('pymthouse', ['usage']), + ); + expect(res.status).toBe(401); + }); + + it('delegates usage scope=me to the adapter', async () => { + const adapter = makeAdapter(); + getBillingProviderAdapter.mockReturnValue(adapter); + const res = await GET( + req('http://localhost/api/v1/billing/pymthouse/usage?scope=me'), + params('pymthouse', ['usage']), + ); + expect(res.status).toBe(200); + expect(adapter.getUsageForExternalUser).toHaveBeenCalledWith( + expect.objectContaining({ externalUserId: 'user-1' }), + ); + }); + + it('403 for app scope when not system:admin', async () => { + const res = await GET( + req('http://localhost/api/v1/billing/pymthouse/usage?scope=app'), + params('pymthouse', ['usage']), + ); + expect(res.status).toBe(403); + }); + + it('delegates token mint to the adapter and returns the session', async () => { + const adapter = makeAdapter(); + getBillingProviderAdapter.mockReturnValue(adapter); + const res = await POST( + req('http://localhost/api/v1/billing/pymthouse/token', { method: 'POST' }), + params('pymthouse', ['token']), + ); + expect(res.status).toBe(200); + expect(adapter.mintSignerSession).toHaveBeenCalledWith( + expect.objectContaining({ externalUserId: 'user-1' }), + ); + const json = await res.json(); + expect(json.data.access_token).toBe('tok-abc'); + }); + + it('maps AdapterNotImplementedError to 501', async () => { + const adapter = makeAdapter({ + mintSignerSession: vi.fn(async () => { + throw new AdapterNotImplementedError('pymthouse', 'mintSignerSession'); + }), + }); + getBillingProviderAdapter.mockReturnValue(adapter); + const res = await POST( + req('http://localhost/api/v1/billing/pymthouse/token', { method: 'POST' }), + params('pymthouse', ['token']), + ); + expect(res.status).toBe(501); + }); + + it('404s for an unsupported operation', async () => { + const res = await GET( + req('http://localhost/api/v1/billing/pymthouse/unknown-op'), + params('pymthouse', ['unknown-op']), + ); + expect(res.status).toBe(404); + }); + + it('400 when the provider is not configured', async () => { + getBillingProviderAdapter.mockReturnValue(makeAdapter({ isConfigured: vi.fn(() => false) })); + const res = await GET( + req('http://localhost/api/v1/billing/pymthouse/usage'), + params('pymthouse', ['usage']), + ); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts new file mode 100644 index 000000000..0ef933f89 --- /dev/null +++ b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts @@ -0,0 +1,272 @@ +/** + * Generic billing provider routing (NAAP-A). + * + * GET /api/v1/billing/{provider}/usage + * POST /api/v1/billing/{provider}/token + * + * Delegates to the BillingProviderAdapter registry instead of any hardcoded + * provider. Gated behind the `provider_adapters` flag (default OFF): when OFF this + * route is a no-op (404), so the existing /api/v1/billing/pymthouse/* routes are + * the only billing surface and their behavior is unchanged. Zero regression. + * + * Never logs secrets/tokens/PII — only request metadata + correlation id. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { randomUUID } from 'node:crypto'; + +import { validateSession } from '@/lib/api/auth'; +import { validateCSRF } from '@/lib/api/csrf'; +import { enforceRateLimit } from '@/lib/api/rate-limit'; +import { error, errors, getAuthToken, success } from '@/lib/api/response'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { AdapterNotImplementedError, type BillingProviderAdapter } from '@/lib/billing/adapter'; +import { getBillingProviderAdapter } from '@/lib/billing/registry'; + +const PROVIDER_ADAPTERS_FLAG = 'provider_adapters'; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_PER_USER = 30; +const PROVIDER_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; + +type Params = { params: Promise<{ provider: string; path?: string[] }> }; + +function noStore(res: NextResponse): NextResponse { + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function correlationIdOf(request: NextRequest): string { + return request.headers.get('x-request-id')?.trim() || randomUUID(); +} + +function log( + level: 'info' | 'warn' | 'error', + event: string, + fields: Record, +): void { + const line = JSON.stringify({ level, event, ...fields }); + if (level === 'error') console.error(line); + else if (level === 'warn') console.warn(line); + else console.info(line); +} + +function isSystemAdmin(roles: string[] | undefined): boolean { + return Boolean(roles?.includes('system:admin')); +} + +/** Drop crypto-unit fields (wei/eth/gwei) so raw on-chain amounts are not exposed. */ +function stripCryptoUnitFields(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripCryptoUnitFields); + if (!value || typeof value !== 'object') return value; + const units = ['wei', 'eth', 'gwei']; + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + const lower = key.toLowerCase(); + if (units.some((u) => lower === u || lower.endsWith(u))) continue; + out[key] = stripCryptoUnitFields(entry); + } + return out; +} + +/** Strict YYYY-MM-DD or ISO 8601 date; returns null when invalid/empty. */ +function parseDateParam(raw: string | null): string | null { + if (raw == null) return null; + const v = raw.trim(); + if (v === '') return null; + const ts = Date.parse(v); + if (Number.isNaN(ts)) return null; + return v; +} + +/** Current UTC calendar-month [from, to] as ISO strings. */ +function currentUtcMonthBounds(): { startDate: string; endDate: string } { + const now = new Date(); + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)); + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999)); + return { startDate: start.toISOString(), endDate: end.toISOString() }; +} + +function mapAdapterError( + e: unknown, + provider: string, + correlationId: string, + event: string, +): NextResponse { + if (e instanceof AdapterNotImplementedError) { + log('warn', event, { provider, correlationId, reason: 'not_implemented', method: e.method }); + return error('NOT_IMPLEMENTED', 'Operation not supported by this provider', 501); + } + const message = e instanceof Error ? e.message : 'Unknown error'; + log('error', event, { provider, correlationId, message }); + return errors.serviceUnavailable('Billing provider request failed'); +} + +interface RouteCtx { + request: NextRequest; + provider: string; + adapter: BillingProviderAdapter; + correlationId: string; + user: { id: string; email?: string | null; roles?: string[] }; +} + +async function handleUsage(ctx: RouteCtx): Promise { + const { request, provider, adapter, correlationId, user } = ctx; + const sp = request.nextUrl.searchParams; + + const scope = (sp.get('scope') ?? 'me').trim().toLowerCase(); + if (scope !== 'me' && scope !== 'app') { + return noStore(errors.badRequest('Invalid scope; use me or app')); + } + + const startRaw = sp.get('startDate'); + const endRaw = sp.get('endDate'); + const hasStart = startRaw != null && startRaw.trim() !== ''; + const hasEnd = endRaw != null && endRaw.trim() !== ''; + if (hasStart !== hasEnd) { + return noStore(errors.badRequest('startDate and endDate must both be set or both omitted')); + } + + let startDate: string; + let endDate: string; + if (hasStart) { + const s = parseDateParam(startRaw); + const e = parseDateParam(endRaw); + if (!s || !e) return noStore(errors.badRequest('Invalid startDate or endDate')); + if (Date.parse(s) > Date.parse(e)) { + return noStore(errors.badRequest('startDate must be <= endDate')); + } + startDate = s; + endDate = e; + } else { + ({ startDate, endDate } = currentUtcMonthBounds()); + } + + if (scope === 'me') { + try { + const body = await adapter.getUsageForExternalUser({ + externalUserId: user.id, + startDate, + endDate, + }); + log('info', 'billing.adapter.usage', { provider, correlationId, scope, status: 200 }); + return noStore(success(stripCryptoUnitFields(body))); + } catch (e) { + return noStore(mapAdapterError(e, provider, correlationId, 'billing.adapter.usage')); + } + } + + if (!isSystemAdmin(user.roles)) { + return noStore(errors.forbidden('App-wide usage requires system:admin')); + } + + const groupByRaw = sp.get('groupBy')?.trim(); + let groupBy: 'none' | 'user' | undefined; + if (groupByRaw) { + if (groupByRaw !== 'none' && groupByRaw !== 'user') { + return noStore(errors.badRequest('groupBy must be none or user')); + } + groupBy = groupByRaw; + } + const userId = sp.get('userId')?.trim() || undefined; + + try { + const usage = await adapter.getAppUsage({ startDate, endDate, groupBy, userId }); + log('info', 'billing.adapter.usage', { provider, correlationId, scope, status: 200 }); + return noStore(success(stripCryptoUnitFields(usage))); + } catch (e) { + return noStore(mapAdapterError(e, provider, correlationId, 'billing.adapter.usage')); + } +} + +async function handleToken(ctx: RouteCtx): Promise { + const { request, provider, adapter, correlationId, user } = ctx; + + const csrfError = validateCSRF(request); + if (csrfError) return csrfError; + + const rateLimited = enforceRateLimit(request, { + keyPrefix: `billing-token:${provider}:${user.id}`, + windowMs: RATE_LIMIT_WINDOW_MS, + maxRequests: RATE_LIMIT_MAX_PER_USER, + }); + if (rateLimited) return rateLimited; + + try { + const session = await adapter.mintSignerSession({ + externalUserId: user.id, + email: user.email ?? undefined, + }); + log('info', 'billing.adapter.token', { provider, correlationId, status: 200 }); + return noStore( + success({ + access_token: session.accessToken, + token_type: session.tokenType, + expires_in: session.expiresIn, + scope: session.scope, + }), + ); + } catch (e) { + return noStore(mapAdapterError(e, provider, correlationId, 'billing.adapter.token')); + } +} + +async function resolve( + request: NextRequest, + ctx: Params, + method: 'GET' | 'POST', +): Promise { + const correlationId = correlationIdOf(request); + try { + // Flag OFF → behave as if this route does not exist (no-op; zero regression). + if (!(await isFeatureEnabled(PROVIDER_ADAPTERS_FLAG))) { + return noStore(errors.notFound('Resource')); + } + + const { provider, path } = await ctx.params; + if (!PROVIDER_SLUG_RE.test(provider)) { + return noStore(errors.notFound('Provider')); + } + + const adapter = getBillingProviderAdapter(provider); + if (!adapter) { + log('warn', 'billing.adapter.unknown_provider', { provider, correlationId }); + return noStore(errors.notFound('Provider')); + } + if (!adapter.isConfigured()) { + return noStore(errors.badRequest(`Provider "${provider}" is not configured`)); + } + + const token = getAuthToken(request); + if (!token) return noStore(errors.unauthorized('No auth token provided')); + const sessionUser = await validateSession(token); + if (!sessionUser) return noStore(errors.unauthorized('Invalid or expired session')); + + const op = (path?.[0] ?? '').toLowerCase(); + const routeCtx: RouteCtx = { + request, + provider, + adapter, + correlationId, + user: { id: sessionUser.id, email: sessionUser.email, roles: sessionUser.roles }, + }; + + if (method === 'GET' && op === 'usage') return handleUsage(routeCtx); + if (method === 'POST' && op === 'token') return handleToken(routeCtx); + + return noStore(errors.notFound('Billing operation')); + } catch (err) { + log('error', 'billing.adapter.unexpected', { + correlationId, + message: err instanceof Error ? err.message : 'unknown', + }); + return noStore(errors.internal('Billing request failed')); + } +} + +export async function GET(request: NextRequest, ctx: Params): Promise { + return resolve(request, ctx, 'GET'); +} + +export async function POST(request: NextRequest, ctx: Params): Promise { + return resolve(request, ctx, 'POST'); +} diff --git a/apps/web-next/src/lib/billing/adapter.ts b/apps/web-next/src/lib/billing/adapter.ts new file mode 100644 index 000000000..9cf601bac --- /dev/null +++ b/apps/web-next/src/lib/billing/adapter.ts @@ -0,0 +1,125 @@ +/** + * Billing Provider Adapter SPI (NAAP-A). + * + * NaaP reaches every billing provider ONLY through this interface + the registry + * (`registry.ts`). NaaP code must never import a provider client directly — the + * reference provider (pymthouse) is wrapped by `PymthouseAdapter` behind this + * SPI. This is the NaaP-side surface of the Billing Provider Protocol (BPP, C0). + * + * Methods that a given provider has not implemented yet throw + * `AdapterNotImplementedError` (HTTP 501-equivalent) rather than guessing. + */ + +/** BPP ② validate result (provider-neutral; mirrors validate.schema.json). */ +export interface ValidateResult { + valid: boolean; + user?: { sub: string }; + billing_account?: { + id: string; + providerSlug: string; + billingMode: 'delegated' | 'prepay'; + }; + capabilities?: string[]; + quota?: { remaining: number; resetAt?: string } | null; + /** Neutral opaque subscription pointer — never a provider-internal id name. */ + subscriptionRef?: string; + signerSession?: SignerSession; +} + +/** Provider-issued signer session, opaque to applications. */ +export interface SignerSession { + url?: string; + headers?: Record; + accessToken?: string; + tokenType?: string; + expiresIn?: number; + scope?: string; +} + +/** BPP ④ plan. */ +export interface Plan { + id: string; + name?: string; + price?: { amount: number; interval: string; currency?: string }; + bundles: Array<{ + capability: string; + sla?: { uptime?: number; p95Ms?: number }; + maxPriceWeiPerUnit?: string; + }>; +} + +/** BPP capability manifest entry. */ +export interface Capability { + id: string; + description?: string; +} + +/** Curated orchestrator (BPP ⑧). */ +export interface CuratedOrchestrator { + address: string; + capabilities: string[]; + score?: number; +} + +export interface UsageForExternalUserInput { + externalUserId: string; + startDate: string; + endDate: string; + maxEndUserIds?: number; +} + +export interface AppUsageInput { + startDate: string; + endDate: string; + groupBy?: 'none' | 'user'; + userId?: string; +} + +export interface MintSignerSessionInput { + externalUserId: string; + email?: string; +} + +/** + * The provider-neutral adapter SPI. Every method maps to a BPP seam. + */ +export interface BillingProviderAdapter { + /** Provider slug, e.g. "pymthouse" | "stub". */ + readonly slug: string; + + /** Whether this provider has the configuration it needs to serve requests. */ + isConfigured(): boolean; + + /** BPP ② — resolve an opaque key into identity + capabilities + signer session. */ + validate(key: string): Promise; + + /** BPP ④ — plan catalogue. */ + getPlans(): Promise; + + /** BPP usage/telemetry — per-user usage rollup for one external user. */ + getUsageForExternalUser(input: UsageForExternalUserInput): Promise; + + /** BPP usage/telemetry — app-wide usage (admin scope). */ + getAppUsage(input: AppUsageInput): Promise; + + /** BPP mintSignerSession — provider-issued, opaque to apps. */ + mintSignerSession(input: MintSignerSessionInput): Promise; + + /** BPP ⑧ — receive a curated orchestrator list for a plan. */ + receiveCuratedOrchestrators(plan: string, list: CuratedOrchestrator[]): Promise; + + /** BPP capability manifest — what this provider can enable. */ + getCapabilityManifest(): Promise; +} + +/** Thrown when a provider has not implemented a BPP method yet. */ +export class AdapterNotImplementedError extends Error { + readonly providerSlug: string; + readonly method: string; + constructor(providerSlug: string, method: string) { + super(`Adapter "${providerSlug}" does not implement "${method}"`); + this.name = 'AdapterNotImplementedError'; + this.providerSlug = providerSlug; + this.method = method; + } +} diff --git a/apps/web-next/src/lib/billing/bpp/conformance.test.ts b/apps/web-next/src/lib/billing/bpp/conformance.test.ts new file mode 100644 index 000000000..8a3abe3b9 --- /dev/null +++ b/apps/web-next/src/lib/billing/bpp/conformance.test.ts @@ -0,0 +1,108 @@ +/** @vitest-environment node */ + +import { describe, it, expect } from 'vitest'; + +import { createStubBillingProvider, type BppConformanceProvider } from './stub-provider'; +import { + ALL_SCHEMA_FILES, + compileAllSchemas, + getForbiddenInternalFieldNames, + findLeakedInternalFields, + runConformance, +} from './conformance'; + +describe('BPP contracts', () => { + it('every schema file compiles (schema lint)', () => { + const compiled = compileAllSchemas(); + expect(Object.keys(compiled)).toEqual([...ALL_SCHEMA_FILES]); + for (const file of ALL_SCHEMA_FILES) { + expect(typeof compiled[file]).toBe('function'); + } + }); + + it('declares forbidden provider-internal field names (⑨ seam isolation)', () => { + const forbidden = getForbiddenInternalFieldNames(); + expect(forbidden).toContain('openmeter_subscription_id'); + expect(forbidden).toContain('network_fee_usd_micros'); + }); +}); + +describe('BPP conformance — stub provider', () => { + it('passes every BPP seam and the seam-isolation assertion', async () => { + const report = await runConformance(createStubBillingProvider()); + const failing = report.seams.filter((s) => !s.valid); + expect(failing, JSON.stringify(failing, null, 2)).toHaveLength(0); + expect(report.leakedFields).toEqual([]); + expect(report.accountRefMismatches).toEqual([]); + expect(report.passed).toBe(true); + }); +}); + +describe('BPP conformance — negative cases (the suite catches drift)', () => { + it('fails schema validation when validate returns the wrong shape', async () => { + const broken: BppConformanceProvider = { + ...createStubBillingProvider(), + // `valid` must be a boolean; a string violates the schema. + async validate() { + return { valid: 'yes' }; + }, + }; + const report = await runConformance(broken); + expect(report.passed).toBe(false); + expect(report.seams.find((s) => s.seam === 'validate')?.valid).toBe(false); + }); + + it('fails seam isolation when a provider-internal id leaks through validate', async () => { + const leaky: BppConformanceProvider = { + ...createStubBillingProvider(), + async validate(key: string) { + const base = (await createStubBillingProvider().validate(key)) as Record; + return { ...base, openmeter_subscription_id: '01J-leaky' }; + }, + }; + const report = await runConformance(leaky); + expect(report.leakedFields).toContain('openmeter_subscription_id'); + expect(report.passed).toBe(false); + }); + + it('fails seam isolation when a provider-internal id leaks through usage ingest (⑥)', async () => { + const leaky: BppConformanceProvider = { + ...createStubBillingProvider(), + async getUsageIngest() { + const base = (await createStubBillingProvider().getUsageIngest()) as Record< + string, + unknown + >; + return { ...base, openmeter_subscription_id: '01J-usage-leak' }; + }, + }; + const report = await runConformance(leaky); + expect(report.leakedFields).toContain('openmeter_subscription_id'); + expect(report.passed).toBe(false); + }); + + it('fails when account.id diverges from billingAccountRef.accountId (⑤ identity)', async () => { + const mismatched: BppConformanceProvider = { + ...createStubBillingProvider(), + async getAccount() { + const base = (await createStubBillingProvider().getAccount()) as Record; + return { + ...base, + billingAccountRef: { providerSlug: 'stub', accountId: 'acct_DIFFERENT' }, + }; + }, + }; + const report = await runConformance(mismatched); + expect(report.accountRefMismatches.length).toBeGreaterThan(0); + expect(report.passed).toBe(false); + }); + + it('findLeakedInternalFields detects nested leaks', () => { + const forbidden = getForbiddenInternalFieldNames(); + const leaked = findLeakedInternalFields( + { window: { from: 'x' }, meta: { source: 'openmeter', model_id: 'm' } }, + forbidden, + ); + expect(leaked).toContain('model_id'); + }); +}); diff --git a/apps/web-next/src/lib/billing/bpp/conformance.ts b/apps/web-next/src/lib/billing/bpp/conformance.ts new file mode 100644 index 000000000..09e2daf0d --- /dev/null +++ b/apps/web-next/src/lib/billing/bpp/conformance.ts @@ -0,0 +1,204 @@ +/** + * BPP conformance suite (C0). + * + * Validates a provider's payloads against the JSON Schemas in + * `contracts/billing-provider-protocol/`. Any provider (pymthouse, the stub, or + * a third party) must pass this. CI runs it so a producer that drifts from the + * protocol fails before integration. + * + * This is test-support code: it reads schema files from disk with ajv and is + * imported only by `conformance.test.ts` (never by the app runtime). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv2020, { type ValidateFunction, type ErrorObject } from 'ajv/dist/2020'; +import addFormats from 'ajv-formats'; + +import type { BppConformanceProvider } from './stub-provider'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +/** Repo root is six levels up from src/lib/billing/bpp/. */ +export const CONTRACTS_DIR = path.resolve( + HERE, + '../../../../../../contracts/billing-provider-protocol', +); + +/** BPP seams the conformance suite exercises against a provider. */ +export const BPP_SEAMS = ['validate', 'plans', 'account', 'usage-ingest', 'curated-list'] as const; +export type BppSeam = (typeof BPP_SEAMS)[number]; + +/** All schema files in the contracts dir (includes the non-BPP ⑨ doc). */ +export const ALL_SCHEMA_FILES = [ + ...BPP_SEAMS.map((s) => `${s}.schema.json`), + 'discovery.schema.json', + 'provider-internal-openmeter.schema.json', +] as const; + +function readSchema(fileName: string): Record { + const full = path.join(CONTRACTS_DIR, fileName); + return JSON.parse(fs.readFileSync(full, 'utf8')) as Record; +} + +function newAjv(): Ajv2020 { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + return ajv; +} + +/** Compile every schema file (schema-lint guardrail). Throws on an invalid schema. */ +export function compileAllSchemas(): Record { + const ajv = newAjv(); + const out: Record = {}; + for (const file of ALL_SCHEMA_FILES) { + out[file] = ajv.compile(readSchema(file)); + } + return out; +} + +/** Provider-internal field names that MUST NOT leak through the BPP seams (⑨). */ +export function getForbiddenInternalFieldNames(): string[] { + const schema = readSchema('provider-internal-openmeter.schema.json'); + const names = schema['x-bpp-forbidden-field-names']; + if (!Array.isArray(names)) { + throw new Error('provider-internal-openmeter.schema.json missing x-bpp-forbidden-field-names'); + } + // Fail fast on malformed schema values rather than silently coercing with + // String(): a non-string entry means the contract itself is wrong. + return names.map((n) => { + if (typeof n !== 'string') { + throw new Error( + 'provider-internal-openmeter.schema.json x-bpp-forbidden-field-names entries must be strings', + ); + } + return n; + }); +} + +/** Recursively collect every object key present in a payload. */ +function collectKeys(value: unknown, acc: Set): void { + if (Array.isArray(value)) { + for (const item of value) collectKeys(item, acc); + return; + } + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value as Record)) { + acc.add(key); + collectKeys(child, acc); + } + } +} + +/** Find any forbidden provider-internal field names present in a payload. */ +export function findLeakedInternalFields(payload: unknown, forbidden: string[]): string[] { + const keys = new Set(); + collectKeys(payload, keys); + return forbidden.filter((name) => keys.has(name)); +} + +/** + * Enforce the ⑤ account identity invariant that plain JSON Schema (2020-12) + * cannot express: `account.id` / `account.providerSlug` MUST equal the neutral + * `billingAccountRef.accountId` / `billingAccountRef.providerSlug` pointer. NaaP + * only ever persists `billingAccountRef`, so a divergence would leave the stored + * pointer aimed at a different account than the one the provider described. + * Returns a list of human-readable mismatches (empty when consistent). + */ +export function checkAccountRefIdentity(accountPayload: unknown): string[] { + if (!accountPayload || typeof accountPayload !== 'object') return []; + const root = accountPayload as Record; + const account = root.account as Record | undefined; + const ref = root.billingAccountRef as Record | undefined; + if (!account || !ref) return []; + + const mismatches: string[] = []; + if (account.id !== ref.accountId) { + mismatches.push( + `account.id (${String(account.id)}) !== billingAccountRef.accountId (${String(ref.accountId)})`, + ); + } + if (account.providerSlug !== ref.providerSlug) { + mismatches.push( + `account.providerSlug (${String(account.providerSlug)}) !== billingAccountRef.providerSlug (${String(ref.providerSlug)})`, + ); + } + return mismatches; +} + +export interface SeamResult { + seam: BppSeam; + valid: boolean; + errors: ErrorObject[]; +} + +export interface ConformanceReport { + provider: string; + seams: SeamResult[]; + /** Seam-isolation: provider-internal (⑨) names found in ② and ⑥ payloads. */ + leakedFields: string[]; + /** ⑤ account ↔ billingAccountRef identity-invariant violations. */ + accountRefMismatches: string[]; + passed: boolean; +} + +function schemaFileForSeam(seam: BppSeam): string { + return `${seam}.schema.json`; +} + +async function payloadForSeam( + provider: BppConformanceProvider, + seam: BppSeam, +): Promise { + switch (seam) { + case 'validate': + return provider.validate('opaque-test-key'); + case 'plans': + return provider.getPlans(); + case 'account': + return provider.getAccount(); + case 'usage-ingest': + return provider.getUsageIngest(); + case 'curated-list': + return provider.getCuratedList(); + } +} + +/** + * Run the BPP conformance suite (② ④ ⑤ ⑥ ⑧) against a provider plus the + * seam-isolation assertion on the ② validate and ⑥ usage payloads and the ⑤ + * account-ref identity invariant. Each seam is fetched from the provider exactly + * once and the captured payloads are reused for the cross-cutting checks, so a + * provider with time-varying/stateful responses cannot make the suite flaky. + */ +export async function runConformance( + provider: BppConformanceProvider, +): Promise { + const ajv = newAjv(); + const seams: SeamResult[] = []; + const payloads = new Map(); + + for (const seam of BPP_SEAMS) { + const validate = ajv.compile(readSchema(schemaFileForSeam(seam))); + const payload = await payloadForSeam(provider, seam); + payloads.set(seam, payload); + const valid = validate(payload) as boolean; + seams.push({ seam, valid, errors: valid ? [] : (validate.errors ?? []) }); + } + + const forbidden = getForbiddenInternalFieldNames(); + const leakedFields = Array.from( + new Set([ + ...findLeakedInternalFields(payloads.get('validate'), forbidden), + ...findLeakedInternalFields(payloads.get('usage-ingest'), forbidden), + ]), + ); + + const accountRefMismatches = checkAccountRefIdentity(payloads.get('account')); + + const passed = + seams.every((s) => s.valid) && + leakedFields.length === 0 && + accountRefMismatches.length === 0; + return { provider: provider.slug, seams, leakedFields, accountRefMismatches, passed }; +} diff --git a/apps/web-next/src/lib/billing/bpp/stub-provider.ts b/apps/web-next/src/lib/billing/bpp/stub-provider.ts new file mode 100644 index 000000000..137bfc8d3 --- /dev/null +++ b/apps/web-next/src/lib/billing/bpp/stub-provider.ts @@ -0,0 +1,118 @@ +/** + * Tiny in-memory **stub billing provider** (C0). + * + * It is the *second* implementation of the Billing Provider Protocol (after the + * reference provider, pymthouse). It exists only to prove the seam is + * provider-neutral — it is NOT a real provider. Keep it minimal. + * + * Every method returns a payload that conforms to the matching BPP schema in + * `contracts/billing-provider-protocol/`. The conformance suite validates it. + */ + +export const STUB_PROVIDER_SLUG = 'stub'; + +/** The provider surface the BPP conformance suite exercises (② ④ ⑤ ⑥ ⑧). */ +export interface BppConformanceProvider { + readonly slug: string; + /** ② validate */ + validate(key: string): Promise; + /** ④ plans */ + getPlans(): Promise; + /** ⑤ account + member + billingAccountRef */ + getAccount(): Promise; + /** ⑥ usage ingest payload */ + getUsageIngest(): Promise; + /** ⑧ curated list (+ optional token bundle) */ + getCuratedList(): Promise; +} + +export function createStubBillingProvider(): BppConformanceProvider { + return { + slug: STUB_PROVIDER_SLUG, + + async validate(key: string): Promise { + if (!key || key.length < 1) { + return { valid: false }; + } + return { + valid: true, + user: { sub: 'stub-user-1' }, + billing_account: { + id: 'acct_stub_1', + providerSlug: STUB_PROVIDER_SLUG, + billingMode: 'delegated', + }, + capabilities: ['text-to-image:sdxl', 'tool:byoc-demo'], + quota: { remaining: 1000, resetAt: '2026-12-31T23:59:59.999Z' }, + // Neutral opaque pointer — never a provider-internal id name. + subscriptionRef: 'sub_stub_opaque_1', + signerSession: { + url: 'https://signer.stub.example/session', + headers: { Authorization: 'Bearer stub-signer-token' }, + }, + }; + }, + + async getPlans(): Promise { + return [ + { + id: 'free', + name: 'Free', + price: { amount: 0, interval: 'month', currency: 'USD' }, + bundles: [ + { + capability: 'text-to-image:sdxl', + sla: { uptime: 0.99, p95Ms: 3000 }, + maxPriceWeiPerUnit: '500', + }, + ], + }, + ]; + }, + + async getAccount(): Promise { + return { + account: { + id: 'acct_stub_1', + ownerSub: 'stub-user-1', + providerSlug: STUB_PROVIDER_SLUG, + planId: 'free', + creditBalanceWei: '0', + billingMode: 'delegated', + }, + members: [{ accountId: 'acct_stub_1', sub: 'stub-user-1', role: 'admin' }], + billingAccountRef: { providerSlug: STUB_PROVIDER_SLUG, accountId: 'acct_stub_1' }, + }; + }, + + async getUsageIngest(): Promise { + return { + providerSlug: STUB_PROVIDER_SLUG, + accountId: 'acct_stub_1', + appId: 'app_demo', + window: { from: '2026-06-01T00:00:00.000Z', to: '2026-06-30T23:59:59.999Z' }, + sessions: 3, + tickets: 12, + feeWei: '1000', + networkFeeUsdMicros: '5000', + byCapability: { + 'text-to-image:sdxl': { tickets: 12, networkFeeUsdMicros: '5000' }, + }, + }; + }, + + async getCuratedList(): Promise { + return { + plan: 'free', + version: '2026-06-17T00:00:00.000Z', + orchestrators: [ + { + address: 'https://orch.stub.example:8935', + capabilities: ['text-to-image:sdxl'], + score: 0.9, + }, + ], + }; + }, + }; +} diff --git a/apps/web-next/src/lib/billing/provider-config.test.ts b/apps/web-next/src/lib/billing/provider-config.test.ts new file mode 100644 index 000000000..38ec0b220 --- /dev/null +++ b/apps/web-next/src/lib/billing/provider-config.test.ts @@ -0,0 +1,92 @@ +/** @vitest-environment node */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { BILLING_PROVIDERS } from '../../../../../packages/database/src/billing-providers'; +import { + PYMTHOUSE_PROVIDER_SLUG, + PYMTHOUSE_STAGING_ISSUER_ORIGIN, + PYMTHOUSE_STAGING_CLIENT_ID, + PYMTHOUSE_ENV_VARS, + verifyPymthouseEnv, + logPymthouseEnvStatus, + findPymthouseSeed, + isPymthouseSeedEnabled, +} from './provider-config'; + +describe('NAAP-0 — BillingProvider seed verify', () => { + it('seeds BillingProvider{slug:pymthouse, enabled:true}', () => { + const seed = findPymthouseSeed(BILLING_PROVIDERS); + expect(seed).toBeDefined(); + expect(seed?.slug).toBe(PYMTHOUSE_PROVIDER_SLUG); + expect(seed?.enabled).toBe(true); + expect(isPymthouseSeedEnabled(BILLING_PROVIDERS)).toBe(true); + }); +}); + +describe('NAAP-0 — PYMTHOUSE_* env wiring', () => { + const saved: Record = {}; + + beforeEach(() => { + for (const name of PYMTHOUSE_ENV_VARS) { + saved[name] = process.env[name]; + delete process.env[name]; + } + }); + + afterEach(() => { + for (const name of PYMTHOUSE_ENV_VARS) { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; + } + }); + + it('reports all vars missing and not configured when env is empty', () => { + const status = verifyPymthouseEnv(); + expect(status.configured).toBe(false); + expect(status.missing).toEqual([...PYMTHOUSE_ENV_VARS]); + expect(Object.values(status.present).every((v) => v === false)).toBe(true); + }); + + it('reports configured + staging match for valid staging wiring', () => { + process.env.PYMTHOUSE_ISSUER_URL = `${PYMTHOUSE_STAGING_ISSUER_ORIGIN}/api/v1/oidc`; + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = PYMTHOUSE_STAGING_CLIENT_ID; + process.env.PYMTHOUSE_M2M_CLIENT_ID = PYMTHOUSE_STAGING_CLIENT_ID; + process.env.PYMTHOUSE_M2M_CLIENT_SECRET = 'test-only-secret-not-real'; + + const status = verifyPymthouseEnv(); + expect(status.configured).toBe(true); + expect(status.missing).toEqual([]); + expect(status.issuerMatchesStaging).toBe(true); + expect(status.clientIdMatchesStaging).toBe(true); + }); + + it('flags only the missing secret when the rest is present', () => { + process.env.PYMTHOUSE_ISSUER_URL = `${PYMTHOUSE_STAGING_ISSUER_ORIGIN}/api/v1/oidc`; + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = PYMTHOUSE_STAGING_CLIENT_ID; + process.env.PYMTHOUSE_M2M_CLIENT_ID = PYMTHOUSE_STAGING_CLIENT_ID; + + const status = verifyPymthouseEnv(); + expect(status.configured).toBe(false); + expect(status.missing).toEqual(['PYMTHOUSE_M2M_CLIENT_SECRET']); + expect(status.present.PYMTHOUSE_M2M_CLIENT_SECRET).toBe(false); + }); + + it('NEVER leaks the secret value via the structured log line', () => { + const secret = 'super-secret-value-should-never-appear'; + process.env.PYMTHOUSE_ISSUER_URL = `${PYMTHOUSE_STAGING_ISSUER_ORIGIN}/api/v1/oidc`; + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = PYMTHOUSE_STAGING_CLIENT_ID; + process.env.PYMTHOUSE_M2M_CLIENT_ID = PYMTHOUSE_STAGING_CLIENT_ID; + process.env.PYMTHOUSE_M2M_CLIENT_SECRET = secret; + + const lines: string[] = []; + const status = logPymthouseEnvStatus({ info: (m) => lines.push(m) }, 'test-correlation-id'); + + expect(status.configured).toBe(true); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('test-correlation-id'); + expect(lines[0]).toContain('"present"'); + // The secret value must NOT appear anywhere in the emitted log line. + expect(lines[0]).not.toContain(secret); + }); +}); diff --git a/apps/web-next/src/lib/billing/provider-config.ts b/apps/web-next/src/lib/billing/provider-config.ts new file mode 100644 index 000000000..86a7998cc --- /dev/null +++ b/apps/web-next/src/lib/billing/provider-config.ts @@ -0,0 +1,141 @@ +/** + * NAAP-0 — pymthouse BillingProvider env wiring + seed verification. + * + * Server-only. Reports the *presence* of the `PYMTHOUSE_*` env vars and whether + * the configured issuer/client match the staging reference values. It NEVER + * reads, returns, or logs the M2M client secret value — only a boolean + * indicating whether it is set. + */ + +import 'server-only'; + +import { randomUUID } from 'node:crypto'; +import { + isPymthouseConfigured, + getPymthouseIssuerUrlFromEnv, + getPymthousePublicClientIdFromEnv, +} from '@pymthouse/builder-sdk/config'; + +export const PYMTHOUSE_PROVIDER_SLUG = 'pymthouse'; + +/** + * Public (non-secret) staging reference values from the execution handover. + * The OIDC issuer origin and the public/M2M client id are NOT secrets. The M2M + * client *secret* is provided ONLY via `PYMTHOUSE_M2M_CLIENT_SECRET` and is never + * stored here. + */ +export const PYMTHOUSE_STAGING_ISSUER_ORIGIN = 'https://staging.pymthouse.com'; +export const PYMTHOUSE_STAGING_CLIENT_ID = 'app_2d89999406f9be57dd0233de'; + +/** Env vars the pymthouse adapter requires (secret listed last, never logged). */ +export const PYMTHOUSE_ENV_VARS = [ + 'PYMTHOUSE_ISSUER_URL', + 'PYMTHOUSE_PUBLIC_CLIENT_ID', + 'PYMTHOUSE_M2M_CLIENT_ID', + 'PYMTHOUSE_M2M_CLIENT_SECRET', +] as const; +export type PymthouseEnvVar = (typeof PYMTHOUSE_ENV_VARS)[number]; + +export interface PymthouseEnvStatus { + /** True when all required vars are present (delegates to the SDK). */ + configured: boolean; + /** Presence booleans only — values (especially the secret) are never exposed. */ + present: Record; + /** Names of the missing required vars. */ + missing: PymthouseEnvVar[]; + /** Configured issuer origin matches the staging reference. */ + issuerMatchesStaging: boolean; + /** Configured public client id matches the staging reference. */ + clientIdMatchesStaging: boolean; +} + +function isPresent(name: PymthouseEnvVar): boolean { + const value = process.env[name]; + return typeof value === 'string' && value.trim().length > 0; +} + +/** Inspect `PYMTHOUSE_*` env wiring without ever touching the secret value. */ +export function verifyPymthouseEnv(): PymthouseEnvStatus { + const present = PYMTHOUSE_ENV_VARS.reduce( + (acc, name) => { + acc[name] = isPresent(name); + return acc; + }, + {} as Record, + ); + + const missing = PYMTHOUSE_ENV_VARS.filter((name) => !present[name]); + + let issuerMatchesStaging = false; + const issuer = getPymthouseIssuerUrlFromEnv(); + if (issuer) { + try { + issuerMatchesStaging = new URL(issuer).origin === PYMTHOUSE_STAGING_ISSUER_ORIGIN; + } catch { + issuerMatchesStaging = false; + } + } + + const clientIdMatchesStaging = + getPymthousePublicClientIdFromEnv() === PYMTHOUSE_STAGING_CLIENT_ID; + + return { + configured: isPymthouseConfigured(), + present, + missing, + issuerMatchesStaging, + clientIdMatchesStaging, + }; +} + +/** Minimal structured logger surface (console satisfies it). */ +export interface StructuredLogger { + info?: (message: string) => void; + log?: (message: string) => void; + warn?: (message: string) => void; +} + +/** + * Emit a single structured log line describing the pymthouse env wiring. + * Logs presence booleans only — never the secret value. Returns the status so + * callers (e.g. a CI presence check) can assert on it. + */ +export function logPymthouseEnvStatus( + logger: StructuredLogger = console, + correlationId: string = randomUUID(), +): PymthouseEnvStatus { + const status = verifyPymthouseEnv(); + const line = JSON.stringify({ + level: status.configured ? 'info' : 'warn', + event: 'billing.provider.pymthouse.env_verify', + correlationId, + configured: status.configured, + present: status.present, + missing: status.missing, + issuerMatchesStaging: status.issuerMatchesStaging, + clientIdMatchesStaging: status.clientIdMatchesStaging, + }); + const emit = status.configured + ? (logger.info ?? logger.log) + : (logger.warn ?? logger.log); + emit?.call(logger, line); + return status; +} + +/** Minimal shape of a seeded `BillingProvider` row (from `@naap/database`). */ +export interface SeedProviderLike { + readonly slug: string; + readonly enabled: boolean; +} + +/** Find the pymthouse entry in a seed/catalog list. */ +export function findPymthouseSeed( + providers: readonly SeedProviderLike[], +): SeedProviderLike | undefined { + return providers.find((p) => p.slug === PYMTHOUSE_PROVIDER_SLUG); +} + +/** True when the seed declares `BillingProvider{slug:pymthouse, enabled:true}`. */ +export function isPymthouseSeedEnabled(providers: readonly SeedProviderLike[]): boolean { + return findPymthouseSeed(providers)?.enabled === true; +} diff --git a/apps/web-next/src/lib/billing/pymthouse-adapter.ts b/apps/web-next/src/lib/billing/pymthouse-adapter.ts new file mode 100644 index 000000000..e00de7580 --- /dev/null +++ b/apps/web-next/src/lib/billing/pymthouse-adapter.ts @@ -0,0 +1,89 @@ +/** + * Reference billing provider adapter: pymthouse (NAAP-A). + * + * Wraps the existing `getPmtHouseServerClient()` BEHIND the BillingProviderAdapter + * SPI. This is the ONLY place that may import the pymthouse client; all other NaaP + * code goes through the adapter + registry. Methods the NaaP side does not yet + * support (BPP validate/plans/curation/manifest — PYMT-3/5/7 pending) throw + * AdapterNotImplementedError rather than fabricating a response. + */ + +import 'server-only'; + +import { isPymthouseConfigured } from '@pymthouse/builder-sdk/config'; + +import { getPmtHouseServerClient } from '@/lib/pymthouse-client'; +import { + AdapterNotImplementedError, + type AppUsageInput, + type BillingProviderAdapter, + type Capability, + type CuratedOrchestrator, + type MintSignerSessionInput, + type Plan, + type SignerSession, + type UsageForExternalUserInput, + type ValidateResult, +} from './adapter'; + +export const PYMTHOUSE_ADAPTER_SLUG = 'pymthouse'; + +export class PymthouseAdapter implements BillingProviderAdapter { + readonly slug = PYMTHOUSE_ADAPTER_SLUG; + + isConfigured(): boolean { + return isPymthouseConfigured(); + } + + async validate(_key: string): Promise { + // BPP ② validate is provider-side (PYMT-3) and not yet C0-shaped on the NaaP + // side; do not fabricate identity/capabilities here. + throw new AdapterNotImplementedError(this.slug, 'validate'); + } + + async getPlans(): Promise { + throw new AdapterNotImplementedError(this.slug, 'getPlans'); + } + + async getUsageForExternalUser(input: UsageForExternalUserInput): Promise { + return getPmtHouseServerClient().fetchUsageForExternalUser({ + externalUserId: input.externalUserId, + startDate: input.startDate, + endDate: input.endDate, + ...(input.maxEndUserIds != null ? { maxEndUserIds: input.maxEndUserIds } : {}), + }); + } + + async getAppUsage(input: AppUsageInput): Promise { + return getPmtHouseServerClient().getUsage({ + startDate: input.startDate, + endDate: input.endDate, + ...(input.groupBy ? { groupBy: input.groupBy } : {}), + ...(input.userId ? { userId: input.userId } : {}), + }); + } + + async mintSignerSession(input: MintSignerSessionInput): Promise { + const session = await getPmtHouseServerClient().mintSignerSessionForExternalUser({ + externalUserId: input.externalUserId, + email: input.email, + }); + return { + accessToken: session.accessToken, + tokenType: session.tokenType, + expiresIn: session.expiresIn, + scope: session.scope, + }; + } + + async receiveCuratedOrchestrators( + _plan: string, + _list: CuratedOrchestrator[], + ): Promise { + throw new AdapterNotImplementedError(this.slug, 'receiveCuratedOrchestrators'); + } + + async getCapabilityManifest(): Promise { + throw new AdapterNotImplementedError(this.slug, 'getCapabilityManifest'); + } +} diff --git a/apps/web-next/src/lib/billing/registry.test.ts b/apps/web-next/src/lib/billing/registry.test.ts new file mode 100644 index 000000000..9a8744049 --- /dev/null +++ b/apps/web-next/src/lib/billing/registry.test.ts @@ -0,0 +1,63 @@ +/** @vitest-environment node */ + +import { describe, it, expect, afterEach } from 'vitest'; + +import type { BillingProviderAdapter } from './adapter'; +import { + getBillingProviderAdapter, + hasBillingProviderAdapter, + listBillingProviderSlugs, + registerBillingProviderAdapter, + resetBillingProviderRegistryForTests, +} from './registry'; + +afterEach(() => resetBillingProviderRegistryForTests()); + +describe('NAAP-A — billing provider adapter registry', () => { + it('resolves the pymthouse + stub reference adapters (≥2 providers)', () => { + expect(hasBillingProviderAdapter('pymthouse')).toBe(true); + expect(hasBillingProviderAdapter('stub')).toBe(true); + expect(getBillingProviderAdapter('pymthouse')?.slug).toBe('pymthouse'); + expect(getBillingProviderAdapter('stub')?.slug).toBe('stub'); + expect(listBillingProviderSlugs().length).toBeGreaterThanOrEqual(2); + }); + + it('returns undefined for an unknown provider', () => { + expect(getBillingProviderAdapter('does-not-exist')).toBeUndefined(); + expect(hasBillingProviderAdapter('does-not-exist')).toBe(false); + }); + + it('accepts any object satisfying the SPI (provider-neutral)', () => { + const fake: BillingProviderAdapter = { + slug: 'fake', + isConfigured: () => true, + validate: async () => ({ valid: true }), + getPlans: async () => [], + getUsageForExternalUser: async () => ({}), + getAppUsage: async () => ({}), + mintSignerSession: async () => ({ accessToken: 'x' }), + receiveCuratedOrchestrators: async () => {}, + getCapabilityManifest: async () => [], + }; + registerBillingProviderAdapter(fake); + expect(getBillingProviderAdapter('fake')).toBe(fake); + }); + + it('reset restores the default registry', () => { + registerBillingProviderAdapter({ + slug: 'temp', + isConfigured: () => true, + validate: async () => ({ valid: true }), + getPlans: async () => [], + getUsageForExternalUser: async () => ({}), + getAppUsage: async () => ({}), + mintSignerSession: async () => ({}), + receiveCuratedOrchestrators: async () => {}, + getCapabilityManifest: async () => [], + }); + expect(hasBillingProviderAdapter('temp')).toBe(true); + resetBillingProviderRegistryForTests(); + expect(hasBillingProviderAdapter('temp')).toBe(false); + expect(hasBillingProviderAdapter('pymthouse')).toBe(true); + }); +}); diff --git a/apps/web-next/src/lib/billing/registry.ts b/apps/web-next/src/lib/billing/registry.ts new file mode 100644 index 000000000..bac1b9872 --- /dev/null +++ b/apps/web-next/src/lib/billing/registry.ts @@ -0,0 +1,53 @@ +/** + * Billing provider adapter registry (NAAP-A). + * + * Resolves a provider slug → its `BillingProviderAdapter`. NaaP code looks up a + * provider here instead of importing a provider client directly. + * + * Keyed by provider slug. (The plan's `BillingProvider.adapterType` column is a + * later refinement; until then `adapterType` defaults to the slug, so the + * registry is keyed by slug and resolves the same adapter.) + */ + +import type { BillingProviderAdapter } from './adapter'; +import { PymthouseAdapter } from './pymthouse-adapter'; +import { StubAdapter } from './stub-adapter'; + +function buildDefaultRegistry(): Map { + const adapters: BillingProviderAdapter[] = [new PymthouseAdapter(), new StubAdapter()]; + const map = new Map(); + for (const adapter of adapters) { + map.set(adapter.slug, adapter); + } + return map; +} + +let registry: Map = buildDefaultRegistry(); + +/** Resolve an adapter by provider slug, or `undefined` if none is registered. */ +export function getBillingProviderAdapter(slug: string): BillingProviderAdapter | undefined { + return registry.get(slug); +} + +/** True when an adapter is registered for the slug. */ +export function hasBillingProviderAdapter(slug: string): boolean { + return registry.has(slug); +} + +/** The slugs of all registered adapters. */ +export function listBillingProviderSlugs(): string[] { + return Array.from(registry.keys()); +} + +/** + * Register (or override) an adapter. Primarily for tests / future dynamic + * registration; production uses the default registry. + */ +export function registerBillingProviderAdapter(adapter: BillingProviderAdapter): void { + registry.set(adapter.slug, adapter); +} + +/** Reset the registry to its defaults (test isolation). */ +export function resetBillingProviderRegistryForTests(): void { + registry = buildDefaultRegistry(); +} diff --git a/apps/web-next/src/lib/billing/stub-adapter.ts b/apps/web-next/src/lib/billing/stub-adapter.ts new file mode 100644 index 000000000..1f081a52e --- /dev/null +++ b/apps/web-next/src/lib/billing/stub-adapter.ts @@ -0,0 +1,91 @@ +/** + * In-memory stub billing provider adapter (NAAP-A). + * + * The second adapter in the registry — it proves the SPI is provider-neutral + * (the Phase 0 gate requires the registry to resolve ≥2 providers). Tiny and + * canned by design; it is NOT a real provider. + */ + +import { + type AppUsageInput, + type BillingProviderAdapter, + type Capability, + type CuratedOrchestrator, + type MintSignerSessionInput, + type Plan, + type SignerSession, + type UsageForExternalUserInput, + type ValidateResult, +} from './adapter'; + +export const STUB_ADAPTER_SLUG = 'stub'; + +export class StubAdapter implements BillingProviderAdapter { + readonly slug = STUB_ADAPTER_SLUG; + + isConfigured(): boolean { + return true; + } + + async validate(key: string): Promise { + if (!key) return { valid: false }; + return { + valid: true, + user: { sub: 'stub-user-1' }, + billing_account: { + id: 'acct_stub_1', + providerSlug: this.slug, + billingMode: 'delegated', + }, + capabilities: ['text-to-image:sdxl'], + quota: { remaining: 1000, resetAt: '2026-12-31T23:59:59.999Z' }, + subscriptionRef: 'sub_stub_opaque_1', + }; + } + + async getPlans(): Promise { + return [ + { + id: 'free', + name: 'Free', + price: { amount: 0, interval: 'month', currency: 'USD' }, + bundles: [{ capability: 'text-to-image:sdxl' }], + }, + ]; + } + + async getUsageForExternalUser(input: UsageForExternalUserInput): Promise { + return { + externalUserId: input.externalUserId, + period: { start: input.startDate, end: input.endDate }, + requestCount: 0, + }; + } + + async getAppUsage(input: AppUsageInput): Promise { + return { + period: { start: input.startDate, end: input.endDate }, + totals: { requestCount: 0 }, + }; + } + + async mintSignerSession(_input: MintSignerSessionInput): Promise { + return { + accessToken: 'stub-signer-token', + tokenType: 'Bearer', + expiresIn: 3600, + scope: 'sign:job', + }; + } + + async receiveCuratedOrchestrators( + _plan: string, + _list: CuratedOrchestrator[], + ): Promise { + // no-op for the in-memory stub + } + + async getCapabilityManifest(): Promise { + return [{ id: 'text-to-image:sdxl', description: 'Stub capability' }]; + } +} diff --git a/apps/web-next/src/lib/feature-flags.ts b/apps/web-next/src/lib/feature-flags.ts index 619a98047..9772080c4 100644 --- a/apps/web-next/src/lib/feature-flags.ts +++ b/apps/web-next/src/lib/feature-flags.ts @@ -21,8 +21,28 @@ export const KNOWN_FLAGS: KnownFlag[] = [ enabled: true, description: 'Enable teams collaboration feature (team creation, team switching, team pages)', }, + { + key: 'provider_adapters', + enabled: false, + description: + 'Route billing requests through the generic BillingProviderAdapter registry (/api/v1/billing/{provider}/*). OFF = legacy /billing/pymthouse/* behavior only.', + }, ]; +/** + * Read a single feature flag's effective state without writing to the DB. + * Falls back to the KNOWN_FLAGS default (or `false`) when the flag row does not + * exist yet, so a flag defaulting OFF is a no-op until an admin enables it. + */ +export async function isFeatureEnabled(key: string): Promise { + const flag = await prisma.featureFlag.findUnique({ + where: { key }, + select: { enabled: true }, + }); + if (flag) return flag.enabled; + return KNOWN_FLAGS.find((f) => f.key === key)?.enabled ?? false; +} + /** * Ensure all known flags exist in the database. * Uses upsert with no-op update so existing flags (and admin overrides) are preserved. diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 000000000..396d48057 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,42 @@ +# Contracts — Billing Provider Protocol (BPP) + +This directory holds the **provider-neutral** cross-repo contracts (C0) for the +NaaP × billing-provider × application integration. They are the *seams* every +billing provider implements and that NaaP reaches **only** through the +`BillingProviderAdapter` SPI (NAAP-A). + +`pymthouse` is the reference provider; a tiny in-memory **stub provider** is the +second implementation that proves the abstraction in CI. The +[conformance test](../apps/web-next/src/lib/billing/bpp/conformance.test.ts) +validates any provider's payloads against these schemas. + +## Files (`billing-provider-protocol/`) + +| Seam | File | Direction | Part of BPP? | +|---|---|---|---| +| ② `validate` response | `validate.schema.json` | provider → NaaP (via adapter) | ✅ yes | +| ④ plans + capability bundles | `plans.schema.json` | provider → NaaP | ✅ yes | +| ⑤ account + member + `billingAccountRef` | `account.schema.json` | provider-internal → NaaP ref | ✅ yes (ref only) | +| ⑥ usage ingest | `usage-ingest.schema.json` | provider → NaaP `/metrics/ingest` | ✅ yes | +| ⑦ discovery response | `discovery.schema.json` | NaaP → gateway | ✅ yes | +| ⑧ curated list + token bundle | `curated-list.schema.json` | NaaP → provider | ✅ yes | +| ⑨ provider-internal metering (OpenMeter) | `provider-internal-openmeter.schema.json` | **provider-internal** | ❌ **NOT BPP** | + +> **Seam isolation (hard rule).** The BPP payloads (② and ⑥ especially) must +> never carry provider-internal field names from ⑨ (e.g. +> `openmeter_subscription_id`, raw `network_fee_usd_micros` OM shapes, +> `source: "openmeter"`). The conformance suite asserts this. If a provider needs +> to surface a subscription pointer it returns a **neutral opaque** +> `subscriptionRef` (the provider decides its meaning). + +## Schema dialect + +All schemas use JSON Schema **2020-12**. They are *additive-only*: never change an +existing field's meaning — add new optional fields / new schema versions. The +conformance suite compiles every schema (a schema-lint guardrail) and validates +the stub provider against them. + +## Versioning + +These are v1 of the BPP. Breaking changes require a new file +(`*.v2.schema.json`) so old consumers keep validating against v1. diff --git a/contracts/billing-provider-protocol/account.schema.json b/contracts/billing-provider-protocol/account.schema.json new file mode 100644 index 000000000..461155489 --- /dev/null +++ b/contracts/billing-provider-protocol/account.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/account.schema.json", + "title": "BPP ⑤ account + member + billingAccountRef", + "description": "The billing account is provider-internal; NaaP only ever stores the neutral billingAccountRef pointer. account and account_member are documented so providers expose a consistent shape.", + "$comment": "Identity invariant (enforced by the BPP conformance suite, not plain JSON Schema 2020-12): account.id MUST equal billingAccountRef.accountId and account.providerSlug MUST equal billingAccountRef.providerSlug, so the persisted neutral ref can never point at a different account than the one described.", + "type": "object", + "required": ["account", "billingAccountRef"], + "additionalProperties": false, + "$defs": { + "account": { + "type": "object", + "required": ["id", "ownerSub", "providerSlug", "billingMode"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "ownerSub": { "type": "string", "minLength": 1 }, + "providerSlug": { "type": "string", "minLength": 1 }, + "planId": { "type": "string" }, + "creditBalanceWei": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Decimal wei string." + }, + "billingMode": { "type": "string", "enum": ["delegated", "prepay"] } + } + }, + "account_member": { + "type": "object", + "required": ["accountId", "sub", "role"], + "additionalProperties": false, + "properties": { + "accountId": { "type": "string", "minLength": 1 }, + "sub": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["admin", "member"] } + } + }, + "billingAccountRef": { + "type": "object", + "description": "Provider-agnostic pointer stored NaaP-side. The ONLY account shape NaaP persists.", + "required": ["providerSlug", "accountId"], + "additionalProperties": false, + "properties": { + "providerSlug": { "type": "string", "minLength": 1 }, + "accountId": { "type": "string", "minLength": 1 } + } + } + }, + "properties": { + "account": { "$ref": "#/$defs/account" }, + "members": { + "type": "array", + "items": { "$ref": "#/$defs/account_member" } + }, + "billingAccountRef": { "$ref": "#/$defs/billingAccountRef" } + } +} diff --git a/contracts/billing-provider-protocol/curated-list.schema.json b/contracts/billing-provider-protocol/curated-list.schema.json new file mode 100644 index 000000000..e68b33500 --- /dev/null +++ b/contracts/billing-provider-protocol/curated-list.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/curated-list.schema.json", + "title": "BPP ⑧ curated list + optional token bundle", + "description": "NaaP → provider via adapter.receiveCuratedOrchestrators(plan, list). The token bundle is OPTIONAL and only used for the direct python-gateway path (Option 2). Headers in the token bundle are opaque to apps.", + "type": "object", + "required": ["plan", "version", "orchestrators"], + "additionalProperties": false, + "$defs": { + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "tokenBundle": { + "type": "object", + "required": ["signer", "signer_headers", "discovery", "discovery_headers"], + "additionalProperties": false, + "properties": { + "signer": { "type": "string", "format": "uri" }, + "signer_headers": { "$ref": "#/$defs/headers" }, + "discovery": { "type": "string", "format": "uri" }, + "discovery_headers": { "$ref": "#/$defs/headers" } + } + } + }, + "properties": { + "plan": { "type": "string", "minLength": 1 }, + "version": { + "type": "string", + "format": "date-time", + "description": "ISO timestamp identifying this curated revision." + }, + "orchestrators": { + "type": "array", + "items": { + "type": "object", + "required": ["address", "capabilities"], + "additionalProperties": false, + "properties": { + "address": { "type": "string", "format": "uri" }, + "capabilities": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "score": { "type": "number" } + } + } + }, + "tokenBundle": { "$ref": "#/$defs/tokenBundle" } + } +} diff --git a/contracts/billing-provider-protocol/discovery.schema.json b/contracts/billing-provider-protocol/discovery.schema.json new file mode 100644 index 000000000..6079df115 --- /dev/null +++ b/contracts/billing-provider-protocol/discovery.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/discovery.schema.json", + "title": "BPP ⑦ discovery response", + "description": "NaaP → gateway. GET /api/v1/orchestrator-leaderboard/python-gateway?caps=… (auth: Bearer gw_…). The list is already filtered by the plan's capabilities.", + "type": "array", + "items": { + "type": "object", + "required": ["address"], + "additionalProperties": false, + "properties": { + "address": { + "type": "string", + "format": "uri", + "description": "Orchestrator endpoint, e.g. https://orch.example:8935" + } + } + } +} diff --git a/contracts/billing-provider-protocol/plans.schema.json b/contracts/billing-provider-protocol/plans.schema.json new file mode 100644 index 000000000..558d5309f --- /dev/null +++ b/contracts/billing-provider-protocol/plans.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/plans.schema.json", + "title": "BPP ④ plans + capability bundles", + "description": "Provider plan catalogue. Each plan declares the capabilities it enables and their SLA / price ceiling. NaaP enforces key → seat → account → plan → capabilities.", + "type": "array", + "items": { + "type": "object", + "required": ["id", "bundles"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "price": { + "type": "object", + "required": ["amount", "interval"], + "additionalProperties": false, + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "interval": { "type": "string", "enum": ["month", "year", "once"] }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$", "description": "ISO 4217 code; defaults to USD when omitted." } + } + }, + "bundles": { + "type": "array", + "items": { + "type": "object", + "required": ["capability"], + "additionalProperties": false, + "properties": { + "capability": { + "type": "string", + "minLength": 1, + "description": "Generic capability id, e.g. text-to-image:sdxl or tool:byoc-foo." + }, + "sla": { + "type": "object", + "additionalProperties": false, + "properties": { + "uptime": { "type": "number", "minimum": 0, "maximum": 1 }, + "p95Ms": { "type": "number", "minimum": 0 } + } + }, + "maxPriceWeiPerUnit": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Decimal wei string price ceiling per unit." + } + } + } + } + } + } +} diff --git a/contracts/billing-provider-protocol/provider-internal-openmeter.schema.json b/contracts/billing-provider-protocol/provider-internal-openmeter.schema.json new file mode 100644 index 000000000..9223a2995 --- /dev/null +++ b/contracts/billing-provider-protocol/provider-internal-openmeter.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/provider-internal-openmeter.schema.json", + "title": "⑨ Provider-internal metering (OpenMeter) — NOT part of the BPP", + "description": "DOCUMENTATION ONLY. This is the reference provider's (pymthouse, PR #133) internal metering shape. NaaP MUST NEVER depend on it — it consumes the neutral ⑥ usage-ingest payload only. The conformance suite uses this file's `x-bpp-forbidden-field-names` to assert these names never leak through the BPP seams (② and ⑥).", + "x-bpp": "non-bpp", + "x-bpp-forbidden-field-names": [ + "openmeter_subscription_id", + "openmeter_customer_id", + "network_fee_usd_micros", + "fee_wei", + "eth_usd_price", + "eth_usd_round_id", + "eth_usd_observed_at", + "external_user_id", + "client_id", + "model_id", + "gateway_request_id", + "specversion" + ], + "type": "object", + "required": ["specversion", "type", "source", "data"], + "properties": { + "specversion": { "type": "string", "const": "1.0" }, + "type": { "type": "string", "const": "create_signed_ticket" }, + "source": { "type": "string", "const": "go-livepeer-remote-signer" }, + "subject": { "type": "string", "description": ":" }, + "data": { + "type": "object", + "properties": { + "client_id": { "type": "string" }, + "external_user_id": { "type": "string" }, + "network_fee_usd_micros": { "type": "string" }, + "fee_wei": { "type": "string" }, + "pixels": { "type": "number" }, + "pipeline": { "type": "string" }, + "model_id": { "type": "string" }, + "gateway_request_id": { "type": "string" }, + "eth_usd_price": { "type": "string" }, + "eth_usd_round_id": { "type": "string" }, + "eth_usd_observed_at": { "type": "string" } + } + } + } +} diff --git a/contracts/billing-provider-protocol/usage-ingest.schema.json b/contracts/billing-provider-protocol/usage-ingest.schema.json new file mode 100644 index 000000000..11dac61d6 --- /dev/null +++ b/contracts/billing-provider-protocol/usage-ingest.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/usage-ingest.schema.json", + "title": "BPP ⑥ usage ingest", + "description": "Neutral usage payload pushed provider → NaaP at POST {NAAP_METRICS_URL}/api/v1/metrics/ingest. The authoritative cross-provider usage path (NOT validate). OM-backed providers must map their internal meters into this shape; raw OpenMeter field names MUST NOT appear here.", + "type": "object", + "required": ["providerSlug", "accountId", "window"], + "additionalProperties": false, + "$defs": { + "capabilityUsage": { + "type": "object", + "additionalProperties": false, + "properties": { + "tickets": { "type": "integer", "minimum": 0 }, + "networkFeeUsdMicros": { "type": "string", "pattern": "^[0-9]+$" } + } + } + }, + "properties": { + "providerSlug": { "type": "string", "minLength": 1 }, + "accountId": { "type": "string", "minLength": 1 }, + "appId": { "type": "string", "minLength": 1 }, + "window": { + "type": "object", + "required": ["from", "to"], + "additionalProperties": false, + "properties": { + "from": { "type": "string", "format": "date-time" }, + "to": { "type": "string", "format": "date-time" } + } + }, + "sessions": { "type": "integer", "minimum": 0 }, + "tickets": { "type": "integer", "minimum": 0 }, + "feeWei": { "type": "string", "pattern": "^[0-9]+$" }, + "networkFeeUsdMicros": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "USD micros; for OM-backed providers that meter in fiat." + }, + "byCapability": { + "type": "object", + "description": "Keyed by generic capability id :.", + "additionalProperties": { "$ref": "#/$defs/capabilityUsage" } + } + } +} diff --git a/contracts/billing-provider-protocol/validate.schema.json b/contracts/billing-provider-protocol/validate.schema.json new file mode 100644 index 000000000..f129794b3 --- /dev/null +++ b/contracts/billing-provider-protocol/validate.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://naap.livepeer.org/contracts/bpp/validate.schema.json", + "title": "BPP ② validate response", + "description": "Provider-neutral response for resolving an opaque key into identity, capabilities, billing account, and an optional provider-issued signer session. Exercised by NaaP through the BillingProviderAdapter. MUST NOT leak provider-internal metering identifiers (see provider-internal-openmeter.schema.json).", + "type": "object", + "required": ["valid"], + "additionalProperties": false, + "properties": { + "valid": { + "type": "boolean", + "description": "Whether the presented key is valid." + }, + "user": { + "type": "object", + "required": ["sub"], + "additionalProperties": false, + "properties": { + "sub": { + "type": "string", + "minLength": 1, + "description": "Stable subject identifier for the resolved user." + } + } + }, + "billing_account": { + "type": "object", + "required": ["id", "providerSlug", "billingMode"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "providerSlug": { "type": "string", "minLength": 1 }, + "billingMode": { "type": "string", "enum": ["delegated", "prepay"] } + } + }, + "capabilities": { + "type": "array", + "description": "Either the wildcard [\"*\"] or a list of generic capability ids in the form : or tool:.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "quota": { + "description": "Remaining quota, or null when not metered/unbounded.", + "oneOf": [ + { + "type": "object", + "required": ["remaining"], + "additionalProperties": false, + "properties": { + "remaining": { "type": "number" }, + "resetAt": { "type": "string", "format": "date-time" } + } + }, + { "type": "null" } + ] + }, + "subscriptionRef": { + "type": "string", + "minLength": 1, + "description": "OPTIONAL neutral opaque subscription pointer. The provider decides its meaning; it MUST NOT encode a provider-internal identifier name (e.g. openmeter_subscription_id)." + }, + "signerSession": { + "type": "object", + "required": ["url", "headers"], + "additionalProperties": false, + "properties": { + "url": { "type": "string", "format": "uri" }, + "headers": { + "type": "object", + "description": "Opaque-to-apps headers, e.g. { \"Authorization\": \"Bearer \" }.", + "additionalProperties": { "type": "string" } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 569197510..3ef7f0f23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,6 +113,8 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.0.18", + "ajv": "^8.18.0", + "ajv-formats": "^2.1.1", "autoprefixer": "^10.4.27", "eslint": "^9.16.0", "eslint-config-next": "15.5.12",