# Plugin Contracts Define behavioral rules that apply across all matching routes and are automatically injected into every matching route's contract evaluation. Plugin contracts let teams codify cross-cutting expectations — "all API routes must carry an auth header," "all POST handlers must close the database connection," "all responses must include a request ID." They're defined once in config and evaluated alongside route-level contracts without per-route boilerplate. ## Table of Contents - [When to Use It](#when-to-use-it) - [Config Format](#config-format) - [PluginContractSpec Reference](#plugincontractspec-reference) - [Route Pattern Matching](#route-pattern-matching) - [Precondition → Skip Semantics](#precondition--skip-semantics) - [Auto-Injected Headers](#auto-injected-headers) - [Failure Attribution](#failure-attribution) - [Extension Dependencies](#extension-dependencies) - [Production Safety](#production-safety) - [Complete Example](#complete-example) - [Limitations](#limitations) ## When to Use It Plugin contracts are ideal for rules that span many routes and don't belong on a single handler: - Auth header presence across all API routes - Response metadata (request IDs, timing headers, version stamps) - Tenant isolation checks that apply to every route in a prefix - Database connection lifecycle guards on write endpoints - Content-type enforcement on all response paths Don't use plugin contracts for route-specific behavioral rules — those belong on the route schema via `x-requires` and `x-ensures`. ## Config Format ### apophis.config.js ```javascript export default { pluginContracts: { "auth-presence": { appliesTo: "/api/**", hooks: { onRequest: { requires: ["request_headers(this).authorization != null"] } } }, "rate-limit-respected": { appliesTo: "POST /api/**", hooks: { onResponse: { ensures: ["status != 429"] } }, meta: { name: "rate-limit-respected", description: "Rate limit should not be hit under normal CI load" } } } } ``` ### Programmatic (Fastify plugin registration) ```javascript await app.register(apophisPlugin, { pluginContracts: { "auth-presence": { appliesTo: "/api/**", hooks: { onRequest: { requires: ["request_headers(this).authorization != null"] } } } } }) ``` Contracts registered programmatically take the same path through the plugin contract registry as config-defined contracts. Both sources are merged into the composed contract at evaluation time. ## PluginContractSpec Reference | Field | Required | Type | Description | |---|---|---|---| | `appliesTo` | yes | `string` | Route pattern (see [Route Pattern Matching](#route-pattern-matching)) | | `hooks` | yes | `object` | Hook-phase contracts keyed by Fastify lifecycle hook name | | `hooks..requires` | no | `string[]` | APOSTL formulas that must be `true` **before** this phase executes | | `hooks..ensures` | no | `string[]` | APOSTL formulas that must be `true` **after** this phase executes | | `meta` | no | `object` | Display metadata | | `meta.name` | no | `string` | Human-readable plugin name | | `meta.version` | no | `string` | Plugin version | | `meta.description` | no | `string` | Description of what the plugin enforces | | `extensions` | no | `array` | APOPHIS extensions this plugin depends on | ### Supported hook phases Keys in `hooks` correspond to Fastify lifecycle hook names: - `onRequest` — evaluated before the route handler runs - `preHandler` — evaluated before the handler, after shared hooks - `preSerialization` — evaluated before the response body is serialized - `onSend` — evaluated before the response is sent - `onResponse` — evaluated after the response is sent (body already consumed) At least one of `requires` or `ensures` must be present per phase. Empty phases are ignored. ### Extension dependencies ```javascript pluginContracts: { "jwt-auth": { appliesTo: "/api/**", extensions: [{ name: "jwt-decoder", required: true }], hooks: { onRequest: { requires: ["decode_jwt(this).valid == true"] } } } } ``` If a required extension is missing at registration time, the plugin's contracts are skipped with a warning. Extensions are resolved from the APOPHIS extension registry at evaluation time, not at route discovery time. ## Route Pattern Matching The `appliesTo` field controls which routes a plugin contract covers. ### Pattern syntax | Pattern | Matches | Does NOT match | |---|---|---| | `"/api/users"` | `/api/users` (exact) | `/api/users/42`, `/api/v2/users` | | `"/api/**"` | `/api/users`, `/api/users/42`, `/api/users/:id/posts` | `/v2/api/users` | | `"/api/*"` | `/api/users`, `/api/status` | `/api/users/42`, `/api/users/:id/posts` | | `"**"` | Everything | — | | `"POST /api/**"` | `POST /api/users`, `POST /api/orders/42` | `GET /api/users`, `POST /v2/api/users` | The `/**` suffix matches any path at any depth below the prefix. The `/*` suffix matches exactly one segment below the prefix. ### Method-prefixed patterns Prepend the HTTP method to restrict a pattern to a specific verb: ```javascript { appliesTo: "POST /api/**", hooks: { preHandler: { requires: ["request_body(this) != null"] } } } ``` When a method is specified, the pattern only matches routes registered with that HTTP method. The method must be a valid HTTP method in uppercase. Pattern matching is evaluated against the route's path string only (the raw argument passed to `fastify.get()`, `fastify.post()`, etc.), not against the dereferenced URL with prefix applied. Fastify prefix application happens before pattern matching, so a route registered as `fastify.get("/users")` under a `prefix: "/api"` is matched against `/api/users`. ## Precondition → Skip Semantics When a plugin contract defines `requires` formulas, the runner evaluates them before `ensures`. If any require formula fails: 1. Require failure is **not** counted as a contract failure 2. All `ensures` formulas for that route+run are **skipped** 3. The skip is counted separately from failures in the artifact summary This distinguishes "the plugin's prerequisites aren't met" from "the route's behavior violated the contract." ### Example ```javascript pluginContracts: { "tenant-check": { appliesTo: "/api/**", hooks: { onRequest: { requires: ["request_headers(this).x-tenant-id != null"], ensures: ["status != 500"] } } } } ``` For a route without the `x-tenant-id` header: - The `requires` check fails → the `ensures` (`status != 500`) is **skipped** - `skippedCount` increments; `failedCount` does not For a route with the `x-tenant-id` header: - The `requires` check passes → the `ensures` is evaluated normally - If `status == 500`, it's counted as a plugin contract **failure** ## Auto-Injected Headers To avoid every test being skipped when a plugin contract checks for header presence, APOPHIS automatically injects headers into test requests when the precondition pattern is simple enough. ### Supported patterns | Formula pattern | Injected header | |---|---| | `request_headers(this). != null` | `: test-value` | | `request_headers(this). == "value"` | `: value` | ### Example ```javascript pluginContracts: { "auth-presence": { appliesTo: "/api/**", hooks: { onRequest: { requires: ["request_headers(this).authorization != null"] } } } } ``` APOPHIS detects the `!= null` pattern on `authorization` and injects `authorization: test-value` into every test request. The precondition passes automatically without user intervention. Auto-injection works in all runners: verify, qualify (scenario, stateful, chaos), and replay. ### Unsupported patterns Complex preconditions are not auto-injected. For these, the requires check will fail and the route's ensures will be skipped: ```javascript // NOT auto-injected — the route's ensures will be skipped requires: ["request_headers(this).x-count >= 5"] ``` For these cases, provide the header explicitly via route variants: ```javascript await fastify.apophis.contract({ runs: 5, variants: [{ name: "with-count", headers: { "x-count": "10" } }] }) ``` ## Failure Attribution When a plugin contract formula fails, the violation carries metadata that distinguishes it from route-level failures. ### ContractViolation fields | Field | Value | Meaning | |---|---|---| | `source` | `"plugin:auth-presence"` | Which plugin added the formula | | `phase` | `"onRequest"` | Which hook phase the formula belongs to | In human-readable output, plugin failures appear separately from route contract failures: ```text Plugin contract violation (plugin:auth-presence) GET /api/users Phase: onRequest Expected response_headers(this).x-request-id != null Observed response_headers(this).x-request-id was null ``` The artifact summary tracks plugin contract outcomes independently: ```json { "summary": { "pluginContractsApplied": 12, "pluginContractsFailed": 2, "failed": 1, "passed": 10 } } ``` - `pluginContractsApplied` — total plugin contract formulas evaluated across all routes - `pluginContractsFailed` — plugin contract formulas that failed - `failed` — total failures (route + plugin) - `passed` — route-level contract passes (plugin-only formulas not counted here) A route can pass its own contracts and still have plugin contract failures. The CLI exit code reflects the aggregate: any failure (route or plugin) produces exit code 1. ## Extension Dependencies Plugin contracts can declare dependencies on APOPHIS extensions. Extension resolution happens at evaluation time via the extension registry. ### Declaring dependencies ```javascript pluginContracts: { "jwt-auth": { appliesTo: "/api/**", extensions: [{ name: "jwt-decoder", required: true }], hooks: { onRequest: { requires: ["decode_jwt(this).valid == true"] } } } } ``` Each extension reference has: - `name` (required) — matches the extension's registered name - `required` (optional, default `true`) — if `false`, missing extension emits a warning but does not skip the plugin ### Missing extension behavior If a required extension is not registered, the plugin's contracts are **skipped** for all matching routes. Warnings are collected in the registry and surfaced via `drainWarnings()`: ```text Plugin 'jwt-auth' requires extensions [jwt-decoder] which are not registered. Skipping its contracts. ``` Warnings appear in the CLI output and the artifact's `warnings` array. ### Extension registration Extensions are registered alongside plugin contracts: ```javascript await app.register(apophisPlugin, { extensions: [ { name: "jwt-decoder", headers: ["decode_jwt"], predicates: { decode_jwt: (ctx) => ({ value: true, success: true }) } } ], pluginContracts: { "jwt-auth": { appliesTo: "/api/**", extensions: [{ name: "jwt-decoder", required: true }], hooks: { onRequest: { requires: ["decode_jwt(this).valid == true"] } } } } }) ``` ## Production Safety `pluginContracts` is a **test-only** feature. If `pluginContracts` is non-empty in a production environment (`NODE_ENV=production`), APOPHIS throws at plugin registration: ```text APOPHIS: Unsafe options detected in production: pluginContracts. These features are test-only and must not be enabled in production. Remove them from ApophisOptions or set NODE_ENV=test. ``` This is enforced by `validateProductionSafety()` before any contract evaluation runs. There is no escape hatch. ## Complete Example ### Config (`apophis.config.js`) ```javascript export default { profiles: { quick: { preset: "safe-ci" }, }, presets: { "safe-ci": { runs: 10 }, }, pluginContracts: { "auth-required": { appliesTo: "/api/**", hooks: { onRequest: { requires: ["request_headers(this).authorization != null"], }, }, meta: { name: "auth-required", description: "All API routes require an Authorization header" }, }, "request-id": { appliesTo: "/api/**", hooks: { onSend: { ensures: ["response_headers(this).x-request-id != null"], }, }, meta: { name: "request-id", description: "All API responses must include x-request-id" }, }, }, } ``` ### Route (`routes/users.js`) ```javascript app.get("/api/users", { schema: { "x-category": "observer", "x-ensures": ["status:200", "response_body(this) is Array"], response: { 200: { type: "array", items: { type: "object", properties: { id: { type: "number" } } }, }, }, }, }, async (request, reply) => { return [{ id: 1 }, { id: 2 }] }) ``` ### Execution (`apophis verify`) 1. Sends a request to `GET /api/users` 2. `auth-required` plugin: `onRequest.requires` → `request_headers(this).authorization != null` → APOPHIS auto-injects `authorization: test-value` → passes 3. Route-level ensures: `status:200` → passes; `response_body(this) is Array` → passes 4. `request-id` plugin: `onSend.ensures` → `response_headers(this).x-request-id != null` → if the handler doesn't set `x-request-id`, this **fails** as a `plugin:request-id` contract violation ```text Plugin contract violation (plugin:request-id) GET /api/users Phase: onSend Expected response_headers(this).x-request-id != null Observed response_headers(this).x-request-id was null ``` ## Limitations - **`appliesTo` matches by route path only** by default. Use method-prefixed syntax (`"POST /api/**"`) for HTTP-method-specific rules. - **Header auto-injection only handles simple patterns** (`!= null` and `== "value"`). Complex preconditions like `>= 5` are not auto-injected; the route's ensures will be skipped. - **No per-route opt-out.** If a plugin contract matches a route, it always applies. Use narrower `appliesTo` patterns or separate route prefixes to exclude routes. - **Extension resolution is lazy.** Extensions are checked at route evaluation time, not at registration time. A missing extension is only discovered when a matching route is evaluated. - **At least one of `requires` or `ensures` must be present per phase.** A phase with neither is silently ignored. - **Runtime hook ordering follows Fastify's lifecycle.** Plugin contracts in `onResponse` cannot inspect the response body (it has already been sent to the client).