chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
+873
View File
@@ -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*
+549
View File
@@ -0,0 +1,549 @@
# APOPHIS v1.1 Architecture — Hybrid Core + Extensions
## Status: Architecture Specification
## Date: 2026-04-24
## Scope: v1.1 First-Class Features & Extension Ecosystem
---
## 1. Philosophy: Core HTTP vs Extensions
**First-class**: Standard HTTP features that require deep integration with APOPHIS core:
- Schema-to-arbitrary integration (teaching fast-check to generate custom data)
- Request builder integration (constructing specialized payloads)
- HTTP executor integration (handling specialized responses)
- APOSTL parser/evaluator integration (new operations)
**Extensions**: Specialized protocols or features with heavy dependencies that should be opt-in:
- Different protocols (WebSockets, not HTTP)
- Heavy dependencies (Protobuf, MessagePack)
- Protocol-specific features such as SSE
**This split keeps common HTTP testing in core while moving specialized protocols out of the default path.**
---
## 2. First-Class Features (v1.1 Core)
### 2.1 Multipart File Uploads
**Module**: Core — `src/infrastructure/multipart.ts`, `src/domain/multipart-generator.ts`
**Schema Annotations**:
```typescript
schema: {
body: {
type: 'object',
'x-content-type': 'multipart/form-data',
'x-multipart-fields': {
description: { type: 'string', maxLength: 500 }
},
'x-multipart-files': {
avatar: {
maxSize: 5 * 1024 * 1024,
mimeTypes: ['image/jpeg', 'image/png'],
maxCount: 1
}
}
}
}
```
**APOSTL Operations**:
```typescript
// request_files(this).avatar.count == 1
// request_files(this).avatar.size <= 5242880
// request_files(this).avatar.mimetype matches "image/(jpeg|png)"
// request_fields(this).description != null
```
**Core Integration Points**:
1. **Schema-to-arbitrary**: Detect `x-content-type: multipart/form-data`, generate `{ fields: {...}, files: [...] }`
2. **Request builder**: Convert generated data to `multipart` payload on `RequestStructure`
3. **HTTP executor**: Build `FormData` from `request.multipart`, inject via Fastify
4. **Parser**: Add `request_files`, `request_fields` to `VALID_HEADERS`
5. **Evaluator**: Add multipart operations to `resolveOperation`
### 2.2 Streaming / NDJSON
**Module**: Core — `src/infrastructure/stream-collector.ts`
**Schema Annotations**:
```typescript
schema: {
response: {
200: {
type: 'object',
'x-streaming': true,
'x-stream-format': 'ndjson',
'x-stream-max-chunks': 100,
'x-stream-timeout': 5000
}
}
}
```
**APOSTL Operations**:
```typescript
// response_body(this) — array of parsed chunks
// stream_chunks(this) — alias for response_body(this)
// stream_duration(this) — total stream time in ms
```
**Core Integration Points**:
1. **Contract extraction**: Extract `x-streaming`, `x-stream-format`, `x-stream-max-chunks`, `x-stream-timeout`
2. **HTTP executor**: After inject, check if route has streaming config. If so:
- Read response payload as string
- Split by `\n`
- `JSON.parse` each line (for NDJSON)
- Respect `maxChunks` and `timeoutMs`
- Store result in `EvalContext.response.body` and `EvalContext.response.chunks`
3. **Parser**: Add `stream_chunks`, `stream_duration` to `VALID_HEADERS`
4. **Evaluator**: Add streaming operations to `resolveOperation`
---
## 3. Extension System (v1.1+ Ecosystem)
The extension system handles features that don't require core HTTP integration.
### 3.1 Extension Interface
```typescript
export interface ApophisExtension {
/** Unique name. Used for state isolation and error attribution. */
name: string
/** APOSTL headers this extension adds. Used for parser validation. */
headers?: string[]
/** APOSTL predicates exposed by this extension. */
predicates?: Record<string, PredicateResolver>
/** Lifecycle hooks. */
hooks?: {
onBuildRequest?: Hook<RequestBuildContext, void>
onBeforeRequest?: Hook<ExecutionContext, void>
onAfterRequest?: Hook<ExecutionContext, void>
onSuiteStart?: Hook<{ routes: RouteContract[] }, void>
onSuiteEnd?: Hook<{ summary: TestSummary }, void>
onViolation?: Hook<{ violation: ContractViolation }, void>
}
/** Severity: 'fatal' (block test), 'warn' (log, don't block). Default: 'fatal'. */
severity?: 'fatal' | 'warn'
/** Redaction: fields to mask in violation output. */
redactFields?: string[]
/** Initial state for this extension. Passed to hooks/predicates. */
state?: Record<string, unknown>
}
```
### 3.2 Extension Registration
```typescript
await fastify.register(apophis, {
extensions: [
sseExtension,
createSerializerExtension(mySerializerRegistry),
websocketExtension,
]
})
```
### 3.3 Extensions Available
#### SSE Extension
**Module**: `src/extensions/sse/`
```typescript
export const sseExtension: ApophisExtension = {
name: 'sse',
headers: ['sse_events'],
predicates: {
sse_events: (ctx) => {
const events = ctx.evalContext.response.sseEvents ?? []
if (ctx.accessor.length === 0) return { value: events, success: true }
const idx = parseInt(ctx.accessor[0], 10)
const event = events[idx]
if (!event) return { value: null, success: true }
if (ctx.accessor[1] === 'event') return { value: event.event, success: true }
if (ctx.accessor[1] === 'data') return { value: event.data, success: true }
if (ctx.accessor[1] === 'id') return { value: event.id, success: true }
if (ctx.accessor[1] === 'retry') return { value: event.retry, success: true }
return { value: event, success: true }
}
}
}
```
#### Serializers Extension
**Module**: `src/extensions/serializers/`
```typescript
export interface Serializer {
readonly name: string
encode(data: unknown): Buffer
decode(buffer: Buffer): unknown
}
export interface SerializerRegistry {
get(name: string): Serializer | undefined
register(name: string, serializer: Serializer): void
}
export const createSerializerExtension = (registry: SerializerRegistry): ApophisExtension => ({
name: 'serializers',
hooks: {
onBuildRequest: async (ctx) => {
const serializerName = ctx.route.serializer?.name
if (!serializerName) return
const serializer = registry.get(serializerName)
if (!serializer) return
// Modify request: encode body, set content-type
ctx.request.body = serializer.encode(ctx.request.body)
ctx.request.headers = {
...ctx.request.headers,
'content-type': `application/x-${serializerName}`,
}
},
onAfterRequest: async (ctx) => {
const serializerName = ctx.route.serializer?.name
if (!serializerName) return
const serializer = registry.get(serializerName)
if (!serializer) return
// Modify response: decode body
const rawBody = Buffer.from(JSON.stringify(ctx.evalContext.response.body))
ctx.evalContext.response.body = serializer.decode(rawBody)
}
}
})
```
#### WebSockets Extension
**Module**: `src/extensions/websocket/`
**Note**: WebSockets are fundamentally different from HTTP. They require a dedicated runner, not just hooks.
```typescript
export const websocketExtension: ApophisExtension = {
name: 'websocket',
headers: ['ws_message', 'ws_state'],
predicates: {
ws_message: (ctx) => {
const msg = ctx.evalContext.ws?.message ?? null
if (ctx.accessor.length === 0) return { value: msg, success: true }
if (!msg) return { value: null, success: true }
if (ctx.accessor[0] === 'type') return { value: msg.type, success: true }
if (ctx.accessor[0] === 'payload') return { value: msg.payload, success: true }
if (ctx.accessor[0] === 'direction') return { value: msg.direction, success: true }
return { value: msg, success: true }
},
ws_state: (ctx) => {
return { value: ctx.evalContext.ws?.state ?? null, success: true }
}
},
hooks: {
onSuiteStart: async ({ routes }) => {
// Pre-validate all WS contracts
const wsRoutes = routes.filter(r => r.ws !== undefined)
for (const route of wsRoutes) {
validateWebSocketContract(route.ws!)
}
}
}
}
```
**WebSocket runner**: Invoked by plugin separately from HTTP runners:
```typescript
// In plugin/index.ts
const buildContract = (fastify, scope) => async (opts) => {
const httpSuite = await runPetitTests(fastify, opts, scope)
const wsSuite = await runWebSocketTests(fastify, opts, scope) // From extension
return mergeSuites(httpSuite, wsSuite)
}
```
---
## 4. Core Changes (Phase 1)
### 4.1 Parser Extensibility
**Current**: `VALID_HEADERS` is hardcoded. Extensions can't add headers.
**Solution**: Extensions register headers. Parser validates against registered + core headers.
```typescript
// src/formula/parser.ts
const CORE_HEADERS: OperationHeader[] = [
'request_body', 'response_body', 'response_code',
'request_headers', 'response_headers', 'query_params', 'cookies', 'response_time',
'redirect_count', 'redirect_url', 'redirect_status',
'timeout_occurred', 'timeout_value',
// v1.1 first-class
'request_files', 'request_fields', 'stream_chunks', 'stream_duration',
]
// ExtensionRegistry provides additional headers
function getValidHeaders(registry?: ExtensionRegistry): string[] {
const extensionHeaders = registry
? registry.extensions.flatMap(e => e.headers ?? [])
: []
return [...CORE_HEADERS, ...extensionHeaders]
}
// In parseOperation, validate against getValidHeaders()
```
### 4.2 Evaluator Extensibility
**Current**: `resolveOperation` checks core operations only.
**Solution**: Check extension predicates BEFORE core operations.
```typescript
function resolveOperation(node, ctx, extensionRegistry, route) {
const { header, accessor } = node
// 1. Check extension predicates FIRST
if (extensionRegistry) {
const resolver = extensionRegistry.resolvePredicate(header)
if (resolver) {
const ownerName = extensionRegistry.getPredicateOwner(header)
const extState = ownerName ? (extensionRegistry.getState(ownerName) ?? {}) : {}
const result = resolver({ route, evalContext: ctx, accessor: accessor ?? [], extensionState: extState })
if (result && typeof result.then !== 'function') {
return (result as PredicateResult).value
}
}
}
// 2. Fall back to core operations
switch (header) {
// ... core cases ...
}
}
```
### 4.3 HTTP Executor Hooks
**Current**: `executeHttp` is a monolithic function.
**Solution**: Add `onTransformResponse` hook point for extensions that need to modify responses.
```typescript
export interface ResponseTransformContext {
responseBody: unknown
evalContext: EvalContext
route: RouteContract
}
export type ResponseTransformHook = (ctx: ResponseTransformContext) => EvalContext | Promise<EvalContext>
// In executeHttp:
let ctx = buildEvalContext(request, response, route)
// Apply extension response transforms
for (const ext of (extensionRegistry?.extensions ?? [])) {
if (ext.hooks?.onAfterRequest) {
await ext.hooks.onAfterRequest({
route,
request,
evalContext: ctx,
extensionState: extensionRegistry?.getState(ext.name) ?? {},
})
}
}
```
---
## 5. Implementation Order
### Phase 1: Core Extension Points (1-2 days)
1. Make parser accept registered headers (CORE_HEADERS + extension headers)
2. Make evaluator check extension predicates before core operations
3. Add response transform hook point to HTTP executor
4. **Test**: Core operations still work; extension predicates resolve
### Phase 2A: Multipart (First-Class, 2-3 days)
1. Add `MultipartFile`, `MultipartPayload` types
2. Add multipart schema-to-arbitrary handler
3. Add multipart request builder support
4. Add multipart HTTP executor support (FormData construction)
5. Add `request_files`, `request_fields` to parser/evaluator
6. Extract multipart config from schema in contract.ts
7. **Test**: `src/test/multipart.test.ts` (10+ tests)
### Phase 2B: Streaming (First-Class, 2-3 days)
1. Add `chunks`, `streamDurationMs` to `EvalContext.response`
2. Add streaming config extraction from schema
3. Add stream collection to HTTP executor (NDJSON parsing)
4. Add `stream_chunks`, `stream_duration` to parser/evaluator
5. **Test**: `src/test/streaming.test.ts` (8+ tests)
### Phase 2C: Extension System Polish (1 day)
1. Document extension registration API
2. Add `extensions: ApophisExtension[]` to `ApophisOptions`
3. Wire extension headers into parser
4. Wire extension predicates into evaluator
### Phase 3: Extensions (Parallel, after Phase 2C)
- **SSE Extension** (2-3 days)
- **Serializers Extension** (2-3 days)
- **WebSockets Extension** (1-2 weeks)
### Phase 4: Integration (2-3 days)
1. Run full test suite
2. Update README
3. Verify benchmarks
---
## 6. File Layout
```
src/
# Core v1.1 First-Class Features
infrastructure/
http-executor.ts # ADD: multipart FormData, stream collection
multipart.ts # NEW: FormData construction
stream-collector.ts # NEW: NDJSON chunk parsing
domain/
schema-to-arbitrary.ts # ADD: multipart schema handler
request-builder.ts # ADD: multipart payload construction
contract.ts # ADD: multipart/streaming config extraction
formula/
parser.ts # MODIFY: extensible VALID_HEADERS
evaluator.ts # MODIFY: extension predicate check
types.ts # ADD: MultipartFile, MultipartPayload, stream fields
# Extension System
extension/
types.ts # ADD: headers, onTransformResponse to interface
registry.ts # ADD: collect extension headers
# Extensions (opt-in)
extensions/
sse/ # SSE extension module
serializers/ # Serializer extension module
websocket/ # WebSocket extension module
```
---
## 7. Test Strategy
### First-Class Features: Red-Green-Refactor
```typescript
// Example: Multipart
// 1. Test: Parser accepts request_files(this).avatar.size
// 2. Implement: Add request_files to VALID_HEADERS
// 3. Test: Evaluator resolves request_files
// 4. Implement: Add multipart operations to resolveOperation
// 5. Test: Schema-to-arbitrary generates fake files
// 6. Implement: Add multipart handler to convertSchemaInternal
// 7. Test: Request builder constructs multipart payload
// 8. Implement: Add multipart support to buildRequest
// 9. Test: HTTP executor sends multipart request
// 10. Implement: Build FormData in executeHttp
// 11. Test: Integration — upload route works end-to-end
// 12. Implement: Full flow
```
### Extensions: Self-Contained Tests
Each extension module has its own `test.ts`:
```typescript
// src/extensions/sse/test.ts
import { test } from 'node:test'
import assert from 'node:assert'
import { sseExtension } from './extension.js'
test('sse: predicate returns events', () => {
const resolver = sseExtension.predicates!.sse_events
const result = resolver({
route: mockRoute,
evalContext: { response: { sseEvents: [{ event: 'update', data: {} }] } },
accessor: [],
extensionState: {},
})
assert.strictEqual((result.value as any[]).length, 1)
})
```
---
## 8. Backward Compatibility
All v1.1 changes are additive:
- Routes without multipart/streaming annotations work unchanged
- Extensions are opt-in via `extensions: [...]` option
- Existing APOSTL formulas work unchanged
- No breaking changes to public API
**Migration path**:
```typescript
// v1.0
await fastify.register(apophis)
// v1.1 (no changes required for existing code)
await fastify.register(apophis)
// v1.1 with extensions
await fastify.register(apophis, {
extensions: [sseExtension, serializerExtension, websocketExtension]
})
```
---
## 9. Risk Assessment
| Risk | Mitigation |
|------|-----------|
| Parser changes break existing formulas | Comprehensive regression tests before parser modification |
| Multipart adds heavy deps | Only use native FormData/Blob (no external deps) |
| Streaming tests are flaky | Mock streams for unit tests; integration tests with deterministic timeouts |
| Extension conflicts | Namespacing by extension name; `ExtensionRegistry.getState(name)` isolates state |
| WebSocket extension too large | Split into sub-workstreams: client, runner, stateful, validation |
---
## 10. Success Criteria
| Criterion | Verification |
|-----------|-------------|
| Multipart upload routes tested | `multipart.test.ts` passes |
| Streaming routes tested | `streaming.test.ts` passes |
| Extension predicates work | Extension `test.ts` files pass |
| No regression | Full source and CLI test suites pass |
| Benchmark targets met | `benchmark.test.ts` passes |
| Documentation updated | README covers multipart and streaming |
---
## 11. Quick Reference: First-Class vs Extension
| Feature | Type | Core Files | Tests | Effort |
|---------|------|-----------|-------|--------|
| **Multipart** | First-class | `multipart.ts`, `schema-to-arbitrary.ts`, `request-builder.ts`, `http-executor.ts`, `parser.ts`, `evaluator.ts` | `multipart.test.ts` | 2-3 days |
| **Streaming** | First-class | `stream-collector.ts`, `http-executor.ts`, `parser.ts`, `evaluator.ts`, `contract.ts` | `streaming.test.ts` | 2-3 days |
| **SSE** | Extension | `src/extensions/sse/*` | `src/extensions/sse/test.ts` | 2-3 days |
| **Serializers** | Extension | `src/extensions/serializers/*` | `src/extensions/serializers/test.ts` | 2-3 days |
| **WebSockets** | Extension | `src/extensions/websocket/*` | `src/extensions/websocket/test.ts` | 1-2 weeks |
+400
View File
@@ -0,0 +1,400 @@
# APOPHIS v2.x — Extension Plugin System Specification
## 1. Overview
APOPHIS supports a **first-class extension plugin system** that enables developers to:
1. **Define custom APOSTL predicates** — Graph traversal, partial graph checks, domain-specific assertions
2. **Hook into request building** — Inject headers, certificates, tokens, or modify request structure
3. **Hook into execution lifecycle** — Preflight checks, budget validation, finalize/rollback
4. **Hook into test suite lifecycle** — Setup, teardown, state management
5. **Maintain isolated state** — Per-extension state that persists across the test run
This replaces the previous annotation-based approach (`x-auth`, `x-scopes`) with a programmatic API that has explicit lifecycle hooks and per-extension state.
---
## 2. Why Extensions?
**Problem**: Arbiter's authorization system is fundamentally incompatible with flat scope arrays:
- Arbiter uses **graph-based authorization** with relation traversal
- Supports **partial graphs** merged from JWT tokens
- Has a **7-layer gate order**: transport → scope/boundary → authz → challenge → resource preflight → execute → finalize
- Auth is declared via `preHandler` composition, not schema annotations
**Solution**: Instead of baking Arbiter-specific code into APOPHIS core, provide a **generic extension API** that Arbiter (and any other system) can use to express its auth model naturally.
---
## 3. Extension API
### 3.1 Extension Interface
```typescript
interface ApophisExtension {
/** Unique extension name (used for logging and state isolation) */
readonly name: string
/** APOSTL operation headers this extension adds */
readonly headers?: readonly string[]
/** Custom APOSTL predicates */
readonly predicates?: Record<string, PredicateResolver>
/** Hook: Modify request before execution */
readonly onBuildRequest?: (context: RequestBuildContext) =>
RequestStructure | Promise<RequestStructure | undefined> | undefined
/** Hook: Called before each request execution */
readonly onBeforeRequest?: (context: ExecutionContext) => Promise<void>
/** Hook: Called after each request execution */
readonly onAfterRequest?: (context: ExecutionContext) => Promise<void>
/** Hook: Initialize extension state before test suite runs */
readonly onSuiteStart?: (config: TestConfig) =>
Promise<Record<string, unknown> | undefined> | Record<string, unknown> | undefined
/** Hook: Cleanup after test suite completes */
readonly onSuiteEnd?: (suite: TestSuite, extensionState: Record<string, unknown>) => Promise<void>
/** Hook: Called when a contract violation is detected */
readonly onViolation?: (violation: ContractViolation, extensionState: Record<string, unknown>) => Promise<void>
}
```
### 3.2 Predicate Resolver
```typescript
interface PredicateContext {
readonly route: RouteContract
readonly evalContext: EvalContext
readonly accessor: string[]
readonly extensionState: Record<string, unknown>
}
interface PredicateResult {
readonly value: unknown
readonly success: boolean
readonly error?: string
}
type PredicateResolver = (context: PredicateContext) =>
PredicateResult | Promise<PredicateResult>
```
---
## 4. Example: Arbiter Extension
```typescript
import type { ApophisExtension, PredicateContext } from 'apophis-fastify'
import { createArbiter } from 'arbiter-sdk'
const arbiterExtension: ApophisExtension = {
name: 'arbiter',
// Initialize Arbiter SDK and load configuration
onSuiteStart: async (config) => {
const arbiter = createArbiter({
apiKey: process.env.ARBITER_API_KEY,
tenantId: process.env.ARBITER_TENANT_ID,
applicationId: process.env.ARBITER_APPLICATION_ID,
})
const graphStore = await arbiter.client.getGraphStore('tenantExternal')
return {
arbiter,
graphStore,
tenantId: process.env.ARBITER_TENANT_ID,
applicationId: process.env.ARBITER_APPLICATION_ID,
}
},
// Inject S2S headers into every request
onBuildRequest: (ctx) => {
const state = ctx.extensionState as {
tenantId: string
applicationId: string
arbiter: ReturnType<typeof createArbiter>
}
return {
...ctx.request,
headers: {
...ctx.request.headers,
'x-tenant-id': state.tenantId,
'x-application-id': state.applicationId,
...(ctx.request.headers['authorization']
? { 'x-s2s-token': ctx.request.headers['authorization'] }
: {}),
},
}
},
// Define graph-based authorization predicates
predicates: {
// APOSTL: graph_check(this).user.can_manage_system
graph_check: (ctx: PredicateContext) => {
const state = ctx.extensionState as { graphStore: any }
const userKey = ctx.evalContext.request.headers['x-user-key']
const relation = ctx.accessor[0] // e.g., 'can_manage_system'
const objectKey = ctx.accessor[1] || 'resource:default'
if (!state.graphStore || !relation) {
return { value: false, success: true }
}
const result = state.graphStore.check(
String(userKey),
relation,
objectKey,
{
partialGraph: ctx.evalContext.request.headers['x-partial-graph']
? JSON.parse(ctx.evalContext.request.headers['x-partial-graph'])
: undefined,
}
)
return {
value: result.allowed === true || result.possibility === 1,
success: true,
}
},
// APOSTL: partial_graph(this).tenant.accessible
partial_graph: (ctx: PredicateContext) => {
const partialGraph = ctx.extensionState.partialGraph as Record<string, unknown> | undefined
const path = ctx.accessor.join('.')
let current: unknown = partialGraph
for (const part of path.split('.')) {
if (current && typeof current === 'object') {
current = (current as Record<string, unknown>)[part]
} else {
current = undefined
break
}
}
return { value: current, success: true }
},
// APOSTL: budget_check(this).operation.credits >= 100
budget_check: async (ctx: PredicateContext) => {
const state = ctx.extensionState as { arbiter: ReturnType<typeof createArbiter> }
const operation = ctx.accessor[0]
const estimatedCost = Number(ctx.accessor[1]) || 1
const budget = await state.arbiter.budget(`op_${operation}`, {
lowerBound: estimatedCost,
upperBound: Math.ceil(estimatedCost * 1.2),
})
return {
value: budget.allowed,
success: true,
}
},
},
// Simulate preflight checks
onBeforeRequest: async (ctx) => {
const state = ctx.extensionState as { arbiter: ReturnType<typeof createArbiter> }
// Create preflight record for metered operations
if (ctx.route.category === 'constructor' || ctx.route.category === 'mutator') {
const preflight = await state.arbiter.preflight({
authorize: {
expression: `can_manage_tenant_accounts(:user)`,
},
budget: {
ref: `op_${ctx.route.method}_${ctx.route.path}`,
estimates: { lowerBound: 1, upperBound: 10 },
},
})
// Store preflight ID in extension state for finalize/rollback
state.preflightId = preflight.preflightId
}
},
// Simulate finalize/rollback
onAfterRequest: async (ctx) => {
const state = ctx.extensionState as {
arbiter: ReturnType<typeof createArbiter>
preflightId?: string
}
if (state.preflightId) {
if (ctx.evalContext.response.statusCode < 400) {
// Success: finalize
await state.arbiter.finalize({
preflight_id: state.preflightId,
summary: {
operation: `${ctx.route.method} ${ctx.route.path}`,
statusCode: ctx.evalContext.response.statusCode,
},
})
} else {
// Failure: rollback
await state.arbiter.rollback({
preflight_id: state.preflightId,
cause: `HTTP ${ctx.evalContext.response.statusCode}`,
})
}
delete state.preflightId
}
},
// Cleanup on suite end
onSuiteEnd: async (suite, state) => {
console.log(`Arbiter extension: ${suite.summary.passed} passed, ${suite.summary.failed} failed`)
},
}
```
---
## 5. Registration
```typescript
import fastify from 'fastify'
import apophis from 'apophis-fastify'
import { arbiterExtension } from './arbiter-extension.js'
const app = fastify()
await app.register(apophis, {
extensions: [arbiterExtension],
})
// Routes are defined normally (no schema annotations for auth)
app.get('/users/:id', {
schema: {
response: {
200: {
type: 'object',
properties: { id: { type: 'string' } },
'x-ensures': [
// Standard APOSTL + extension predicates
'status:200',
'graph_check(this).user.can_read_user == true',
'partial_graph(this).tenant.accessible == true',
],
},
},
},
}, async (req, reply) => {
// Auth is handled by Arbiter preHandlers (not shown)
return { id: req.params.id }
})
// Run tests with Arbiter extension active
const suite = await app.apophis.contract({ depth: 'standard' })
```
---
## 6. Extension Lifecycle
```
onSuiteStart(config)
→ [for each test command]
→ onBuildRequest(ctx)
→ onBeforeRequest(ctx)
→ [execute HTTP request]
→ onAfterRequest(ctx)
→ [validate postconditions with extension predicates]
→ onSuiteEnd(suite)
```
**State Management**:
- Each extension has isolated state keyed by `extension.name`
- State is set by `onSuiteStart` return value
- State is accessible in all hooks via `ctx.extensionState`
- State persists across the entire test suite
---
## 7. Predicate Resolution
When evaluating APOSTL expressions, the evaluator checks extension predicates **before** standard operations:
```
Expression: graph_check(this).user.can_manage_system
1. Parse: { type: 'operation', header: 'graph_check', accessor: ['user', 'can_manage_system'] }
2. Check extension predicates: 'graph_check' found in arbiter extension
3. Call resolver({ route, evalContext, accessor: ['user', 'can_manage_system'], extensionState })
4. Return resolver result
```
**Important**: Extensions must not override core operation names unless an explicit override policy is enabled.
---
## 8. Composability
Multiple extensions can be registered and their hooks are called in order:
```typescript
await app.register(apophis, {
extensions: [
loggingExtension, // Logs all requests
arbiterExtension, // Auth + accounting
metricsExtension, // Collects timing metrics
],
})
```
**Hook calling semantics**:
- `onBuildRequest`: Sequential, each extension can modify the request
- `onBeforeRequest` / `onAfterRequest`: Sequential in registration order when hooks can mutate extension state; parallel only for hooks declared side-effect-free
- `onSuiteStart`: Sequential, state is set per-extension
- `onSuiteEnd`: Parallel
---
## 9. Error Handling
**Hook failure handling follows extension severity**:
- `fatal` failures block execution
- `warn` failures record diagnostics and continue
- `onBuildRequest` failures propagate because they prevent request construction
- Predicate resolver failures throw and are caught by the formula evaluator
**Best practices**:
- Validate inputs in predicates and return `{ value: false, success: true }` for graceful failure
- Use `try/catch` in async hooks to prevent unhandled rejections
- Log extension errors with the extension name for debugging
---
## 10. Backward Compatibility
- Extensions are **opt-in** — existing APOPHIS v2.x code works unchanged
- No schema annotations required for extensions
- Standard APOSTL expressions work without any extensions registered
- The `evaluate()` function still works for expressions without extension predicates
---
## 11. File Paths
| File | Purpose |
|------|---------|
| `src/extension/types.ts` | Extension interfaces and context types |
| `src/extension/registry.ts` | ExtensionRegistry implementation |
| `src/test/extension.test.ts` | Extension system tests |
| `src/formula/evaluator.ts` | APOSTL evaluator with extension predicate resolution |
| `src/domain/contract-validation.ts` | Passes extension registry to evaluator |
| `src/test/petit-runner.ts` | Calls extension hooks |
| `src/plugin/index.ts` | Creates and passes ExtensionRegistry |
---
*End of Extension Plugin System Specification*
File diff suppressed because it is too large Load Diff
+758
View File
@@ -0,0 +1,758 @@
# Extension Quick Reference — Hybrid Architecture
## Overview
APOPHIS v2.x uses a **hybrid architecture**:
- **First-class features**: Standard HTTP capabilities built into core (multipart, streaming, timeouts, redirects)
- **Extensions**: Specialized protocols via the extension system (SSE, serializers, WebSockets, JWT, X.509, SPIFFE, etc.)
Extensions integrate with APOSTL by registering custom predicates and operation headers that can be used in contract formulas.
**When to implement first-class vs extension**:
- **First-class**: Required by common HTTP request/response execution, schema-to-arbitrary integration, or request builder changes
- **Extension**: Protocol-specific, dependency-heavy, or uncommon in the default HTTP path
---
## New in v2.2
### Route Targeting
Test only specific routes instead of all discovered routes:
```typescript
await fastify.apophis.contract({
depth: 'quick',
routes: ['GET /health', 'POST /billing/plans']
})
```
### Chaos Configuration
Per-route chaos with include/exclude patterns:
```typescript
await fastify.apophis.contract({
chaos: {
probability: 0.3,
include: ['/billing/*'],
exclude: ['/billing/sensitive'],
routes: {
'/billing/plans': { dropout: { probability: 0 } }
},
resilience: { enabled: true, maxRetries: 3 }
}
})
```
### wrapFetch for Outbound Interception
```typescript
import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify'
const interceptor = createOutboundInterceptor([
{
target: 'api.stripe.com',
delay: { probability: 0.1, minMs: 1000, maxMs: 5000 },
error: {
probability: 0.05,
responses: [{ statusCode: 429, headers: { 'retry-after': '60' } }]
}
}
], 42)
const interceptedFetch = wrapFetch(globalThis.fetch, interceptor)
```
### Mutation Testing
Measure contract strength by injecting synthetic bugs:
```typescript
import { runMutationTesting } from 'apophis-fastify/quality/mutation'
const report = await runMutationTesting(fastify)
console.log(`Score: ${report.score}%`) // 0-100
console.log('Weak contracts:', report.weakContracts)
```
---
## First-Class Features (Built-In)
### Multipart File Uploads
**Always available. No registration needed.**
```typescript
// Route definition
fastify.post('/upload', {
schema: {
body: {
type: 'object',
'x-content-type': 'multipart/form-data',
'x-multipart-fields': {
description: { type: 'string', maxLength: 500 }
},
'x-multipart-files': {
avatar: {
maxSize: 5 * 1024 * 1024,
mimeTypes: ['image/jpeg', 'image/png'],
maxCount: 1
}
}
},
'x-ensures': [
'request_files(this).avatar.count == 1',
'request_files(this).avatar.size <= 5242880',
'request_fields(this).description != null'
]
}
}, handler)
```
**APOSTL Expressions**:
```apostl
request_files(this).avatar.count // number
request_files(this).avatar.size // bytes
request_files(this).avatar.mimetype // string
request_fields(this).description // string
```
**Core Files**:
- `src/infrastructure/multipart.ts` — FormData construction
- `src/domain/multipart-generator.ts` — Fake file generation
- `src/domain/schema-to-arbitrary.ts` — Detect `x-content-type: multipart/form-data`
- `src/domain/request-builder.ts` — Build multipart payload
- `src/infrastructure/http-executor.ts` — Inject multipart via Fastify
---
### Streaming / NDJSON
**Always available. No registration needed.**
```typescript
// Route definition
fastify.get('/events', {
schema: {
response: {
200: {
type: 'object',
'x-streaming': true,
'x-stream-format': 'ndjson',
'x-stream-max-chunks': 100,
'x-stream-timeout': 5000,
'x-ensures': [
'stream_chunks(this).length <= 100',
'stream_duration(this) < 5000'
]
}
}
}
}, handler)
```
**APOSTL Expressions**:
```apostl
stream_chunks(this) // array of parsed chunks (for NDJSON)
stream_duration(this) // milliseconds
```
**Core Files**:
- `src/infrastructure/stream-collector.ts` — Chunk collection & NDJSON parsing
- `src/infrastructure/http-executor.ts` — Apply streaming config after inject
- `src/domain/contract.ts` — Extract streaming annotations
---
### Timeouts & Redirects
Implemented in the current core.
```apostl
timeout_occurred(this) == false
timeout_value(this) < 5000
redirect_count(this) == 1
redirect_url(this).0 == "https://example.com"
redirect_status(this).0 == 301
```
---
## Extensions (Opt-In)
Extensions register custom APOSTL predicates that can be used in `x-ensures` and `x-requires` formulas.
### SSE (Server-Sent Events)
**Register via `extensions: [sseExtension]`**
```typescript
import { sseExtension } from 'apophis-fastify/extensions/sse'
await fastify.register(apophis, {
extensions: [sseExtension]
})
// Route definition
fastify.get('/notifications', {
schema: {
response: {
200: {
'x-sse': true,
'x-sse-events': ['update', 'delete'],
'x-sse-max-events': 10,
'x-sse-timeout': 30000,
'x-ensures': [
'sse_events(this).length <= 10',
'sse_events(this).0.event == "update"'
]
}
}
}
}, handler)
```
**APOSTL Expressions**:
```apostl
sse_events(this) // array of events
sse_events(this).0.event // string
sse_events(this).0.data // unknown
sse_events(this).0.retry // number (ms)
```
**Extension Files**:
- `src/extensions/sse/types.ts`
- `src/extensions/sse/predicates.ts`
- `src/extensions/sse/extension.ts`
- `src/extensions/sse/test.ts`
---
### Custom Serializers
**Register via `extensions: [createSerializerExtension(registry)]`**
```typescript
import { createSerializerExtension, createSerializerRegistry } from 'apophis-fastify/extensions/serializers'
const registry = createSerializerRegistry()
registry.register('protobuf', {
encode: (data) => protobuf.encode(data),
decode: (buffer) => protobuf.decode(buffer),
})
await fastify.register(apophis, {
extensions: [createSerializerExtension(registry)]
})
// Route definition
fastify.post('/users', {
schema: {
body: {
'x-serializer': 'protobuf',
'x-serializer-schema': './schemas/user.proto'
}
}
}, handler)
```
**No new APOSTL expressions.** Use existing `response_body(this)`, `response_headers(this)`.
**Extension Files**:
- `src/extensions/serializers/types.ts`
- `src/extensions/serializers/extension.ts`
- `src/extensions/serializers/test.ts`
---
### WebSockets
**Register via `extensions: [websocketExtension]`**
```typescript
import { websocketExtension } from 'apophis-fastify/extensions/websocket'
await fastify.register(apophis, {
extensions: [websocketExtension]
})
// Route definition
fastify.get('/ws/events', {
websocket: true,
schema: {
'x-ws-messages': [
{ type: 'auth', direction: 'outgoing', schema: { type: 'object', properties: { token: { type: 'string' } } } },
{ type: 'ready', direction: 'incoming', schema: { type: 'object', properties: { status: { type: 'string', const: 'ready' } } } }
],
'x-ws-transitions': [
{ from: 'open', to: 'authenticating', trigger: 'auth' },
{ from: 'authenticating', to: 'ready', trigger: 'ready' }
],
'x-ensures': [
'ws_state(this) == "ready"'
]
}
}, handler)
```
**APOSTL Expressions**:
```apostl
ws_message(this).type // string
ws_message(this).payload // unknown
ws_state(this) // string
```
**Extension Files**:
- `src/extensions/websocket/types.ts`
- `src/extensions/websocket/predicates.ts`
- `src/extensions/websocket/client.ts`
- `src/extensions/websocket/runner.ts`
- `src/extensions/websocket/extension.ts`
- `src/extensions/websocket/test.ts`
---
### JWT
**Register via `extensions: [jwtExtension(config)]`**
```typescript
import { jwtExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [
jwtExtension({
jwks: 'https://auth.example.com/.well-known/jwks.json',
verify: true,
})
]
})
```
**APOSTL Expressions**:
```apostl
jwt_claims(this).sub != null
jwt_claims(this).exp > jwt_claims(this).iat
jwt_header(this).alg == "RS256"
jwt_valid(this) == true
jwt_format(this) == "compact"
```
---
### X.509 Certificates
**Register via `extensions: [x509Extension(config)]`**
```typescript
import { x509Extension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [x509Extension()]
})
```
**APOSTL Expressions**:
```apostl
x509_uri_sans(this).length == 1
x509_ca(this) == false
x509_expired(this) == false
x509_self_signed(this) == false
```
---
### SPIFFE
**Register via `extensions: [spiffeExtension(config)]`**
```typescript
import { spiffeExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [spiffeExtension()]
})
```
**APOSTL Expressions**:
```apostl
spiffe_parse(this).trustDomain matches "^[a-z0-9.-]+$"
spiffe_parse(this).path.length > 0
spiffe_validate(this) == true
```
---
### Token Hash (WIMSE S2S)
**Register via `extensions: [tokenHashExtension(config)]`**
```typescript
import { tokenHashExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [tokenHashExtension()]
})
```
**APOSTL Expressions**:
```apostl
ath_valid(this) == true
tth_valid(this) == true
token_hash(this, "sha256") == jwt_claims(this).ath
```
---
### HTTP Signature
**Register via `extensions: [httpSignatureExtension(config)]`**
```typescript
import { httpSignatureExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [httpSignatureExtension()]
})
```
**APOSTL Expressions**:
```apostl
signature_covers(this, "@method") == true
signature_covers(this, "@request-target") == true
signature_valid(this) == true
```
---
### Time Control
**Register via `extensions: [timeExtension(config)]`**
```typescript
import { timeExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [timeExtension()]
})
```
**APOSTL Expressions**:
```apostl
jwt_claims(this).exp > now()
jwt_claims(this).exp <= now() + 30000
```
---
### Stateful Cross-Request
**Register via `extensions: [statefulExtension()]`**
```typescript
import { statefulExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [statefulExtension()]
})
```
**APOSTL Expressions**:
```apostl
already_seen(this, jwt_claims(this).jti) == false
is_consumed(this, jwt_claims(this).jti) == false
previous(constructor).jwt_claims(this).refresh_token != null
```
---
### Cross-Route Relationships
**Always available. No registration needed.**
Validate hypermedia links and parent-child relationships using APOSTL predicates:
**APOSTL Expressions**:
```apostl
// Verify hypermedia controls resolve to real routes
route_exists(this).controls.self.href == true
route_exists(this).controls.tenant.href == true
// Verify parent-child consistency
relationship_valid("parent", request_params(this).tenantId, response_body(this).tenantId) == true
// Verify cascade after DELETE
cascade_valid("tenant", request_params(this).id, ["application", "user"]) == true
```
**Example**:
```typescript
fastify.get('/tenants/:id', {
schema: {
'x-category': 'observer',
'x-ensures': [
'route_exists(this).controls.self.href == true',
'route_exists(this).controls.applications.href == true',
],
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
controls: {
type: 'object',
properties: {
self: { type: 'object', properties: { href: { type: 'string' } } },
applications: { type: 'object', properties: { href: { type: 'string' } } },
},
},
},
},
},
},
})
```
### Request Context
**Register via `extensions: [requestContextExtension(config)]`**
```typescript
import { requestContextExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [requestContextExtension()]
})
```
**APOSTL Expressions**:
```apostl
jwt_claims(this).aud == request_url(this)
request_url(this).path == "/api/users"
request_body_hash(this, "sha256") == expected_hash
```
---
## Chaos Quick Reference
### Basic Chaos
```typescript
await fastify.apophis.contract({
chaos: {
probability: 0.3,
delay: { probability: 0.5, minMs: 50, maxMs: 200 },
error: { probability: 0.2, statusCode: 503 },
dropout: { probability: 0.1 },
corruption: { probability: 0.1 }
}
})
```
### Outbound Interception
```typescript
import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify'
const interceptor = createOutboundInterceptor([{
target: 'api.stripe.com',
error: {
probability: 0.05,
responses: [{ statusCode: 429, headers: { 'retry-after': '60' } }]
}
}], 42)
const interceptedFetch = wrapFetch(globalThis.fetch, interceptor)
```
### Per-Route Overrides
```typescript
chaos: {
probability: 0.3,
exclude: ['/health'],
include: ['/api/*'],
routes: {
'/billing/plans': { dropout: { probability: 0 } }
}
}
```
### Blast Radius Cap
```typescript
chaos: {
probability: 0.5,
delay: { probability: 1.0, minMs: 10, maxMs: 50 },
maxInjectionsPerSuite: 10
}
```
### ChaosConfig Options
| Field | Type | Description |
|-------|------|-------------|
| `probability` | `number` | Top-level injection probability (0.01.0) |
| `delay` | `{ probability, minMs, maxMs }` | Delay injection |
| `error` | `{ probability, statusCode, body? }` | Forced error responses |
| `dropout` | `{ probability, statusCode? }` | Simulated network failure (default 504) |
| `corruption` | `{ probability }` | Body truncation / malformed payloads |
| `outbound` | `OutboundChaosConfig[]` | Intercept outbound HTTP requests |
| `routes` | `Record<string, Partial<ChaosConfig>>` | Per-route config overrides |
| `include` | `string[]` | Whitelist routes (supports `*` suffix) |
| `exclude` | `string[]` | Blacklist routes |
| `resilience` | `{ enabled, maxRetries?, backoffMs? }` | Retry after chaos to confirm recovery |
| `skipResilienceFor` | `OperationCategory[]` | Skip retries for non-idempotent categories |
| `dropoutStatusCode` | `number` | Override dropout status (default 504) |
| `maxInjectionsPerSuite` | `number` | Cap total injections per test suite |
### Body Corruption Strategies
| Content Type | Strategy | Kind |
|-------------|----------|------|
| `application/json` | Truncate or null random field | `body-truncate` / `body-malformed` |
| `application/x-ndjson` | Corrupt random chunk | `body-malformed` |
| `text/event-stream` | Corrupt SSE event format | `body-malformed` |
| `multipart/form-data` | Corrupt multipart field | `body-malformed` |
| `text/plain` / `text/html` | Truncate text | `body-truncate` |
---
## Decision Matrix
| Question | If YES → | If NO → |
|----------|----------|---------|
| Is this standard HTTP (RFC)? | **First-class** | Consider extension |
| Does it need fast-check schema integration? | **First-class** | Extension |
| Is it in >50% of APIs? | **First-class** | Extension |
| Does it need heavy dependencies (>100KB)? | Extension | **First-class** |
| Is it a different protocol (WS, gRPC)? | Extension | **First-class** |
| Is it declining in popularity (<10% usage)? | Extension | **First-class** |
---
## Core Extension Points
### For First-Class Features
Modify these core files:
1. **Types** (`src/types.ts`):
- Add new fields to `EvalContext` if needed
- Add new `OperationHeader` values
2. **HTTP Executor** (`src/infrastructure/http-executor.ts`):
- Multipart: Build FormData
- Streaming: Collect chunks
3. **Schema-to-Arbitrary** (`src/domain/schema-to-arbitrary.ts`):
- Multipart: Generate fake files
- Streaming: No changes (streaming is response-only)
4. **Evaluator** (`src/formula/evaluator.ts`):
- Add new `resolveStandardOperation` cases
### For Extensions
Implement these in your extension module:
1. **Extension Config** (`extension.ts`):
```typescript
export const myExtension: ApophisExtension = {
name: 'my-extension',
headers: ['my_predicate'],
predicates: {
my_predicate: (ctx) => ({ value: 'test', success: true })
},
hooks: {
onAfterRequest: async (ctx) => {
// Transform response
}
}
}
```
2. **Registration**:
```typescript
await fastify.register(apophis, {
extensions: [myExtension]
})
```
---
## Testing Strategy
### First-Class Features
Test in `src/test/FEATURE.test.ts`:
```typescript
import { test } from 'node:test'
import assert from 'node:assert'
import Fastify from 'fastify'
test('multipart: upload with fake file', async () => {
const fastify = Fastify()
// ... setup route with multipart schema ...
const result = await fastify.apophis.contract()
assert.strictEqual(result.summary.failed, 0)
})
```
### Extensions
Test in `src/extensions/NAME/test.ts`:
```typescript
import { test } from 'node:test'
import assert from 'node:assert'
import { myExtension } from './extension.js'
test('extension: predicate resolves', () => {
const resolver = myExtension.predicates!.my_predicate
const result = resolver(mockContext)
assert.strictEqual(result.value, expected)
})
```
---
## Getting Started
### Adding a First-Class Feature
1. Identify if feature needs schema-to-arbitrary integration
2. If yes → implement in core
3. Add types to `src/types.ts`
4. Add evaluator cases to `src/formula/evaluator.ts`
5. Add HTTP executor support
6. Add tests to `src/test/FEATURE.test.ts`
### Adding an Extension
1. Create module: `src/extensions/my-feature/`
2. Implement `extension.ts` with `ApophisExtension` config
3. Add tests to `src/extensions/my-feature/test.ts`
4. Export from `src/extensions/my-feature/index.ts`
5. Register via `extensions: [myExtension]`
---
## Questions?
**Q: Can I make a first-class feature into an extension later?**
A: Yes, but it's a breaking change. Better to start as first-class if unsure.
**Q: Can extensions depend on first-class features?**
A: Yes. Extensions can use any core capability.
**Q: How do I test without the extension loaded?**
A: Extensions are self-contained. Each module is testable in isolation.
**Q: What if two extensions define the same predicate?**
A: Duplicate predicate names should fail registration unless an explicit override policy is enabled. Use namespacing: `sse_events` not `events`.
@@ -0,0 +1,341 @@
# APOPHIS v1.0 Extension Specification: Timeouts and Redirects
## Document Information
- **Version**: 1.0
- **Status**: Implemented
- **Scope**: APOPHIS v1.0 core extension
- **Date**: 2026-04-24
---
## Table of Contents
1. [Request Timeouts](#1-request-timeouts)
2. [Redirect Chains](#2-redirect-chains)
3. [APOSTL Formula Reference](#3-apostl-formula-reference)
4. [Integration Guide](#4-integration-guide)
---
## 1. Request Timeouts
### 1.1 Overview
Timeout support enables APOPHIS to detect slow endpoints and treat timeout violations as first-class contract violations. Timeouts are configurable at three levels (from highest to lowest precedence):
1. **Per-route schema annotation**: `x-timeout: 5000`
2. **Test configuration**: `config.timeout`
3. **No timeout**: Default behavior (no timeout enforced)
### 1.2 Configuration
#### Global Plugin Timeout
```typescript
await fastify.register(apophis, {
timeout: 5000, // 5 seconds for all routes
})
```
#### Per-Test Timeout
```typescript
const suite = await fastify.apophis.contract({
timeout: 1000, // 1 second for this test run
})
```
#### Per-Route Timeout (Schema Annotation)
```typescript
fastify.get('/slow-endpoint', {
schema: {
'x-timeout': 10000, // 10 seconds for this route
'x-ensures': [
'timeout_occurred(this) == false',
'response_code(this) == 200',
]
}
}, async (request, reply) => {
// Implementation
})
```
### 1.3 HTTP Executor Behavior
When a timeout is configured, `executeHttp` uses an abortable timer where supported. The timeout must be cleared in `finally`; Fastify injection may continue running after timeout if the underlying transport cannot be cancelled.
```typescript
// In src/infrastructure/http-executor.ts
if (timeoutMs && timeoutMs > 0) {
response = await Promise.race([
fastify.inject(injectOptions),
new Promise<never>((_, reject) =>
setTimeout(() => {
timedOut = true
reject(new Error(`Request timeout after ${timeoutMs}ms`))
}, timeoutMs)
),
])
}
```
On timeout, the executor returns a special `EvalContext` with:
- `timedOut: true`
- `timeoutMs: <configured timeout>`
- `response.statusCode: 0`
- `response.body: undefined`
- `redirects: []`
### 1.4 APOSTL Formulas
New operation headers for timeout inspection:
| Formula | Description |
|---------|-------------|
| `timeout_occurred(this)` | Returns `true` if request timed out, `false` otherwise |
| `timeout_value(this)` | Returns configured timeout in milliseconds, or `null` |
Example formulas:
```apostl
timeout_occurred(this) == false
timeout_value(this) == 5000
response_time(this) <= timeout_value(this)
```
### 1.5 Type Changes
#### `EvalContext` Extension
```typescript
export interface EvalContext {
// ... existing fields ...
timedOut?: boolean // True if request hit timeout
timeoutMs?: number // Configured timeout value
redirects?: RedirectEntry[]
}
```
#### `RouteContract` Extension
```typescript
export interface RouteContract {
// ... existing fields ...
timeout?: number // Per-route timeout in milliseconds
}
```
#### `TestConfig` Extension
```typescript
export interface TestConfig {
// ... existing fields ...
timeout?: number // Request timeout in milliseconds
}
```
---
## 2. Redirect Chains
### 2.1 Overview
Redirect support captures a 3xx response returned by `inject()` with its `Location` header. Multi-hop redirect following is not implemented here. When a response has:
- Status code 300-399
- A `location` header
APOPHIS captures the redirect entry in `EvalContext.redirects`.
### 2.2 HTTP Executor Behavior
After executing the request, `executeHttp` checks for redirects:
```typescript
const redirectChain: RedirectEntry[] = []
const location = response.headers['location']
if (location && (response.statusCode >= 300 && response.statusCode < 400)) {
redirectChain.push({
statusCode: response.statusCode,
location: String(location),
headers: stringifyHeaders(response.headers),
})
}
```
Note: Fastify injection returns the redirect response unless the caller implements redirect following. To test redirect behavior itself, assert the 3xx response and `location` header directly.
### 2.3 APOSTL Formulas
New operation headers for redirect inspection:
| Formula | Description |
|---------|-------------|
| `redirect_count(this)` | Returns number of redirect hops captured |
| `redirect_url(this).N` | Returns location URL of Nth redirect (0-indexed) |
| `redirect_status(this).N` | Returns status code of Nth redirect (0-indexed) |
Example formulas:
```apostl
redirect_count(this) == 0
redirect_count(this) <= 3
redirect_status(this).0 == 301
redirect_url(this).0 == "/v2/legacy"
```
### 2.4 Type Changes
#### `RedirectEntry`
```typescript
export interface RedirectEntry {
readonly statusCode: number
readonly location: string
readonly headers: Record<string, string>
}
```
#### `EvalContext` Extension
```typescript
export interface EvalContext {
// ... existing fields ...
redirects?: RedirectEntry[]
}
```
---
## 3. APOSTL Formula Reference
### Complete Operation Header List
```typescript
export type OperationHeader =
| 'request_body' | 'response_body' | 'response_code'
| 'request_headers' | 'response_headers' | 'query_params' | 'cookies' | 'response_time'
| 'redirect_count' | 'redirect_url' | 'redirect_status'
| 'timeout_occurred' | 'timeout_value'
```
### Formula Examples
```apostl
# Timeout assertions
timeout_occurred(this) == false
timeout_value(this) == 5000
response_time(this) <= timeout_value(this)
# Redirect assertions
redirect_count(this) == 1
redirect_count(this) <= 3
redirect_status(this).0 == 301
redirect_url(this).0 == "/new-path"
redirect_status(this).1 == 302
# Combined
timeout_occurred(this) == false && redirect_count(this) == 0
```
---
## 4. Integration Guide
### 4.1 Fastify Route Examples
#### Health Check with Timeout
```typescript
fastify.get('/health', {
schema: {
'x-timeout': 100,
'x-ensures': [
'timeout_occurred(this) == false',
'response_code(this) == 200',
'response_body(this).status == "ok"',
]
}
}, async () => ({ status: 'ok' }))
```
#### Legacy Endpoint with Redirect
```typescript
fastify.get('/legacy', {
schema: {
'x-ensures': [
'redirect_count(this) == 1',
'redirect_status(this).0 == 301',
'redirect_url(this).0 == "/v2/resource"',
]
}
}, async (request, reply) => {
reply.code(301).header('location', '/v2/resource')
return { moved: true }
})
```
#### API Endpoint with Combined Checks
```typescript
fastify.get('/api/resource', {
schema: {
'x-timeout': 5000,
'x-ensures': [
'timeout_occurred(this) == false',
'redirect_count(this) == 0',
'response_code(this) == 200',
'response_body(this).id != null',
]
}
}, handler)
```
### 4.2 Test Configuration Examples
```typescript
// Quick test with 1 second timeout
const quick = await fastify.apophis.contract({
depth: 'quick',
timeout: 1000,
})
// Thorough test with 30 second timeout
const thorough = await fastify.apophis.contract({
depth: 'thorough',
timeout: 30000,
})
// Stateful test with timeout
const stateful = await fastify.apophis.stateful({
depth: 'standard',
timeout: 5000,
seed: 42,
})
```
### 4.3 Extension Plugin Integration
The timeout and redirect features integrate with the extension plugin system. Extensions can access timeout and redirect data via `PredicateContext.evalContext`:
```typescript
const myExtension: ApophisExtension = {
name: 'timeout-monitor',
predicates: {
slow_endpoint: (ctx) => ({
value: ctx.evalContext.timedOut === true,
success: true,
}),
},
}
```
---
## Backward Compatibility
All timeout and redirect features are additive:
- Routes without `x-timeout` have no timeout enforced
- Routes without redirects have empty `redirects` array
- Formulas without timeout/redirect operations work unchanged
- Default behavior is unchanged from v0.9