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.jsfiles, and the express-adjacentrouteTool()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.mdheritage banner). The canonical Phase 0 chain is in../../concepts/α-system-core.mdand../../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 >= 5andtimeSince < 30s: throws"Circuit breaker open for tool: X. Try again in Ns" - On success: deletes entry from map (reset)
- On failure: increments
failures, setslastFailure = 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:
AMS_AUTH_SECRET(base64url env var)AMS_AUTH_SECRET_FILE(path to file)- Auto-generate to
~/.ams/auth-secret(ifAMS_AUTH_AUTO_GENERATE=true) - Throws
AuthError('INVALID_SECRET')
Cached in memory after first load.
extractToken(args) → string|null
Tries in order:
args.__auth_tokenargs.__auth.tokenprocess.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_ENABLEDis false: passthrough, returns{ projectId: (resolved), userId: null, role: null } userId=AMS_USER_IDenv 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:
args.project_idorargs.project_name(explicit, looked up viadbResolveProjectByIdOrName)- Session override (set by
unified_set_project) AMS_CURRENT_PROJECTenv var- 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:
- Reads session from
AsyncLocalStorage - Binds or creates session as needed (uses
sessionIdHintfrom args) - Executes
fn() - Hashes result with
hashResult()(stable JSON serialization → SHA256) - Calls
dbLogAction(...)with the result hash - 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_MSelapsed - Waits up to
AMS_MCP_TOOL_LOCK_TIMEOUT_MS, polls everyAMS_MCP_TOOL_LOCK_POLL_MS - Also serializes within-process via
localQueuePromise 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.