Files
apophis-fastify/docs/extensions/EXTENSION-PLUGIN-SYSTEM.md
T
John Dvorak 31530fe899
CI / test (20.x) (push) Failing after 1m55s
CI / test (22.x) (push) Failing after 35s
(mess) Stuffing commit.
2026-05-20 16:09:43 -07:00

12 KiB

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

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

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

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

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:

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