Middleware Chain Reference

⚠ HERITAGE EXTRACTION — donor AMS middleware stack (Wave 8 quarantine)

This file extracts the donor AMS middleware chain from projects/unified-mcp/src/middleware/ (deleted R53). The “11 middleware layers”, auth.js, acl.js, auth-permissions.js, watcher-leader.js, startup-lock.js, runtime-pid-lock.js files, and the express-adjacent routeTool() wrap pattern are donor accretion — Phase 0 Colibri does not ship them.

Phase 0 ships a 5-stage α middleware chain: tool-lock → schema-validate → audit-enter → dispatch → audit-exit. No JWT, no API keys, no ACL, no rate limiter, no orchestration middleware, no leader election, no startup PID lock. Auth, ACL, and rate-limiting are Phase 1+ territory (see ../../security/auth.md heritage banner). The canonical Phase 0 chain is in ../../concepts/α-system-core.md and ../../spec/s17-mcp-surface.md.

Read this file as donor genealogy only.

The AMS middleware stack is not express-style. It is a sequential call stack inside routeTool() in src/controllers/index.js. Each middleware function wraps or gates a tool call.


Execution Order in routeTool()

Tool call arrives
  │
  ├─ 1. Profile gate (AMS_TOOL_PROFILE_STRICT)
  │       Rejects hidden tools with McpError(MethodNotFound)
  │
  ├─ 2. ACL gate: enforceACL(name, args)
  │       Resolves projectId, checks role hierarchy
  │       Injects canonical project_id into args
  │
  ├─ 3. Audit context enrichment: enrichSessionWithAcl(userId, projectId)
  │
  ├─ 4. Orchestration before: applyOrchestrationBefore(name, args)
  │       Pre-flight validation, scaffold, project registration
  │
  ├─ 5. Tool dispatch (first match wins):
  │       a. handlePerformanceTool   (read-only, no audit)
  │       b. handleAuditTool         (no audit)
  │       c. MUTATING_THOUGHT_TOOLS  → withMetrics → withAudit → handleThoughtTool
  │       d. handleThoughtTool       (read-only thought tools)
  │       e. handleMerkleTool
  │       f. handleAnalyticsTool
  │       g. handleClaudeCoreTool
  │       h. All other domain handlers wrapped in:
  │              withMetrics(name, args, fn)
  │                └─ withAudit(name, args, fn, {sessionIdHint, contextIdHint})
  │                     └─ withRetry(fn)
  │                          └─ withRateLimit(name, fn)
  │                               └─ withCircuitBreaker(name, fn)
  │                                    └─ actual handler
  │
  └─ 6. Orchestration after: applyOrchestrationAfter(name, args, result)
             Post-success side effects, webhook emission, GC, binding sync

src/middleware/index.js — Core Middleware

Circuit Breaker

State: Map<toolName, {failures, lastFailure}>
Threshold: 5 failures
Timeout: 30s (monotonic performance.now())
Cleanup: every 64 calls or when map > 500 entries

withCircuitBreaker(toolName, fn) → Promise<T>

  • If failures >= 5 and timeSince < 30s: throws "Circuit breaker open for tool: X. Try again in Ns"
  • On success: deletes entry from map (reset)
  • On failure: increments failures, sets lastFailure = performance.now()
  • Uses performance.now() (monotonic) — immune to system clock changes

withRetry(fn, maxRetries?) → Promise<T>

  • Default retries from AMS_DB_RETRY_MAX_RETRIES
  • Retryable errors: SQLITE_BUSY, SQLITE_LOCKED, EBUSY, EAGAIN, “database is locked”
  • Validation errors (ZodError, ERR_VALIDATION) are NOT retried
  • Delay: exponential backoff = RETRY_BASE_DELAY_MS * 2^attempt + jitter(0-50ms), capped at 3000ms
  • Budget guard: if elapsed + delay > AMS_DB_RETRY_BUDGET_MS, throws immediately

withRateLimit(toolName, fn) → Promise<T>

  • Window: 60s tumbling, 100 requests per window per tool
  • State: Map<toolName, {window, count}>
  • Throws: "Rate limit exceeded for X. Retry after Ns"
  • Cleanup: every 64 calls or map > 500 entries

getMiddlewareStats() → {circuitBreakers[], rateLimits[]}

Returns current circuit breaker and rate limit snapshots.

resetMiddlewareState() → void

Clears all in-memory state. Test isolation.


src/middleware/auth.js — JWT Authentication

Auth Modes

| Mode | Behavior | |——|———-| | trust | No authentication required (default) | | token | Token optional but validated if present | | hybrid | Token preferred, fall back to legacy | | required | Token mandatory for all calls |

Set via AMS_AUTH_MODE env var.

Functions

getAuthMode() → string

Returns AMS_AUTH_MODE or 'trust'.

isAuthRequired() → boolean

True only when mode is 'required'.

isTokenAuthEnabled() → boolean

True for token, hybrid, required modes.

getSigningSecret() → Promise<Uint8Array>

Resolution order:

  1. AMS_AUTH_SECRET (base64url env var)
  2. AMS_AUTH_SECRET_FILE (path to file)
  3. Auto-generate to ~/.ams/auth-secret (if AMS_AUTH_AUTO_GENERATE=true)
  4. Throws AuthError('INVALID_SECRET')

Cached in memory after first load.

extractToken(args) → string|null

Tries in order:

  1. args.__auth_token
  2. args.__auth.token
  3. process.env.AMS_AUTH_TOKEN

validateToken(token) → Promise<AuthContext>

Uses jose library: jwtVerify(token, secret, { issuer, audience }). Checks revocation map. Returns { authenticated, userId, role, permissions, scope, sessionId, expiresAt, jti, mode }.

Throws AuthError codes: AUTH_REQUIRED | INVALID_TOKEN | TOKEN_EXPIRED | TOKEN_REVOKED | INSUFFICIENT_PERMISSIONS | INVALID_SECRET

extractAndValidateToken(args) → Promise<AuthContext>

Combines extract + validate. In trust mode with no token, returns { authenticated: false }.

generateToken(options) → Promise<{token, tokenId, expiresAt}>

Creates HS256 JWT with AMS-specific claims:

{ "ams": { "role": "...", "perms": [...], "scope": "...", "session": "...", "name": "..." } }

Options: { userId, role, permissions, scope, sessionId, expiresIn, name }

revokeToken(jti, reason?) → void

Adds to in-memory tokenRevocations Map with 7-day TTL.

isTokenRevoked(jti) → boolean

listRevokedTokens() → {jti, expiresAt}[]

runWithAuthContext(authContext, fn) → Promise<T>

Runs fn in an AsyncLocalStorage context with the given AuthContext.

getAuthContext() → AuthContext

Returns current context or { authenticated: false }.

requireAuth() → AuthContext

Throws if not authenticated.

permissionMatches(granted, required) → boolean

Permission syntax: resource:action[:scope]. Wildcards: * matches anything.

hasPermission(permission, authContext?) → boolean

In trust mode with unauthenticated context: always true.

requirePermission(permission) → true

Throws AuthError('INSUFFICIENT_PERMISSIONS') if missing.

getAuthStats() → object

resetAuthState() → void

Test isolation — clears cached secret and revocations.


src/middleware/acl.js — Role-Based Access Control

Role Hierarchy

owner (4) > admin (3) > member (2) > viewer (1)

Tool Permission Map (TOOL_PERMISSIONS)

Three categories:

  • null — no project context needed (global tools: roadmap_, audit_, thought_, merkle_, memory_, unified_, gsd_vitals, etc.)
  • 'viewer' — read-only project tools: task_list, context_get, watcher_status, unified_set_project
  • 'member' — read+write: task_create, task_update, task_delete, context_create, watcher_start
  • 'admin' — member management: acl_add_member, acl_remove_member
  • 'owner' — ownership transfer: acl_set_owner

Unknown tools default to 'member' (safe default-deny).

Bootstrap-exempt tools (always null): unified_init, roadmap_export_to_gsd, ams_autonomous_run, ams_autonomous_apply_changes.

Functions

enforceACL(toolName, args) → Promise<{projectId, userId, role}>

Main gate called before every tool execution.

  • When AMS_ACL_ENABLED is false: passthrough, returns { projectId: (resolved), userId: null, role: null }
  • userId = AMS_USER_ID env var
  • Calls resolveProjectContext(args) to find canonical project
  • Calls checkAccess(projectId, userId, requiredRole)
  • Throws on denial: "ACL: Access denied for 'X'. Requires Y role, user has Z"

resolveProjectContext(args, options?) → Promise<string|null>

Resolution order:

  1. args.project_id or args.project_name (explicit, looked up via dbResolveProjectByIdOrName)
  2. Session override (set by unified_set_project)
  3. AMS_CURRENT_PROJECT env var
  4. Single-project auto-detect (if exactly 1 project exists)

checkAccess(projectId, userId, requiredRole) → Promise<{allowed, role, reason}>

setProjectOverride(projectId, scopeId?) → void

Called by unified_set_project. Stores in AsyncLocalStorage store and projectOverrideByScope Map. TTL: 30 minutes. Max 2000 scoped overrides.

clearProjectOverride(scopeId?) → void

getProjectOverride(scopeId?) → string|null

runWithAclContext(fn, options) → Promise<T>

Wraps fn in AsyncLocalStorage context with { scopeId, projectId }.


src/middleware/audit.js — Audit Session Management

Session Storage

Uses AsyncLocalStorage (sessionStorage) — each concurrent request gets isolated session context. Persisted cross-call via sessionByScope Map (TTL 30 min, max 2000 entries).

Session Data Shape

{ scopeId, sessionId, contextId, userId, projectId }

Functions

runWithAuditContext(fn, options) → Promise<T>

Wraps fn with audit context. Hydrates from sessionByScope if scope session exists.

generateSessionId() → string

Returns session-<uuid>.

startSession(contextId?, userId?, projectId?, sessionIdHint?) → string

Creates new session, stores in AsyncLocalStorage and sessionByScope (if scopeId). Returns sessionId.

bindSession(sessionId, contextId?) → SessionData

Binds current context to an explicit session ID. Useful for tools that persist their own session_id.

getCurrentSession() → SessionData

Returns current session from AsyncLocalStorage, or all-null object.

enrichSessionWithAcl(userId, projectId) → void

Called after ACL enforcement. Mutates the current store’s userId and projectId.

setSessionContext(contextId) → void

Sets contextId on current session.

endSession() → void

Clears session, resets AsyncLocalStorage store to null-session state.

withAudit(toolName, args, fn, options?) → Promise<result>

Core audit wrapper:

  1. Reads session from AsyncLocalStorage
  2. Binds or creates session as needed (uses sessionIdHint from args)
  3. Executes fn()
  4. Hashes result with hashResult() (stable JSON serialization → SHA256)
  5. Calls dbLogAction(...) with the result hash
  6. Returns result

options: { skipIfNull, sessionIdHint, contextIdHint }

stableSerialize(value): Deterministic JSON serialization (sorted keys, handles circular refs). hashResult(result): SHA256 of stable-serialized result.


src/middleware/metrics.js — Performance Monitoring

Storage

In-memory metricsStore = Map<toolName, {count, successCount, errorCount, totalDuration, minDuration, maxDuration, slowQueries[]}>. Max 1000 entries; LRU eviction.

Slow Query Threshold

1000ms. Stores last 100 slow queries per tool.

Functions

withMetrics(toolName, args, fn) → Promise<result>

Wraps fn(). On success, merges _metrics: { duration_ms, tool_name } into result object. Records recordMetric() in both success and error paths.

getMetrics(toolName?) → object|null

Returns metrics for one tool or all. Fields: count, success_count, error_count, avg_duration_ms, min_duration_ms, max_duration_ms, slow_query_count.

getSlowOperations(thresholdMs, limit, toolName?) → []

Exported for performance_slow_queries tool.

getMetricsStore() → Map

Raw store access for performance_report.


src/middleware/orchestration.js — Pre/Post Hooks

Deterministic side-effect rules: “if tool A succeeds then do B+C+D”.

applyOrchestrationBefore(toolName, args) → Promise<{args, steps[]}>

Pre-flight checks and mutations:

Trigger Tool Actions
roadmap_export_to_gsd Ensure core dirs, validate roadmap (fail if status=fail)
roadmap_repair Validate roadmap before repair, log results
roadmap_repair_all Validate all roadmaps batch, log summary
watcher_start, watcher_stop, task_sync_to_gsd Ensure core dirs, resolve project name, scaffold project dir + TODOS.md, register project in DB if missing, sync roadmap-GSD binding

Returns mutated args (e.g. project_name auto-filled).

applyOrchestrationAfter(toolName, args, result) → Promise<{result, steps[]}>

Post-success side effects:

Trigger Action
analysis_rag_index (success) Auto-GC RAG if policy.auto_gc=true
memory_pack (success) Auto-GC memory frames if policy.auto_gc=true
roadmap_export_to_gsd (success) Sync roadmap-GSD binding, queue agent spawn plan
roadmap_repair_all (applied) Invalidate all roadmap analysis caches
roadmap_register (success) Invalidate cache for roadmap, re-validate
roadmap_repair (applied) Invalidate cache for roadmap
Any success with mapped event Emit webhook event via emitWebhookEvent()

attachOrchestrationMetadata(result, beforeSteps, afterSteps) → result

Merges _orchestration: { before, after } into result.


src/middleware/auth-permissions.js — Token Permission Map

Maps tool names to required permission strings (resource:action[:scope]). Used by hasPermission() / requirePermission() for token-based auth (separate from ACL role hierarchy).

Sample mappings:

roadmap_list      → 'roadmap:list'
task_create       → 'task:create'
task_delete       → 'task:delete'
audit_session_start → 'audit:write'
merkle_finalize   → 'merkle:write'
memory_gc         → 'memory:admin'
gsd_evolve        → 'gsd:admin'

src/middleware/tool-lock.js — Global Tool Serialization

File-based mutex at data/locks/mcp-tool.lock.

acquireGlobalToolLock(toolName) → Promise<release()>

Used when AMS_MCP_SERIALIZE_TOOL_CALLS=true. Prevents concurrent tool execution.

  • Lock file stores { pid, toolName, acquiredAt, epochMs }
  • Stale detection: checks if owner PID is alive; clears if dead or AMS_MCP_TOOL_LOCK_STALE_MS elapsed
  • Waits up to AMS_MCP_TOOL_LOCK_TIMEOUT_MS, polls every AMS_MCP_TOOL_LOCK_POLL_MS
  • Also serializes within-process via localQueue Promise chain

src/middleware/startup-lock.js — DB Init Mutex

File-based mutex at data/locks/db-init.lock.

acquireStartupLock() → Promise<release()>

Ensures only one process runs DB initialization at a time. Same stale-detection pattern as tool-lock. Config: AMS_DB_INIT_LOCK_TIMEOUT_MS, AMS_DB_INIT_LOCK_POLL_MS, AMS_DB_INIT_LOCK_STALE_MS.


src/middleware/watcher-leader.js — Watcher Election

File-based mutex at data/locks/watcher-owner.lock.

acquireWatcherLeaderLock() → Promise<release()|null>

Tries to claim watcher leader role. Returns null if another process owns it. Only the leader process runs file watchers. Config: AMS_WATCHER_LEADER_STALE_MS.


src/middleware/runtime-pid-lock.js — Runtime PID Tracking

Writes current PID to a lock file for cross-process liveness checks. Used by startup-lock and tool-lock stale detection.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.