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

@@ -1,15 +1,10 @@
import { Type, type Static, KindGuard } from "@alkdev/typebox";
import { Value } from "@alkdev/typebox/value";
import { Type, type Static } from "@alkdev/typebox";
import { createPubSub, type PubSub } from "@alkdev/pubsub";
import { getLogger } from "@logtape/logtape";
import { OperationRegistry } from "./registry.js";
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
import { validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
import { ResponseEnvelopeSchema, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
import { ResponseEnvelopeSchema } from "./response-envelope.js";
import type { ResponseEnvelope } from "./response-envelope.js";
import type { Identity, OperationContext, AccessControl, OperationSpec } from "./types.js";
const logger = getLogger("operations:call");
import type { Identity, OperationContext, AccessControl } from "./types.js";
export const CallEventSchema = {
"call.requested": Type.Object({
@@ -191,66 +186,18 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
return async (event: CallRequestedEvent): Promise<void> => {
const { requestId, operationId, input, identity } = event;
const context: OperationContext = {
requestId,
parentRequestId: event.parentRequestId,
identity,
};
try {
const spec = registry.getSpec(operationId);
if (!spec) {
throw new CallError(
InfrastructureErrorCode.OPERATION_NOT_FOUND,
`Operation not found: ${operationId}`,
{ operationId },
);
}
const handler = registry.getHandler(operationId);
if (!handler) {
throw new CallError(
InfrastructureErrorCode.OPERATION_NOT_FOUND,
`No handler registered for operation: ${operationId}`,
{ operationId },
);
}
const accessControl: AccessControl = spec.accessControl as AccessControl;
if (identity && !checkAccess(accessControl, identity)) {
throw new CallError(
InfrastructureErrorCode.ACCESS_DENIED,
`Access denied for operation: ${operationId}`,
{ requiredScopes: accessControl.requiredScopes },
);
}
const context: OperationContext = {
requestId,
parentRequestId: event.parentRequestId,
identity,
};
validateOrThrow(spec.inputSchema, input, `Input validation for ${operationId}`);
const result = await handler(input, context);
let envelope: ResponseEnvelope;
if (isResponseEnvelope(result)) {
envelope = result as ResponseEnvelope;
} else {
envelope = localEnvelope(result, operationId);
}
if (!KindGuard.IsUnknown(spec.outputSchema)) {
envelope.data = Value.Cast(spec.outputSchema, envelope.data);
}
const errors = collectErrors(spec.outputSchema, envelope.data);
if (errors.length > 0) {
logger.warn(`Output validation failed for ${operationId}:\n${formatValueErrors(errors)}`);
}
const envelope = await registry.execute(operationId, input, context);
if (callMap) {
callMap.respond(requestId, envelope);
}
} catch (error) {
const callError = mapError(error);
if (callMap) {
@@ -262,7 +209,7 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
};
}
function checkAccess(accessControl: AccessControl, identity: Identity): boolean {
export function checkAccess(accessControl: AccessControl, identity: Identity): boolean {
const { requiredScopes, requiredScopesAny, resourceType, resourceAction } = accessControl;
if (requiredScopes.length > 0) {
@@ -286,4 +233,12 @@ function checkAccess(accessControl: AccessControl, identity: Identity): boolean
}
return true;
}
function isResponseEnvelope(value: unknown): value is ResponseEnvelope {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
if (!("data" in obj) || !("meta" in obj)) return false;
if (typeof obj.meta !== "object" || obj.meta === null) return false;
return ["local", "http", "mcp"].includes((obj.meta as Record<string, unknown>).source as string);
}

View File

@@ -1,24 +1,18 @@
import { OperationType } from "./types.js";
import type { OperationContext, OperationEnv, Identity } from "./types.js";
import type { OperationContext, OperationEnv } from "./types.js";
import type { OperationRegistry } from "./registry.js";
import type { ResponseEnvelope } from "./response-envelope.js";
import { getLogger } from "@logtape/logtape";
const logger = getLogger("operations:env");
export interface CallMap {
call(operationId: string, input: unknown, options?: { parentRequestId?: string; deadline?: number; identity?: Identity }): Promise<ResponseEnvelope>;
}
export interface EnvOptions {
registry: OperationRegistry;
context: OperationContext;
allowedNamespaces?: string[];
callMap?: CallMap;
}
export function buildEnv(options: EnvOptions): OperationEnv {
const { registry, context, allowedNamespaces, callMap } = options;
const { registry, context, allowedNamespaces } = options;
const specs = registry.getAllSpecs();
const namespaces: OperationEnv = {};
@@ -38,20 +32,15 @@ export function buildEnv(options: EnvOptions): OperationEnv {
const operationId = `${spec.namespace}.${spec.name}`;
if (callMap) {
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
logger.debug(`Call protocol: ${operationId}`);
return await callMap.call(operationId, input, {
parentRequestId: context.requestId,
identity: context.identity,
});
};
} else {
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
logger.debug(`Executing: ${operationId}`);
return await registry.execute(operationId, input, context);
};
}
const nestedContext: OperationContext = {
...context,
trusted: true,
};
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
logger.debug(`Executing: ${operationId}`);
return await registry.execute(operationId, input, nestedContext);
};
}
return namespaces;

View File

@@ -3,7 +3,7 @@ export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Ident
export { OperationRegistry } from "./registry.js";
export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js";
export { buildEnv } from "./env.js";
export type { CallMap, EnvOptions } from "./env.js";
export type { EnvOptions } from "./env.js";
export { FromSchema } from "./from_schema.js";
export { FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl } from "./from_openapi.js";
export type { OpenAPISpec, OpenAPIOperation, OpenAPIParameter, HTTPServiceConfig, OpenAPIFS } from "./from_openapi.js";
@@ -11,7 +11,7 @@ export { scanOperations } from "./scanner.js";
export type { OperationManifest, ScannerFS } from "./scanner.js";
export { CallError, InfrastructureErrorCode, mapError } from "./error.js";
export type { CallErrorCode } from "./error.js";
export { PendingRequestMap, buildCallHandler } from "./call.js";
export { PendingRequestMap, buildCallHandler, checkAccess } from "./call.js";
export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
export { subscribe } from "./subscribe.js";
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js";

View File

@@ -1,9 +1,11 @@
import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler } from "./types.js";
import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler, Identity, AccessControl } from "./types.js";
import { getLogger } from "@logtape/logtape";
import { Value } from "@alkdev/typebox/value";
import { KindGuard } from "@alkdev/typebox";
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js";
import { CallError, InfrastructureErrorCode } from "./error.js";
import { checkAccess } from "./call.js";
const logger = getLogger("operations:registry");
@@ -86,12 +88,40 @@ export class OperationRegistry {
): Promise<ResponseEnvelope<TOutput>> {
const spec = this.specs.get(operationId);
if (!spec) {
throw new Error(`Operation not found: ${operationId}`);
throw new CallError(
InfrastructureErrorCode.OPERATION_NOT_FOUND,
`Operation not found: ${operationId}`,
{ operationId },
);
}
const handler = this.handlers.get(operationId);
if (!handler) {
throw new Error(`No handler registered for operation: ${operationId}`);
throw new CallError(
InfrastructureErrorCode.OPERATION_NOT_FOUND,
`No handler registered for operation: ${operationId}`,
{ operationId },
);
}
if (!context.trusted) {
const accessControl: AccessControl = spec.accessControl as AccessControl;
if (accessControl.requiredScopes.length > 0 || accessControl.requiredScopesAny?.length || accessControl.resourceType) {
if (!context.identity) {
throw new CallError(
InfrastructureErrorCode.ACCESS_DENIED,
`Access denied for operation: ${operationId} — identity required`,
{ operationId, requiredScopes: accessControl.requiredScopes },
);
}
if (!checkAccess(accessControl, context.identity)) {
throw new CallError(
InfrastructureErrorCode.ACCESS_DENIED,
`Access denied for operation: ${operationId}`,
{ requiredScopes: accessControl.requiredScopes },
);
}
}
}
validateOrThrow(spec.inputSchema, input, `Input validation failed for ${operationId}`);

View File

@@ -1,6 +1,8 @@
import type { OperationContext } from "./types.js";
import type { OperationContext, AccessControl } from "./types.js";
import { OperationRegistry } from "./registry.js";
import { type ResponseEnvelope, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
import { CallError, InfrastructureErrorCode } from "./error.js";
import { checkAccess } from "./call.js";
export async function* subscribe(
registry: OperationRegistry,
@@ -11,13 +13,41 @@ export async function* subscribe(
const spec = registry.getSpec(operationId);
if (!spec) {
throw new Error(`Operation not found: ${operationId}`);
throw new CallError(
InfrastructureErrorCode.OPERATION_NOT_FOUND,
`Operation not found: ${operationId}`,
{ operationId },
);
}
const handler = registry.getHandler(operationId);
if (!handler) {
throw new Error(`No handler registered for operation: ${operationId}`);
throw new CallError(
InfrastructureErrorCode.OPERATION_NOT_FOUND,
`No handler registered for operation: ${operationId}`,
{ operationId },
);
}
if (!context.trusted) {
const accessControl: AccessControl = spec.accessControl as AccessControl;
if (accessControl.requiredScopes.length > 0 || accessControl.requiredScopesAny?.length || accessControl.resourceType) {
if (!context.identity) {
throw new CallError(
InfrastructureErrorCode.ACCESS_DENIED,
`Access denied for operation: ${operationId} — identity required`,
{ operationId, requiredScopes: accessControl.requiredScopes },
);
}
if (!checkAccess(accessControl, context.identity)) {
throw new CallError(
InfrastructureErrorCode.ACCESS_DENIED,
`Access denied for operation: ${operationId}`,
{ requiredScopes: accessControl.requiredScopes },
);
}
}
}
const generator = handler(input, context) as AsyncGenerator<unknown, void, unknown>;

View File

@@ -24,6 +24,7 @@ export const OperationContextSchema = Type.Object({
scopes: Type.Array(Type.String()),
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String())))
})),
trusted: Type.Optional(Type.Boolean({ description: "INTERNAL: set by buildEnv(), not by callers" })),
}, {
description: "Context provided to all operation handlers"
});