diff --git a/.changeset/explain-rest-endpoint.md b/.changeset/explain-rest-endpoint.md new file mode 100644 index 0000000000..46ce2e838d --- /dev/null +++ b/.changeset/explain-rest-endpoint.md @@ -0,0 +1,24 @@ +--- +"@objectstack/rest": minor +"@objectstack/plugin-security": minor +--- + +ADR-0090 D6 — the explain engine gets its REST face (#2696). + +**`@objectstack/rest`**: new `GET/POST /api/v1/security/explain` +(`object`/`operation`/`userId`, validated against the spec's +`ExplainRequestSchema`) delegating to the `security` service's +`explain(request, callerContext)` — the same code paths the enforcement +middleware runs, so the returned `ExplainDecision` is explained by +construction. The route is authenticated-only (401 even on +`requireAuth=false` deployments), returns 501 when no security service +exposes `explain`, and maps the service's `PermissionDeniedError` to 403. +Registered on scoped (`/environments/:environmentId`) and unscoped base +paths; the env kernel's own `security` service is preferred, with a new +host-kernel `securityServiceProvider` fallback wired by the REST plugin. + +**`@objectstack/plugin-security`**: `explainAccessForCaller` now honors +delegated administration (D12) — explaining ANOTHER user is authorized by +`manage_users` **or** a delegated `adminScope` whose business-unit subtree +covers the target user (new `DelegatedAdminGate.scopesCoverUser`, fail-closed +on unresolvable scopes/memberships). Self-explain still needs neither. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index b3a42037fa..958fe8a035 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -170,8 +170,17 @@ Each layer carries a verdict (`grants` / `denies` / `narrows` / `widens` / `neutral` / `not_applicable`), a human explanation, and **contributor attribution** — which permission set granted, reached via which position / additive baseline / direct grant. For reads, the decision includes the -composed row filter as the machine artifact. Explaining another user requires -the `manage_users` capability. +composed row filter as the machine artifact. + +The same report is reachable over REST as `GET/POST /api/v1/security/explain` +(`object`, `operation`, optional `userId`; validated against +`ExplainRequestSchema`). The endpoint is authenticated-only and delegates to +the service, so both surfaces share one authorization rule: explaining +**another** user requires the `manage_users` capability **or** a delegated +`adminScope` whose business-unit subtree covers that user (ADR-0090 D12) — +an administrator who can rewire a user's grants may read why they resolve as +they do. Studio's Access pillar ships a "why can this user access?" panel on +top of this endpoint. ## Governance: how "declared = enforced" is kept true diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 2e03b6ac2f..e6f06ad0c0 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -181,6 +181,28 @@ export class DelegatedAdminGate { } } + /** + * [ADR-0090 D6 × D12] Does the actor (via their resolved sets) hold a + * delegated `adminScope` whose BU subtree covers `targetUserId`? Used by + * the explain API: an administrator who can already manage a user's + * assignments inside their delegation boundary may also read WHY that + * user's access resolves the way it does — without tenant-level + * `manage_users`. Any scope kind qualifies (assignments, bindings, + * authoring): all of them administer capability inside the subtree, and + * the report is read-only. Fail-closed: unresolvable scopes/memberships + * cover nothing. + */ + async scopesCoverUser(sets: PermissionSet[], targetUserId: string): Promise { + const held = await this.resolveHeldScopes(sets); + if (held.length === 0) return false; + const userBUs = await this.businessUnitsOfUser(targetUserId); + if (userBUs.size === 0) return false; + for (const s of held) { + for (const bu of userBUs) if (s.subtree.has(bu)) return true; + } + return false; + } + // ── sys_user_position: user ↔ position assignments ────────────────── private async assertAssignmentWrite(opCtx: any, ctx: any, held: HeldScope[]): Promise { diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a7550ac21e..3099c52ef3 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2074,3 +2074,122 @@ describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => await expect(h.run(opCtx)).resolves.toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// ADR-0090 D6 × D12 — explainAccessForCaller authorization: manage_users OR a +// delegated adminScope whose BU subtree covers the target user. +// --------------------------------------------------------------------------- +describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { + const EAST_SCOPE = { + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + assignablePermissionSets: ['sales_user'], + }; + + const memberDefault: PermissionSet = { + name: 'member_default', label: 'Member', + objects: { task: { allowRead: true } }, + } as any; + const subAdmin: PermissionSet = { + name: 'sub_admin', label: 'East delegate', + objects: {}, adminScope: EAST_SCOPE, + } as any; + const hrAdmin: PermissionSet = { + name: 'hr_admin', label: 'HR admin', + objects: {}, systemPermissions: ['manage_users'], + } as any; + + /** Middleware harness + in-memory tables for BU subtree / membership reads. */ + const makeExplainHarness = () => { + const tables: Record = { + sys_business_unit: [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, + { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' }, + ], + sys_business_unit_member: [ + { id: 'm1', business_unit_id: 'bu_east', user_id: 'u_east_1' }, + { id: 'm2', business_unit_id: 'bu_west', user_id: 'u_west_1' }, + ], + sys_user: [{ id: 'u_delegate' }, { id: 'u_east_1' }, { id: 'u_west_1' }, { id: 'u_hr' }], + sys_user_position: [], + sys_user_permission_set: [], + sys_permission_set: [], + sys_position: [], + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + const baseSchema: any = { name: 'task', fields: { id: { name: 'id' }, owner_id: { name: 'owner_id' }, name: { name: 'name' } } }; + const ql = { + registerMiddleware: vi.fn(), + getSchema: () => baseSchema, + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return rows[0] ?? null; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => baseSchema, list: async () => [memberDefault, subAdmin, hrAdmin] }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { ctx, tables }; + }; + + const boot = async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const h = makeExplainHarness(); + await plugin.init(h.ctx); + await plugin.start(h.ctx); + return { plugin, h }; + }; + + it('a delegated admin may explain a user INSIDE their scope subtree without manage_users', async () => { + const { plugin } = await boot(); + const caller = { userId: 'u_delegate', positions: [], permissions: ['sub_admin'] }; + const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_east_1' }, caller); + expect(d.principal.userId).toBe('u_east_1'); + expect(d.object).toBe('task'); + }); + + it('the same delegate is DENIED for a user OUTSIDE the subtree (fail closed)', async () => { + const { plugin } = await boot(); + const caller = { userId: 'u_delegate', positions: [], permissions: ['sub_admin'] }; + await expect( + plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, caller), + ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('manage_users still explains anyone (tenant-level path unchanged)', async () => { + const { plugin } = await boot(); + const caller = { userId: 'u_hr', positions: [], permissions: ['hr_admin'] }; + const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, caller); + expect(d.principal.userId).toBe('u_west_1'); + }); + + it('no manage_users and no covering scope → denied; self-explain needs neither', async () => { + const { plugin } = await boot(); + const plain = { userId: 'u_east_1', positions: [], permissions: [] }; + await expect( + plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, plain), + ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + const self = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_east_1' }, plain); + expect(self.principal.userId).toBe('u_east_1'); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b15f8ff20b..3234f28c68 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1162,9 +1162,12 @@ export class SecurityPlugin implements Plugin { */ /** * [ADR-0090 D6] Explain access for a caller. `request.userId` (explaining - * someone else) requires the caller to hold `manage_users` or be system — - * an access report is itself sensitive data. The evaluation delegates to - * {@link explainAccess} with the SAME internals the middleware uses. + * someone else) requires the caller to hold `manage_users`, be system, or — + * [D12] — hold a delegated `adminScope` whose BU subtree covers the target + * user (an access report is itself sensitive data, but an admin who can + * already rewire a user's grants may read why they resolve as they do). + * The evaluation delegates to {@link explainAccess} with the SAME internals + * the middleware uses. */ async explainAccessForCaller( request: { object: string; operation: string; userId?: string }, @@ -1181,10 +1184,20 @@ export class SecurityPlugin implements Plugin { const callerSets = await this.resolvePermissionSetsForContext(callerContext).catch(() => []); const held = this.permissionEvaluator.getSystemPermissions(callerSets); if (!held.has('manage_users')) { - throw new PermissionDeniedError( - `[Security] Access denied: explaining another user's access requires the 'manage_users' capability (ADR-0090 D6).`, - { object, operation, targetUserId: request.userId }, - ); + // [ADR-0090 D12] Delegated administrators may explain principals + // inside their delegation boundary (fail-closed on any error). + const delegated = this.delegatedAdminGate + ? await this.delegatedAdminGate + .scopesCoverUser(callerSets, request.userId) + .catch(() => false) + : false; + if (!delegated) { + throw new PermissionDeniedError( + `[Security] Access denied: explaining another user's access requires the 'manage_users' ` + + `capability or a delegated adminScope covering that user (ADR-0090 D6/D12).`, + { object, operation, targetUserId: request.userId }, + ); + } } } targetContext = await buildContextForUser(this.ql, request.userId); diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 78e1034e2c..8aec444123 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -192,7 +192,9 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { }; // Security service resolver — used by the ADR-0090 D5/D9 - // /security/suggested-bindings routes (plugin-security). + // /security/suggested-bindings routes and the D6 /security/explain + // route (plugin-security). Returns undefined when plugin-security + // is not mounted so the routes fail cleanly (501). const securityServiceProvider = async (_environmentId?: string): Promise => { try { return ctx.getService('security'); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e5f21819c7..5034b68a72 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -970,8 +970,9 @@ export class RestServer { private i18nServiceProvider?: (environmentId?: string) => Promise; private analyticsServiceProvider?: (environmentId?: string) => Promise; private settingsServiceProvider?: (environmentId?: string) => Promise; - /** [ADR-0090 D5/D9] `security` service resolver — used by the - * /security/suggested-bindings routes (plugin-security). */ + /** [ADR-0090] `security` service resolver — used by the + * /security/suggested-bindings (D5/D9) and /security/explain (D6) + * routes (plugin-security). */ private securityServiceProvider?: (environmentId?: string) => Promise; /** Sync probe: is a kernel service registered? Single-env path for nav * capability gates (ADR-0057 D10) — resolveExecCtx sets no kernel in @@ -1887,6 +1888,7 @@ export class RestServer { this.registerApprovalsEndpoints(bp); this.registerAnalyticsEndpoints(bp); this.registerSecurityEndpoints(bp); + this.registerSecurityExplainEndpoints(bp); // Data-action routes (e.g. GET /data/:object/export, POST // /data/:object/import) use static-literal action segments that // MUST be registered BEFORE the greedy GET /data/:object/:id @@ -4863,6 +4865,113 @@ export class RestServer { }); } + /** + * [ADR-0090 D6] Access-explanation endpoint — the REST face of the + * explain engine (framework#2696). + * + * GET {basePath}/security/explain?object=…&operation=…&userId=… + * POST {basePath}/security/explain body: { object, operation, userId? } + * + * Delegates to the security service's `explain(request, callerContext)` + * (`SecurityPlugin.explainAccessForCaller`) — the same code paths the + * enforcement middleware runs, so the report is explained by + * construction. Caller authorization lives in the SERVICE, not here: + * explaining ANOTHER user requires `manage_users` or a delegated + * `adminScope` covering that user (D12); the service's + * `PermissionDeniedError` maps to 403. The route itself only insists on + * an authenticated caller (an access report is sensitive even about + * oneself, and the anonymous `guest` posture is not this endpoint's + * business) and returns 501 when no security service exposing `explain` + * is mounted (a deployment without `@objectstack/plugin-security`). + */ + private registerSecurityExplainEndpoints(basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + // Resolve the ENVIRONMENT's security service first (its resolver / + // evaluator / RLS compiler are bound to the env kernel's own data + // engine); the host provider is the single-kernel fallback. + const resolveService = async (environmentId?: string, req?: any) => { + try { + const envId = await this.resolveRequestEnvironmentId(environmentId, req); + if (envId && envId !== 'platform' && this.kernelManager) { + const kernel = await this.kernelManager.getOrCreate(envId); + const svc = await kernel.getServiceAsync('security').catch(() => undefined); + if (svc) return svc; + } + } catch { /* fall back to the host service */ } + if (!this.securityServiceProvider) return undefined; + try { return await this.securityServiceProvider(environmentId); } + catch { return undefined; } + }; + + const handler = async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (!context?.userId) { + // Even on requireAuth=false deployments the explain surface + // stays authenticated-only — it is an admin diagnosis tool. + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'The access-explanation endpoint requires an authenticated caller.', + }); + } + + const svc = await resolveService(environmentId, req); + if (!svc || typeof svc.explain !== 'function') { + return res.status(501).json({ + code: 'NOT_IMPLEMENTED', + message: 'Access explanation is not available on this deployment (no security service with explain).', + }); + } + + // GET reads the request from the query string, POST from the + // body — one contract (ExplainRequestSchema), two transports. + const src = req.method === 'GET' ? (req.query ?? {}) : (req.body ?? {}); + const { ExplainRequestSchema } = await import('@objectstack/spec/security'); + const parsed = (ExplainRequestSchema as any).safeParse({ + object: src.object, + operation: src.operation ?? 'read', + ...(src.userId != null && src.userId !== '' ? { userId: src.userId } : {}), + }); + if (!parsed.success) { + return res.status(400).json({ + code: 'VALIDATION_FAILED', + message: 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.', + detail: String(parsed.error?.message ?? '').slice(0, 1000), + }); + } + + const decision = await svc.explain(parsed.data, context); + res.json(decision); + } catch (error: any) { + const msg = String(error?.message ?? error ?? ''); + if ( + error?.code === 'PERMISSION_DENIED' || + error?.name === 'PermissionDeniedError' || + msg.startsWith('[Security] Access denied') + ) { + return res.status(403).json({ code: 'PERMISSION_DENIED', message: msg.slice(0, 1000) }); + } + logError('[REST] Security explain error:', error); + res.status(500).json({ code: 'EXPLAIN_FAILED', error: msg.slice(0, 500) }); + } + }; + + this.routeManager.register({ + method: 'GET', + path: `${basePath}/security/explain`, + handler, + metadata: { summary: 'Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)', tags: ['security'] }, + }); + this.routeManager.register({ + method: 'POST', + path: `${basePath}/security/explain`, + handler, + metadata: { summary: 'Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)', tags: ['security'] }, + }); + } + private registerSharingEndpoints(basePath: string): void { const { crud } = this.config; const dataPath = `${basePath}${crud.dataPrefix}`; diff --git a/packages/rest/src/security-routes.test.ts b/packages/rest/src/security-routes.test.ts new file mode 100644 index 0000000000..05fcf1576c --- /dev/null +++ b/packages/rest/src/security-routes.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// ADR-0090 D6 — REST face of the access-explanation engine (framework#2696). + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} +function mockProtocol() { + return { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }), getMetaTypes: vi.fn().mockResolvedValue([]), getMetaItems: vi.fn().mockResolvedValue([]) }; +} +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.end = vi.fn(() => res); + return res; +} + +const CALLER = { userId: 'u_admin', positions: ['everyone'], permissions: [], systemPermissions: ['manage_users'] }; + +const DECISION = { + allowed: true, + object: 'task', + operation: 'read', + principal: { userId: 'u_target', positions: ['everyone'], permissionSets: ['member_default'] }, + layers: [], + readFilter: null, +}; + +/** Build a RestServer with an optional security provider (positional arg #18). */ +function buildServer(securityProvider?: any, opts: { callerCtx?: any } = {}) { + const server = mockServer(); + const rest = new RestServer( + server as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + securityProvider, + ); + // The route handler derives the caller context from the auth session; the + // mock harness has no auth service, so stub the resolver directly. + if ('callerCtx' in opts) { + vi.spyOn(rest as any, 'resolveExecCtx').mockResolvedValue(opts.callerCtx); + } + rest.registerRoutes(); + const routes = rest.getRoutes().filter((r) => r.path.endsWith('/security/explain')); + return { + get: routes.find((r) => r.method === 'GET'), + post: routes.find((r) => r.method === 'POST'), + }; +} + +describe('GET/POST /security/explain (ADR-0090 D6)', () => { + it('registers both transports of the route', () => { + const { get, post } = buildServer(async () => ({ explain: vi.fn() })); + expect(get).toBeTruthy(); + expect(post).toBeTruthy(); + expect(get!.metadata?.tags).toContain('security'); + expect(post!.metadata?.tags).toContain('security'); + }); + + it('POST delegates the parsed request + caller context to security.explain', async () => { + const explain = vi.fn().mockResolvedValue(DECISION); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'read', userId: 'u_target' } } as any, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(DECISION); + expect(explain).toHaveBeenCalledWith({ object: 'task', operation: 'read', userId: 'u_target' }, CALLER); + }); + + it('GET reads the request from the query string and defaults operation to read', async () => { + const explain = vi.fn().mockResolvedValue(DECISION); + const { get } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + await get!.handler({ method: 'GET', params: {}, headers: {}, query: { object: 'task' } } as any, res); + + expect(res.statusCode).toBe(200); + expect(explain).toHaveBeenCalledWith({ object: 'task', operation: 'read' }, CALLER); + }); + + it('stays authenticated-only even on requireAuth=false deployments', async () => { + const explain = vi.fn(); + const { post } = buildServer(async () => ({ explain }), { callerCtx: undefined }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'read' } } as any, res); + + expect(res.statusCode).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + expect(explain).not.toHaveBeenCalled(); + }); + + it('returns 400 for a missing object or unknown operation', async () => { + const { post } = buildServer(async () => ({ explain: vi.fn() }), { callerCtx: CALLER }); + + let res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { operation: 'read' } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + + res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'frobnicate' } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + }); + + it("maps the service's PermissionDeniedError to 403 (manage_users / D12 gate)", async () => { + const denial = Object.assign( + new Error("[Security] Access denied: explaining another user's access requires the 'manage_users' capability or a delegated adminScope covering that user (ADR-0090 D6/D12)."), + { code: 'PERMISSION_DENIED', name: 'PermissionDeniedError' }, + ); + const { post } = buildServer(async () => ({ explain: vi.fn().mockRejectedValue(denial) }), { callerCtx: { userId: 'u_plain', positions: ['everyone'] } }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'read', userId: 'u_target' } } as any, res); + + expect(res.statusCode).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('returns 501 when no security service exposes explain', async () => { + for (const provider of [undefined, async () => ({ getReadFilter: vi.fn() })]) { + const { post } = buildServer(provider as any, { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'read' } } as any, res); + expect(res.statusCode).toBe(501); + expect(res.body.code).toBe('NOT_IMPLEMENTED'); + } + }); + + it('maps unexpected service failures to 500', async () => { + const { post } = buildServer(async () => ({ explain: vi.fn().mockRejectedValue(new Error('boom')) }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'read' } } as any, res); + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('EXPLAIN_FAILED'); + }); +});