Initial public release of Apophis — invariant-driven automated API testing
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
## Outbound Contract-Driven Mocking Spec
|
||||
|
||||
Status: Implemented (Phase 1)
|
||||
|
||||
Phase 1 (implemented): Schema parsing (`x-outbound`), mock runtime, imperative API (`enableOutboundMocks`, `getOutboundCalls`), fetch patching.
|
||||
Phase 2 (pending): APOSTL extensions `outbound_calls(this)` and `outbound_last(this)` for contract assertions.
|
||||
Date: 2026-04-27
|
||||
|
||||
This document supersedes Arbiter's local draft at `~/Business/workspace/Arbiter/docs/APOPHIS_OUTBOUND_MOCK_PROPOSAL.md` and its interim adapter at `~/Business/workspace/Arbiter/src/server/server/services/StripeFetchAdapter.js`.
|
||||
|
||||
The direction in that proposal is correct: routes should be able to declare the contracts and expectations of their outbound dependencies, and APOPHIS should use those declarations to generate mocks, inject dependency-layer chaos, and support both contract testing and imperative E2E testing.
|
||||
|
||||
This spec keeps that idea small and consistent with the runtime paths APOPHIS already has.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Let routes declare outbound dependency contracts once and reuse them anywhere.
|
||||
2. Generate contract-conformant outbound mock responses from JSON Schema.
|
||||
3. Apply chaos at the dependency layer, before application code receives the response.
|
||||
4. Record outbound calls so tests and contracts can inspect them.
|
||||
5. Work in both APOPHIS contract tests and imperative E2E tests.
|
||||
6. Reuse existing chaos, fast-check, and flake-detection infrastructure.
|
||||
7. Avoid service-specific adapters and avoid a second testing engine.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. No Stripe-specific or service-specific code in APOPHIS.
|
||||
2. No second DSL for outbound expectations.
|
||||
3. No new backward-compatibility layer for old chaos config.
|
||||
4. No static JS analysis in this cut. The design must enable it later, not implement it now.
|
||||
|
||||
## Parsimony Rules
|
||||
|
||||
1. One schema annotation: `x-outbound`.
|
||||
2. One shared registry: `outboundContracts`.
|
||||
3. One runtime owner: `OutboundMockRuntime`.
|
||||
4. One fetch interception path: reuse `wrapFetch()` and `createOutboundInterceptor()` instead of inventing another chaos stack.
|
||||
5. One property-generation engine: reuse `convertSchema()` for dependency responses instead of creating a second generator pipeline.
|
||||
6. One seeded randomness model: derive outbound mock randomness from the same test seed via sub-seeds, never `Date.now()`.
|
||||
7. No service adapters in core. If an application wants an adapter, it should be a thin local wrapper over fetch, not the core abstraction.
|
||||
|
||||
## Core Design
|
||||
|
||||
### 1. Shared outbound contracts are registered once
|
||||
|
||||
Add a plugin-level registry:
|
||||
|
||||
```ts
|
||||
await fastify.register(apophis, {
|
||||
outboundContracts: {
|
||||
'stripe.paymentIntents.create': {
|
||||
target: 'https://api.stripe.com/v1/payment_intents',
|
||||
method: 'POST',
|
||||
request: { ...json schema... },
|
||||
response: {
|
||||
200: { ...json schema... },
|
||||
402: { ...json schema... },
|
||||
429: { ...json schema... }
|
||||
},
|
||||
chaos: {
|
||||
error: {
|
||||
probability: 0.02,
|
||||
responses: [
|
||||
{ statusCode: 429, headers: { 'retry-after': '60' } },
|
||||
{ statusCode: 503, body: { error: { type: 'api_error' } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Routes reference or inline outbound contracts with one annotation
|
||||
|
||||
Do not add `x-outbound-uses` and `x-outbound-contracts` as two separate concepts. Use one annotation:
|
||||
|
||||
```ts
|
||||
schema: {
|
||||
'x-outbound': [
|
||||
'stripe.paymentIntents.create',
|
||||
{
|
||||
ref: 'stripe.customers.retrieve',
|
||||
chaos: {
|
||||
error: {
|
||||
probability: 1,
|
||||
responses: [{ statusCode: 404, body: { error: { type: 'invalid_request_error' } } }]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'audit.events.write',
|
||||
target: 'https://audit.internal/v1/events',
|
||||
method: 'POST',
|
||||
request: { ...json schema... },
|
||||
response: { 202: { ...json schema... } }
|
||||
}
|
||||
],
|
||||
'x-ensures': [
|
||||
'if response_code(this) == 200 then response_body(this).paid == true else true'
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Why this shape:
|
||||
|
||||
1. One-off dependencies can be inline.
|
||||
2. Shared dependencies can be referenced by name.
|
||||
3. Route-local chaos overrides are possible without duplicating the shared contract.
|
||||
4. We do not create a second metadata system just to support references.
|
||||
|
||||
### 3. APOPHIS owns the outbound runtime in test mode
|
||||
|
||||
Contract tests and stateful tests should automatically install outbound mocking when a route declares `x-outbound`.
|
||||
|
||||
Imperative E2E tests should be able to opt in manually:
|
||||
|
||||
```ts
|
||||
await fastify.apophis.enableOutboundMocks({
|
||||
contracts: ['stripe.paymentIntents.create'],
|
||||
mode: 'property',
|
||||
overrides: {
|
||||
'stripe.paymentIntents.create': {
|
||||
forceStatus: 402,
|
||||
body: { error: { type: 'card_error', code: 'card_declined' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// normal app-level test code here
|
||||
|
||||
const calls = fastify.apophis.getOutboundCalls('stripe.paymentIntents.create')
|
||||
await fastify.apophis.disableOutboundMocks()
|
||||
```
|
||||
|
||||
This is the right place for E2E support. It keeps imperative tests imperative while letting APOPHIS provide deterministic dependency behavior.
|
||||
|
||||
### 4. Outbound expectations should reuse APOSTL, not invent a second DSL
|
||||
|
||||
Do not add a new outbound assertion language.
|
||||
|
||||
Expose outbound call facts through a built-in extension surface so existing `x-ensures` and `x-requires` can talk about dependency behavior.
|
||||
|
||||
Target shape:
|
||||
|
||||
```ts
|
||||
'outbound_calls(this).stripe.paymentIntents.create.count == 1'
|
||||
'outbound_last(this).stripe.paymentIntents.create.response.statusCode == 402'
|
||||
'if outbound_last(this).stripe.paymentIntents.create.response.statusCode == 402 then response_code == 400 else true'
|
||||
```
|
||||
|
||||
This keeps all behavioral expectations in APOSTL.
|
||||
|
||||
Implementation note: contract names should be dot-separated identifiers so they naturally project into accessor paths.
|
||||
|
||||
### 5. Property-based testing should run on both sides
|
||||
|
||||
Today we generate route inputs from request schemas. We should also be able to generate dependency outputs from outbound response schemas.
|
||||
|
||||
That means APOPHIS can test:
|
||||
|
||||
1. many valid caller requests to the route
|
||||
2. many valid dependency responses allowed by the outbound contract
|
||||
3. whether the route still satisfies its own postconditions
|
||||
|
||||
This is not a second property engine. It is an augmentation of the existing command generation and execution flow.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
For a single route execution under contract testing:
|
||||
|
||||
1. runner resolves the route's `x-outbound` bindings
|
||||
2. bindings are normalized against the shared registry
|
||||
3. an `OutboundMockRuntime` is installed for the duration of that request execution
|
||||
4. `globalThis.fetch` is wrapped in test mode for the duration of execution
|
||||
5. outbound calls matching a resolved contract return generated or overridden responses
|
||||
6. the existing outbound chaos layer decorates those responses
|
||||
7. call traces are recorded
|
||||
8. after the request completes, fetch is restored
|
||||
9. the recorded outbound facts are attached to the eval context for formulas and diagnostics
|
||||
|
||||
For imperative E2E:
|
||||
|
||||
1. test calls `enableOutboundMocks()`
|
||||
2. runtime installs fetch patch once
|
||||
3. test drives the app normally
|
||||
4. test inspects `getOutboundCalls()`
|
||||
5. test calls `disableOutboundMocks()`
|
||||
|
||||
## Public API Changes
|
||||
|
||||
### `ApophisOptions`
|
||||
|
||||
Add shared outbound contract registration:
|
||||
|
||||
```ts
|
||||
readonly outboundContracts?: Record<string, OutboundContractSpec>
|
||||
```
|
||||
|
||||
### `TestConfig`
|
||||
|
||||
Add runner-level outbound mock control:
|
||||
|
||||
```ts
|
||||
readonly outboundMocks?: false | {
|
||||
readonly mode?: 'example' | 'property'
|
||||
readonly contracts?: readonly string[]
|
||||
readonly overrides?: Record<string, {
|
||||
readonly forceStatus?: number
|
||||
readonly headers?: Record<string, string>
|
||||
readonly body?: unknown
|
||||
}>
|
||||
readonly unmatched?: 'error' | 'passthrough'
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
1. `false` disables outbound mocking even if routes declare `x-outbound`.
|
||||
2. `mode: 'example'` returns one contract-conformant response per dependency and is the default.
|
||||
3. `mode: 'property'` samples across documented dependency responses.
|
||||
4. `unmatched: 'error'` should be the default in test mode to prevent accidental real network access.
|
||||
|
||||
### `ApophisDecorations`
|
||||
|
||||
Add imperative test helpers:
|
||||
|
||||
```ts
|
||||
readonly registerOutboundContracts: (contracts: Record<string, OutboundContractSpec>) => void
|
||||
readonly enableOutboundMocks: (opts?: TestConfig['outboundMocks']) => Promise<void>
|
||||
readonly disableOutboundMocks: () => Promise<void>
|
||||
readonly getOutboundCalls: (name?: string) => ReadonlyArray<OutboundCallRecord>
|
||||
```
|
||||
|
||||
## Concrete File Plan
|
||||
|
||||
Line numbers below are current as of 2026-04-27 and should be rechecked before editing.
|
||||
|
||||
### 1. `src/types.ts`
|
||||
|
||||
Current anchors:
|
||||
|
||||
1. `RouteContract`: lines 28-42
|
||||
2. `TestConfig`: lines 256-264
|
||||
3. `ChaosConfig`: lines 308-355
|
||||
4. `ApophisOptions`: lines 498-519
|
||||
5. `ApophisDecorations`: lines 612-627
|
||||
|
||||
Modify:
|
||||
|
||||
1. Add `outbound?: readonly OutboundBinding[]` to `RouteContract` at lines 28-42.
|
||||
2. Add `OutboundContractSpec`, `OutboundBinding`, `ResolvedOutboundContract`, and `OutboundCallRecord` near existing outbound chaos types at lines 266-356.
|
||||
3. Add `outboundMocks?: false | { ... }` to `TestConfig` at lines 256-264.
|
||||
4. Add `outboundContracts?: Record<string, OutboundContractSpec>` to `ApophisOptions` at lines 498-519.
|
||||
5. Add `registerOutboundContracts`, `enableOutboundMocks`, `disableOutboundMocks`, and `getOutboundCalls` to `ApophisDecorations` at lines 612-627.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Do not add a second chaos config type.
|
||||
2. Do not add separate inline-vs-reference types if a discriminated union on `x-outbound` handles both.
|
||||
3. Keep `OutboundContractSpec` JSON-Schema-centric so `convertSchema()` can reuse it directly.
|
||||
|
||||
### 2. `src/domain/contract.ts`
|
||||
|
||||
Current anchor: lines 35-95 extract `x-category`, `x-requires`, `x-ensures`, and `x-timeout`.
|
||||
|
||||
Modify:
|
||||
|
||||
1. Parse `schema['x-outbound']`.
|
||||
2. Normalize string refs and inline objects into `RouteContract.outbound`.
|
||||
3. Preserve current caching behavior in `contractCache`.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Normalize shape here once.
|
||||
2. Do not resolve references here because this module does not own plugin-level registries.
|
||||
3. Do not add outbound-specific execution logic here.
|
||||
|
||||
### 3. `src/plugin/index.ts`
|
||||
|
||||
Current anchor: lines 29-102 own plugin setup, registries, route capture, and decorations.
|
||||
|
||||
Modify:
|
||||
|
||||
1. Instantiate an `OutboundContractRegistry` and `OutboundMockRuntime` next to existing registries.
|
||||
2. Register `opts.outboundContracts` during plugin setup.
|
||||
3. Add imperative outbound mock decorations.
|
||||
4. Register a built-in outbound extension that exposes `outbound_calls(this)` and `outbound_last(this)`.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Do not create a separate plugin or extension package for outbound support.
|
||||
2. Reuse existing plugin lifecycle instead of bolting on a second orchestrator.
|
||||
|
||||
### 4. `src/plugin/contract-builder.ts`
|
||||
|
||||
Current anchor: lines 14-29 build the config passed into `runPetitTests()`.
|
||||
|
||||
Modify:
|
||||
|
||||
1. Pass through `opts.outboundMocks`.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. No logic here beyond forwarding config.
|
||||
|
||||
### 5. `src/plugin/stateful-builder.ts`
|
||||
|
||||
Current anchor: lines 13-29 build the config passed into `runStatefulTests()`.
|
||||
|
||||
Modify:
|
||||
|
||||
1. Pass through `opts.outboundMocks`.
|
||||
2. Keep parity with `contract-builder.ts`.
|
||||
|
||||
### 6. `src/test/petit-runner.ts`
|
||||
|
||||
Current anchors:
|
||||
|
||||
1. lines 237-243 function signature
|
||||
2. line 322 constructs `EnhancedChaosEngine`
|
||||
3. lines 342-430 build request and execute the route
|
||||
4. lines 497-512 attach chaos diagnostics
|
||||
|
||||
Modify:
|
||||
|
||||
1. Resolve `route.outbound` against the shared registry before execution.
|
||||
2. Install `OutboundMockRuntime` around the single route execution.
|
||||
3. If `chaosEngine.executeWithChaos()` returns `outboundInterceptor`, compose it into the runtime instead of inventing a second path.
|
||||
4. Attach outbound call trace into diagnostics and eval context.
|
||||
5. In property mode, expand outbound response scenarios using `convertSchema(..., { context: 'response' })` and deterministic seeds.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Do not create a second runner.
|
||||
2. Do not fork request generation logic; augment the existing execution loop.
|
||||
3. Reuse the runner seed and `SeededRng` instead of introducing local randomness.
|
||||
|
||||
### 7. `src/test/stateful-runner.ts`
|
||||
|
||||
Current anchors:
|
||||
|
||||
1. line 25 imports legacy `ChaosEngine` from `../quality/chaos.js`
|
||||
2. line 62 stores `chaosEngine?: ChaosEngine`
|
||||
3. lines 272-279 execute with chaos
|
||||
4. line 394 constructs `new ChaosEngine(config.chaos, config.seed)`
|
||||
|
||||
Modify:
|
||||
|
||||
1. Migrate stateful testing to `EnhancedChaosEngine` from `src/quality/chaos-v2.ts`.
|
||||
2. Install the same outbound mock runtime used by `petit-runner.ts`.
|
||||
3. Use the same outbound scenario generation rules so stateful and contract runners do not diverge.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Do not maintain two chaos stacks.
|
||||
2. Do not implement outbound mocking twice.
|
||||
|
||||
### 8. `src/quality/chaos-v2.ts`
|
||||
|
||||
Current anchors:
|
||||
|
||||
1. lines 52-125 `wrapFetch()`
|
||||
2. lines 148-188 seed management and outbound interceptor construction
|
||||
3. lines 214-274 route execution and outbound interceptor attachment
|
||||
|
||||
Modify:
|
||||
|
||||
1. Keep `wrapFetch()` as the only fetch interception primitive.
|
||||
2. Add a small composition helper if needed so `OutboundMockRuntime` can run `mock response -> outbound chaos overlay -> Response`.
|
||||
3. Keep per-route chaos resolution in `buildOutboundInterceptor()`.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Do not move mock generation into chaos-v2.
|
||||
2. Chaos owns chaos, not contract generation.
|
||||
|
||||
### 9. `src/quality/chaos-outbound.ts`
|
||||
|
||||
Current anchor: lines 36-105 create the pure outbound interceptor.
|
||||
|
||||
Modify:
|
||||
|
||||
1. No structural redesign required.
|
||||
2. Ensure the interceptor remains transport-agnostic and can wrap both real fetch and mock responders.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. This file should stay pure.
|
||||
2. Do not add runtime registry logic here.
|
||||
|
||||
### 10. `src/domain/schema-to-arbitrary.ts`
|
||||
|
||||
Current anchor: lines 214-217 export `convertSchema()`.
|
||||
|
||||
Modify:
|
||||
|
||||
1. Reuse `convertSchema(responseSchema, { context: 'response' })` for generated dependency responses.
|
||||
2. Add a tiny helper for weighted status-code sampling if needed, but do not fork the schema conversion logic.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. No second schema generator.
|
||||
2. No outbound-specific arbitrary builder unless it is only a thin composition over `convertSchema()`.
|
||||
|
||||
### 11. `src/quality/flake.ts`
|
||||
|
||||
Current anchor: lines 56-97 derive reruns from a seed.
|
||||
|
||||
Modify:
|
||||
|
||||
1. Public API can stay unchanged if outbound runtime derives every sub-seed from the rerun seed.
|
||||
2. If diagnostics are added, include outbound scenario seed in rerun metadata, but do not add a separate flake engine.
|
||||
|
||||
Keep it parsimonious:
|
||||
|
||||
1. Flake support should come from determinism, not from more feature flags.
|
||||
|
||||
### 12. New files
|
||||
|
||||
Add only these new files:
|
||||
|
||||
1. `src/domain/outbound-contracts.ts`
|
||||
- normalize, resolve, and validate `x-outbound` bindings against the shared registry
|
||||
2. `src/infrastructure/outbound-mock-runtime.ts`
|
||||
- install and restore fetch patch
|
||||
- record calls
|
||||
- resolve overrides
|
||||
- return generated or overridden responses
|
||||
3. `src/extensions/outbound.ts`
|
||||
- built-in extension exposing outbound call facts to APOSTL
|
||||
|
||||
Do not add service-specific adapters, provider-specific modules, or a separate outbound runner.
|
||||
|
||||
## Interaction With Existing Chaos
|
||||
|
||||
The order must be:
|
||||
|
||||
1. route declares outbound contract
|
||||
2. runtime resolves contract
|
||||
3. runtime generates or overrides mock response
|
||||
4. existing outbound chaos interceptor applies delay/error/dropout if configured
|
||||
5. application code receives the final dependency response
|
||||
|
||||
This keeps chaos at the correct layer and reuses the current outbound chaos implementation.
|
||||
|
||||
Do not invert the order by making chaos choose a response before the contract mock runtime runs. Outbound mocking must generate the dependency response first; chaos then mutates or delays that response.
|
||||
|
||||
## Interaction With flake detection
|
||||
|
||||
This feature must be deterministic under a single seed.
|
||||
|
||||
Rules:
|
||||
|
||||
1. route command generation already depends on `config.seed`
|
||||
2. outbound response generation must derive from that same seed
|
||||
3. per-contract sampling must use stable sub-seeds, e.g. `hashCombine(seed, stableHash(contractName))`
|
||||
4. route-local chaos and outbound mock generation must not perturb each other beyond their dedicated sub-streams
|
||||
|
||||
If these rules hold, `FlakeDetector` needs no public redesign.
|
||||
|
||||
## Interaction With property-based testing
|
||||
|
||||
Phase 1:
|
||||
|
||||
1. `mode: 'example'` uses one generated success response per dependency plus explicit override cases.
|
||||
2. This is enough to support contract tests and imperative E2E immediately.
|
||||
|
||||
Phase 2:
|
||||
|
||||
1. `mode: 'property'` samples across every documented outbound status code.
|
||||
2. For each sampled dependency response, APOPHIS executes the route and checks route postconditions.
|
||||
3. This gives property-based testing on both sides of the integration boundary.
|
||||
|
||||
We should not block phase 1 on phase 2, but the types and runtime must be designed so phase 2 is an additive change, not a rewrite.
|
||||
|
||||
## Suggested Test Plan
|
||||
|
||||
Add tests in these areas:
|
||||
|
||||
1. `src/test/domain.test.ts`
|
||||
- `extractContract()` parses `x-outbound` string refs
|
||||
- `extractContract()` parses inline outbound contracts
|
||||
- route-local chaos overrides are preserved
|
||||
2. `src/test/outbound-runtime.test.ts`
|
||||
- generated success response matches schema shape
|
||||
- override response takes precedence
|
||||
- unmatched fetch throws by default in test mode
|
||||
- call recording works
|
||||
- fetch patch restore is correct
|
||||
3. `src/test/outbound-interceptor.test.ts`
|
||||
- existing outbound chaos still works when wrapping a mock executor
|
||||
4. `src/test/integration.test.ts`
|
||||
- `fastify.apophis.contract()` with `x-outbound` exercises dependency-layer failures
|
||||
- `enableOutboundMocks()` supports imperative E2E style
|
||||
5. `src/test/stateful-runner.test.ts` or new stateful integration tests
|
||||
- stateful runner uses the same outbound runtime and chaos path
|
||||
6. `src/test/flake.test.ts` new
|
||||
- same seed gives same outbound responses and same call trace
|
||||
- different seeds explore different dependency outputs without nondeterministic drift
|
||||
|
||||
## Migration Guidance
|
||||
|
||||
For Arbiter:
|
||||
|
||||
1. move the Stripe contract definitions out of `src/server/server/services/StripeFetchAdapter.js`
|
||||
2. register them once via `outboundContracts`
|
||||
3. change route schemas to use `x-outbound`
|
||||
4. delete the local adapter after APOPHIS fetch instrumentation is in place
|
||||
|
||||
The long-term target is that applications declare outbound behavior through `outboundContracts` and `x-outbound`; provider-specific fetch wrappers remain application-local.
|
||||
|
||||
## Deferred, But Enabled By This Design
|
||||
|
||||
1. static analysis of whether a route contract can be satisfied for all permitted dependency responses
|
||||
2. detection of impossible route postconditions before running property tests
|
||||
3. contract coverage reports across inbound and outbound boundaries
|
||||
|
||||
Those features should be built later on top of the normalized outbound contract registry, not by expanding the runtime surface prematurely.
|
||||
@@ -0,0 +1,428 @@
|
||||
# APOPHIS Plugin Contract System Specification
|
||||
|
||||
## Status: Partially implemented
|
||||
|
||||
- Registry, types, and registration API: **implemented**
|
||||
- Runner integration (merging plugin contracts into route execution): **pending**
|
||||
- Built-in contracts for `@fastify/auth`, `@fastify/compress`, `@fastify/cors`, `@fastify/rate-limit`: **registered but not yet applied**
|
||||
|
||||
**Note**: Plugin contracts are complementary to Protocol Extensions (see `docs/protocol-extensions-spec.md`). Protocol extensions add domain-specific predicates (JWT, X.509, SPIFFE); plugin contracts add hook-phase behavioral contracts for Fastify plugins.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Plugin Contract System enables Fastify plugins to declare APOPHIS contracts that are automatically merged into route contracts at test time. Plugins specify which hooks they participate in and what behavioral contracts they enforce at each phase of the request lifecycle.
|
||||
|
||||
**Key invariant**: Route contracts are the **composition** of route-level contracts plus all plugin contracts whose `appliesTo` pattern matches the route path.
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
- **MUST**: Absolute requirement. Violation prevents system operation.
|
||||
- **SHOULD**: Strong recommendation. Violation produces warnings.
|
||||
- **MAY**: Optional capability. No impact if absent.
|
||||
- **MUST NOT**: Prohibited behavior. Violation is a bug.
|
||||
- **Plugin Contract**: A set of APOSTL expressions declared by a plugin, scoped to specific hook phases and route prefixes.
|
||||
- **Phase Contract**: Contracts that apply at a specific point in the Fastify hook pipeline.
|
||||
- **Contract Composition**: The merging of route-level and plugin-level contracts into a single testable set.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Plugin Contract Declaration
|
||||
|
||||
Plugins declare contracts via the APOPHIS registry during registration:
|
||||
|
||||
```typescript
|
||||
fastify.apophis.registerPluginContracts(name: string, spec: PluginContractSpec)
|
||||
```
|
||||
|
||||
**File**: `src/plugin/index.ts` (NEW METHOD, line 130+)
|
||||
|
||||
### 3.2 Plugin Contract Specification
|
||||
|
||||
```typescript
|
||||
interface PluginContractSpec {
|
||||
/** Route path prefix pattern. Plugin contracts apply to routes matching this prefix.
|
||||
* MUST support wildcards: '/api/**' matches '/api/users', '/api/users/:id'
|
||||
* MUST default to '**' (all routes) if omitted
|
||||
*/
|
||||
appliesTo: string
|
||||
|
||||
/** Contracts organized by hook phase.
|
||||
* MUST support: onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse
|
||||
* MAY support additional phases as Fastify evolves
|
||||
*/
|
||||
hooks: {
|
||||
[phase: string]: {
|
||||
/** Preconditions that MUST hold before this phase executes */
|
||||
requires?: string[]
|
||||
/** Postconditions that MUST hold after this phase executes */
|
||||
ensures?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugin metadata for diagnostics */
|
||||
meta?: {
|
||||
name?: string
|
||||
version?: string
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `src/types.ts` (NEW SECTION after line 223)
|
||||
|
||||
### 3.3 Contract Registry
|
||||
|
||||
APOPHIS maintains an in-memory registry of plugin contracts:
|
||||
|
||||
```typescript
|
||||
class PluginContractRegistry {
|
||||
private contracts: Map<string, PluginContractSpec[]> = new Map()
|
||||
|
||||
/** Register a plugin's contract specification.
|
||||
* MUST validate that appliesTo is a valid pattern.
|
||||
* MUST reject duplicate registrations unless the new spec is byte-for-byte equivalent.
|
||||
* SHOULD warn if a plugin declares contracts for phases it doesn't actually hook into.
|
||||
*/
|
||||
register(name: string, spec: PluginContractSpec): void
|
||||
|
||||
/** Find all plugin contracts that apply to a given route.
|
||||
* MUST match appliesTo pattern against route.path.
|
||||
* MUST return contracts from all matching plugins.
|
||||
* MUST preserve plugin registration order in results.
|
||||
*/
|
||||
findContractsForRoute(route: RouteContract): Array<{ plugin: string; spec: PluginContractSpec }>
|
||||
|
||||
/** Merge route contracts with applicable plugin contracts.
|
||||
* MUST deduplicate identical formulas.
|
||||
* MUST preserve source attribution for diagnostics.
|
||||
* MUST NOT mutate original route contracts.
|
||||
*/
|
||||
composeContracts(route: RouteContract): ComposedContract
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `src/domain/plugin-contracts.ts` (NEW FILE)
|
||||
|
||||
### 3.4 Contract Composition
|
||||
|
||||
When APOPHIS tests a route, it composes contracts from all applicable sources:
|
||||
|
||||
```
|
||||
ComposedContract = {
|
||||
route: RouteContract,
|
||||
phases: {
|
||||
[phase: string]: {
|
||||
requires: Array<{ formula: string; source: 'route' | 'plugin:name' }>
|
||||
ensures: Array<{ formula: string; source: 'route' | 'plugin:name' }>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Composition rules**:
|
||||
- Route-level `x-requires` → `route` phase (handler execution)
|
||||
- Route-level `x-ensures` → `route` phase (handler execution)
|
||||
- Plugin `hooks[phase].requires` → respective phase
|
||||
- Plugin `hooks[phase].ensures` → respective phase
|
||||
- Phase `onRequest` contracts run before route handler
|
||||
- Phase `onSend` contracts run after route handler but before response sent
|
||||
- Phase `onResponse` contracts run after response fully sent
|
||||
|
||||
**File**: `src/domain/plugin-contracts.ts:80-120` (NEW)
|
||||
|
||||
### 3.5 Route Schema Integration
|
||||
|
||||
Routes MAY declare which plugins they expect via `x-plugins`:
|
||||
|
||||
```typescript
|
||||
schema: {
|
||||
'x-plugins': ['auth', 'rate-limit'],
|
||||
'x-ensures': ['status:200'],
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- If `x-plugins` is present, APOPHIS MUST warn if any listed plugin has no registered contracts.
|
||||
- If `x-plugins` is present, APOPHIS MUST warn if a plugin's `appliesTo` doesn't match this route.
|
||||
- If `x-plugins` is absent, APOPHIS MUST still apply all matching plugin contracts silently.
|
||||
- `x-plugins` is for documentation and validation, not contract scoping.
|
||||
|
||||
**File**: `src/domain/contract.ts` (MODIFY `extractContract`, line 45+)
|
||||
|
||||
### 3.6 Built-in Plugin Contracts
|
||||
|
||||
APOPHIS MUST provide contract specifications for common Fastify plugins:
|
||||
|
||||
**File**: `src/plugins/built-in-contracts.ts` (NEW FILE)
|
||||
|
||||
```typescript
|
||||
export const BUILTIN_PLUGIN_CONTRACTS: Record<string, PluginContractSpec> = {
|
||||
'@fastify/auth': {
|
||||
appliesTo: '**',
|
||||
hooks: {
|
||||
onRequest: {
|
||||
requires: ['request_headers(this).authorization != null'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'@fastify/compress': {
|
||||
appliesTo: '**',
|
||||
hooks: {
|
||||
onSend: {
|
||||
ensures: ['response_headers(this).content-encoding != null'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'@fastify/cors': {
|
||||
appliesTo: '**',
|
||||
hooks: {
|
||||
onRequest: {
|
||||
ensures: ['response_headers(this).access-control-allow-origin != null'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'@fastify/rate-limit': {
|
||||
appliesTo: '**',
|
||||
hooks: {
|
||||
onRequest: {
|
||||
ensures: [
|
||||
'response_headers(this).x-ratelimit-limit != null',
|
||||
'response_headers(this).x-ratelimit-remaining != null',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
- Built-in contracts MUST be registered automatically when APOPHIS plugin initializes.
|
||||
- Built-in contracts MAY be overridden by explicit plugin registrations.
|
||||
- Built-in contracts SHOULD be documented in `docs/PLUGIN_CONTRACTS_SPEC.md`.
|
||||
|
||||
**File**: `src/plugin/index.ts:48-69` (MODIFY initialization)
|
||||
|
||||
### 3.7 Test Runner Integration
|
||||
|
||||
The PETIT runner MUST compose contracts before executing each route:
|
||||
|
||||
**File**: `src/test/petit-runner.ts:180-190` (MODIFY)
|
||||
|
||||
```typescript
|
||||
// Before generating commands, compose contracts for each route
|
||||
const composedRoutes = routes.map(route => {
|
||||
const composed = pluginContractRegistry.composeContracts(route)
|
||||
|
||||
// Warn if route declares x-plugins but plugin contracts don't match
|
||||
const declaredPlugins = route.schema?.['x-plugins'] as string[] | undefined
|
||||
if (declaredPlugins) {
|
||||
for (const pluginName of declaredPlugins) {
|
||||
const pluginContracts = pluginContractRegistry.findContractsForRoute(route)
|
||||
.filter(c => c.plugin === pluginName)
|
||||
if (pluginContracts.length === 0) {
|
||||
console.warn(`Route ${route.method} ${route.path} declares plugin '${pluginName}' but no contracts match`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...route, composed }
|
||||
})
|
||||
```
|
||||
|
||||
### 3.8 Phase-Aware Contract Testing
|
||||
|
||||
APOPHIS MUST label each plugin contract with its phase. Exact phase execution is required only where hook interception is implemented:
|
||||
|
||||
**File**: `src/test/petit-runner.ts:250-300` (MODIFY execute loop)
|
||||
|
||||
```typescript
|
||||
// For each command:
|
||||
// 1. Test onRequest phase contracts (plugin only)
|
||||
// 2. Execute request
|
||||
// 3. Test route-level contracts (handler)
|
||||
// 4. Test onSend/onResponse phase contracts (plugin only)
|
||||
|
||||
// Phase 1: onRequest contracts
|
||||
if (composed.hooks?.onRequest?.requires) {
|
||||
const preCtx = buildPreRequestContext(request)
|
||||
validatePhaseContracts(composed.hooks.onRequest.requires, preCtx, route)
|
||||
}
|
||||
|
||||
// Phase 2: Execute request (existing code)
|
||||
ctx = await executeHttp(...)
|
||||
|
||||
// Phase 3: Route-level contracts (existing code)
|
||||
validatePostconditions(composed.route.ensures, ctx, route)
|
||||
|
||||
// Phase 4: onSend/onResponse contracts
|
||||
if (composed.hooks?.onSend?.ensures) {
|
||||
validatePhaseContracts(composed.hooks.onSend.ensures, ctx, route)
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Phase-aware testing requires hook interception. Since Fastify doesn't expose hook execution points, APOPHIS MAY approximate by testing all plugin contracts against the final response context, with phase noted in diagnostics.
|
||||
|
||||
### 3.9 Diagnostics and Reporting
|
||||
|
||||
Contract violations MUST include source attribution:
|
||||
|
||||
```typescript
|
||||
interface ContractViolation {
|
||||
// ... existing fields ...
|
||||
readonly source: 'route' | 'plugin:name'
|
||||
readonly phase?: string // 'onRequest', 'onSend', etc.
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `src/types.ts:170-192` (EXTEND ContractViolation)
|
||||
|
||||
Test results MUST show plugin contract coverage:
|
||||
|
||||
```typescript
|
||||
interface TestSummary {
|
||||
// ... existing fields ...
|
||||
readonly pluginContractsApplied: number
|
||||
readonly pluginContractsFailed: number
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `src/types.ts:277-285` (EXTEND TestSummary)
|
||||
|
||||
## 4. API Surface
|
||||
|
||||
### 4.1 Plugin Registration
|
||||
|
||||
```typescript
|
||||
// In plugin registration
|
||||
fastify.apophis.registerPluginContracts('my-auth', {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
onRequest: {
|
||||
requires: ['request_headers(this).authorization != null'],
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**File**: `src/plugin/index.ts` (NEW METHOD)
|
||||
|
||||
### 4.2 Route Declaration
|
||||
|
||||
```typescript
|
||||
fastify.get('/api/users', {
|
||||
schema: {
|
||||
'x-plugins': ['my-auth', '@fastify/rate-limit'],
|
||||
'x-ensures': ['status:200', 'response_body(this).id != null'],
|
||||
}
|
||||
}, handler)
|
||||
```
|
||||
|
||||
### 4.3 Test Execution
|
||||
|
||||
```typescript
|
||||
const result = await fastify.apophis.contract({
|
||||
depth: 'standard',
|
||||
// Plugin contracts are applied automatically
|
||||
})
|
||||
|
||||
// Results include plugin contract attribution
|
||||
console.log(result.summary.pluginContractsApplied)
|
||||
```
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Phase 1: Core Registry (2 hours)
|
||||
|
||||
**Files**:
|
||||
- `src/types.ts:223+` — Add `PluginContractSpec`, `ComposedContract`, extend `ContractViolation`
|
||||
- `src/domain/plugin-contracts.ts` — `PluginContractRegistry` class
|
||||
- `src/plugin/index.ts:130+` — Add `registerPluginContracts()` method
|
||||
- `src/plugin/index.ts:48-69` — Auto-register built-in contracts
|
||||
|
||||
**Tests**:
|
||||
- `src/test/plugin-contracts.test.ts` — Registry operations, pattern matching, composition
|
||||
|
||||
### Phase 2: Route Integration (2 hours)
|
||||
|
||||
**Files**:
|
||||
- `src/domain/contract.ts:45+` — Extract `x-plugins` from schema
|
||||
- `src/test/petit-runner.ts:180-190` — Compose contracts before test generation
|
||||
- `src/test/petit-runner.ts:250-300` — Apply composed contracts during execution
|
||||
|
||||
**Tests**:
|
||||
- `src/test/plugin-contracts-integration.test.ts` — End-to-end plugin contract testing
|
||||
|
||||
### Phase 3: Built-in Contracts (1 hour)
|
||||
|
||||
**Files**:
|
||||
- `src/plugins/built-in-contracts.ts` — Common plugin contracts
|
||||
- `docs/PLUGIN_CONTRACTS_SPEC.md` — Documentation
|
||||
|
||||
**Tests**:
|
||||
- `src/test/built-in-contracts.test.ts` — Verify built-in contracts load correctly
|
||||
|
||||
### Phase 4: Diagnostics (1 hour)
|
||||
|
||||
**Files**:
|
||||
- `src/types.ts:277-285` — Extend `TestSummary` with plugin metrics
|
||||
- `src/domain/contract-validation.ts` — Add source attribution to violations
|
||||
- `src/test/error-renderer.ts` — Show plugin source in failure output
|
||||
|
||||
## 6. Invariants
|
||||
|
||||
### 6.1 Registry Invariants
|
||||
|
||||
- **I1**: Plugin contract registration MUST be idempotent. Registering the same plugin twice with identical spec MUST NOT throw.
|
||||
- **I2**: Plugin contract registration order MUST be deterministic and preserved in diagnostics.
|
||||
- **I3**: The registry MUST NOT mutate plugin specs after registration. All returned specs MUST be deep copies.
|
||||
|
||||
### 6.2 Composition Invariants
|
||||
|
||||
- **I4**: Contract composition MUST be deterministic. Same route + same plugins = same composed contract.
|
||||
- **I5**: Contract composition MUST be idempotent. Composing an already-composed route MUST produce identical results.
|
||||
- **I6**: Plugin contracts MUST NOT override route contracts. If route and plugin declare the same formula, route's version takes precedence.
|
||||
|
||||
### 6.3 Execution Invariants
|
||||
|
||||
- **I7**: Plugin contracts MUST be tested even if route has no `x-plugins` annotation. `x-plugins` is for validation, not scoping.
|
||||
- **I8**: Plugin contract failures MUST include plugin name in diagnostics. Users MUST know which plugin's contract failed.
|
||||
- **I9**: Plugin contract warnings (missing plugins, pattern mismatches) MUST NOT fail the test suite. They are informational only.
|
||||
|
||||
## 7. Backward Compatibility
|
||||
|
||||
- Routes without `x-plugins` still receive matching plugin contracts; this is additive validation behavior and must be called out in migration notes.
|
||||
- Plugins without `registerPluginContracts()` MUST NOT cause errors.
|
||||
- Existing `TestSuite` and `TestResult` types MUST remain compatible. New fields are optional.
|
||||
|
||||
## 8. Open Questions
|
||||
|
||||
1. **Phase interception**: Can we actually test onRequest/onSend contracts separately without monkey-patching Fastify? If not, we test all plugin contracts against final context with phase noted.
|
||||
|
||||
2. **Plugin versioning**: Should contracts include version constraints? e.g., `@fastify/auth@^4.0.0` contracts.
|
||||
|
||||
3. **Conditional contracts**: Should plugins declare contracts conditionally based on configuration? e.g., auth plugin with `optional: true` mode.
|
||||
|
||||
4. **Performance**: Composing contracts for 10K routes with 20 plugins. O(n*m) is acceptable but should be cached.
|
||||
|
||||
## 9. References
|
||||
|
||||
### Codebase Citations
|
||||
|
||||
- **Route discovery**: `src/domain/discovery.ts:26-32`
|
||||
- **Hook validator**: `src/infrastructure/hook-validator.ts`
|
||||
- **Contract extraction**: `src/domain/contract.ts:45+`
|
||||
- **PETIT runner**: `src/test/petit-runner.ts:166-428`
|
||||
- **Plugin entry**: `src/plugin/index.ts:48-69`
|
||||
- **Types**: `src/types.ts:170-292`
|
||||
|
||||
### External References
|
||||
|
||||
- Fastify Hooks: https://www.fastify.io/docs/latest/Reference/Hooks/
|
||||
- Fastify Plugin: https://github.com/fastify/fastify-plugin
|
||||
- Fastify Encapsulation: https://www.fastify.io/docs/latest/Reference/Encapsulation/
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Author: APOPHIS Architecture Team*
|
||||
*Date: 2026-04-25*
|
||||
@@ -0,0 +1,873 @@
|
||||
# APOPHIS v1.0 — Authentication, Authorization & Rate Limiting Extension (REVISED)
|
||||
|
||||
> **Status: NOT IMPLEMENTED**
|
||||
> This document describes a proposed extension that is not yet available in APOPHIS. The predicates, types, and infrastructure described here do not exist in the current codebase. Use `createAuthExtension` from `apophis-fastify/extension/factories` for auth testing today.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document specifies the extension of APOPHIS v1.0 to support production-critical concerns:
|
||||
|
||||
1. **Authentication Flows** — JWT, OAuth 2.1, session-based, and mTLS authentication
|
||||
2. **Rate Limiting** — Contract-level rate limit validation and burst testing
|
||||
3. **Authorization/Scope Claims** — Fine-grained permission modeling in contracts
|
||||
|
||||
**Critical Design Constraint**: Arbiter (the primary production user) uses **programmatic gate-based auth**, not JSON Schema annotations. Routes validate auth in `preHandler` hooks, not via `schema:` properties. This spec supports **both** annotation-based and programmatic contract definition.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Principles
|
||||
|
||||
- **Auth is a cross-cutting concern**, not a route category
|
||||
- **Two contract definition modes**:
|
||||
- **Annotation mode**: `x-auth`, `x-scopes`, `x-rate-limit` in JSON Schema (for standard REST APIs)
|
||||
- **Programmatic mode**: Pass auth/rate-limit config directly to `contract()`/`stateful()` (for gate-based architectures like Arbiter)
|
||||
- **Test isolation**: Each test run receives its own auth context. No shared tokens across tests.
|
||||
- **Deterministic when seeded**: Auth flows are simulated, not delegated to external IdPs. Token/session generation must receive the test seed and clock.
|
||||
- **No breaking changes**: All new features are opt-in. Existing v1.0 contracts work unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Auth State Model
|
||||
|
||||
Auth state is tracked per-test-run in an `AuthContext` object:
|
||||
|
||||
```typescript
|
||||
// src/types.ts (additions)
|
||||
|
||||
export type AuthFlow = 'jwt' | 'oauth2' | 'session' | 'mtls' | 'none'
|
||||
|
||||
export interface AuthContext {
|
||||
readonly flow: AuthFlow
|
||||
readonly token: string | null // Current access token (JWT or OAuth)
|
||||
readonly refreshToken: string | null // OAuth refresh token
|
||||
readonly tokenExpiry: number | null // Unix timestamp (ms)
|
||||
readonly sessionCookie: string | null // Session ID for cookie flows
|
||||
readonly clientCert: string | null // mTLS client certificate
|
||||
readonly scopes: string[] // Granted scopes
|
||||
readonly claims: Record<string, unknown> // Decoded claims (JWT payload or OAuth token introspection)
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
readonly flow: AuthFlow
|
||||
readonly issuer?: string
|
||||
readonly audience?: string
|
||||
readonly clientId?: string
|
||||
readonly clientSecret?: string
|
||||
readonly tokenEndpoint?: string
|
||||
readonly authorizationEndpoint?: string
|
||||
readonly scopes?: string[]
|
||||
readonly testKeyPair?: { publicKey: string; privateKey: string }
|
||||
readonly sessionSecret?: string
|
||||
readonly clientCert?: string // PEM-encoded client certificate for mTLS
|
||||
readonly clientKey?: string // PEM-encoded client private key for mTLS
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Contract Definition Modes
|
||||
|
||||
### 4.1 Annotation Mode (JSON Schema)
|
||||
|
||||
For APIs that use schema annotations, auth requirements are declared in the schema:
|
||||
|
||||
```typescript
|
||||
fastify.get('/users/:id', {
|
||||
schema: {
|
||||
params: { type: 'object', properties: { id: { type: 'string' } } },
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, email: { type: 'string' } },
|
||||
'x-auth': 'jwt',
|
||||
'x-scopes': ['read:users'],
|
||||
'x-ensures': ['jwt_claims(this).sub != null']
|
||||
}
|
||||
}
|
||||
}
|
||||
}, handler)
|
||||
```
|
||||
|
||||
**Annotation semantics**:
|
||||
|
||||
- `x-auth`: Required auth flow. Values: `"jwt"`, `"oauth2"`, `"session"`, `"mtls"`, `"none"` (default).
|
||||
- `x-scopes`: Array of scope strings. Checked against `AuthContext.scopes`.
|
||||
- `x-scopes-match`: `"any"` (at least one) or `"all"` (all required). Default: `"any"`.
|
||||
- `x-auth-optional`: If `true`, route works with or without auth.
|
||||
|
||||
### 4.2 Programmatic Mode (No Schema Annotations)
|
||||
|
||||
For architectures like Arbiter that don't use schema annotations for auth, pass auth requirements directly to the test runner:
|
||||
|
||||
```typescript
|
||||
// Arbiter-style: auth is handled in preHandler gates, not schema annotations
|
||||
const suite = await fastify.apophis.contract({
|
||||
scope: 'tenant-a',
|
||||
auth: {
|
||||
flow: 'jwt',
|
||||
issuer: 'https://auth.example.com',
|
||||
scopes: ['read:users', 'read:posts']
|
||||
},
|
||||
// Optional: per-route auth overrides
|
||||
routeAuth: {
|
||||
'GET /users/:id': { requiredScopes: ['read:users'] },
|
||||
'POST /admin/users': { requiredScopes: ['admin'], scopesMatch: 'all' }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Programmatic mode semantics**:
|
||||
|
||||
- `auth` in `TestConfig` initializes the auth context for the entire test run
|
||||
- `routeAuth` provides per-route auth requirements when schemas don't have annotations
|
||||
- Auth headers are injected into all requests automatically
|
||||
- Postconditions can still use `jwt_claim(this).sub` etc. to validate claims in responses
|
||||
|
||||
---
|
||||
|
||||
## 5. Type Changes in `src/types.ts`
|
||||
|
||||
### 5.1 RouteContract Extension
|
||||
|
||||
```typescript
|
||||
export interface RouteContract {
|
||||
path: string
|
||||
method: string
|
||||
category: OperationCategory
|
||||
requires: string[]
|
||||
ensures: string[]
|
||||
invariants: string[]
|
||||
regexPatterns: Record<string, string>
|
||||
validateRuntime: boolean
|
||||
schema?: Record<string, unknown>
|
||||
// NEW:
|
||||
authFlow: AuthFlow
|
||||
requiredScopes: string[]
|
||||
scopesMatch: 'any' | 'all'
|
||||
authOptional: boolean
|
||||
rateLimit?: RateLimitConfig
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 EvalContext Extension
|
||||
|
||||
```typescript
|
||||
export interface EvalContext {
|
||||
readonly request: { /* ... */ }
|
||||
readonly response: { /* ... */ }
|
||||
readonly previous?: EvalContext
|
||||
// NEW:
|
||||
readonly auth: AuthContext
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 TestConfig Extension
|
||||
|
||||
```typescript
|
||||
export interface TestConfig {
|
||||
readonly depth?: TestDepth
|
||||
readonly scope?: string
|
||||
readonly seed?: number
|
||||
// NEW:
|
||||
readonly auth?: AuthConfig
|
||||
readonly routeAuth?: Record<string, { requiredScopes?: string[]; scopesMatch?: 'any' | 'all'; authOptional?: boolean }>
|
||||
readonly burst?: boolean // Enable burst testing for rate limits
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 ApophisOptions Extension
|
||||
|
||||
```typescript
|
||||
export interface ApophisOptions {
|
||||
readonly swagger?: Record<string, unknown>
|
||||
readonly runtime?: 'off' | 'warn' | 'error'
|
||||
readonly cleanup?: boolean
|
||||
readonly scopes?: Record<string, ScopeConfig>
|
||||
// NEW:
|
||||
readonly auth?: AuthConfig
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. APOSTL Extensions for Auth
|
||||
|
||||
New operation headers for auth introspection:
|
||||
|
||||
```typescript
|
||||
export type OperationHeader =
|
||||
| 'request_body' | 'response_body' | 'response_code'
|
||||
| 'request_headers' | 'response_headers' | 'query_params'
|
||||
| 'cookies' | 'response_time'
|
||||
// NEW:
|
||||
| 'jwt_claim' | 'auth_scope' | 'rate_limit_remaining' | 'rate_limit_limit' | 'rate_limit_reset'
|
||||
```
|
||||
|
||||
**New formula syntax**:
|
||||
|
||||
```
|
||||
jwt_claims(this).sub == "user-123"
|
||||
jwt_claims(this).role == "admin"
|
||||
auth_has_scope(this, "read:users") == true
|
||||
auth_has_scope(this, "admin") == true
|
||||
rate_limit_remaining(this) >= 0
|
||||
rate_limit_limit(this) == 100
|
||||
```
|
||||
|
||||
**Semantics**:
|
||||
|
||||
- `jwt_claim(this).<claim>`: Access a claim from the decoded JWT payload. Returns `undefined` if no JWT or claim missing.
|
||||
- `auth_scope(this).<scope>`: Returns `true` if the scope is present in `AuthContext.scopes`, `false` otherwise.
|
||||
- `rate_limit_remaining(this)`: Returns the number of requests remaining in the current window (from response headers).
|
||||
- `rate_limit_limit(this)`: Returns the total request limit for the window.
|
||||
- `rate_limit_reset(this)`: Returns the Unix timestamp when the rate limit window resets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Token Generation Helpers for Testing
|
||||
|
||||
New module: `src/infrastructure/auth-test-helpers.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Auth Test Helpers
|
||||
* Deterministic token generation for testing. No external IdP calls.
|
||||
*/
|
||||
|
||||
import { createSign, createVerify, randomBytes, createHash, createHmac } from 'node:crypto'
|
||||
|
||||
export interface TestKeyPair {
|
||||
readonly publicKey: string
|
||||
readonly privateKey: string
|
||||
}
|
||||
|
||||
export const generateTestKeyPair = (): TestKeyPair => {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
})
|
||||
return { publicKey, privateKey }
|
||||
}
|
||||
|
||||
export const signTestJwt = (
|
||||
payload: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
options: { expiresIn?: number; issuer?: string; audience?: string } = {}
|
||||
): string => {
|
||||
const header = { alg: 'RS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const claims = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: options.expiresIn ? now + options.expiresIn : now + 3600,
|
||||
...(options.issuer ? { iss: options.issuer } : {}),
|
||||
...(options.audience ? { aud: options.audience } : {}),
|
||||
}
|
||||
|
||||
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url')
|
||||
const claimsB64 = Buffer.from(JSON.stringify(claims)).toString('base64url')
|
||||
const signingInput = `${headerB64}.${claimsB64}`
|
||||
|
||||
const signer = createSign('RSA-SHA256')
|
||||
signer.update(signingInput)
|
||||
const signature = signer.sign(privateKey, 'base64url')
|
||||
|
||||
return `${signingInput}.${signature}`
|
||||
}
|
||||
|
||||
export const verifyTestJwt = (token: string, publicKey: string): Record<string, unknown> | null => {
|
||||
const [headerB64, claimsB64, signature] = token.split('.')
|
||||
if (!headerB64 || !claimsB64 || !signature) return null
|
||||
|
||||
const verifier = createVerify('RSA-SHA256')
|
||||
verifier.update(`${headerB64}.${claimsB64}`)
|
||||
const valid = verifier.verify(publicKey, signature, 'base64url')
|
||||
|
||||
if (!valid) return null
|
||||
return JSON.parse(Buffer.from(claimsB64, 'base64url').toString())
|
||||
}
|
||||
|
||||
export const generateTestSessionCookie = (sessionId: string, secret: string): string => {
|
||||
const signature = createHmac('sha256', secret).update(sessionId).digest('base64url')
|
||||
return `session=${sessionId}.${signature}`
|
||||
}
|
||||
|
||||
export const parseTestSessionCookie = (cookie: string, secret: string): string | null => {
|
||||
const match = cookie.match(/session=([^;]+)/)
|
||||
if (!match) return null
|
||||
const [sessionId, signature] = match[1].split('.')
|
||||
if (!sessionId || !signature) return null
|
||||
const expected = createHmac('sha256', secret).update(sessionId).digest('base64url')
|
||||
return signature === expected ? sessionId : null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. OAuth 2.1 Grant Flow Simulation
|
||||
|
||||
New module: `src/infrastructure/oauth-simulator.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* OAuth 2.1 Grant Flow Simulator
|
||||
* Simulates authorization code, client credentials, and PKCE flows
|
||||
* without external IdP dependency. Returns tokens deterministically.
|
||||
*/
|
||||
|
||||
import { signTestJwt, generateTestKeyPair } from './auth-test-helpers.js'
|
||||
import type { AuthContext, AuthConfig } from '../types.js'
|
||||
import { randomBytes, createHash } from 'node:crypto'
|
||||
|
||||
export interface OAuthSimulationResult {
|
||||
readonly accessToken: string
|
||||
readonly refreshToken: string
|
||||
readonly tokenType: 'Bearer'
|
||||
readonly expiresIn: number
|
||||
readonly scope: string
|
||||
}
|
||||
|
||||
export class OAuthSimulator {
|
||||
private readonly keyPair: TestKeyPair
|
||||
private readonly config: AuthConfig
|
||||
private codeChallengeStore: Map<string, string> = new Map()
|
||||
|
||||
constructor(config: AuthConfig) {
|
||||
this.config = config
|
||||
this.keyPair = config.testKeyPair ?? generateTestKeyPair()
|
||||
}
|
||||
|
||||
async authorizationCode(params: {
|
||||
code: string
|
||||
codeVerifier?: string
|
||||
redirectUri: string
|
||||
clientId: string
|
||||
}): Promise<OAuthSimulationResult> {
|
||||
if (params.codeVerifier) {
|
||||
const challenge = this.codeChallengeStore.get(params.code)
|
||||
const verifierHash = createHash('sha256').update(params.codeVerifier).digest('base64url')
|
||||
if (verifierHash !== challenge) {
|
||||
throw new Error('invalid_grant: PKCE verification failed')
|
||||
}
|
||||
}
|
||||
return this.issueToken(params.clientId, this.config.scopes ?? ['openid'])
|
||||
}
|
||||
|
||||
async clientCredentials(params: {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
scope?: string
|
||||
}): Promise<OAuthSimulationResult> {
|
||||
if (params.clientSecret !== `secret-${params.clientId}`) {
|
||||
throw new Error('invalid_client: Client authentication failed')
|
||||
}
|
||||
const scopes = params.scope ? params.scope.split(' ') : (this.config.scopes ?? [])
|
||||
return this.issueToken(params.clientId, scopes)
|
||||
}
|
||||
|
||||
async authorize(params: {
|
||||
responseType: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
scope?: string
|
||||
state?: string
|
||||
codeChallenge?: string
|
||||
codeChallengeMethod?: 'S256' | 'plain'
|
||||
}): Promise<{ code: string; state?: string }> {
|
||||
if (params.responseType !== 'code') {
|
||||
throw new Error('unsupported_response_type')
|
||||
}
|
||||
const code = randomBytes(16).toString('hex')
|
||||
if (params.codeChallenge) {
|
||||
this.codeChallengeStore.set(code, params.codeChallenge)
|
||||
}
|
||||
return { code, state: params.state }
|
||||
}
|
||||
|
||||
private issueToken(clientId: string, scopes: string[]): OAuthSimulationResult {
|
||||
const accessToken = signTestJwt(
|
||||
{ sub: clientId, scope: scopes.join(' '), client_id: clientId },
|
||||
this.keyPair.privateKey,
|
||||
{ issuer: this.config.issuer, audience: this.config.audience, expiresIn: 3600 }
|
||||
)
|
||||
const refreshToken = randomBytes(32).toString('base64url')
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
scope: scopes.join(' '),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Session Cookie Flow Simulation
|
||||
|
||||
New module: `src/infrastructure/session-simulator.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Session Cookie Flow Simulator
|
||||
* Manages session state for cookie-based auth testing.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { generateTestSessionCookie, parseTestSessionCookie } from './auth-test-helpers.js'
|
||||
import type { AuthConfig } from '../types.js'
|
||||
|
||||
interface Session {
|
||||
readonly id: string
|
||||
readonly data: Record<string, unknown>
|
||||
readonly createdAt: number
|
||||
}
|
||||
|
||||
export class SessionSimulator {
|
||||
private readonly sessions: Map<string, Session> = new Map()
|
||||
private readonly secret: string
|
||||
|
||||
constructor(config: AuthConfig) {
|
||||
this.secret = config.sessionSecret ?? 'test-session-secret-change-in-production'
|
||||
}
|
||||
|
||||
createSession(data: Record<string, unknown> = {}): Session {
|
||||
const id = randomBytes(16).toString('hex')
|
||||
const session: Session = { id, data, createdAt: Date.now() }
|
||||
this.sessions.set(id, session)
|
||||
return session
|
||||
}
|
||||
|
||||
getSession(sessionId: string): Session | undefined {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
destroySession(sessionId: string): boolean {
|
||||
return this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
generateCookie(sessionId: string): string {
|
||||
return generateTestSessionCookie(sessionId, this.secret)
|
||||
}
|
||||
|
||||
parseCookie(cookieHeader: string): string | null {
|
||||
return parseTestSessionCookie(cookieHeader, this.secret)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Rate Limiting
|
||||
|
||||
### 10.1 Contract Annotations (Annotation Mode)
|
||||
|
||||
```typescript
|
||||
{
|
||||
"x-rate-limit": {
|
||||
"requests": 100,
|
||||
"window": "1m",
|
||||
"burst": 10,
|
||||
"key": "ip"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Annotation semantics**:
|
||||
|
||||
- `x-rate-limit.requests`: Maximum requests allowed in the window.
|
||||
- `x-rate-limit.window`: Time window as a duration string (e.g., `"1m"`, `"1h"`, `"1d"`).
|
||||
- `x-rate-limit.burst`: Maximum burst size.
|
||||
- `x-rate-limit.key`: Rate limit bucket key: `"ip"`, `"user"`, `"tenant"`, `"global"`.
|
||||
|
||||
### 10.2 Programmatic Rate Limit Config
|
||||
|
||||
```typescript
|
||||
const suite = await fastify.apophis.contract({
|
||||
auth: { flow: 'jwt', scopes: ['read:users'] },
|
||||
routeRateLimits: {
|
||||
'GET /api/data': { requests: 100, window: '1m', burst: 10, key: 'ip' },
|
||||
'POST /api/action': { requests: 10, window: '1h', burst: 2, key: 'user' }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 10.3 Rate Limit State Tracking
|
||||
|
||||
New module: `src/infrastructure/rate-limit-tracker.ts`
|
||||
|
||||
```typescript
|
||||
export interface RateLimitState {
|
||||
readonly bucket: string
|
||||
readonly remaining: number
|
||||
readonly limit: number
|
||||
readonly resetAt: number
|
||||
readonly window: string
|
||||
}
|
||||
|
||||
export class RateLimitTracker {
|
||||
private readonly state: Map<string, RateLimitState> = new Map()
|
||||
|
||||
update(bucket: string, remaining: number, limit: number, resetAt: number, window: string): void {
|
||||
this.state.set(bucket, { bucket, remaining, limit, resetAt, window })
|
||||
}
|
||||
|
||||
get(bucket: string): RateLimitState | undefined {
|
||||
return this.state.get(bucket)
|
||||
}
|
||||
|
||||
isExhausted(bucket: string): boolean {
|
||||
const state = this.state.get(bucket)
|
||||
if (!state) return false
|
||||
return state.remaining <= 0 && Date.now() < state.resetAt
|
||||
}
|
||||
|
||||
reset(bucket: string): void {
|
||||
this.state.delete(bucket)
|
||||
}
|
||||
|
||||
getAll(): ReadonlyMap<string, RateLimitState> {
|
||||
return this.state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Scope Registry Integration
|
||||
|
||||
The scope registry integrates auth context into scope resolution:
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/scope-registry.ts
|
||||
|
||||
getHeaders(
|
||||
scopeName: string | null,
|
||||
overrides?: Record<string, string>,
|
||||
authContext?: AuthContext
|
||||
): Record<string, string> {
|
||||
const scope = scopeName !== null ? this.scopes.get(scopeName) : undefined
|
||||
const base = scope ?? this.defaultScope
|
||||
|
||||
const tenantId = base.metadata?.tenantId as string | undefined
|
||||
const applicationId = base.metadata?.applicationId as string | undefined
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...base.headers,
|
||||
...(tenantId !== undefined && tenantId !== 'default' ? { 'x-tenant-id': tenantId } : {}),
|
||||
...(applicationId !== undefined && applicationId !== 'default' ? { 'x-application-id': applicationId } : {}),
|
||||
...(overrides ?? {}),
|
||||
}
|
||||
|
||||
// Inject auth headers if auth context is provided
|
||||
if (authContext?.token) {
|
||||
if (authContext.flow === 'jwt' || authContext.flow === 'oauth2') {
|
||||
headers['authorization'] = `Bearer ${authContext.token}`
|
||||
} else if (authContext.flow === 'session' && authContext.sessionCookie) {
|
||||
headers['cookie'] = authContext.sessionCookie
|
||||
}
|
||||
}
|
||||
|
||||
// Inject mTLS certificate info if present
|
||||
if (authContext?.clientCert && authContext.flow === 'mtls') {
|
||||
headers['x-client-cert'] = authContext.clientCert
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Request Builder Integration
|
||||
|
||||
The request builder injects auth headers based on route requirements and current auth context:
|
||||
|
||||
```typescript
|
||||
// src/domain/request-builder.ts
|
||||
|
||||
const buildHeaders = (
|
||||
route: RouteContract,
|
||||
scopeHeaders: Record<string, string>,
|
||||
data: Record<string, unknown>,
|
||||
_state: ModelState,
|
||||
authContext?: AuthContext
|
||||
): Record<string, string> => {
|
||||
const headers: Record<string, string> = { ...scopeHeaders }
|
||||
|
||||
if (route.schema?.body) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
|
||||
// Inject auth headers based on route's auth flow requirement
|
||||
if (route.authFlow !== 'none' && authContext) {
|
||||
if (route.authFlow === 'jwt' || route.authFlow === 'oauth2') {
|
||||
if (authContext.token) {
|
||||
headers['authorization'] = `Bearer ${authContext.token}`
|
||||
}
|
||||
} else if (route.authFlow === 'session' && authContext.sessionCookie) {
|
||||
headers['cookie'] = authContext.sessionCookie
|
||||
} else if (route.authFlow === 'mtls' && authContext.clientCert) {
|
||||
headers['x-client-cert'] = authContext.clientCert
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Auth Context Initialization in Test Runners
|
||||
|
||||
Both `petit-runner.ts` and `stateful-runner.ts` initialize auth context before test execution:
|
||||
|
||||
```typescript
|
||||
// In runPetitTests()
|
||||
|
||||
let authContext: AuthContext = {
|
||||
flow: config.auth?.flow ?? 'none',
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
tokenExpiry: null,
|
||||
sessionCookie: null,
|
||||
clientCert: null,
|
||||
scopes: [],
|
||||
claims: {},
|
||||
}
|
||||
|
||||
if (config.auth && config.auth.flow !== 'none') {
|
||||
authContext = await initializeAuth(config.auth)
|
||||
}
|
||||
|
||||
// Pass authContext to buildRequest in the execution loop
|
||||
for (const command of allCommands) {
|
||||
const request = buildRequest(command.route, command.params, scopeHeaders, state, rng, authContext)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Auth initialization helper**:
|
||||
|
||||
```typescript
|
||||
async function initializeAuth(config: AuthConfig): Promise<AuthContext> {
|
||||
switch (config.flow) {
|
||||
case 'jwt': {
|
||||
const keyPair = config.testKeyPair ?? generateTestKeyPair()
|
||||
const token = signTestJwt(
|
||||
{ sub: 'test-user', scope: (config.scopes ?? []).join(' ') },
|
||||
keyPair.privateKey,
|
||||
{ issuer: config.issuer, audience: config.audience }
|
||||
)
|
||||
const claims = verifyTestJwt(token, keyPair.publicKey) ?? {}
|
||||
return {
|
||||
flow: 'jwt',
|
||||
token,
|
||||
refreshToken: null,
|
||||
tokenExpiry: Date.now() + 3600000,
|
||||
sessionCookie: null,
|
||||
clientCert: null,
|
||||
scopes: config.scopes ?? [],
|
||||
claims,
|
||||
}
|
||||
}
|
||||
|
||||
case 'oauth2': {
|
||||
const simulator = new OAuthSimulator(config)
|
||||
const result = await simulator.clientCredentials({
|
||||
clientId: config.clientId ?? 'test-client',
|
||||
clientSecret: config.clientSecret ?? `secret-${config.clientId ?? 'test-client'}`,
|
||||
scope: (config.scopes ?? []).join(' '),
|
||||
})
|
||||
const claims = verifyTestJwt(result.accessToken, simulator['keyPair'].publicKey) ?? {}
|
||||
return {
|
||||
flow: 'oauth2',
|
||||
token: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
tokenExpiry: Date.now() + result.expiresIn * 1000,
|
||||
sessionCookie: null,
|
||||
clientCert: null,
|
||||
scopes: result.scope.split(' '),
|
||||
claims,
|
||||
}
|
||||
}
|
||||
|
||||
case 'session': {
|
||||
const simulator = new SessionSimulator(config)
|
||||
const session = simulator.createSession({ userId: 'test-user', roles: config.scopes ?? [] })
|
||||
const cookie = simulator.generateCookie(session.id)
|
||||
return {
|
||||
flow: 'session',
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
tokenExpiry: null,
|
||||
sessionCookie: cookie,
|
||||
clientCert: null,
|
||||
scopes: config.scopes ?? [],
|
||||
claims: session.data,
|
||||
}
|
||||
}
|
||||
|
||||
case 'mtls': {
|
||||
return {
|
||||
flow: 'mtls',
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
tokenExpiry: null,
|
||||
sessionCookie: null,
|
||||
clientCert: config.clientCert ?? null,
|
||||
scopes: config.scopes ?? [],
|
||||
claims: {},
|
||||
}
|
||||
}
|
||||
|
||||
case 'none':
|
||||
default:
|
||||
return { flow: 'none', token: null, refreshToken: null, tokenExpiry: null, sessionCookie: null, clientCert: null, scopes: [], claims: {} }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Contract Extraction
|
||||
|
||||
Update `src/domain/contract.ts` to extract auth annotations from schema (annotation mode):
|
||||
|
||||
```typescript
|
||||
const contract: RouteContract = {
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
category,
|
||||
requires,
|
||||
ensures,
|
||||
invariants: EMPTY_INVARIANTS,
|
||||
regexPatterns: {},
|
||||
validateRuntime,
|
||||
schema: s,
|
||||
// NEW:
|
||||
authFlow: (s['x-auth'] as AuthFlow) ?? 'none',
|
||||
requiredScopes: Array.isArray(s['x-scopes']) ? (s['x-scopes'] as string[]) : [],
|
||||
scopesMatch: (s['x-scopes-match'] as 'any' | 'all') ?? 'any',
|
||||
authOptional: s['x-auth-optional'] === true,
|
||||
rateLimit: s['x-rate-limit'] ? {
|
||||
requests: Number(s['x-rate-limit'].requests) || 100,
|
||||
window: String(s['x-rate-limit'].window) || '1m',
|
||||
burst: Number(s['x-rate-limit'].burst) || 10,
|
||||
key: (s['x-rate-limit'].key as 'ip' | 'user' | 'tenant' | 'global') || 'global',
|
||||
} : undefined,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Example: Arbiter-Style Programmatic Auth
|
||||
|
||||
```typescript
|
||||
import fastify from 'fastify'
|
||||
import { apophisPlugin } from 'apophis-fastify'
|
||||
|
||||
const app = fastify()
|
||||
|
||||
// Register APOPHIS with auth support
|
||||
await app.register(apophisPlugin, {
|
||||
scopes: {
|
||||
'tenant-a': {
|
||||
headers: { 'x-tenant-id': 'tenant-a' },
|
||||
metadata: { tenantId: 'tenant-a' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Arbiter-style route: NO schema annotations for auth
|
||||
// Auth is handled in preHandler gates (not shown)
|
||||
app.get('/users/:id', {
|
||||
schema: {
|
||||
params: { type: 'object', properties: { id: { type: 'string' } } },
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, email: { type: 'string' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
// Gate-based auth happens in preHandler
|
||||
return { id: req.params.id, email: 'user@example.com' }
|
||||
})
|
||||
|
||||
// Test with programmatic auth config
|
||||
const suite = await app.apophis.contract({
|
||||
scope: 'tenant-a',
|
||||
auth: {
|
||||
flow: 'jwt',
|
||||
issuer: 'https://auth.example.com',
|
||||
scopes: ['read:users']
|
||||
},
|
||||
routeAuth: {
|
||||
'GET /users/:id': { requiredScopes: ['read:users'] }
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Tests: ${suite.summary.passed} passed, ${suite.summary.failed} failed`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. Test Plan
|
||||
|
||||
### 16.1 Auth Tests
|
||||
|
||||
1. **JWT Flow**: Verify `jwt_claim(this).sub` works with generated test tokens.
|
||||
2. **OAuth 2.1 Client Credentials**: Verify token acquisition and scope assignment.
|
||||
3. **OAuth 2.1 Authorization Code + PKCE**: Verify full flow simulation.
|
||||
4. **Session Cookie**: Verify session creation, cookie generation, and validation.
|
||||
5. **mTLS**: Verify client certificate injection.
|
||||
6. **Scope Enforcement**: Verify routes reject requests without required scopes.
|
||||
7. **Auth Optional**: Verify `x-auth-optional: true` allows unauthenticated access.
|
||||
8. **Programmatic Mode**: Verify `routeAuth` config works without schema annotations.
|
||||
|
||||
### 16.2 Rate Limit Tests
|
||||
|
||||
1. **Header Validation**: Verify `response_headers(this).x-ratelimit-remaining >= 0` passes.
|
||||
2. **Burst Mode**: Verify rapid sequential requests trigger rate limit responses.
|
||||
3. **State Tracking**: Verify rate limit state persists across requests within one test run and resets between runs.
|
||||
4. **Contract Violation**: Verify 429 responses are handled correctly when rate limit exceeded.
|
||||
|
||||
### 16.3 Integration Tests
|
||||
|
||||
1. **Auth + Scope**: Verify JWT route with `read:users` scope works when scope is granted.
|
||||
2. **Auth + Rate Limit**: Verify authenticated requests are rate-limited per-user.
|
||||
3. **Scope + Tenant**: Verify tenant isolation with per-tenant auth contexts.
|
||||
4. **Programmatic + Annotation**: Verify both modes work in the same test run.
|
||||
|
||||
---
|
||||
|
||||
## 17. Backward Compatibility
|
||||
|
||||
All new features are **opt-in**:
|
||||
|
||||
- Routes without `x-auth` default to `authFlow: 'none'`.
|
||||
- Routes without `x-scopes` default to `requiredScopes: []`.
|
||||
- Routes without `x-rate-limit` default to no rate limit validation.
|
||||
- Test configurations without `auth` default to no auth context.
|
||||
- Test configurations without `routeAuth` default to annotation-only mode.
|
||||
|
||||
No breaking changes to existing APOPHIS v1.0 APIs.
|
||||
|
||||
---
|
||||
|
||||
## 18. Security Considerations
|
||||
|
||||
1. **Test Keys**: `generateTestKeyPair()` generates 2048-bit RSA keys for testing only. Never use in production.
|
||||
2. **Session Secrets**: `SessionSimulator` uses a default secret if none provided. Production code must always provide a strong secret.
|
||||
3. **Token Expiry**: Test JWTs expire after 1 hour by default. Short-lived tokens prevent accidental reuse.
|
||||
4. **No External Calls**: The OAuth simulator does not make HTTP requests to external IdPs. All tokens are generated locally.
|
||||
5. **Scope Validation**: Scope checks are exact-match only. No wildcard or regex matching to prevent scope escalation attacks in tests.
|
||||
6. **mTLS Certificates**: Test client certificates should be generated for each test run. Never reuse production certificates.
|
||||
|
||||
---
|
||||
|
||||
*End of Revised Specification*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,459 @@
|
||||
# Structuring Your Fastify App for APOPHIS
|
||||
|
||||
APOPHIS requires that you register its plugin **before** defining routes, and it needs to access your route schemas at test time. If your application is a single file that creates the server, connects to databases, registers routes, and starts listening, you cannot test it with APOPHIS.
|
||||
|
||||
This guide shows how to restructure a monolithic Fastify application into a testable plugin architecture.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Here is what Arbiter's setup looked like — a single file doing everything:
|
||||
|
||||
```typescript
|
||||
// server.ts — THE WRONG WAY
|
||||
import Fastify from 'fastify'
|
||||
import database from './database'
|
||||
import routes from './routes'
|
||||
|
||||
const fastify = Fastify()
|
||||
|
||||
// Database setup
|
||||
await database.connect(process.env.DATABASE_URL)
|
||||
|
||||
// Register plugins
|
||||
await fastify.register(require('@fastify/swagger'))
|
||||
await fastify.register(require('@fastify/cors'))
|
||||
|
||||
// Register routes
|
||||
fastify.register(routes)
|
||||
|
||||
// Add decorators
|
||||
fastify.decorate('db', database)
|
||||
|
||||
// Start server
|
||||
await fastify.listen({ port: 3000 })
|
||||
```
|
||||
|
||||
**Why this breaks APOPHIS:**
|
||||
|
||||
1. **Routes are registered before APOPHIS** — APOPHIS must hook into the registration process, so it must be registered first.
|
||||
2. **No way to create a test instance** — The database connection and server start are unconditional. You cannot create a second Fastify instance for testing without starting another server.
|
||||
3. **No cleanup hook** — File system state (WAL logs, uploaded files) accumulates between runs.
|
||||
4. **Side effects at import time** — Importing the file has side effects. You cannot import routes without importing the database connection.
|
||||
|
||||
---
|
||||
|
||||
## The Solution: App Factory Pattern
|
||||
|
||||
Separate **application creation** from **server startup**. Export a function that creates a configured Fastify instance without starting it.
|
||||
|
||||
### Recommended Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
app.ts # App factory: creates Fastify instance, registers plugins
|
||||
server.ts # Entry point: creates app, connects DB, starts server
|
||||
plugins/
|
||||
database.ts # Database connection plugin
|
||||
auth.ts # Auth decorator plugin
|
||||
routes/
|
||||
users.ts # Route definitions with schema annotations
|
||||
health.ts # Health check route
|
||||
test/
|
||||
setup.ts # Test bootstrap: creates app, registers APOPHIS
|
||||
contracts.test.ts # Contract test entry point
|
||||
```
|
||||
|
||||
### 1. App Factory (`src/app.ts`)
|
||||
|
||||
This file exports a function that creates a Fastify instance and registers all plugins **except** APOPHIS and the database connection. It should have no side effects.
|
||||
|
||||
```typescript
|
||||
import Fastify from 'fastify'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
// Your application plugins
|
||||
import databasePlugin from './plugins/database'
|
||||
import authPlugin from './plugins/auth'
|
||||
import userRoutes from './routes/users'
|
||||
import healthRoutes from './routes/health'
|
||||
|
||||
export interface AppOptions {
|
||||
// Pass configuration explicitly instead of reading env vars
|
||||
databaseUrl?: string
|
||||
jwtSecret?: string
|
||||
enableLogging?: boolean
|
||||
}
|
||||
|
||||
export async function buildApp(opts: AppOptions = {}): Promise<FastifyInstance> {
|
||||
const fastify = Fastify({
|
||||
logger: opts.enableLogging ?? true,
|
||||
// Disable request logging in test mode to reduce noise
|
||||
disableRequestLogging: process.env.NODE_ENV === 'test',
|
||||
})
|
||||
|
||||
// Register infrastructure plugins
|
||||
await fastify.register(databasePlugin, { url: opts.databaseUrl })
|
||||
await fastify.register(authPlugin, { secret: opts.jwtSecret })
|
||||
|
||||
// Register route plugins
|
||||
await fastify.register(userRoutes, { prefix: '/api/users' })
|
||||
await fastify.register(healthRoutes, { prefix: '/health' })
|
||||
|
||||
return fastify
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Plugin (`src/plugins/database.ts`)
|
||||
|
||||
Encapsulate database setup in a Fastify plugin. This makes it composable and testable.
|
||||
|
||||
```typescript
|
||||
import fp from 'fastify-plugin'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createConnection } from './db-client'
|
||||
|
||||
export interface DatabasePluginOptions {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export default fp(async (fastify: FastifyInstance, opts: DatabasePluginOptions) => {
|
||||
const db = await createConnection(opts.url ?? process.env.DATABASE_URL)
|
||||
|
||||
// Decorate fastify with db access
|
||||
fastify.decorate('db', db)
|
||||
|
||||
// Clean up on close
|
||||
fastify.addHook('onClose', async () => {
|
||||
await db.disconnect()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Route Files with Contracts (`src/routes/users.ts`)
|
||||
|
||||
Define routes in separate files. Each route file is a Fastify plugin that receives the parent instance.
|
||||
|
||||
```typescript
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export default async function userRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).id != null',
|
||||
'response_body(this).email matches "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"',
|
||||
],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email' },
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const user = await fastify.db.users.create(req.body)
|
||||
reply.status(201)
|
||||
return user
|
||||
})
|
||||
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': [
|
||||
'if status:200 then response_body(this).id != null',
|
||||
],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const user = await fastify.db.users.findById(req.params.id)
|
||||
if (!user) {
|
||||
reply.status(404)
|
||||
return { error: 'Not found' }
|
||||
}
|
||||
return user
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Production Entry Point (`src/server.ts`)
|
||||
|
||||
The production entry point imports the app factory, adds APOPHIS, connects to services, and starts the server.
|
||||
|
||||
```typescript
|
||||
import { buildApp } from './app'
|
||||
import apophis from 'apophis-fastify'
|
||||
|
||||
async function start() {
|
||||
const fastify = await buildApp({
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
})
|
||||
|
||||
// Register APOPHIS before ready() but after all routes
|
||||
await fastify.register(apophis, {
|
||||
runtime: process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
|
||||
// Start server
|
||||
await fastify.listen({ port: Number(process.env.PORT) || 3000 })
|
||||
|
||||
console.log(`Server listening on ${fastify.server.address()}`)
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Test Bootstrap (`src/test/setup.ts`)
|
||||
|
||||
The test file creates a fresh app instance, registers APOPHIS, and runs contract tests against it.
|
||||
|
||||
```typescript
|
||||
import { buildApp } from '../app'
|
||||
import apophis from 'apophis-fastify'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function createTestApp(): Promise<FastifyInstance> {
|
||||
// Use test database or in-memory store
|
||||
const fastify = await buildApp({
|
||||
databaseUrl: process.env.TEST_DATABASE_URL ?? ':memory:',
|
||||
jwtSecret: 'test-secret',
|
||||
enableLogging: false,
|
||||
})
|
||||
|
||||
// Register APOPHIS for testing
|
||||
await fastify.register(apophis, {
|
||||
timeout: 2000, // Faster timeouts in tests
|
||||
cleanup: true, // Auto-cleanup resources
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
return fastify
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Contract Test Entry Point (`src/test/contracts.test.ts`)
|
||||
|
||||
```typescript
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { createTestApp } from './setup'
|
||||
|
||||
test('contract tests', async () => {
|
||||
const fastify = await createTestApp()
|
||||
|
||||
try {
|
||||
const result = await fastify.apophis.contract({
|
||||
depth: 'standard',
|
||||
seed: 42, // Deterministic
|
||||
})
|
||||
|
||||
console.log(result.summary)
|
||||
|
||||
// Fail the test suite if any contract fails
|
||||
assert.strictEqual(
|
||||
result.summary.failed,
|
||||
0,
|
||||
`Contract failures: ${result.tests
|
||||
.filter((t) => !t.ok)
|
||||
.map((t) => t.name)
|
||||
.join(', ')}`
|
||||
)
|
||||
} finally {
|
||||
// Always clean up
|
||||
await fastify.apophis.cleanup()
|
||||
await fastify.close()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Principles
|
||||
|
||||
### 1. No Side Effects at Import Time
|
||||
|
||||
**Wrong:**
|
||||
```typescript
|
||||
// db.ts
|
||||
export const db = await createConnection(process.env.DATABASE_URL) // Side effect!
|
||||
```
|
||||
|
||||
**Right:**
|
||||
```typescript
|
||||
// db.ts
|
||||
export async function createDb(url: string) {
|
||||
return createConnection(url)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Separate App Creation from Server Start
|
||||
|
||||
**Wrong:**
|
||||
```typescript
|
||||
// server.ts
|
||||
const app = Fastify()
|
||||
// ... setup ...
|
||||
await app.listen({ port: 3000 }) // Cannot test without starting server
|
||||
export default app
|
||||
```
|
||||
|
||||
**Right:**
|
||||
```typescript
|
||||
// app.ts
|
||||
export async function buildApp() {
|
||||
const app = Fastify()
|
||||
// ... setup without listen() ...
|
||||
return app
|
||||
}
|
||||
|
||||
// server.ts
|
||||
const app = await buildApp()
|
||||
await app.listen({ port: 3000 })
|
||||
```
|
||||
|
||||
### 3. Use Fastify Plugins for Everything
|
||||
|
||||
Routes, database connections, auth, decorators — everything should be a Fastify plugin. This makes composition explicit and testable.
|
||||
|
||||
### 4. APOPHIS Registration Order
|
||||
|
||||
```typescript
|
||||
// 1. Create app (registers routes)
|
||||
const app = await buildApp()
|
||||
|
||||
// 2. Register APOPHIS (hooks into existing routes)
|
||||
await app.register(apophis, opts)
|
||||
|
||||
// 3. Ready (compiles schemas)
|
||||
await app.ready()
|
||||
|
||||
// 4. Test or serve
|
||||
await app.apophis.contract({...})
|
||||
// OR
|
||||
await app.listen({...})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handling Arbiter-Specific Issues
|
||||
|
||||
### File System State (WAL Logs)
|
||||
|
||||
If your server writes to files or WAL logs:
|
||||
|
||||
```typescript
|
||||
// test/setup.ts
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
let testCounter = 0
|
||||
|
||||
export function createTestWorkspace() {
|
||||
const dir = join(tmpdir(), `apophis-test-${++testCounter}`)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
return {
|
||||
path: dir,
|
||||
cleanup() {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// In your test:
|
||||
const workspace = createTestWorkspace()
|
||||
const app = await buildApp({
|
||||
dataDir: workspace.path, // Server writes here instead of production path
|
||||
})
|
||||
```
|
||||
|
||||
### Database Seeding
|
||||
|
||||
```typescript
|
||||
// test/setup.ts
|
||||
export async function seedTestDatabase(db: Database) {
|
||||
await db.migrate.latest()
|
||||
await db.seed.run()
|
||||
}
|
||||
|
||||
// In your contract test:
|
||||
const app = await createTestApp()
|
||||
await seedTestDatabase(app.db)
|
||||
```
|
||||
|
||||
### Complex Dependency Injection
|
||||
|
||||
If routes depend on external services (ledger, graph store):
|
||||
|
||||
```typescript
|
||||
// Use test doubles via plugin options
|
||||
export async function buildApp(opts: AppOptions) {
|
||||
const app = Fastify()
|
||||
|
||||
// Production: real ledger
|
||||
// Test: mock ledger
|
||||
await app.register(ledgerPlugin, {
|
||||
client: opts.ledgerClient ?? new RealLedgerClient(),
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
If you have a monolithic `server.ts` like Arbiter:
|
||||
|
||||
- [x] Extract route definitions into `src/routes/*.ts` files
|
||||
- [x] Extract database/auth setup into `src/plugins/*.ts` files
|
||||
- [x] Create `src/app.ts` with a `buildApp()` factory function
|
||||
- [x] Move `fastify.listen()` from `app.ts` to `src/server.ts`
|
||||
- [x] Create `src/test/setup.ts` that calls `buildApp()` + `apophis.register()`
|
||||
- [x] Ensure no side effects at import time in any `src/` file
|
||||
- [x] Run `npx tsc --noEmit` to verify no circular dependencies
|
||||
- [x] Run contract tests: `npm run test:contracts`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Monolithic | Plugin Architecture |
|
||||
|-----------|-------------------|
|
||||
| Single file with everything | Factory function + plugin files |
|
||||
| Side effects at import | Pure functions, explicit initialization |
|
||||
| Cannot create test instance | Create unlimited instances |
|
||||
| APOPHIS must be first (impossible) | APOPHIS registered after routes, before ready() |
|
||||
| Manual cleanup | Hooks for automatic cleanup |
|
||||
| Database URL hardcoded | Injected via options |
|
||||
|
||||
The plugin architecture takes 30 minutes to set up and saves hours of debugging when APOPHIS cannot access your routes.
|
||||
@@ -113,7 +113,7 @@ See [docs/llm-safe-adoption.md](docs/llm-safe-adoption.md) for templates and CI
|
||||
## Operator Resources
|
||||
|
||||
- [Troubleshooting matrix](docs/troubleshooting.md) — Categorized failure classes with resolution steps
|
||||
- [Adoption certification scorecard](docs/adoption-certification-scorecard.md) — Review template for team rollout
|
||||
- [Adoption certification scorecard](adoption-certification-scorecard.md) — Review template for team rollout
|
||||
|
||||
## CTAs
|
||||
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
# APOPHIS Protocol Extensions Specification
|
||||
|
||||
## Status: Active design; shipped baseline: v2.0.0; remaining targets listed per feature
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This specification defines protocol-specific extensions for APOPHIS, driven by the Arbiter team's requirements for testing OAuth 2.1, WIMSE S2S, Transaction Tokens (RFC 8693), SPIFFE/SPIRE, and related security protocols.
|
||||
|
||||
APOPHIS is grounded in [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021). Protocol extensions add domain-specific predicates (JWT, X.509, SPIFFE) to the core invariant framework.
|
||||
|
||||
Arbiter maintains 58 protocol conformance test files covering 138 behaviors across 7 specifications. These extensions bridge the gap between declarative APOSTL contracts and the domain-specific predicates required for security protocol validation.
|
||||
|
||||
### 1.1 Current Shipped vs Not-Shipped Snapshot
|
||||
|
||||
**Shipped in v2.0.0:**
|
||||
|
||||
- `contract({ variants })` for multi-header/media negotiation execution.
|
||||
- `fastify.apophis.scenario(...)` for multi-step capture/rebind flows.
|
||||
- `response_payload(this)` for JSON/LDF semantic payload access.
|
||||
- Chaos testing (`chaos` config) for resilience/failure-path validation.
|
||||
- Extension registration API (`extensions` plugin option).
|
||||
|
||||
**Not shipped yet:**
|
||||
|
||||
- Route-level `x-variants` schema extraction.
|
||||
|
||||
Use the shipped foundations today. Route-level `x-variants` is follow-up work.
|
||||
|
||||
### 1.2 Extension Registration
|
||||
|
||||
Register extensions via the plugin options:
|
||||
|
||||
```javascript
|
||||
await fastify.register(apophis, {
|
||||
extensions: [
|
||||
jwtExtension({ jwks: 'https://auth.example.com/.well-known/jwks.json' }),
|
||||
x509Extension(),
|
||||
spiffeExtension(),
|
||||
tokenHashExtension()
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
Extensions are loaded at plugin registration time and validated before routes are processed.
|
||||
|
||||
### 1.3 x-variants Status
|
||||
|
||||
Route-level `x-variants` schema extraction is **not shipped** yet. Use call-site `contract({ variants })` instead:
|
||||
|
||||
```javascript
|
||||
const suite = await fastify.apophis.contract({
|
||||
depth: 'quick',
|
||||
variants: [
|
||||
{ name: 'json', headers: { accept: 'application/json' } },
|
||||
{ name: 'ldf', headers: { accept: 'application/ld+json' } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 1.4 Protocol Packs Status
|
||||
|
||||
Built-in protocol pack presets are **shipped**. Reference them by name in `apophis.config.js`:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
packs: ['oauth21'],
|
||||
// User profiles and presets override pack defaults
|
||||
};
|
||||
```
|
||||
|
||||
Available packs:
|
||||
- `oauth21` — OAuth 2.1 authorization code flow with PKCE
|
||||
- `rfc8628-device-auth` — Device Authorization Grant
|
||||
- `rfc8693-token-exchange` — Token Exchange
|
||||
|
||||
Packs resolve during config loading and merge profiles/presets into the config. User config always takes precedence.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Principles
|
||||
|
||||
### 2.1 Extension Architecture
|
||||
All protocol extensions follow the v1.1 extension architecture:
|
||||
|
||||
```javascript
|
||||
await fastify.register(apophis, {
|
||||
extensions: [
|
||||
jwtExtension({ jwks: 'https://auth.example.com/.well-known/jwks.json' }),
|
||||
x509Extension(),
|
||||
spiffeExtension(),
|
||||
tokenHashExtension()
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 Configuration Per Route
|
||||
Routes may need different validation keys or extraction sources:
|
||||
|
||||
```javascript
|
||||
fastify.get('/wimse/wit', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-extension-config': {
|
||||
jwt: { verify: false, extractFrom: 'body' }
|
||||
},
|
||||
'x-ensures': [
|
||||
'jwt_claims(this).sub != null',
|
||||
'jwt_claims(this).cnf.jwk != null'
|
||||
]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 Test Data Seeding
|
||||
Stateful tests may need pre-existing resources:
|
||||
|
||||
```javascript
|
||||
await fastify.apophis.seed([
|
||||
{ method: 'POST', url: '/oauth/clients', body: { client_id: 'test-client' } },
|
||||
{ method: 'POST', url: '/wimse/wit', body: { workload: 'frontend' } }
|
||||
]);
|
||||
|
||||
const results = await fastify.apophis.stateful({ depth: 'standard' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. JWT Extension
|
||||
|
||||
### 3.1 Use Cases
|
||||
OAuth 2.1, Transaction Tokens, WIMSE S2S, SPIFFE JWT-SVID
|
||||
|
||||
### 3.2 Predicates
|
||||
|
||||
```apostl
|
||||
# Access JWT claims
|
||||
jwt_claims(this).sub # subject
|
||||
jwt_claims(this).aud # audience
|
||||
jwt_claims(this).iss # issuer
|
||||
jwt_claims(this).exp # expiration (numeric timestamp)
|
||||
jwt_claims(this).iat # issued at (numeric timestamp)
|
||||
jwt_claims(this).jti # JWT ID (for replay detection)
|
||||
jwt_claims(this).scope # scope
|
||||
jwt_claims(this).cnf.jwk # confirmation key (WIMSE)
|
||||
jwt_claims(this).txn # transaction token ID
|
||||
|
||||
# Access JWT header
|
||||
jwt_header(this).alg # algorithm
|
||||
jwt_header(this).kid # key ID
|
||||
jwt_header(this).typ # type
|
||||
|
||||
# Validation
|
||||
jwt_valid(this) # signature verifies against known key
|
||||
jwt_format(this) == "compact" # compact vs JSON serialization
|
||||
```
|
||||
|
||||
### 3.3 Configuration
|
||||
|
||||
```javascript
|
||||
jwtExtension({
|
||||
jwks: 'https://auth.example.com/.well-known/jwks.json',
|
||||
extractFrom: 'authorization',
|
||||
verify: true,
|
||||
})
|
||||
```
|
||||
|
||||
### 3.4 Extension State
|
||||
The JWT extension maintains state across a test run:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* JWT extension state across a test run.
|
||||
* @property {Set<string>} seenJtis - Track seen JTIs for replay detection
|
||||
* @property {Map<string, DecodedJwt>} decodedCache - Cached decoded JWTs
|
||||
*/
|
||||
const jwtExtensionState = {
|
||||
seenJtis: new Set(),
|
||||
decodedCache: new Map()
|
||||
};
|
||||
```
|
||||
|
||||
### 3.5 Example Contracts
|
||||
|
||||
```apostl
|
||||
# OAuth 2.1: Token response contains required claims
|
||||
if response_code(this) == 200 then jwt_claims(this).sub != null else T
|
||||
if response_code(this) == 200 then jwt_claims(this).exp > jwt_claims(this).iat else T
|
||||
|
||||
# WIMSE: WPT expiration must be short-lived
|
||||
if response_code(this) == 200 then jwt_claims(this).exp <= jwt_claims(this).iat + 30 else T
|
||||
|
||||
# Transaction Tokens: Token type must be transaction_token
|
||||
if response_code(this) == 200 then jwt_claims(this).txn != null else T
|
||||
```
|
||||
|
||||
### 3.6 Implementation Notes
|
||||
- Decode Base64URL without verification for claim inspection
|
||||
- Verify signatures using configured JWKS or key material
|
||||
- Support extracting JWT from multiple sources
|
||||
- Track `seen_jtis` for replay detection within a test run
|
||||
|
||||
---
|
||||
|
||||
## 4. Time Control Extension
|
||||
|
||||
### 4.1 Problem
|
||||
Many protocol behaviors depend on time:
|
||||
- Token expiration (JWT `exp` claim)
|
||||
- Refresh token rotation windows
|
||||
- WIMSE WPT short TTL (≤30 seconds)
|
||||
- Challenge TTLs
|
||||
|
||||
Current limitation: APOSTL has `response_time(this)` (wall clock duration) but no way to compare JWT timestamps to "now" or fast-forward time.
|
||||
|
||||
### 4.2 Predicates
|
||||
|
||||
```apostl
|
||||
# Compare JWT exp to current time (server time)
|
||||
jwt_claims(this).exp > now()
|
||||
jwt_claims(this).exp <= now() + 30
|
||||
|
||||
# Time since previous request
|
||||
response_time(this) <= 5000 # already exists
|
||||
elapsed_since_previous(this) <= 30 # new: seconds since last request in stateful test
|
||||
```
|
||||
|
||||
### 4.3 Server-Level Time Mocking
|
||||
|
||||
```javascript
|
||||
await fastify.register(apophis, {
|
||||
timeMock: true // enables apophis.time control
|
||||
});
|
||||
|
||||
// In tests or stateful sequences:
|
||||
await fastify.apophis.time.advance(30000); // +30 seconds
|
||||
await fastify.apophis.time.set('2026-04-25T12:00:00Z');
|
||||
```
|
||||
|
||||
### 4.4 Implementation
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Time control for deterministic testing.
|
||||
* @property {function(number): void} advance - Advance simulated time by milliseconds
|
||||
* @property {function(string): void} set - Set simulated time to specific ISO timestamp
|
||||
* @property {function(): number} now - Get current simulated time
|
||||
* @property {function(): void} reset - Reset to real time
|
||||
*/
|
||||
const timeControl = {
|
||||
advance(ms) { /* ... */ },
|
||||
set(isoString) { /* ... */ },
|
||||
now() { return Date.now(); },
|
||||
reset() { /* ... */ }
|
||||
};
|
||||
```
|
||||
|
||||
The `now()` predicate returns simulated time when time mocking is enabled, or the host wall clock outside deterministic test mode. Deterministic runs must inject or freeze time.
|
||||
|
||||
### 4.5 DST Testing Example
|
||||
|
||||
```apostl
|
||||
# Test that tokens issued before DST transition work after
|
||||
if previous(jwt_claims(this).iat).hour == 1 then jwt_valid(this) == true else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Stateful Cross-Request Predicates
|
||||
|
||||
### 5.1 Problem
|
||||
Protocols have multi-step flows where step N depends on step N-1:
|
||||
|
||||
1. **OAuth 2.1 refresh token rotation:** First refresh succeeds and returns NEW token. Second refresh with OLD token fails.
|
||||
2. **Transaction token single-use:** First consumption succeeds. Second consumption with same token fails.
|
||||
3. **WIMSE WPT replay:** First verification succeeds. Second verification with same jti fails.
|
||||
|
||||
Current limitation: `previous()` only compares values, not state transitions.
|
||||
|
||||
### 5.2 Predicates
|
||||
|
||||
```apostl
|
||||
# Check if token was seen in previous requests
|
||||
already_seen(this, jwt_claims(this).jti) == false
|
||||
|
||||
# Check if token was consumed
|
||||
is_consumed(this, jwt_claims(this).jti) == false
|
||||
|
||||
# Reference specific previous request by category
|
||||
previous(constructor).jwt_claims(this).refresh_token # last constructor's refresh token
|
||||
previous(mutator).jwt_claims(this).txn # last mutator's transaction token
|
||||
previous(observer).jwt_claims(this).jti # last observer's JWT ID
|
||||
```
|
||||
|
||||
### 5.3 Implementation
|
||||
|
||||
Extension state tracks tokens across requests:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Stateful extension state tracking tokens across requests.
|
||||
* @property {Set<string>} seenTokens - Tokens observed in previous requests
|
||||
* @property {Set<string>} consumedTokens - Tokens that have been consumed
|
||||
* @property {Map<string, EvalContext>} categoryHistory - category -> last context
|
||||
*/
|
||||
const statefulExtensionState = {
|
||||
seenTokens: new Set(),
|
||||
consumedTokens: new Set(),
|
||||
categoryHistory: new Map()
|
||||
};
|
||||
```
|
||||
|
||||
### 5.4 Example Contracts
|
||||
|
||||
```apostl
|
||||
# OAuth 2.1 refresh: new token must differ from old
|
||||
if response_code(this) == 200 then
|
||||
response_body(this).refresh_token != previous(request_body(this)).refresh_token
|
||||
else T
|
||||
|
||||
# Transaction token: single use
|
||||
if response_code(this) == 409 then
|
||||
response_body(this).error == "transaction_token_replay_detected" &&
|
||||
already_seen(this, jwt_claims(this).jti) == true
|
||||
else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. X.509 Extension
|
||||
|
||||
### 6.1 Use Cases
|
||||
SPIFFE X509-SVID, mTLS certificate validation
|
||||
|
||||
### 6.2 Predicates
|
||||
|
||||
```apostl
|
||||
# Certificate properties
|
||||
x509_uri_sans(this) # array of URI subject alternative names
|
||||
x509_uri_sans(this).length # count of URI SANs
|
||||
x509_ca(this) # is CA certificate? (boolean)
|
||||
x509_expired(this) # is expired? (boolean)
|
||||
x509_not_before(this) # notBefore timestamp
|
||||
x509_not_after(this) # notAfter timestamp
|
||||
|
||||
# Chain validation (lightweight)
|
||||
x509_self_signed(this) # is self-signed?
|
||||
x509_issuer(this) # issuer DN
|
||||
x509_subject(this) # subject DN
|
||||
```
|
||||
|
||||
### 6.3 Explicitly Out of Scope
|
||||
- `x509_chain_valid(this)` — APOPHIS does not implement RFC 5280 path validation. Applications may expose chain-validation results and test them as ordinary response behavior.
|
||||
|
||||
### 6.4 Example Contracts
|
||||
|
||||
```apostl
|
||||
# SPIFFE: X509-SVID must have exactly 1 URI SAN
|
||||
if response_code(this) == 200 then x509_uri_sans(this).length == 1 else T
|
||||
|
||||
# SPIFFE: X509-SVID leaf must not be CA
|
||||
if response_code(this) == 200 then x509_ca(this) == false else T
|
||||
|
||||
# SPIFFE: Certificate must not be expired
|
||||
if response_code(this) == 200 then x509_expired(this) == false else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SPIFFE Extension
|
||||
|
||||
### 7.1 Use Cases
|
||||
SPIFFE ID validation, trust domain checks
|
||||
|
||||
### 7.2 Predicates
|
||||
|
||||
```apostl
|
||||
# SPIFFE ID parsing
|
||||
spiffe_parse(this).trustDomain # trust domain string
|
||||
spiffe_parse(this).path # path segments (array)
|
||||
spiffe_parse(this).path.length # path depth
|
||||
spiffe_validate(this) # boolean: valid SPIFFE ID?
|
||||
|
||||
# Properties
|
||||
spiffe_id(this) # full SPIFFE ID string
|
||||
spiffe_trust_domain(this) # alias for spiffe_parse(this).trustDomain
|
||||
```
|
||||
|
||||
### 7.3 Example Contracts
|
||||
|
||||
```apostl
|
||||
# SPIFFE: Trust domain must be lowercase
|
||||
if response_code(this) == 200 then spiffe_parse(this).trustDomain matches "^[a-z0-9.-]+$" else T
|
||||
|
||||
# SPIFFE: Path must not be empty
|
||||
if response_code(this) == 200 then spiffe_parse(this).path.length > 0 else T
|
||||
|
||||
# SPIFFE: ID must be valid
|
||||
if response_code(this) == 200 then spiffe_validate(this) == true else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Token Hash Extension
|
||||
|
||||
### 8.1 Use Cases
|
||||
WIMSE S2S `ath` (access token hash), `tth` (transaction token hash), `oth` (other token hash)
|
||||
|
||||
### 8.2 Predicates
|
||||
|
||||
```apostl
|
||||
# Token hash validation
|
||||
ath_valid(this) # access token hash matches Authorization header
|
||||
tth_valid(this) # transaction token hash matches Txn-Token header
|
||||
oth_valid(this, "header-name") # custom token hash matches named header
|
||||
|
||||
# Raw hash computation
|
||||
token_hash(this, "sha256") # SHA-256 hash of token from context
|
||||
```
|
||||
|
||||
### 8.3 Example Contracts
|
||||
|
||||
```apostl
|
||||
# WIMSE: If ath claim present, must match access token
|
||||
if jwt_claims(this).ath != null then ath_valid(this) == true else T
|
||||
|
||||
# WIMSE: If tth claim present, must match transaction token
|
||||
if jwt_claims(this).tth != null then tth_valid(this) == true else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. HTTP Signature Extension
|
||||
|
||||
### 9.1 Use Cases
|
||||
WIMSE S2S detached HTTP signatures
|
||||
|
||||
### 9.2 Predicates
|
||||
|
||||
```apostl
|
||||
# Signature components
|
||||
signature_input(this) # Signature-Input header parsed
|
||||
signature(this) # Signature header value
|
||||
signature_valid(this) # signature verifies against key
|
||||
|
||||
# Coverage
|
||||
signature_covers(this, "@method") # covers HTTP method
|
||||
signature_covers(this, "@request-target") # covers request target
|
||||
signature_covers(this, "authorization") # covers auth header
|
||||
signature_covers(this, "txn-token") # covers txn-token header
|
||||
```
|
||||
|
||||
### 9.3 Example Contracts
|
||||
|
||||
```apostl
|
||||
# WIMSE: Signature must cover @method and @request-target
|
||||
if response_code(this) == 200 then signature_covers(this, "@method") == true else T
|
||||
if response_code(this) == 200 then signature_covers(this, "@request-target") == true else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Request Context Extension
|
||||
|
||||
### 10.1 Predicates
|
||||
|
||||
```apostl
|
||||
# URL components
|
||||
request_url(this) # full URL
|
||||
request_url(this).path # path only
|
||||
request_url(this).host # host header
|
||||
|
||||
# TLS info (when available)
|
||||
request_tls(this).cipher # TLS cipher suite
|
||||
request_tls(this).version # TLS version
|
||||
request_tls(this).client_cert # client certificate (if mTLS)
|
||||
|
||||
# Body hash (for content integrity)
|
||||
request_body_hash(this, "sha256") # SHA-256 of raw request body
|
||||
```
|
||||
|
||||
### 10.2 Example Contracts
|
||||
|
||||
```apostl
|
||||
# WIMSE audience validation: WPT aud claim must match request URL
|
||||
if response_code(this) == 200 then jwt_claims(this).aud == request_url(this) else T
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Priority Matrix
|
||||
|
||||
| Feature | Impact | Effort | Priority | Protocols Needing It |
|
||||
|---------|--------|--------|----------|---------------------|
|
||||
| JWT extension (claims + validation) | Very High | Medium | **P0** | OAuth 2.1, WIMSE, Txn Tokens, SPIFFE |
|
||||
| Time control (`now()`, `advance()`) | Very High | Medium | **P0** | OAuth 2.1, WIMSE, Txn Tokens, CIBA |
|
||||
| Stateful predicates (`previous()`, `already_seen()`) | High | Medium | **P1** | OAuth 2.1, Txn Tokens, WIMSE |
|
||||
| X.509 extension (basic properties) | High | Low | **P1** | SPIFFE, WIMSE |
|
||||
| SPIFFE extension | Medium | Low | **P2** | SPIFFE |
|
||||
| Token hash extension | Medium | Low | **P2** | WIMSE |
|
||||
| HTTP signature extension | Medium | Medium | **P2** | WIMSE |
|
||||
| Request context (`request_url()`) | Medium | Low | **P2** | WIMSE |
|
||||
| Parallel execution | Low | High | **P3** | — |
|
||||
|
||||
---
|
||||
|
||||
## 12. Protocol Test Inventory
|
||||
|
||||
| Protocol | Test File | Behaviors | Needs Extensions |
|
||||
|----------|-----------|-----------|------------------|
|
||||
| OAuth 2.1 | `oauth21-profile-conformance.test.js` | 13 | JWT, time control |
|
||||
| WIMSE S2S | `draft-wimse-s2s-protocol-conformance.test.js` | 31 | JWT, token hash, HTTP sig, X.509 |
|
||||
| Transaction Tokens | `draft-oauth-transaction-tokens-conformance.test.js` | 25 | JWT, time control, stateful |
|
||||
| SPIFFE/SPIRE | `spiffe-spire-conformance.test.js` | 24 | SPIFFE, X.509, JWT |
|
||||
| Token Exchange | `rfc8693-token-exchange-conformance.test.js` | 15 | JWT |
|
||||
| Device Auth | `rfc8628-device-authorization-conformance.test.js` | 12 | JWT |
|
||||
| CIBA | `ciba-conformance.test.js` | 18 | JWT, time control |
|
||||
|
||||
**Total: 138 protocol behaviors across 7 specifications.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Out of Scope
|
||||
|
||||
We acknowledge these are too complex or inappropriate for Apophis:
|
||||
|
||||
| Feature | Why Out of Scope |
|
||||
|---------|-----------------|
|
||||
| Replay detection across restarts | Cross-run replay detection requires application-owned persistent state. |
|
||||
| Full X.509 chain validation | Requires trust store, CRL/OCSP, and policy validation. Applications may expose the result for APOPHIS to check. |
|
||||
| Cryptographic algorithm implementation | Apophis should not implement crypto. It should verify signatures using existing libraries. |
|
||||
| Protocol state machines | Full state-machine extraction is still out of scope at route-schema level, but protocol flows are supported through `fastify.apophis.scenario(...)` and can be combined with `contract({ variants })` and APOSTL formulas. |
|
||||
| Network-level testing | TCP behavior, packet inspection, MTU issues. Out of scope for HTTP contract testing. |
|
||||
| Parallel execution for race detection | Can be tested with separate load testing tools. Not essential for contract testing. |
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation Plan
|
||||
|
||||
### Phase 1: JWT + Time Control (P0) — Shipped in v2.0.0
|
||||
**Status**: Complete
|
||||
**Files**:
|
||||
- `src/extensions/jwt.ts` — JWT extension implementation
|
||||
- `src/extensions/time.ts` — Time control extension
|
||||
- `src/extensions/stateful.ts` — Stateful predicates extension
|
||||
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
|
||||
- `src/test/cli/protocol-conformance-p2.test.ts` — Protocol conformance tests
|
||||
|
||||
**Tests**:
|
||||
- Decode Base64URL claims without verification
|
||||
- Verify signatures against JWKS
|
||||
- Extract from multiple sources (header, body, query)
|
||||
- `seen_jtis` replay detection
|
||||
- `now()` predicate with mocked time
|
||||
- `apophis.time.advance()` in stateful tests
|
||||
|
||||
### Phase 2: X.509 + SPIFFE (P1) — Shipped in v2.0.0
|
||||
**Status**: Complete
|
||||
**Files**:
|
||||
- `src/extensions/x509.ts` — X.509 extension
|
||||
- `src/extensions/spiffe.ts` — SPIFFE extension
|
||||
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
|
||||
|
||||
### Phase 3: Token Hash + HTTP Signature (P2) — Shipped in v2.0.0
|
||||
**Status**: Complete
|
||||
**Files**:
|
||||
- `src/extensions/token-hash.ts` — Token hash extension
|
||||
- `src/extensions/http-signature.ts` — HTTP signature extension
|
||||
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
|
||||
|
||||
### Phase 4: Request Context (P2) — Shipped in v2.0.0
|
||||
**Status**: Complete
|
||||
**Files**:
|
||||
- `src/extensions/request-context.ts` — Request context predicates
|
||||
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
|
||||
|
||||
---
|
||||
|
||||
## 15. References
|
||||
|
||||
### Codebase Citations
|
||||
- **Extension architecture**: `docs/extensions/EXTENSION-ARCHITECTURE.md`
|
||||
- **Extension types**: `src/extension/types.ts`
|
||||
- **Extension registry**: `src/extension/registry.ts`
|
||||
- **Formula parser**: `src/formula/parser.ts`
|
||||
- **Test runner**: `src/test/petit-runner.ts`
|
||||
|
||||
### External References
|
||||
- JWT RFC 7519: https://tools.ietf.org/html/rfc7519
|
||||
- WIMSE S2S: https://datatracker.ietf.org/doc/draft-ietf-wimse-s2s-protocol/
|
||||
- Transaction Tokens (RFC 8693): https://tools.ietf.org/html/rfc8693
|
||||
- SPIFFE/SPIRE: https://spiffe.io/
|
||||
- OAuth 2.1: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Author: APOPHIS Architecture Team*
|
||||
*Date: 2026-04-25*
|
||||
*Source Feedback: docs/attic/root-history/FEEDBACK-protocol-extensions-wishlist.md*
|
||||
Reference in New Issue
Block a user