From e138866fcd6f1ab210caaada75f838a183349d49 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 11 May 2026 03:04:19 +0000 Subject: [PATCH] feat(unified-execute): implement ADR-006 unified invocation path with access control - Add access control to registry.execute(): checks requiredScopes, requiredScopesAny, and resourceType/resourceAction; rejects with ACCESS_DENIED when identity required but absent; skips when context.trusted is true - Add trusted field to OperationContext schema (internal, set by buildEnv for nested calls to skip redundant scope checks) - Simplify CallHandler to thin adapter: delegates to registry.execute() instead of duplicating lookup, validation, and access control - Remove callMap option from buildEnv(): always uses execute(), propagates context with trusted: true for nested calls - Add access control to subscribe(): same default-deny logic as execute() - Change execute() to throw CallError instead of plain Error for not found, no handler, and validation errors - Export checkAccess from call.ts and index.ts for external use - Remove CallMap type export, update EnvOptions - Update architecture docs: api-surface.md, call-protocol.md, ADR-006 status to implemented, source vs spec drift sections - All 228 tests passing --- docs/architecture/api-surface.md | 49 ++--- docs/architecture/call-protocol.md | 83 +++---- .../decisions/006-unified-invocation-path.md | 4 +- src/call.ts | 83 ++----- src/env.ts | 33 +-- src/index.ts | 4 +- src/registry.ts | 36 +++- src/subscribe.ts | 36 +++- src/types.ts | 1 + test/call.test.ts | 203 +++++++++--------- test/env.test.ts | 188 +++++++--------- test/registry.test.ts | 196 ++++++++++++++++- test/subscribe.test.ts | 102 ++++++++- 13 files changed, 608 insertions(+), 410 deletions(-) diff --git a/docs/architecture/api-surface.md b/docs/architecture/api-surface.md index 3cdae3e..b19c520 100644 --- a/docs/architecture/api-surface.md +++ b/docs/architecture/api-surface.md @@ -117,7 +117,7 @@ type OperationContext = Static & { } ``` -Passed to every handler. `env` provides namespace-keyed access to other operations (via `buildEnv`). `stream` and `pubsub` support subscription and event patterns. +Passed to every handler. `env` provides namespace-keyed access to other operations (via `buildEnv`). `stream` and `pubsub` support subscription and event patterns. `trusted` is set by `buildEnv()` for nested calls to skip redundant access control checks. It is not serialized in remote calls — trust does not cross process boundaries. ### `OperationSpec` @@ -192,7 +192,7 @@ The registry stores specs and handlers in separate internal maps. Specs are seri | `getByName(namespace, name)` | `(namespace: string, name: string) => (OperationSpec & { handler?: ... }) \| undefined` | Get by parts. | | `list()` | `() => Array` | All registered entries (spec + handler if present). | | `getAllSpecs()` | `() => OperationSpec[]` | All serializable specs. | -| `execute(operationId, input, context)` | `(id: string, input: TInput, ctx: OperationContext) => Promise>` | Validate input, run handler, wrap result in `ResponseEnvelope`, warn on output mismatch. Throws if spec or handler not found. | +| `execute(operationId, input, context)` | `(id: string, input: TInput, ctx: OperationContext) => Promise>` | Validate input, check access control (skip if `context.trusted`), run handler, wrap result in `ResponseEnvelope`, warn on output mismatch. Throws `CallError` for not found, access denied, validation, or handler errors. | Registration key format: `{namespace}.{name}`. Overwrite on duplicate. @@ -221,7 +221,7 @@ See [call-protocol.md](call-protocol.md) for full semantics. type CallHandler = (event: CallRequestedEvent) => Promise ``` -Created by `buildCallHandler({ registry, eventTarget? })`. Subscribes to `call.requested`, checks access control, validates input, calls the handler directly (not via `registry.execute()`), applies the shared result pipeline (detect → wrap → normalize → validate), and publishes `call.responded`. On failure: publishes `call.error` with mapped `CallError`. Adapters that return pre-built envelopes (MCP, OpenAPI) pass through via `isResponseEnvelope()` detection. See [response-envelopes.md](response-envelopes.md#shared-result-pipeline) for the shared pipeline definition. +Created by `buildCallHandler({ registry, eventTarget? })`. Subscribes to `call.requested`, delegates to `registry.execute()` for the full invocation pipeline (lookup, access control, validation, handler, envelope wrapping, normalization), and publishes `call.responded` or `call.error` via the provided `callMap`. Adapters that return pre-built envelopes (MCP, OpenAPI) pass through via `isResponseEnvelope()` detection in `execute()`. See [response-envelopes.md](response-envelopes.md#shared-result-pipeline) for the shared pipeline definition. ### `CallEventMap` @@ -273,14 +273,10 @@ interface EnvOptions { registry: OperationRegistry context: OperationContext allowedNamespaces?: string[] - callMap?: PendingRequestMap } ``` -Creates a namespace-keyed `OperationEnv` for nested operation calls. Each env function returns `Promise` — callers access typed data via `envelope.data` or use `unwrap(envelope)`. Two modes: - -- **Direct mode**: `buildEnv({ registry, context })` — env functions call `registry.execute()`, which wraps in `localEnvelope` -- **Call protocol mode**: `buildEnv({ registry, context, callMap })` — env functions call `callMap.call()`, which resolves to `ResponseEnvelope` directly, publishing `call.requested` events with `parentRequestId` for call graph tracking +Creates a namespace-keyed `OperationEnv` for nested operation calls. Each env function returns `Promise` — callers access typed data via `envelope.data` or use `unwrap(envelope)`. Env functions call `registry.execute()` directly with the outer context plus `trusted: true`, which skips redundant access control checks for nested calls. `SUBSCRIPTION` operations are filtered out — env only provides QUERY and MUTATION operations for nested calls. @@ -373,31 +369,20 @@ See [adapters.md](adapters.md) for detailed adapter documentation. ## Source vs. Spec Drift -This section documents differences between the architecture spec (this document) and the current source code. Items marked **ADR-005** or **ADR-006** are planned changes not yet implemented. +This section documents differences between the architecture spec (this document) and the current source code. -### ADR-005 (Response Envelopes) — not yet implemented +### ADR-005 (Response Envelopes) — ✅ Implemented -| What | Spec says | Source currently does | -|------|----------|----------------------| -| `ResponseEnvelope`, `ResponseMeta`, factory functions, `isResponseEnvelope()`, `unwrap()` | Exported from `src/response-envelope.ts` | None of these types or functions exist in source | -| `execute()` return type | `Promise>` | `Promise` | -| `execute()` result pipeline | Detect → wrap → normalize → validate | Returns raw `result`, validates raw output with `collectErrors` | -| `OperationEnv` inner function return type | `Promise` | `Promise` | -| `PendingRequestMap.call()` return type | `Promise` | `Promise` | -| `PendingRequestMap.respond()` validation | Enforces `isResponseEnvelope()`, throws on raw values | Accepts `unknown`, no validation | -| `subscribe()` yield type | `AsyncGenerator` | `AsyncGenerator` | -| `CallRespondedEvent.output` | `ResponseEnvelope` | `unknown` | -| `CallHandler` description | Wraps handler result, applies pipeline, publishes `call.responded` | Discards handler return value; handler publishes `call.responded` itself | -| `from_mcp` handler | Returns `mcpEnvelope()`, uses `structuredContent`, extracts `outputSchema` | Returns `result.content`, types `outputSchema` as `Type.Unknown()`, throws on `isError` | -| `from_openapi` handler | Returns `httpEnvelope()` with HTTP metadata | Returns raw response data, throws on HTTP error status | +All ADR-005 changes have been implemented in source. No remaining drift. -### ADR-006 (Unified Invocation Path) — not yet implemented +### ADR-006 (Unified Invocation Path) — ✅ Implemented in source -| What | Spec says | Source currently does | -|------|----------|----------------------| -| `execute()` access control | Checks `accessControl` when `identity` present | Skips access control entirely | -| `execute()` on unauthenticated access | Rejects with `ACCESS_DENIED` when `requiredScopes` non-empty and no `identity` | Always allows | -| `execute()` error type | Throws `CallError` | Throws plain `Error` | -| `buildEnv()` | Always uses `execute()`, no `callMap` option | Toggles between `execute()` and `callMap.call()` | -| `CallHandler` | Thin adapter calling `registry.execute()` | Reimplements lookup, validation, and access control | -| `OperationContext.trusted` | New field for nested call auth bypass | Does not exist | \ No newline at end of file +| What | Spec says | Source now does | +|------|----------|----------------| +| `execute()` access control | Checks `accessControl` when `identity` present; `ACCESS_DENIED` when `requiredScopes` non-empty and no `identity` | ✅ Implemented — checks access control unless `context.trusted` | +| `execute()` error type | Throws `CallError` | ✅ `CallError(OPERATION_NOT_FOUND)`, `CallError(ACCESS_DENIED)`, `CallError(VALIDATION_ERROR)` | +| `buildEnv()` | Always uses `execute()`, no `callMap` option | ✅ `callMap` removed, always calls `registry.execute()` with `trusted: true` | +| `CallHandler` | Thin adapter calling `registry.execute()` | ✅ Delegates to `registry.execute()`, publishes events | +| `OperationContext.trusted` | New field for nested call auth bypass | ✅ Added to `OperationContextSchema` and type | +| `subscribe()` access control | Checks access control when `identity` present | ✅ Implemented — same logic as `execute()` | +| `checkAccess()` export | Available for external use | ✅ Exported from `call.ts` and `index.ts` | \ No newline at end of file diff --git a/docs/architecture/call-protocol.md b/docs/architecture/call-protocol.md index 90b76dc..d025369 100644 --- a/docs/architecture/call-protocol.md +++ b/docs/architecture/call-protocol.md @@ -152,14 +152,14 @@ Looks up the `PendingRequest`, clears its timer, publishes `call.aborted`, rejec ## CallHandler -`buildCallHandler` creates a function that bridges pubsub events to `OperationRegistry.execute()`. It takes full ownership of publishing `call.responded` — handlers return values; they do NOT publish events. +`buildCallHandler` creates a function that bridges pubsub events to `OperationRegistry.execute()`. It delegates to `execute()` for the full invocation pipeline (lookup, access control, validation, handler, envelope wrapping, normalization, output validation), taking full ownership of publishing `call.responded`. ```ts function buildCallHandler(config: CallHandlerConfig): CallHandler interface CallHandlerConfig { registry: OperationRegistry - eventTarget?: EventTarget + callMap?: PendingRequestMap } type CallHandler = (event: CallRequestedEvent) => Promise @@ -167,19 +167,10 @@ type CallHandler = (event: CallRequestedEvent) => Promise ### Handler Flow -1. Look up spec by `operationId` from the registry via `getSpec()` -2. If not found, throw `CallError(OPERATION_NOT_FOUND, ...)` -3. Look up handler by `operationId` via `getHandler()` -4. If not found, throw `CallError(OPERATION_NOT_FOUND, "No handler registered for operation: ...")` -5. Check access control (see below) -6. Validate input with `validateOrThrow` -7. Execute operation handler -8. On success: apply the shared result pipeline (see [Response Envelopes → Shared Result Pipeline](response-envelopes.md#shared-result-pipeline)): - - Detect: `isResponseEnvelope(result)` → pass through, otherwise `localEnvelope(result, operationId)` - - Normalize: `Value.Cast(spec.outputSchema, envelope.data)` when `outputSchema` is not `Type.Unknown()` - - Validate: `collectErrors(spec.outputSchema, envelope.data)` — warning-only - - Publish `call.responded` via `callMap.respond(requestId, envelope)` -9. On failure: `mapError` converts the thrown value to `CallError`, publish `call.error` +1. Construct `OperationContext` from the event (`requestId`, `parentRequestId`, `identity` — `trusted` is NOT set, remote calls always run access control) +2. Call `registry.execute(operationId, input, context)` — this performs all validation, access control, and result pipeline +3. On success: publish `call.responded` via `callMap.respond(requestId, envelope)` +4. On failure: `mapError` converts the thrown value to `CallError`, publish `call.error` **Key change**: In the pre-envelope model, handlers were responsible for publishing `call.responded` themselves (the handler return value was discarded). In the envelope model, `CallHandler` owns wrapping and publishing. Handler return values are captured and wrapped. This ensures every response goes through the envelope pipeline — no raw values can bypass it. @@ -191,20 +182,25 @@ For MCP results with `meta.isError: true`, the handler still returns an envelope ## Access Control -### Enforcement Point +### Enforcement Points -`CallHandler` enforces `AccessControl` before calling the handler directly. Direct `registry.execute()` calls bypass access control — this is by design for trusted internal calls. +Access control is enforced in two places: + +1. **`registry.execute()`** — Checks `accessControl` on every invocation. Skips access control when `context.trusted === true` (nested calls from `buildEnv()`). When `requiredScopes` is non-empty and no `identity` is present, rejects with `ACCESS_DENIED`. + +2. **`subscribe()`** — Checks `accessControl` when called. Skips access control when `context.trusted === true`. Same default-deny logic as `execute()`. + +3. **`CallHandler`** — Delegates to `registry.execute()`, which performs access control. `CallHandler` does NOT set `trusted` on the context — remote calls always run access control because trust does not cross process boundaries. ### Flow ``` -call.requested event arrives with Identity - → Look up operation's AccessControl - → Check requiredScopes (caller has ALL?) - → Check requiredScopesAny (caller has ANY?) - → Check resourceType/resourceAction against identity.resources - → All pass → proceed to execute - → Any fail → throw CallError(ACCESS_DENIED, ...) +invoke execute(operationId, input, context) + → if context.trusted → skip access control + → if requiredScopes/requiredScopesAny/resourceType non-empty and no identity → ACCESS_DENIED + → else check identity against accessControl + → all pass → proceed to execute + → any fail → ACCESS_DENIED ``` ### `checkAccess` Implementation @@ -264,8 +260,7 @@ Operations declare their possible errors via `errorSchemas` on `IOperationDefini Routing is an env construction concern, not a separate protocol layer. `buildEnv` creates the `OperationEnv`: -- **Direct mode**: `buildEnv({ registry, context })` — env functions call `registry.execute()` directly, returning `Promise` -- **Call protocol mode**: `buildEnv({ registry, context, callMap })` — env functions call `callMap.call()`, which resolves to `Promise`, publishing `call.requested` events with `parentRequestId` propagation +- **Unified mode**: `buildEnv({ registry, context })` — env functions call `registry.execute()` directly, returning `Promise`. The context is propagated with `trusted: true` so nested calls skip redundant access control checks. `parentRequestId` enables call graph reconstruction and abort cascading — every nested call includes it. @@ -294,7 +289,7 @@ async function* subscribe( ): AsyncGenerator ``` -Gets the operation from the registry, casts its handler to `AsyncGenerator`, and yields each value wrapped in `ResponseEnvelope`. If a yielded value `isResponseEnvelope()`, it passes through (e.g., for adapter handlers). Otherwise, `localEnvelope(value, operationId)` wraps it with a fresh `timestamp` per yield. Properly cleans up with `generator.return()` in a `finally` block. +Gets the operation spec and checks access control (same default-deny logic as `execute()` — rejects with `ACCESS_DENIED` when `requiredScopes` is non-empty and no `identity` is present; skips check when `context.trusted`). Then casts the handler to `AsyncGenerator` and yields each value wrapped in `ResponseEnvelope`. If a yielded value `isResponseEnvelope()`, it passes through (e.g., for adapter handlers). Otherwise, `localEnvelope(value, operationId)` wraps it with a fresh `timestamp` per yield. Properly cleans up with `generator.return()` in a `finally` block. Use `subscribe()` for in-process consumption. Use `PendingRequestMap.call()` for cross-transport invocation that resolves after one event. For cross-transport streaming, use `PendingRequestMap.subscribe()` to yield multiple events. @@ -309,32 +304,22 @@ This allows spec-only registration for scenarios where handlers are provided sep ## Source vs. Spec Drift -This section documents differences between the architecture spec (this document) and the current source code. Items are planned changes not yet implemented. +This section documents differences between the architecture spec (this document) and the current source code. -### ADR-005 (Response Envelopes) — not yet implemented +### ADR-005 (Response Envelopes) — ✅ Implemented -| What | Spec says | Source currently does | -|------|----------|----------------------| -| `CallEventSchema["call.responded"].output` | `ResponseEnvelopeSchema` | `Type.Unknown()` | -| `CallHandler` behavior | Wraps handler return value, publishes `call.responded` | Discards handler return value; handler must publish itself | -| `CallHandler` error handling | Publishes `call.error` via pubsub | Re-throws `CallError` (does not publish) | -| `call()` return type | `Promise` | `Promise` | -| `call()` resolution | Resolves with `ResponseEnvelope` from `output` field | Resolves with raw `unknown` from `output` | -| `respond()` validation | Enforces `isResponseEnvelope()` guard, throws on raw values | Accepts `unknown`, no validation | -| `subscribe()` yield type | `AsyncGenerator`, wraps yields | `AsyncGenerator`, yields raw values | -| `buildEnv()` return types | `Promise` per function | `Promise` per function | +All ADR-005 changes have been implemented in source. No remaining drift. -### ADR-006 (Unified Invocation Path) — not yet implemented +### ADR-006 (Unified Invocation Path) — ✅ Implemented in source -| What | Spec says | Source currently does | -|------|----------|----------------------| -| `execute()` access control | Checks `accessControl` when `identity` present | Skips access control entirely | -| `execute()` unauthenticated calls | Rejects with `ACCESS_DENIED` when `requiredScopes` non-empty and `identity` absent | Always allows (no access check) | -| `CallHandler` calls `execute()` | Thin adapter that calls `registry.execute()` internally | Reimplements lookup, validation, and access control independently | -| `buildEnv()` | Always uses `execute()`, no `callMap` option | Toggles between `execute()` and `callMap.call()` via `if (callMap)` | -| `OperationContext.trusted` | New field for nested call bypass | Does not exist | -| `execute()` return type | `Promise>` | `Promise` | -| `execute()` error type | Throws `CallError` | Throws plain `Error` | +| What | Spec says | Source now does | +|------|----------|----------------| +| `execute()` access control | Checks `accessControl` when `identity` present; `ACCESS_DENIED` when `requiredScopes` non-empty and no `identity` | ✅ Implemented — checks access control unless `context.trusted` | +| `CallHandler` calls `execute()` | Thin adapter that calls `registry.execute()` internally | ✅ Delegates to `registry.execute()`, publishes events | +| `buildEnv()` | Always uses `execute()`, no `callMap` option | ✅ `callMap` removed, always calls `registry.execute()` with `trusted: true` | +| `OperationContext.trusted` | New field for nested call bypass | ✅ Added to `OperationContextSchema` and type | +| `execute()` error type | Throws `CallError` | ✅ `CallError(OPERATION_NOT_FOUND)`, `CallError(ACCESS_DENIED)`, `CallError(VALIDATION_ERROR)` | +| `subscribe()` access control | Checks access control when `identity` present; `ACCESS_DENIED` when `requiredScopes` non-empty and no `identity` | ✅ Implemented — same logic as `execute()` | ## References diff --git a/docs/architecture/decisions/006-unified-invocation-path.md b/docs/architecture/decisions/006-unified-invocation-path.md index 2165e8a..d2a8101 100644 --- a/docs/architecture/decisions/006-unified-invocation-path.md +++ b/docs/architecture/decisions/006-unified-invocation-path.md @@ -1,6 +1,6 @@ --- -status: draft -last_updated: 2026-05-10 +status: implemented +last_updated: 2026-05-11 --- # ADR-006: Unified Invocation Path diff --git a/src/call.ts b/src/call.ts index 5ca991e..cc8f53b 100644 --- a/src/call.ts +++ b/src/call.ts @@ -1,15 +1,10 @@ -import { Type, type Static, KindGuard } from "@alkdev/typebox"; -import { Value } from "@alkdev/typebox/value"; +import { Type, type Static } from "@alkdev/typebox"; import { createPubSub, type PubSub } from "@alkdev/pubsub"; -import { getLogger } from "@logtape/logtape"; import { OperationRegistry } from "./registry.js"; import { CallError, InfrastructureErrorCode, mapError } from "./error.js"; -import { validateOrThrow, collectErrors, formatValueErrors } from "./validation.js"; -import { ResponseEnvelopeSchema, isResponseEnvelope, localEnvelope } from "./response-envelope.js"; +import { ResponseEnvelopeSchema } from "./response-envelope.js"; import type { ResponseEnvelope } from "./response-envelope.js"; -import type { Identity, OperationContext, AccessControl, OperationSpec } from "./types.js"; - -const logger = getLogger("operations:call"); +import type { Identity, OperationContext, AccessControl } from "./types.js"; export const CallEventSchema = { "call.requested": Type.Object({ @@ -191,66 +186,18 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler { return async (event: CallRequestedEvent): Promise => { const { requestId, operationId, input, identity } = event; + const context: OperationContext = { + requestId, + parentRequestId: event.parentRequestId, + identity, + }; + try { - const spec = registry.getSpec(operationId); - - if (!spec) { - throw new CallError( - InfrastructureErrorCode.OPERATION_NOT_FOUND, - `Operation not found: ${operationId}`, - { operationId }, - ); - } - - const handler = registry.getHandler(operationId); - if (!handler) { - throw new CallError( - InfrastructureErrorCode.OPERATION_NOT_FOUND, - `No handler registered for operation: ${operationId}`, - { operationId }, - ); - } - - const accessControl: AccessControl = spec.accessControl as AccessControl; - - if (identity && !checkAccess(accessControl, identity)) { - throw new CallError( - InfrastructureErrorCode.ACCESS_DENIED, - `Access denied for operation: ${operationId}`, - { requiredScopes: accessControl.requiredScopes }, - ); - } - - const context: OperationContext = { - requestId, - parentRequestId: event.parentRequestId, - identity, - }; - - validateOrThrow(spec.inputSchema, input, `Input validation for ${operationId}`); - - const result = await handler(input, context); - - let envelope: ResponseEnvelope; - if (isResponseEnvelope(result)) { - envelope = result as ResponseEnvelope; - } else { - envelope = localEnvelope(result, operationId); - } - - if (!KindGuard.IsUnknown(spec.outputSchema)) { - envelope.data = Value.Cast(spec.outputSchema, envelope.data); - } - - const errors = collectErrors(spec.outputSchema, envelope.data); - if (errors.length > 0) { - logger.warn(`Output validation failed for ${operationId}:\n${formatValueErrors(errors)}`); - } + const envelope = await registry.execute(operationId, input, context); if (callMap) { callMap.respond(requestId, envelope); } - } catch (error) { const callError = mapError(error); if (callMap) { @@ -262,7 +209,7 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler { }; } -function checkAccess(accessControl: AccessControl, identity: Identity): boolean { +export function checkAccess(accessControl: AccessControl, identity: Identity): boolean { const { requiredScopes, requiredScopesAny, resourceType, resourceAction } = accessControl; if (requiredScopes.length > 0) { @@ -286,4 +233,12 @@ function checkAccess(accessControl: AccessControl, identity: Identity): boolean } return true; +} + +function isResponseEnvelope(value: unknown): value is ResponseEnvelope { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + if (!("data" in obj) || !("meta" in obj)) return false; + if (typeof obj.meta !== "object" || obj.meta === null) return false; + return ["local", "http", "mcp"].includes((obj.meta as Record).source as string); } \ No newline at end of file diff --git a/src/env.ts b/src/env.ts index 02e9c2b..4439347 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,24 +1,18 @@ import { OperationType } from "./types.js"; -import type { OperationContext, OperationEnv, Identity } from "./types.js"; +import type { OperationContext, OperationEnv } from "./types.js"; import type { OperationRegistry } from "./registry.js"; -import type { ResponseEnvelope } from "./response-envelope.js"; import { getLogger } from "@logtape/logtape"; const logger = getLogger("operations:env"); -export interface CallMap { - call(operationId: string, input: unknown, options?: { parentRequestId?: string; deadline?: number; identity?: Identity }): Promise; -} - export interface EnvOptions { registry: OperationRegistry; context: OperationContext; allowedNamespaces?: string[]; - callMap?: CallMap; } export function buildEnv(options: EnvOptions): OperationEnv { - const { registry, context, allowedNamespaces, callMap } = options; + const { registry, context, allowedNamespaces } = options; const specs = registry.getAllSpecs(); const namespaces: OperationEnv = {}; @@ -38,20 +32,15 @@ export function buildEnv(options: EnvOptions): OperationEnv { const operationId = `${spec.namespace}.${spec.name}`; - if (callMap) { - namespaces[spec.namespace][spec.name] = async (input: unknown) => { - logger.debug(`Call protocol: ${operationId}`); - return await callMap.call(operationId, input, { - parentRequestId: context.requestId, - identity: context.identity, - }); - }; - } else { - namespaces[spec.namespace][spec.name] = async (input: unknown) => { - logger.debug(`Executing: ${operationId}`); - return await registry.execute(operationId, input, context); - }; - } + const nestedContext: OperationContext = { + ...context, + trusted: true, + }; + + namespaces[spec.namespace][spec.name] = async (input: unknown) => { + logger.debug(`Executing: ${operationId}`); + return await registry.execute(operationId, input, nestedContext); + }; } return namespaces; diff --git a/src/index.ts b/src/index.ts index e3b2c1e..728f16d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Ident export { OperationRegistry } from "./registry.js"; export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js"; export { buildEnv } from "./env.js"; -export type { CallMap, EnvOptions } from "./env.js"; +export type { EnvOptions } from "./env.js"; export { FromSchema } from "./from_schema.js"; export { FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl } from "./from_openapi.js"; export type { OpenAPISpec, OpenAPIOperation, OpenAPIParameter, HTTPServiceConfig, OpenAPIFS } from "./from_openapi.js"; @@ -11,7 +11,7 @@ export { scanOperations } from "./scanner.js"; export type { OperationManifest, ScannerFS } from "./scanner.js"; export { CallError, InfrastructureErrorCode, mapError } from "./error.js"; export type { CallErrorCode } from "./error.js"; -export { PendingRequestMap, buildCallHandler } from "./call.js"; +export { PendingRequestMap, buildCallHandler, checkAccess } from "./call.js"; export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js"; export { subscribe } from "./subscribe.js"; export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js"; diff --git a/src/registry.ts b/src/registry.ts index 7eb0539..56b9939 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,9 +1,11 @@ -import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler } from "./types.js"; +import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler, Identity, AccessControl } from "./types.js"; import { getLogger } from "@logtape/logtape"; import { Value } from "@alkdev/typebox/value"; import { KindGuard } from "@alkdev/typebox"; import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js"; import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js"; +import { CallError, InfrastructureErrorCode } from "./error.js"; +import { checkAccess } from "./call.js"; const logger = getLogger("operations:registry"); @@ -86,12 +88,40 @@ export class OperationRegistry { ): Promise> { const spec = this.specs.get(operationId); if (!spec) { - throw new Error(`Operation not found: ${operationId}`); + throw new CallError( + InfrastructureErrorCode.OPERATION_NOT_FOUND, + `Operation not found: ${operationId}`, + { operationId }, + ); } const handler = this.handlers.get(operationId); if (!handler) { - throw new Error(`No handler registered for operation: ${operationId}`); + throw new CallError( + InfrastructureErrorCode.OPERATION_NOT_FOUND, + `No handler registered for operation: ${operationId}`, + { operationId }, + ); + } + + if (!context.trusted) { + const accessControl: AccessControl = spec.accessControl as AccessControl; + if (accessControl.requiredScopes.length > 0 || accessControl.requiredScopesAny?.length || accessControl.resourceType) { + if (!context.identity) { + throw new CallError( + InfrastructureErrorCode.ACCESS_DENIED, + `Access denied for operation: ${operationId} — identity required`, + { operationId, requiredScopes: accessControl.requiredScopes }, + ); + } + if (!checkAccess(accessControl, context.identity)) { + throw new CallError( + InfrastructureErrorCode.ACCESS_DENIED, + `Access denied for operation: ${operationId}`, + { requiredScopes: accessControl.requiredScopes }, + ); + } + } } validateOrThrow(spec.inputSchema, input, `Input validation failed for ${operationId}`); diff --git a/src/subscribe.ts b/src/subscribe.ts index f053f4e..c34283e 100644 --- a/src/subscribe.ts +++ b/src/subscribe.ts @@ -1,6 +1,8 @@ -import type { OperationContext } from "./types.js"; +import type { OperationContext, AccessControl } from "./types.js"; import { OperationRegistry } from "./registry.js"; import { type ResponseEnvelope, isResponseEnvelope, localEnvelope } from "./response-envelope.js"; +import { CallError, InfrastructureErrorCode } from "./error.js"; +import { checkAccess } from "./call.js"; export async function* subscribe( registry: OperationRegistry, @@ -11,13 +13,41 @@ export async function* subscribe( const spec = registry.getSpec(operationId); if (!spec) { - throw new Error(`Operation not found: ${operationId}`); + throw new CallError( + InfrastructureErrorCode.OPERATION_NOT_FOUND, + `Operation not found: ${operationId}`, + { operationId }, + ); } const handler = registry.getHandler(operationId); if (!handler) { - throw new Error(`No handler registered for operation: ${operationId}`); + throw new CallError( + InfrastructureErrorCode.OPERATION_NOT_FOUND, + `No handler registered for operation: ${operationId}`, + { operationId }, + ); + } + + if (!context.trusted) { + const accessControl: AccessControl = spec.accessControl as AccessControl; + if (accessControl.requiredScopes.length > 0 || accessControl.requiredScopesAny?.length || accessControl.resourceType) { + if (!context.identity) { + throw new CallError( + InfrastructureErrorCode.ACCESS_DENIED, + `Access denied for operation: ${operationId} — identity required`, + { operationId, requiredScopes: accessControl.requiredScopes }, + ); + } + if (!checkAccess(accessControl, context.identity)) { + throw new CallError( + InfrastructureErrorCode.ACCESS_DENIED, + `Access denied for operation: ${operationId}`, + { requiredScopes: accessControl.requiredScopes }, + ); + } + } } const generator = handler(input, context) as AsyncGenerator; diff --git a/src/types.ts b/src/types.ts index 875d49c..90d615e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,7 @@ export const OperationContextSchema = Type.Object({ scopes: Type.Array(Type.String()), resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))) })), + trusted: Type.Optional(Type.Boolean({ description: "INTERNAL: set by buildEnv(), not by callers" })), }, { description: "Context provided to all operation handlers" }); diff --git a/test/call.test.ts b/test/call.test.ts index f16a4df..9a07b6b 100644 --- a/test/call.test.ts +++ b/test/call.test.ts @@ -251,14 +251,14 @@ describe("CallHandler", () => { return registry; } - it("wraps handler return value in localEnvelope", async () => { + it("wraps handler return value in localEnvelope and publishes call.responded", async () => { const registry = makeRegistry(); const callMap = new PendingRequestMap(); const handler = buildCallHandler({ registry, callMap }); const callPromise = callMap.call("test.echo", { value: "hello" }); - handler({ + await handler({ requestId: [...callMap["requests"].keys()][0], operationId: "test.echo", input: { value: "hello" }, @@ -273,14 +273,14 @@ describe("CallHandler", () => { expect(result.data).toEqual({ value: "hello" }); }); - it("wraps undefined handler return value in localEnvelope", async () => { + it("wraps undefined handler return value and publishes call.responded", async () => { const registry = makeRegistry(); const callMap = new PendingRequestMap(); const handler = buildCallHandler({ registry, callMap }); const callPromise = callMap.call("test.voidOp", {}); - handler({ + await handler({ requestId: [...callMap["requests"].keys()][0], operationId: "test.voidOp", input: {}, @@ -318,7 +318,7 @@ describe("CallHandler", () => { const callPromise = callMap.call("test.mcpOp", {}); - handler({ + await handler({ requestId: [...callMap["requests"].keys()][0], operationId: "test.mcpOp", input: {}, @@ -353,7 +353,7 @@ describe("CallHandler", () => { const callPromise = callMap.call("test.httpOp", {}); - handler({ + await handler({ requestId: [...callMap["requests"].keys()][0], operationId: "test.httpOp", input: {}, @@ -432,64 +432,6 @@ describe("CallHandler", () => { } }); - it("applies Value.Cast normalization when outputSchema is not Unknown", async () => { - const registry = new OperationRegistry(); - registry.register({ - name: "defaultsFields", - namespace: "test", - version: "1.0.0", - type: OperationType.QUERY, - description: "op with default fields", - inputSchema: Type.Object({}), - outputSchema: Type.Object({ name: Type.String(), count: Type.Number({ default: 0 }) }), - accessControl: { requiredScopes: [] }, - handler: async () => ({ name: "test" }), - }); - - const callMap = new PendingRequestMap(); - const handler = buildCallHandler({ registry, callMap }); - - const callPromise = callMap.call("test.defaultsFields", {}); - - handler({ - requestId: [...callMap["requests"].keys()][0], - operationId: "test.defaultsFields", - input: {}, - }); - - const result = await callPromise; - expect(result.data).toEqual({ name: "test", count: 0 }); - }); - - it("does not normalize with Value.Cast when outputSchema is Unknown", async () => { - const registry = new OperationRegistry(); - registry.register({ - name: "unknownOutput", - namespace: "test", - version: "1.0.0", - type: OperationType.QUERY, - description: "op with unknown output", - inputSchema: Type.Object({}), - outputSchema: Type.Unknown(), - accessControl: { requiredScopes: [] }, - handler: async () => ({ name: "test", extra: "field" }), - }); - - const callMap = new PendingRequestMap(); - const handler = buildCallHandler({ registry, callMap }); - - const callPromise = callMap.call("test.unknownOutput", {}); - - handler({ - requestId: [...callMap["requests"].keys()][0], - operationId: "test.unknownOutput", - input: {}, - }); - - const result = await callPromise; - expect(result.data).toEqual({ name: "test", extra: "field" }); - }); - it("publishes call.error when operation not found", async () => { const registry = new OperationRegistry(); const callMap = new PendingRequestMap(); @@ -609,27 +551,85 @@ describe("CallHandler", () => { resources: { "project:abc": ["read"] }, }; - await expect( - handler({ - requestId: "r1", - operationId: "test.guarded", - input: {}, - identity, - }), - ).resolves.toBeUndefined(); + const result = await handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }); + + expect(result).toBeUndefined(); }); it("works without callMap for open operations", async () => { const registry = makeRegistry(); const handler = buildCallHandler({ registry }); - await expect( - handler({ - requestId: "r1", - operationId: "test.open", - input: {}, - }), - ).resolves.toBeUndefined(); + const result = await handler({ + requestId: "r1", + operationId: "test.open", + input: {}, + }); + + expect(result).toBeUndefined(); + }); + + it("applies Value.Cast normalization via execute()", async () => { + const registry = new OperationRegistry(); + registry.register({ + name: "defaultsFields", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "op with default fields", + inputSchema: Type.Object({}), + outputSchema: Type.Object({ name: Type.String(), count: Type.Number({ default: 0 }) }), + accessControl: { requiredScopes: [] }, + handler: async () => ({ name: "test" }), + }); + + const callMap = new PendingRequestMap(); + const handler = buildCallHandler({ registry, callMap }); + + const callPromise = callMap.call("test.defaultsFields", {}); + + await handler({ + requestId: [...callMap["requests"].keys()][0], + operationId: "test.defaultsFields", + input: {}, + }); + + const result = await callPromise; + expect(result.data).toEqual({ name: "test", count: 0 }); + }); + + it("does not normalize with Value.Cast when outputSchema is Unknown", async () => { + const registry = new OperationRegistry(); + registry.register({ + name: "unknownOutput", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "op with unknown output", + inputSchema: Type.Object({}), + outputSchema: Type.Unknown(), + accessControl: { requiredScopes: [] }, + handler: async () => ({ name: "test", extra: "field" }), + }); + + const callMap = new PendingRequestMap(); + const handler = buildCallHandler({ registry, callMap }); + + const callPromise = callMap.call("test.unknownOutput", {}); + + await handler({ + requestId: [...callMap["requests"].keys()][0], + operationId: "test.unknownOutput", + input: {}, + }); + + const result = await callPromise; + expect(result.data).toEqual({ name: "test", extra: "field" }); }); }); @@ -750,14 +750,13 @@ describe("checkAccess resource access control", () => { resources: { "project:abc": ["read"] }, }; - await expect( - handler({ - requestId: "r1", - operationId: "test.guarded", - input: {}, - identity, - }), - ).resolves.toBeUndefined(); + const result = await handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }); + expect(result).toBeUndefined(); }); it("grants access when neither resourceType nor resourceAction are set", async () => { @@ -766,14 +765,13 @@ describe("checkAccess resource access control", () => { const identity: Identity = { id: "user1", scopes: [] }; - await expect( - handler({ - requestId: "r1", - operationId: "test.open", - input: {}, - identity, - }), - ).resolves.toBeUndefined(); + const result = await handler({ + requestId: "r1", + operationId: "test.open", + input: {}, + identity, + }); + expect(result).toBeUndefined(); }); it("grants access when identity.resources matches and identity has no scopes required", async () => { @@ -786,13 +784,12 @@ describe("checkAccess resource access control", () => { resources: { "project:xyz": ["read", "write"] }, }; - await expect( - handler({ - requestId: "r1", - operationId: "test.guarded", - input: {}, - identity, - }), - ).resolves.toBeUndefined(); + const result = await handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }); + expect(result).toBeUndefined(); }); }); \ No newline at end of file diff --git a/test/env.test.ts b/test/env.test.ts index 3c5af00..2612e21 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from "vitest"; import { OperationRegistry, OperationType, buildEnv, type IOperationDefinition, type OperationContext } from "../src/index.js"; import * as Type from "@alkdev/typebox"; -import { PendingRequestMap } from "../src/call.js"; -import { localEnvelope, httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js"; +import { httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js"; +import { CallError, InfrastructureErrorCode } from "../src/error.js"; import type { Identity } from "../src/types.js"; function makeOperation(name: string, handler?: any): IOperationDefinition { @@ -124,135 +124,95 @@ describe("buildEnv", () => { expect(env.other).toBeUndefined(); }); - it("routes through callMap in call protocol mode", async () => { + it("always uses execute() and sets trusted: true on nested context", async () => { + let capturedContext: OperationContext | undefined; const registry = new OperationRegistry(); - registry.register(makeOperation("readFile")); - - const callMap = { - call: async (opId: string, input: unknown, opts?: any): Promise => { - return localEnvelope({ result: `routed: ${opId}` }, opId); + registry.register({ + name: "inner", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "inner op", + inputSchema: Type.Object({ value: Type.String() }), + outputSchema: Type.Object({ result: Type.String() }), + accessControl: { requiredScopes: [] }, + handler: async (input: any, ctx: OperationContext) => { + capturedContext = ctx; + return { result: input.value }; }, - }; - - const env = buildEnv({ - registry, - context: {} as OperationContext, - callMap, }); - const result = await env.test.readFile({ value: "test" }); + const outerContext: OperationContext = { + requestId: "outer-123", + identity: { id: "user1", scopes: ["read"] }, + }; + + const env = buildEnv({ registry, context: outerContext }); + await env.test.inner({ value: "hello" }); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.trusted).toBe(true); + expect(capturedContext!.requestId).toBe("outer-123"); + expect(capturedContext!.identity).toEqual({ id: "user1", scopes: ["read"] }); + }); + + it("skips access control for trusted nested calls", async () => { + const registry = new OperationRegistry(); + registry.register({ + name: "guarded", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "guarded op", + inputSchema: Type.Object({ value: Type.String() }), + outputSchema: Type.Object({ result: Type.String() }), + accessControl: { + requiredScopes: ["admin"], + }, + handler: async (input: any) => ({ result: input.value }), + }); + + const outerContext: OperationContext = { + requestId: "outer-456", + identity: { id: "user1", scopes: ["read"] }, + }; + + const env = buildEnv({ registry, context: outerContext }); + const result = await env.test.guarded({ value: "secret" }); expect(isResponseEnvelope(result)).toBe(true); - expect(result.data).toEqual({ result: "routed: test.readFile" }); + expect(result.data).toEqual({ result: "secret" }); }); - it("passes parentRequestId through callMap in call protocol mode", async () => { + it("propagates identity through nested calls with trusted flag", async () => { + let capturedContext: OperationContext | undefined; const registry = new OperationRegistry(); - registry.register(makeOperation("op1")); - - let capturedOptions: any = null; - const callMap = { - call: async (opId: string, input: unknown, opts?: any): Promise => { - capturedOptions = opts; - return localEnvelope({ result: "ok" }, opId); + registry.register({ + name: "inner", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "inner op", + inputSchema: Type.Object({ value: Type.String() }), + outputSchema: Type.Object({ result: Type.String() }), + accessControl: { requiredScopes: [] }, + handler: async (input: any, ctx: OperationContext) => { + capturedContext = ctx; + return { result: input.value }; }, - }; - - const context: OperationContext = { - requestId: "parent-req-123", - }; - - const env = buildEnv({ - registry, - context, - callMap, }); - await env.test.op1({ value: "test" }); - - expect(capturedOptions).not.toBeNull(); - expect(capturedOptions.parentRequestId).toBe("parent-req-123"); - }); - - it("passes identity through callMap in call protocol mode", async () => { - const registry = new OperationRegistry(); - registry.register(makeOperation("op1")); - - let capturedOptions: any = null; - const callMap = { - call: async (opId: string, input: unknown, opts?: any): Promise => { - capturedOptions = opts; - return localEnvelope({ result: "ok" }, opId); - }, - }; - const identity: Identity = { id: "user1", scopes: ["read"] }; - const context: OperationContext = { + const outerContext: OperationContext = { requestId: "parent-req-456", identity, }; - const env = buildEnv({ - registry, - context, - callMap, - }); + const env = buildEnv({ registry, context: outerContext }); + await env.test.inner({ value: "test" }); - await env.test.op1({ value: "test" }); - - expect(capturedOptions).not.toBeNull(); - expect(capturedOptions.parentRequestId).toBe("parent-req-456"); - expect(capturedOptions.identity).toEqual(identity); - }); - - it("does not pass identity when context has no identity in call protocol mode", async () => { - const registry = new OperationRegistry(); - registry.register(makeOperation("op1")); - - let capturedOptions: any = null; - const callMap = { - call: async (opId: string, input: unknown, opts?: any): Promise => { - capturedOptions = opts; - return localEnvelope({ result: "ok" }, opId); - }, - }; - - const context: OperationContext = { - requestId: "parent-req-789", - }; - - const env = buildEnv({ - registry, - context, - callMap, - }); - - await env.test.op1({ value: "test" }); - - expect(capturedOptions).not.toBeNull(); - expect(capturedOptions.parentRequestId).toBe("parent-req-789"); - expect(capturedOptions.identity).toBeUndefined(); - }); - - it("works with PendingRequestMap as callMap", async () => { - const registry = new OperationRegistry(); - registry.register(makeOperation("echo")); - - const callMap = new PendingRequestMap(); - const env = buildEnv({ - registry, - context: {} as OperationContext, - callMap, - }); - - const callPromise = env.test.echo({ value: "hello" }); - - const requestId = [...(callMap as any).requests.keys()][0]; - callMap.respond(requestId, localEnvelope({ result: "echoed" }, "test.echo")); - - const result = await callPromise; - expect(isResponseEnvelope(result)).toBe(true); - expect(result.data).toEqual({ result: "echoed" }); - expect(result.meta.source).toBe("local"); + expect(capturedContext).toBeDefined(); + expect(capturedContext!.identity).toEqual(identity); + expect(capturedContext!.trusted).toBe(true); }); it("returns empty env when registry has no specs", () => { diff --git a/test/registry.test.ts b/test/registry.test.ts index 0ded768..a7ef543 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { OperationRegistry } from "../src/registry.js"; import { OperationType, type IOperationDefinition, type OperationContext, type OperationSpec, type OperationHandler } from "../src/index.js"; import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "../src/response-envelope.js"; +import { CallError, InfrastructureErrorCode } from "../src/error.js"; import * as Type from "@alkdev/typebox"; import { Value } from "@alkdev/typebox/value"; @@ -158,19 +159,27 @@ describe("OperationRegistry", () => { ).rejects.toThrow(); }); - it("throws on missing operation", async () => { + it("throws CallError on missing operation", async () => { const registry = new OperationRegistry(); - await expect( - registry.execute("missing.op", {}, {} as OperationContext) - ).rejects.toThrow("Operation not found"); + try { + await registry.execute("missing.op", {}, {} as OperationContext); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND); + } }); - it("throws on missing handler", async () => { + it("throws CallError on missing handler", async () => { const registry = new OperationRegistry(); registry.registerSpec(makeSpec()); - await expect( - registry.execute("test.testOp", { value: "hello" }, {} as OperationContext) - ).rejects.toThrow("No handler registered"); + try { + await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND); + } }); it("registerSpec and registerHandler separately", async () => { @@ -221,4 +230,175 @@ describe("OperationRegistry", () => { expect(spec.name).toBe("testOp"); expect((spec as any).handler).toBeUndefined(); }); +}); + +describe("OperationRegistry access control", () => { + it("denies access when requiredScopes are set and no identity provided", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: ["admin"] }, + })); + + try { + await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED); + } + }); + + it("denies access when identity lacks required scopes", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: ["admin", "write"] }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: ["read"] }, + }; + + try { + await registry.execute("test.testOp", { value: "hello" }, context); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED); + } + }); + + it("grants access when identity has all required scopes", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: ["read", "write"] }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: ["read", "write", "admin"] }, + }; + + const envelope = await registry.execute("test.testOp", { value: "hello" }, context); + expect(envelope.data).toEqual({ result: "processed: hello" }); + }); + + it("grants access when no scopes are required", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: [] }, + })); + + const envelope = await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext); + expect(envelope.data).toEqual({ result: "processed: hello" }); + }); + + it("denies access when requiredScopesAny requires at least one scope and identity has none", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: [], requiredScopesAny: ["admin", "write"] }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: ["read"] }, + }; + + try { + await registry.execute("test.testOp", { value: "hello" }, context); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED); + } + }); + + it("grants access when requiredScopesAny and identity has one matching scope", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: [], requiredScopesAny: ["admin", "write"] }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: ["write"] }, + }; + + const envelope = await registry.execute("test.testOp", { value: "hello" }, context); + expect(envelope.data).toEqual({ result: "processed: hello" }); + }); + + it("denies access when resourceType/resourceAction are set and identity has no resources", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: [], resourceType: "project", resourceAction: "read" }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: [] }, + }; + + try { + await registry.execute("test.testOp", { value: "hello" }, context); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED); + } + }); + + it("grants access when resourceType/resourceAction match identity resources", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: [], resourceType: "project", resourceAction: "read" }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: [], resources: { "project:abc": ["read", "write"] } }, + }; + + const envelope = await registry.execute("test.testOp", { value: "hello" }, context); + expect(envelope.data).toEqual({ result: "processed: hello" }); + }); + + it("skips access control when context.trusted is true", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: ["admin"] }, + })); + + const context: OperationContext = { + identity: { id: "user1", scopes: ["read"] }, + trusted: true, + }; + + const envelope = await registry.execute("test.testOp", { value: "hello" }, context); + expect(envelope.data).toEqual({ result: "processed: hello" }); + }); + + it("skips access control when context.trusted is true even without identity", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: ["admin"] }, + })); + + const context: OperationContext = { trusted: true }; + + const envelope = await registry.execute("test.testOp", { value: "hello" }, context); + expect(envelope.data).toEqual({ result: "processed: hello" }); + }); + + it("denies access when identity is missing but requiredScopes are set", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation({ + accessControl: { requiredScopes: ["read"] }, + })); + + const context: OperationContext = {}; + + try { + await registry.execute("test.testOp", { value: "hello" }, context); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED); + expect((error as CallError).message).toContain("identity required"); + } + }); }); \ No newline at end of file diff --git a/test/subscribe.test.ts b/test/subscribe.test.ts index 586d78f..df8c107 100644 --- a/test/subscribe.test.ts +++ b/test/subscribe.test.ts @@ -6,9 +6,10 @@ import { OperationType } from "../src/types.js"; import type { OperationContext } from "../src/types.js"; import { localEnvelope, httpEnvelope, mcpEnvelope, isResponseEnvelope } from "../src/response-envelope.js"; import type { ResponseEnvelope, LocalResponseMeta } from "../src/response-envelope.js"; +import { CallError, InfrastructureErrorCode } from "../src/error.js"; -function makeContext(): OperationContext { - return { requestId: "test-req-1" }; +function makeContext(overrides: Partial = {}): OperationContext { + return { requestId: "test-req-1", ...overrides }; } function makeRegistry( @@ -139,17 +140,21 @@ describe("subscribe", () => { expect(done.done).toBe(true); }); - it("throws when operation spec not found", async () => { + it("throws CallError when operation spec not found", async () => { const registry = new OperationRegistry(); - await expect(async () => { + try { for await (const _ of subscribe(registry, "nonexistent.op", {}, makeContext())) { // should not reach here } - }).rejects.toThrow("Operation not found: nonexistent.op"); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND); + } }); - it("throws when handler not registered", async () => { + it("throws CallError when handler not registered", async () => { const registry = new OperationRegistry(); registry.registerSpec({ name: "unhandled", @@ -162,11 +167,15 @@ describe("subscribe", () => { accessControl: { requiredScopes: [] }, }); - await expect(async () => { + try { for await (const _ of subscribe(registry, "test.unhandled", {}, makeContext())) { // should not reach here } - }).rejects.toThrow("No handler registered for operation: test.unhandled"); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND); + } }); it("handles generator that yields nothing", async () => { @@ -294,4 +303,81 @@ describe("subscribe", () => { expect(returnCalled).toBe(true); }); + + it("denies access when requiredScopes are set and no identity provided", async () => { + const registry = new OperationRegistry(); + registry.register({ + name: "guardedSub", + namespace: "test", + version: "1.0.0", + type: OperationType.SUBSCRIPTION, + description: "guarded sub", + inputSchema: Type.Object({}), + outputSchema: Type.Unknown(), + accessControl: { requiredScopes: ["admin"] }, + handler: async function* (_input: unknown, _context: OperationContext) { + yield "should not reach"; + }, + }); + + try { + for await (const _ of subscribe(registry, "test.guardedSub", {}, makeContext())) { + // should not reach here + } + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED); + } + }); + + it("grants access when identity has required scopes", async () => { + const registry = new OperationRegistry(); + registry.register({ + name: "guardedSub", + namespace: "test", + version: "1.0.0", + type: OperationType.SUBSCRIPTION, + description: "guarded sub", + inputSchema: Type.Object({}), + outputSchema: Type.Unknown(), + accessControl: { requiredScopes: ["read"] }, + handler: async function* (_input: unknown, _context: OperationContext) { + yield "event1"; + }, + }); + + const context = makeContext({ identity: { id: "user1", scopes: ["read"] } }); + const results: ResponseEnvelope[] = []; + for await (const envelope of subscribe(registry, "test.guardedSub", {}, context)) { + results.push(envelope); + } + expect(results).toHaveLength(1); + expect(results[0].data).toBe("event1"); + }); + + it("skips access control when context.trusted is true", async () => { + const registry = new OperationRegistry(); + registry.register({ + name: "trustedSub", + namespace: "test", + version: "1.0.0", + type: OperationType.SUBSCRIPTION, + description: "trusted sub", + inputSchema: Type.Object({}), + outputSchema: Type.Unknown(), + accessControl: { requiredScopes: ["admin"] }, + handler: async function* (_input: unknown, _context: OperationContext) { + yield "secret-event"; + }, + }); + + const context = makeContext({ trusted: true }); + const results: ResponseEnvelope[] = []; + for await (const envelope of subscribe(registry, "test.trustedSub", {}, context)) { + results.push(envelope); + } + expect(results).toHaveLength(1); + expect(results[0].data).toBe("secret-event"); + }); }); \ No newline at end of file