401 lines
12 KiB
Markdown
401 lines
12 KiB
Markdown
# 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*
|