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