/** * APOPHIS Plugin v1.0 — Fastify plugin entry point. * Thin wrapper: delegates all work to pure domain functions. * Fastify plugin API is accidental; APOPHIS logic is essential. * * Architecture: Orchestrator — imports focused builders from submodules. */ import type { FastifyInstance } from 'fastify' import { ScopeRegistry } from '../infrastructure/scope-registry.js' import { CleanupManager } from '../infrastructure/cleanup-manager.js' import { captureRoute } from '../domain/discovery.js' import { registerValidationHooks, storeRouteContract } from '../infrastructure/hook-validator.js' import { extractContract } from '../domain/contract.js' import { createExtensionRegistry } from '../extension/registry.js' import type { ApophisExtension } from '../extension/types.js' import { createPluginContractRegistry } from '../domain/plugin-contracts.js' import type { PluginContractRegistry } from '../domain/plugin-contracts.js' import { OutboundContractRegistry } from '../domain/outbound-contracts.js' import { createOutboundMockRuntime, type OutboundMockRuntime } from '../infrastructure/outbound-mock-runtime.js' import { validateProductionSafety, assertTestEnv } from '../infrastructure/production-safety.js' import { registerSwagger, buildSpec, buildScenario, buildCleanup, buildContract, buildStateful, buildCheck, } from './builders.js' import type { ApophisDecorations, ApophisOptions, OutboundCallRecord, OutboundContractSpec, TestConfig } from '../types.js' export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptions): Promise => { // Production safety: hard-fail if test-only options are present in production validateProductionSafety(opts) await registerSwagger(fastify, opts) // Initialize registries before route capture so onRoute can validate formulas // with any registered extension operation headers. const pluginContractRegistry = createPluginContractRegistry() const extensionRegistry = createExtensionRegistry() extensionRegistry.setPluginContractRegistry(pluginContractRegistry) // Initialize outbound contract registry const outboundContractRegistry = new OutboundContractRegistry() if (opts.outboundContracts) { outboundContractRegistry.registerAll(opts.outboundContracts) } // Track active outbound mock runtime for imperative E2E tests let activeMockRuntime: OutboundMockRuntime | undefined // Capture routes as they're registered via Fastify's onRoute hook fastify.addHook('onRoute', (routeOptions) => { const method = Array.isArray(routeOptions.method) ? routeOptions.method.join(',') : routeOptions.method const schema = routeOptions.schema as Record | undefined const prefix = (routeOptions as unknown as Record).prefix as string | undefined const url = prefix && !routeOptions.url.startsWith(prefix) ? `${prefix}${routeOptions.url}` : routeOptions.url captureRoute(fastify, { method, url, schema, prefix, }) // Extract contract and attach to route config for runtime validation hooks const contract = extractContract(url, method, schema) if (contract.validateRuntime && (contract.requires.length > 0 || contract.ensures.length > 0)) { const config = routeOptions.config as Record || {} config.apophisContract = contract routeOptions.config = config as typeof routeOptions.config // Store for hook validator lookup (Fastify doesn't expose routes after ready) const routeKey = `${contract.method} ${contract.path}` storeRouteContract(routeKey, contract, extensionRegistry.getExtensionHeaders()) } }) // Initialize scope registry with explicit config or empty const scope = new ScopeRegistry(opts.scopes ?? {}) const cleanupManager = new CleanupManager(fastify, scope, opts.cleanup ?? false) // Register user-provided plugin contracts if (opts.pluginContracts) { for (const [name, spec] of Object.entries(opts.pluginContracts)) { pluginContractRegistry.register(name, spec) } } if (opts.extensions) { for (const ext of opts.extensions) { extensionRegistry.register(ext as ApophisExtension) } } const decorations: ApophisDecorations = { scope, contract: buildContract(fastify, scope, extensionRegistry, pluginContractRegistry, outboundContractRegistry), stateful: buildStateful(fastify, scope, cleanupManager, extensionRegistry, pluginContractRegistry, outboundContractRegistry), check: buildCheck(fastify, scope, extensionRegistry, pluginContractRegistry), scenario: buildScenario(fastify, scope, extensionRegistry), cleanup: buildCleanup(cleanupManager), spec: buildSpec(fastify), test: { registerPluginContracts: (name: string, spec: import('../types.js').PluginContractSpec) => { assertTestEnv('registerPluginContracts') pluginContractRegistry.register(name, spec) }, registerOutboundContracts: (contracts: Record) => { assertTestEnv('registerOutboundContracts') outboundContractRegistry.registerAll(contracts) }, enableOutboundMocks: (mockOpts?: TestConfig['outboundMocks']) => { assertTestEnv('enableOutboundMocks') if (activeMockRuntime) { activeMockRuntime.restore() } // Disable case if (mockOpts === false) { activeMockRuntime = undefined return } const contracts = outboundContractRegistry.resolve( Array.from(outboundContractRegistry['contracts'].entries()).map(([name]) => name) ) const mode = mockOpts?.mode ?? 'example' const unmatched = mockOpts?.unmatched ?? 'error' const seed = Math.floor(Math.random() * 0xFFFFFFFF) activeMockRuntime = createOutboundMockRuntime({ contracts, mode, overrides: mockOpts?.overrides, unmatched, seed, }) activeMockRuntime.install() }, disableOutboundMocks: () => { assertTestEnv('disableOutboundMocks') if (activeMockRuntime) { activeMockRuntime.restore() activeMockRuntime = undefined } }, getOutboundCalls: (name?: string): ReadonlyArray => { assertTestEnv('getOutboundCalls') return activeMockRuntime?.getCalls(name) ?? [] }, }, } fastify.decorate('apophis', decorations) // Runtime validation: never register hooks in production const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod' if (opts.runtime && opts.runtime !== 'off' && !isProd) { registerValidationHooks(fastify, { validateRuntime: true, runtimeLevel: opts.runtime }) } }