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` ### `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. | | `getByName(namespace, name)` | `(namespace: string, name: string) => (OperationSpec & { handler?: ... }) \| undefined` | Get by parts. |
| `list()` | `() => Array<OperationSpec & { handler?: ... }>` | All registered entries (spec + handler if present). | | `list()` | `() => Array<OperationSpec & { handler?: ... }>` | All registered entries (spec + handler if present). |
| `getAllSpecs()` | `() => OperationSpec[]` | All serializable specs. | | `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. 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> 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` ### `CallEventMap`
@@ -273,14 +273,10 @@ interface EnvOptions {
registry: OperationRegistry registry: OperationRegistry
context: OperationContext context: OperationContext
allowedNamespaces?: string[] 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: 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.
- **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
`SUBSCRIPTION` operations are filtered out — env only provides QUERY and MUTATION operations 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 ## 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 | All ADR-005 changes have been implemented in source. No remaining drift.
|------|----------|----------------------|
| `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 |
### ADR-006 (Unified Invocation Path) — not yet implemented ### ADR-006 (Unified Invocation Path) — ✅ Implemented in source
| What | Spec says | Source currently does | | What | Spec says | Source now does |
|------|----------|----------------------| |------|----------|----------------|
| `execute()` access control | Checks `accessControl` when `identity` present | Skips access control entirely | | `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()` on unauthenticated access | Rejects with `ACCESS_DENIED` when `requiredScopes` non-empty and no `identity` | Always allows | | `execute()` error type | Throws `CallError` | ✅ `CallError(OPERATION_NOT_FOUND)`, `CallError(ACCESS_DENIED)`, `CallError(VALIDATION_ERROR)` |
| `execute()` error type | Throws `CallError` | Throws plain `Error` | | `buildEnv()` | Always uses `execute()`, no `callMap` option | ✅ `callMap` removed, always calls `registry.execute()` with `trusted: true` |
| `buildEnv()` | Always uses `execute()`, no `callMap` option | Toggles between `execute()` and `callMap.call()` | | `CallHandler` | Thin adapter calling `registry.execute()` | ✅ Delegates to `registry.execute()`, publishes events |
| `CallHandler` | Thin adapter calling `registry.execute()` | Reimplements lookup, validation, and access control | | `OperationContext.trusted` | New field for nested call auth bypass | ✅ Added to `OperationContextSchema` and type |
| `OperationContext.trusted` | New field for nested call auth bypass | Does not exist | | `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 ## 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 ```ts
function buildCallHandler(config: CallHandlerConfig): CallHandler function buildCallHandler(config: CallHandlerConfig): CallHandler
interface CallHandlerConfig { interface CallHandlerConfig {
registry: OperationRegistry registry: OperationRegistry
eventTarget?: EventTarget callMap?: PendingRequestMap
} }
type CallHandler = (event: CallRequestedEvent) => Promise<void> type CallHandler = (event: CallRequestedEvent) => Promise<void>
@@ -167,19 +167,10 @@ type CallHandler = (event: CallRequestedEvent) => Promise<void>
### Handler Flow ### Handler Flow
1. Look up spec by `operationId` from the registry via `getSpec()` 1. Construct `OperationContext` from the event (`requestId`, `parentRequestId`, `identity``trusted` is NOT set, remote calls always run access control)
2. If not found, throw `CallError(OPERATION_NOT_FOUND, ...)` 2. Call `registry.execute(operationId, input, context)` — this performs all validation, access control, and result pipeline
3. Look up handler by `operationId` via `getHandler()` 3. On success: publish `call.responded` via `callMap.respond(requestId, envelope)`
4. If not found, throw `CallError(OPERATION_NOT_FOUND, "No handler registered for operation: ...")` 4. On failure: `mapError` converts the thrown value to `CallError`, publish `call.error`
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`
**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. **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 ## 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 ### Flow
``` ```
call.requested event arrives with Identity invoke execute(operationId, input, context)
Look up operation's AccessControl if context.trusted → skip access control
Check requiredScopes (caller has ALL?) if requiredScopes/requiredScopesAny/resourceType non-empty and no identity → ACCESS_DENIED
Check requiredScopesAny (caller has ANY?) else check identity against accessControl
Check resourceType/resourceAction against identity.resources all pass → proceed to execute
All pass → proceed to execute any fail → ACCESS_DENIED
→ Any fail → throw CallError(ACCESS_DENIED, ...)
``` ```
### `checkAccess` Implementation ### `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`: 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>` - **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.
- **Call protocol mode**: `buildEnv({ registry, context, callMap })` — env functions call `callMap.call()`, which resolves to `Promise<ResponseEnvelope>`, publishing `call.requested` events with `parentRequestId` propagation
`parentRequestId` enables call graph reconstruction and abort cascading — every nested call includes it. `parentRequestId` enables call graph reconstruction and abort cascading — every nested call includes it.
@@ -294,7 +289,7 @@ async function* subscribe(
): AsyncGenerator<ResponseEnvelope, void, unknown> ): 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. 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 ## 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 | All ADR-005 changes have been implemented in source. No remaining drift.
|------|----------|----------------------|
| `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 |
### ADR-006 (Unified Invocation Path) — not yet implemented ### ADR-006 (Unified Invocation Path) — ✅ Implemented in source
| What | Spec says | Source currently does | | What | Spec says | Source now does |
|------|----------|----------------------| |------|----------|----------------|
| `execute()` access control | Checks `accessControl` when `identity` present | Skips access control entirely | | `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()` 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 | ✅ Delegates to `registry.execute()`, publishes events |
| `CallHandler` calls `execute()` | Thin adapter that calls `registry.execute()` internally | Reimplements lookup, validation, and access control independently | | `buildEnv()` | Always uses `execute()`, no `callMap` option | ✅ `callMap` removed, always calls `registry.execute()` with `trusted: true` |
| `buildEnv()` | Always uses `execute()`, no `callMap` option | Toggles between `execute()` and `callMap.call()` via `if (callMap)` | | `OperationContext.trusted` | New field for nested call bypass | ✅ Added to `OperationContextSchema` and type |
| `OperationContext.trusted` | New field for nested call bypass | Does not exist | | `execute()` error type | Throws `CallError` | ✅ `CallError(OPERATION_NOT_FOUND)`, `CallError(ACCESS_DENIED)`, `CallError(VALIDATION_ERROR)` |
| `execute()` return type | `Promise<ResponseEnvelope<TOutput>>` | `Promise<TOutput>` | | `subscribe()` access control | Checks access control when `identity` present; `ACCESS_DENIED` when `requiredScopes` non-empty and no `identity` | ✅ Implemented — same logic as `execute()` |
| `execute()` error type | Throws `CallError` | Throws plain `Error` |
## References ## References

View File

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

View File

@@ -1,15 +1,10 @@
import { Type, type Static, KindGuard } from "@alkdev/typebox"; import { Type, type Static } from "@alkdev/typebox";
import { Value } from "@alkdev/typebox/value";
import { createPubSub, type PubSub } from "@alkdev/pubsub"; import { createPubSub, type PubSub } from "@alkdev/pubsub";
import { getLogger } from "@logtape/logtape";
import { OperationRegistry } from "./registry.js"; import { OperationRegistry } from "./registry.js";
import { CallError, InfrastructureErrorCode, mapError } from "./error.js"; import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
import { validateOrThrow, collectErrors, formatValueErrors } from "./validation.js"; import { ResponseEnvelopeSchema } from "./response-envelope.js";
import { ResponseEnvelopeSchema, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
import type { ResponseEnvelope } from "./response-envelope.js"; import type { ResponseEnvelope } from "./response-envelope.js";
import type { Identity, OperationContext, AccessControl, OperationSpec } from "./types.js"; import type { Identity, OperationContext, AccessControl } from "./types.js";
const logger = getLogger("operations:call");
export const CallEventSchema = { export const CallEventSchema = {
"call.requested": Type.Object({ "call.requested": Type.Object({
@@ -191,66 +186,18 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
return async (event: CallRequestedEvent): Promise<void> => { return async (event: CallRequestedEvent): Promise<void> => {
const { requestId, operationId, input, identity } = event; const { requestId, operationId, input, identity } = event;
const context: OperationContext = {
requestId,
parentRequestId: event.parentRequestId,
identity,
};
try { try {
const spec = registry.getSpec(operationId); const envelope = await registry.execute(operationId, input, context);
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)}`);
}
if (callMap) { if (callMap) {
callMap.respond(requestId, envelope); callMap.respond(requestId, envelope);
} }
} catch (error) { } catch (error) {
const callError = mapError(error); const callError = mapError(error);
if (callMap) { 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; const { requiredScopes, requiredScopesAny, resourceType, resourceAction } = accessControl;
if (requiredScopes.length > 0) { if (requiredScopes.length > 0) {
@@ -286,4 +233,12 @@ function checkAccess(accessControl: AccessControl, identity: Identity): boolean
} }
return true; 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 { 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 { OperationRegistry } from "./registry.js";
import type { ResponseEnvelope } from "./response-envelope.js";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
const logger = getLogger("operations:env"); 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 { export interface EnvOptions {
registry: OperationRegistry; registry: OperationRegistry;
context: OperationContext; context: OperationContext;
allowedNamespaces?: string[]; allowedNamespaces?: string[];
callMap?: CallMap;
} }
export function buildEnv(options: EnvOptions): OperationEnv { export function buildEnv(options: EnvOptions): OperationEnv {
const { registry, context, allowedNamespaces, callMap } = options; const { registry, context, allowedNamespaces } = options;
const specs = registry.getAllSpecs(); const specs = registry.getAllSpecs();
const namespaces: OperationEnv = {}; const namespaces: OperationEnv = {};
@@ -38,20 +32,15 @@ export function buildEnv(options: EnvOptions): OperationEnv {
const operationId = `${spec.namespace}.${spec.name}`; const operationId = `${spec.namespace}.${spec.name}`;
if (callMap) { const nestedContext: OperationContext = {
namespaces[spec.namespace][spec.name] = async (input: unknown) => { ...context,
logger.debug(`Call protocol: ${operationId}`); trusted: true,
return await callMap.call(operationId, input, { };
parentRequestId: context.requestId,
identity: context.identity, namespaces[spec.namespace][spec.name] = async (input: unknown) => {
}); logger.debug(`Executing: ${operationId}`);
}; return await registry.execute(operationId, input, nestedContext);
} else { };
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
logger.debug(`Executing: ${operationId}`);
return await registry.execute(operationId, input, context);
};
}
} }
return namespaces; return namespaces;

View File

@@ -3,7 +3,7 @@ export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Ident
export { OperationRegistry } from "./registry.js"; export { OperationRegistry } from "./registry.js";
export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js"; export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js";
export { buildEnv } from "./env.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 { FromSchema } from "./from_schema.js";
export { FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl } from "./from_openapi.js"; export { FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl } from "./from_openapi.js";
export type { OpenAPISpec, OpenAPIOperation, OpenAPIParameter, HTTPServiceConfig, OpenAPIFS } 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 type { OperationManifest, ScannerFS } from "./scanner.js";
export { CallError, InfrastructureErrorCode, mapError } from "./error.js"; export { CallError, InfrastructureErrorCode, mapError } from "./error.js";
export type { CallErrorCode } 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 type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
export { subscribe } from "./subscribe.js"; export { subscribe } from "./subscribe.js";
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.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 { getLogger } from "@logtape/logtape";
import { Value } from "@alkdev/typebox/value"; import { Value } from "@alkdev/typebox/value";
import { KindGuard } from "@alkdev/typebox"; import { KindGuard } from "@alkdev/typebox";
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js"; import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.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"); const logger = getLogger("operations:registry");
@@ -86,12 +88,40 @@ export class OperationRegistry {
): Promise<ResponseEnvelope<TOutput>> { ): Promise<ResponseEnvelope<TOutput>> {
const spec = this.specs.get(operationId); const spec = this.specs.get(operationId);
if (!spec) { 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); const handler = this.handlers.get(operationId);
if (!handler) { 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}`); 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 { OperationRegistry } from "./registry.js";
import { type ResponseEnvelope, isResponseEnvelope, localEnvelope } from "./response-envelope.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( export async function* subscribe(
registry: OperationRegistry, registry: OperationRegistry,
@@ -11,13 +13,41 @@ export async function* subscribe(
const spec = registry.getSpec(operationId); const spec = registry.getSpec(operationId);
if (!spec) { 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); const handler = registry.getHandler(operationId);
if (!handler) { 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>; 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()), scopes: Type.Array(Type.String()),
resources: Type.Optional(Type.Record(Type.String(), 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" description: "Context provided to all operation handlers"
}); });

View File

@@ -251,14 +251,14 @@ describe("CallHandler", () => {
return registry; 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 registry = makeRegistry();
const callMap = new PendingRequestMap(); const callMap = new PendingRequestMap();
const handler = buildCallHandler({ registry, callMap }); const handler = buildCallHandler({ registry, callMap });
const callPromise = callMap.call("test.echo", { value: "hello" }); const callPromise = callMap.call("test.echo", { value: "hello" });
handler({ await handler({
requestId: [...callMap["requests"].keys()][0], requestId: [...callMap["requests"].keys()][0],
operationId: "test.echo", operationId: "test.echo",
input: { value: "hello" }, input: { value: "hello" },
@@ -273,14 +273,14 @@ describe("CallHandler", () => {
expect(result.data).toEqual({ value: "hello" }); 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 registry = makeRegistry();
const callMap = new PendingRequestMap(); const callMap = new PendingRequestMap();
const handler = buildCallHandler({ registry, callMap }); const handler = buildCallHandler({ registry, callMap });
const callPromise = callMap.call("test.voidOp", {}); const callPromise = callMap.call("test.voidOp", {});
handler({ await handler({
requestId: [...callMap["requests"].keys()][0], requestId: [...callMap["requests"].keys()][0],
operationId: "test.voidOp", operationId: "test.voidOp",
input: {}, input: {},
@@ -318,7 +318,7 @@ describe("CallHandler", () => {
const callPromise = callMap.call("test.mcpOp", {}); const callPromise = callMap.call("test.mcpOp", {});
handler({ await handler({
requestId: [...callMap["requests"].keys()][0], requestId: [...callMap["requests"].keys()][0],
operationId: "test.mcpOp", operationId: "test.mcpOp",
input: {}, input: {},
@@ -353,7 +353,7 @@ describe("CallHandler", () => {
const callPromise = callMap.call("test.httpOp", {}); const callPromise = callMap.call("test.httpOp", {});
handler({ await handler({
requestId: [...callMap["requests"].keys()][0], requestId: [...callMap["requests"].keys()][0],
operationId: "test.httpOp", operationId: "test.httpOp",
input: {}, 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 () => { it("publishes call.error when operation not found", async () => {
const registry = new OperationRegistry(); const registry = new OperationRegistry();
const callMap = new PendingRequestMap(); const callMap = new PendingRequestMap();
@@ -609,27 +551,85 @@ describe("CallHandler", () => {
resources: { "project:abc": ["read"] }, resources: { "project:abc": ["read"] },
}; };
await expect( const result = await handler({
handler({ requestId: "r1",
requestId: "r1", operationId: "test.guarded",
operationId: "test.guarded", input: {},
input: {}, identity,
identity, });
}),
).resolves.toBeUndefined(); expect(result).toBeUndefined();
}); });
it("works without callMap for open operations", async () => { it("works without callMap for open operations", async () => {
const registry = makeRegistry(); const registry = makeRegistry();
const handler = buildCallHandler({ registry }); const handler = buildCallHandler({ registry });
await expect( const result = await handler({
handler({ requestId: "r1",
requestId: "r1", operationId: "test.open",
operationId: "test.open", input: {},
input: {}, });
}),
).resolves.toBeUndefined(); 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"] }, resources: { "project:abc": ["read"] },
}; };
await expect( const result = await handler({
handler({ requestId: "r1",
requestId: "r1", operationId: "test.guarded",
operationId: "test.guarded", input: {},
input: {}, identity,
identity, });
}), expect(result).toBeUndefined();
).resolves.toBeUndefined();
}); });
it("grants access when neither resourceType nor resourceAction are set", async () => { 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: [] }; const identity: Identity = { id: "user1", scopes: [] };
await expect( const result = await handler({
handler({ requestId: "r1",
requestId: "r1", operationId: "test.open",
operationId: "test.open", input: {},
input: {}, identity,
identity, });
}), expect(result).toBeUndefined();
).resolves.toBeUndefined();
}); });
it("grants access when identity.resources matches and identity has no scopes required", async () => { 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"] }, resources: { "project:xyz": ["read", "write"] },
}; };
await expect( const result = await handler({
handler({ requestId: "r1",
requestId: "r1", operationId: "test.guarded",
operationId: "test.guarded", input: {},
input: {}, identity,
identity, });
}), expect(result).toBeUndefined();
).resolves.toBeUndefined();
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { OperationRegistry, OperationType, buildEnv, type IOperationDefinition, type OperationContext } from "../src/index.js"; import { OperationRegistry, OperationType, buildEnv, type IOperationDefinition, type OperationContext } from "../src/index.js";
import * as Type from "@alkdev/typebox"; import * as Type from "@alkdev/typebox";
import { PendingRequestMap } from "../src/call.js"; import { httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
import { localEnvelope, httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js"; import { CallError, InfrastructureErrorCode } from "../src/error.js";
import type { Identity } from "../src/types.js"; import type { Identity } from "../src/types.js";
function makeOperation(name: string, handler?: any): IOperationDefinition { function makeOperation(name: string, handler?: any): IOperationDefinition {
@@ -124,135 +124,95 @@ describe("buildEnv", () => {
expect(env.other).toBeUndefined(); 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(); const registry = new OperationRegistry();
registry.register(makeOperation("readFile")); registry.register({
name: "inner",
const callMap = { namespace: "test",
call: async (opId: string, input: unknown, opts?: any): Promise<ResponseEnvelope> => { version: "1.0.0",
return localEnvelope({ result: `routed: ${opId}` }, opId); 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(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(); const registry = new OperationRegistry();
registry.register(makeOperation("op1")); registry.register({
name: "inner",
let capturedOptions: any = null; namespace: "test",
const callMap = { version: "1.0.0",
call: async (opId: string, input: unknown, opts?: any): Promise<ResponseEnvelope> => { type: OperationType.QUERY,
capturedOptions = opts; description: "inner op",
return localEnvelope({ result: "ok" }, opId); 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 identity: Identity = { id: "user1", scopes: ["read"] };
const context: OperationContext = { const outerContext: OperationContext = {
requestId: "parent-req-456", requestId: "parent-req-456",
identity, identity,
}; };
const env = buildEnv({ const env = buildEnv({ registry, context: outerContext });
registry, await env.test.inner({ value: "test" });
context,
callMap,
});
await env.test.op1({ value: "test" }); expect(capturedContext).toBeDefined();
expect(capturedContext!.identity).toEqual(identity);
expect(capturedOptions).not.toBeNull(); expect(capturedContext!.trusted).toBe(true);
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");
}); });
it("returns empty env when registry has no specs", () => { 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 { OperationRegistry } from "../src/registry.js";
import { OperationType, type IOperationDefinition, type OperationContext, type OperationSpec, type OperationHandler } from "../src/index.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 { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
import { CallError, InfrastructureErrorCode } from "../src/error.js";
import * as Type from "@alkdev/typebox"; import * as Type from "@alkdev/typebox";
import { Value } from "@alkdev/typebox/value"; import { Value } from "@alkdev/typebox/value";
@@ -158,19 +159,27 @@ describe("OperationRegistry", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
it("throws on missing operation", async () => { it("throws CallError on missing operation", async () => {
const registry = new OperationRegistry(); const registry = new OperationRegistry();
await expect( try {
registry.execute("missing.op", {}, {} as OperationContext) await registry.execute("missing.op", {}, {} as OperationContext);
).rejects.toThrow("Operation not found"); 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(); const registry = new OperationRegistry();
registry.registerSpec(makeSpec()); registry.registerSpec(makeSpec());
await expect( try {
registry.execute("test.testOp", { value: "hello" }, {} as OperationContext) await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext);
).rejects.toThrow("No handler registered"); 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 () => { it("registerSpec and registerHandler separately", async () => {
@@ -221,4 +230,175 @@ describe("OperationRegistry", () => {
expect(spec.name).toBe("testOp"); expect(spec.name).toBe("testOp");
expect((spec as any).handler).toBeUndefined(); 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 type { OperationContext } from "../src/types.js";
import { localEnvelope, httpEnvelope, mcpEnvelope, isResponseEnvelope } from "../src/response-envelope.js"; import { localEnvelope, httpEnvelope, mcpEnvelope, isResponseEnvelope } from "../src/response-envelope.js";
import type { ResponseEnvelope, LocalResponseMeta } from "../src/response-envelope.js"; import type { ResponseEnvelope, LocalResponseMeta } from "../src/response-envelope.js";
import { CallError, InfrastructureErrorCode } from "../src/error.js";
function makeContext(): OperationContext { function makeContext(overrides: Partial<OperationContext> = {}): OperationContext {
return { requestId: "test-req-1" }; return { requestId: "test-req-1", ...overrides };
} }
function makeRegistry( function makeRegistry(
@@ -139,17 +140,21 @@ describe("subscribe", () => {
expect(done.done).toBe(true); 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(); const registry = new OperationRegistry();
await expect(async () => { try {
for await (const _ of subscribe(registry, "nonexistent.op", {}, makeContext())) { for await (const _ of subscribe(registry, "nonexistent.op", {}, makeContext())) {
// should not reach here // 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(); const registry = new OperationRegistry();
registry.registerSpec({ registry.registerSpec({
name: "unhandled", name: "unhandled",
@@ -162,11 +167,15 @@ describe("subscribe", () => {
accessControl: { requiredScopes: [] }, accessControl: { requiredScopes: [] },
}); });
await expect(async () => { try {
for await (const _ of subscribe(registry, "test.unhandled", {}, makeContext())) { for await (const _ of subscribe(registry, "test.unhandled", {}, makeContext())) {
// should not reach here // 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 () => { it("handles generator that yields nothing", async () => {
@@ -294,4 +303,81 @@ describe("subscribe", () => {
expect(returnCalled).toBe(true); 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");
});
}); });