chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -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*
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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.0–1.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
|
||||
Reference in New Issue
Block a user