Files
apophis-fastify/docs/extensions/EXTENSION-PLUGIN-SYSTEM.md
T

401 lines
12 KiB
Markdown
Raw Normal View History

# 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': [
// Behavioral: returned user must match the requested id
'response_body(this).id == request_params(this).id',
'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*