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
This commit is contained in:
2026-05-11 03:04:19 +00:00
parent d74b750ecb
commit e138866fcd
13 changed files with 608 additions and 410 deletions

View File

@@ -117,7 +117,7 @@ type OperationContext = Static<typeof OperationContextSchema> & {
}
```
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<OperationSpec & { handler?: ... }>` | All registered entries (spec + handler if present). |
| `getAllSpecs()` | `() => OperationSpec[]` | All serializable specs. |
| `execute(operationId, input, context)` | `(id: string, input: TInput, ctx: OperationContext) => Promise<ResponseEnvelope<TOutput>>` | 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<ResponseEnvelope<TOutput>>` | 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<void>
```
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<ResponseEnvelope>` — 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<ResponseEnvelope>` — 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<ResponseEnvelope<TOutput>>` | `Promise<TOutput>` |
| `execute()` result pipeline | Detect → wrap → normalize → validate | Returns raw `result`, validates raw output with `collectErrors` |
| `OperationEnv` inner function return type | `Promise<ResponseEnvelope>` | `Promise<unknown>` |
| `PendingRequestMap.call()` return type | `Promise<ResponseEnvelope>` | `Promise<unknown>` |
| `PendingRequestMap.respond()` validation | Enforces `isResponseEnvelope()`, throws on raw values | Accepts `unknown`, no validation |
| `subscribe()` yield type | `AsyncGenerator<ResponseEnvelope, void, unknown>` | `AsyncGenerator<unknown, void, unknown>` |
| `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 |
| 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` |

View File

@@ -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<void>
@@ -167,19 +167,10 @@ type CallHandler = (event: CallRequestedEvent) => Promise<void>
### 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<ResponseEnvelope>`
- **Call protocol mode**: `buildEnv({ registry, context, callMap })` — env functions call `callMap.call()`, which resolves to `Promise<ResponseEnvelope>`, publishing `call.requested` events with `parentRequestId` propagation
- **Unified mode**: `buildEnv({ registry, context })` — env functions call `registry.execute()` directly, returning `Promise<ResponseEnvelope>`. 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<ResponseEnvelope, void, unknown>
```
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<ResponseEnvelope>` | `Promise<unknown>` |
| `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<ResponseEnvelope, void, unknown>`, wraps yields | `AsyncGenerator<unknown, void, unknown>`, yields raw values |
| `buildEnv()` return types | `Promise<ResponseEnvelope>` per function | `Promise<unknown>` 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<ResponseEnvelope<TOutput>>` | `Promise<TOutput>` |
| `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

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-05-10
status: implemented
last_updated: 2026-05-11
---
# ADR-006: Unified Invocation Path

View File

@@ -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<void> => {
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<string, unknown>;
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<string, unknown>).source as string);
}

View File

@@ -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<ResponseEnvelope>;
}
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;

View File

@@ -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";

View File

@@ -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<ResponseEnvelope<TOutput>> {
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}`);

View File

@@ -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<unknown, void, unknown>;

View File

@@ -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"
});

View File

@@ -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();
});
});

View File

@@ -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<ResponseEnvelope> => {
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<ResponseEnvelope> => {
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<ResponseEnvelope> => {
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<ResponseEnvelope> => {
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", () => {

View File

@@ -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");
}
});
});

View File

@@ -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> = {}): 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");
});
});