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:
@@ -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` |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-10
|
||||
status: implemented
|
||||
last_updated: 2026-05-11
|
||||
---
|
||||
|
||||
# ADR-006: Unified Invocation Path
|
||||
|
||||
83
src/call.ts
83
src/call.ts
@@ -1,15 +1,10 @@
|
||||
import { Type, type Static, KindGuard } from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
import { Type, type Static } from "@alkdev/typebox";
|
||||
import { createPubSub, type PubSub } from "@alkdev/pubsub";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { OperationRegistry } from "./registry.js";
|
||||
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||
import { validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
|
||||
import { ResponseEnvelopeSchema, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
|
||||
import { ResponseEnvelopeSchema } from "./response-envelope.js";
|
||||
import type { ResponseEnvelope } from "./response-envelope.js";
|
||||
import type { Identity, OperationContext, AccessControl, OperationSpec } from "./types.js";
|
||||
|
||||
const logger = getLogger("operations:call");
|
||||
import type { Identity, OperationContext, AccessControl } from "./types.js";
|
||||
|
||||
export const CallEventSchema = {
|
||||
"call.requested": Type.Object({
|
||||
@@ -191,66 +186,18 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
||||
return async (event: CallRequestedEvent): Promise<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);
|
||||
}
|
||||
33
src/env.ts
33
src/env.ts
@@ -1,24 +1,18 @@
|
||||
import { OperationType } from "./types.js";
|
||||
import type { OperationContext, OperationEnv, Identity } from "./types.js";
|
||||
import type { OperationContext, OperationEnv } from "./types.js";
|
||||
import type { OperationRegistry } from "./registry.js";
|
||||
import type { ResponseEnvelope } from "./response-envelope.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger("operations:env");
|
||||
|
||||
export interface CallMap {
|
||||
call(operationId: string, input: unknown, options?: { parentRequestId?: string; deadline?: number; identity?: Identity }): Promise<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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
188
test/env.test.ts
188
test/env.test.ts
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { OperationRegistry, OperationType, buildEnv, type IOperationDefinition, type OperationContext } from "../src/index.js";
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { PendingRequestMap } from "../src/call.js";
|
||||
import { localEnvelope, httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
|
||||
import { httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
|
||||
import { CallError, InfrastructureErrorCode } from "../src/error.js";
|
||||
import type { Identity } from "../src/types.js";
|
||||
|
||||
function makeOperation(name: string, handler?: any): IOperationDefinition {
|
||||
@@ -124,135 +124,95 @@ describe("buildEnv", () => {
|
||||
expect(env.other).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes through callMap in call protocol mode", async () => {
|
||||
it("always uses execute() and sets trusted: true on nested context", async () => {
|
||||
let capturedContext: OperationContext | undefined;
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("readFile"));
|
||||
|
||||
const callMap = {
|
||||
call: async (opId: string, input: unknown, opts?: any): Promise<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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user