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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user