diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index b2d9a11deda..644561d8608 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1627,7 +1627,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // text is every file on it, in order — so the zero is not a list ignored. // [#23050 slice 4] Length 4 → 5 (`sharing-routes.ts`), still 0 over it. // [#23050 slice 5] Length 5 → 6 (`analytics-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // [#23050 slice 6] Length 6 → 7 (`security-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index ff3fb5a593d..d8285fe50b7 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -519,10 +519,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'indentation and the subtrahend is pinned at 1 by the `forwarder slice:` control, never inferred from a ' + 'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' + 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + - '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts` and ' + - '`analytics-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, ' + - '`registerApprovalsEndpoints`, `registerSharingEndpoints`, `registerSharingRuleEndpoints` and ' + - '`registerAnalyticsEndpoints` moved to; ' + + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + + '`analytics-routes.ts` and `security-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, ' + + '`registerBatchEndpoints`, `registerApprovalsEndpoints`, `registerSharingEndpoints`, ' + + '`registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, `registerSecurityExplainEndpoints` and ' + + '`registerSecurityEndpoints` moved to; ' + 'reachable = those inside registerMetadataEndpoints', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -703,15 +704,29 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // counted in both readings; the four new occurrences are this slice's // seam, the same two spellings once (the host literal's forwarder, two; // the host interface's member, two). No call site was added. + // [#23050 slice 6] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerSecurityExplainEndpoints`' and `registerSecurityEndpoints`' + // bodies (six registration sites, three and three, all outside + // `registerMetadataEndpoints`, so all six in the blind spot) moved to + // `security-routes.ts`, which joins the list (length 6 -> 7) with its own + // control. `this.routeManager.register(` stays 63 over the seven files + // (27 here + 9 + 5 + 7 + 8 + 1 + 6), `private register*Endpoints(` stays + // 16 (both registrars are still declared here), and `enforceAuth` 84 -> 92 + // is RE-ANCHORED, not relaxed: the five call sites moved with their routes + // and are counted in both readings; the eight new occurrences are this + // slice's seam, the same two spellings TWICE, because the module carries + // two registrars (two host literals' forwarders, four; two host + // interfaces' members, four). No call site was added. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 6, + 'registrar list length': 7, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, 'sharing-routes.ts: this.routeManager.register(': 8, 'analytics-routes.ts: this.routeManager.register(': 1, + 'security-routes.ts: this.routeManager.register(': 6, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -719,7 +734,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 84, + enforceAuth: 92, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index eedb9158a9e..d85f4a08248 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -760,7 +760,9 @@ export function sandboxFaultAnswer( * ## Why it exists * * Three hand-built route families in `rest-server.ts` — the sharing-rule, the - * suggested-binding (and the since-retired overlay-discard) and the approval arms — answered a + * suggested-binding (and the since-retired overlay-discard) and the approval arms, which + * have since moved to `sharing-routes.ts`, `security-routes.ts` and `approvals-routes.ts` + * (#23050 slices 4, 6 and 3) — answered a * refusal their own arms could not read as a server fault, relaying * `.message`. For a sandboxed hook's refusal `.message` is the QuickJS debug * wrapper, so each answered `500` with `hook '' threw: Error: …` where diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index ab8bed72613..b313e9721f0 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -93,6 +93,10 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * * [#23050 slice 5] …and `registerAnalyticsEndpoints`' body to * `analytics-routes.ts`, with its one. + * + * [#23050 slice 6] …and `registerSecurityExplainEndpoints`' and + * `registerSecurityEndpoints`' bodies to `security-routes.ts`, with their five + * (two and three) — the second module on the list to carry TWO registrars. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -104,7 +108,8 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * The frames in `rest-server.ts` that are NOT consumers: the host literal each * moved registrar (`registerDataActionEndpoints`, `registerBatchEndpoints`, * `registerApprovalsEndpoints`, `registerSharingEndpoints`, - * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`) hands + * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, + * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`) hands * its moved body forwards each member to the server with * `(...a) => this.(...a)`, so a moved site's stack shows that forwarder * above the site itself. The site is the first censused frame that is not one. @@ -120,9 +125,12 @@ const FORWARDERS = new Set(FORWARDER_LINES.map((line) => siteKey('rest-server.ts * [#23050 slice 4] The moved registrars beyond one per moved module: * `sharing-routes.ts` carries two registrars' bodies, each handed over by its * own host literal and so through its own forwarder. + * + * [#23050 slice 6] `security-routes.ts` carries two as well, on the same terms. */ const EXTRA_MOVED_REGISTRARS: Readonly>> = { 'sharing-routes.ts': 1, + 'security-routes.ts': 1, }; /** A stack frame in any censused file, as `::`. */ @@ -598,11 +606,21 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // mentions are this slice's seam, the same two spellings once (its host // literal's forwarder, two; its host interface's member, two). Six // forwarders now, one per moved registrar: (6 - 1) + 1. - expect(SITE_FILES).toHaveLength(6); + // + // [#23050 slice 6] 62 sites UNCHANGED / 101 → 109 mentions — the same MOVE. + // `registerSecurityExplainEndpoints`' and `registerSecurityEndpoints`' + // bodies left for `security-routes.ts` with their five BARE sites (two + // and three): 27 + 9 + 5 + 7 + 8 + 1 + 5 = 62 sites. The eight new + // mentions are this slice's seam, the same two spellings TWICE, as slice + // 4's (two host literals' forwarders, four; two host interfaces' + // members, four). Eight forwarders now, one per moved registrar: + // (7 - 1) + 2, the second module carrying two registrars counted in + // `EXTRA_MOVED_REGISTRARS`. + expect(SITE_FILES).toHaveLength(7); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(101); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(109); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); - expect(extraMovedRegistrars).toBe(1); + expect(extraMovedRegistrars).toBe(2); expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved registrar: one was respelt or removed, or a module moved without joining SITE_FILES') .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars); }); diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 79489a8b594..bd13662e699 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -246,8 +246,10 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // and `req.query?.preview === 'draft'`, admitted in one declaration) // moved with its body to `analytics-routes.ts`. A key names its route, // never its file, so no ledger row changed: 8 = 6 + 0 + 0 + 0 + 0 + 2, - // length 5 → 6. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // length 5 → 6. [#23050 slice 6] The two security bodies + // (`security-routes.ts`) read no draft: 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0, + // length 6 → 7. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index a6203c09c28..182adf2a1a6 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -135,6 +135,11 @@ interface CensusEntry { * [#23050 slice 5] 4 = 1 + 3 + 0 + 0 + 0 + 0: the analytics dataset body * (`analytics-routes.ts`) builds no `query:` literal either — it hands * `queryDataset` the caller's own `selection`, never a `query:` slot. + * + * [#23050 slice 6] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0: the two security bodies + * (`security-routes.ts`) build no `query:` literal either — the explain doors + * hand the security service a parsed explain request, and the suggestion list + * a `status` / `packageId` pair read off `req.query`, never a `query:` slot. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, @@ -143,6 +148,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // A module added to the list makes this re-read its number rather than // widening unnoticed. [#23050 slice 4] Length 4 → 5, the sum unchanged. // [#23050 slice 5] Length 5 → 6, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // [#23050 slice 6] Length 6 → 7, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 1fa7b85bc25..bc0b1e39f71 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -153,6 +153,13 @@ describe('rest-server.ts docblock position', () => { * approval modules do: the module header, the host interface's block and the * function's. The analytics body held no TSDoc block of its own, so * `rest-server.ts` keeps every block it had. + * + * [#23050 slice 6] `security-routes.ts` carries eight: the module header, two + * host interfaces' blocks and two functions' (it holds two registrars' bodies), + * plus the three blocks that moved with those bodies (the explain family's + * `respondError` and `delegableHandler` blocks, and the suggested-binding + * family's `respondError` block), which `rest-server.ts` therefore no longer + * holds (167 → 164 there, still far above its floor). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -160,6 +167,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -189,9 +197,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // widening unnoticed: 4 + 3 + 3 = 10 blocks over the three modules. // [#23050 slice 4] 4 + 3 + 3 + 8 = 18 over four, length 4 → 5. // [#23050 slice 5] 4 + 3 + 3 + 8 + 3 = 21 over five, length 5 → 6. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // [#23050 slice 6] 4 + 3 + 3 + 8 + 3 + 8 = 29 over six, length 6 → 7. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(21); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(29); }); }); diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index b7f92900af3..93f392334a8 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -321,7 +321,9 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // record-sharing bodies (`sharing-routes.ts`): 9 = 8 + 1 + 0 + 0 + 0, // length 4 → 5. [#23050 slice 5] Nor does the analytics dataset body // (`analytics-routes.ts`): 9 = 8 + 1 + 0 + 0 + 0 + 0, length 5 → 6. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // [#23050 slice 6] Nor do the two security bodies (`security-routes.ts`): + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0, length 6 → 7. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 68142fb1077..7a75b2bc026 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -39,6 +39,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'sharing-routes.ts', // [#23050 slice 5] `registerAnalyticsEndpoints`' body. 'analytics-routes.ts', + // [#23050 slice 6] `registerSecurityExplainEndpoints`' and `registerSecurityEndpoints`' bodies. + 'security-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d5ddbc9d001..4867a14302b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -23,9 +23,9 @@ import { // [#23367] The API-key lifecycle policy source, and its branded refusal. apiKeyLifecyclePolicySourceOf, isApiKeyLifecycleRefusalError, type ApiKeyLifecycleRefusalError, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, - // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one - // owner, shared with the runtime dispatcher's `/security` domain. - isAudienceBindingSuggestionStatus, unknownAudienceBindingSuggestionStatusMessage, + // `isAudienceBindingSuggestionStatus` and `unknownAudienceBindingSuggestionStatusMessage`, + // the suggested-binding `?status=` vocabulary (#7678), moved with `registerSecurityEndpoints`' + // body to `security-routes.ts` (#23050 slice 6). // `omitInternalFieldsFromWriteResponse`, the cross-object batch's update-arm // write-response rules, moved with that door to `batch-routes.ts` (#23050 slice 2). } from '@objectstack/core'; @@ -107,11 +107,9 @@ import { refuseRepeatedQueryParams, assertFilterParamSuppliedOnce } from './quer import { refuseUnknownQueryParams } from './query-allowlist.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfigParsed } from '@objectstack/spec/api'; -// [#11683] The catalog's own floor for "a required `code` and no more specific -// one" — see its use in `registerSharingEndpoints` (`sharing-routes.ts` since -// #23050 slice 4), where the nested ADR-0112 envelope declares `code` REQUIRED -// while the flat classification it re-dresses legitimately carries none. -import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +// `standardErrorCodeForHttpStatus` (#11683), the catalog's floor for a required `code`, read +// only by the security-explain family's classified-refusal arm, moved with that body to +// `security-routes.ts` (#23050 slice 6). // [#11637] The DECLARED contract for `config.api`, imported as a VALUE rather // than a type. Both hops into this package were casts, so this schema had // never run on any deployment path — see `parseDeclaredApiConfig` below. @@ -164,11 +162,8 @@ import type { // The four bulk request types (`BatchDataRequest`, `CreateManyDataRequest`, // `UpdateManyDataRequest`, `DeleteManyDataRequest`) moved with their doors to // `batch-routes.ts` (#23050 slice 2). -// [#8073] The closed ADR-0112 error vocabulary, so the explain family's single -// refusal emitter types its `code` parameter as the vocabulary rather than as -// `string` — an invented code is a compile error at the call site instead of a -// runtime surprise on whichever arm a test happens to drive. -import type { ErrorCode } from '@objectstack/spec/api'; +// `ErrorCode` (#8073), the closed vocabulary the security-explain family's one refusal +// emitter types its `code` as, moved with that body to `security-routes.ts` (#23050 slice 6). // [#10235] The per-column sortability projection the object read serves on its // envelope (2026-08-23 ruling, option A): computed HERE, at the one seam every // single-item read path rebuilds its body through, from the spec's own storage @@ -329,6 +324,13 @@ import { // [#23050 slice 5] The analytics dataset route's body moved to its own module — see its header. // `datasetSelectionRefusal`, that door's selection parse (#17551), moved with it. import { registerAnalyticsEndpoints, type AnalyticsRouteHost } from './analytics-routes.js'; +// [#23050 slice 6] The security-explain and suggested-binding routes' bodies moved to one module — see its header. +import { + registerSecurityEndpoints, + registerSecurityExplainEndpoints, + type SecurityExplainRouteHost, + type SecurityRouteHost, +} from './security-routes.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -359,9 +361,9 @@ import { // `sandboxBusinessMessage`, `boundedDeclaredUserMessage` and `declaredServerFaultAnswer`, // read only by the analytics dataset door, moved with that door to `analytics-routes.ts` // (#23050 slice 5). - classifiedRefusalAnswer, - nestedRefusalEnvelope, - sandboxFaultAnswer, + // `classifiedRefusalAnswer`, `nestedRefusalEnvelope` and `sandboxFaultAnswer`, read only + // by the two security families' refusal and fault arms, moved with those bodies to + // `security-routes.ts` (#23050 slice 6). boundedDeclaredRefusalMessage, declaredHttpStatus, sendThrownError, @@ -10306,298 +10308,21 @@ export class RestServer { * 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; } - }; - - /** - * [#8073] The ONE refusal emitter for this route family — every arm of - * both handlers goes through it, so "explain and my-delegable-scope - * answer the same shape" is a property of the code rather than of - * eight literals that happen to agree. - * - * Before this, the family carried BOTH dialects ADR-0112 D5 retires: - * the 401/501/400/403 arms were flat `{ code, message }` and the two - * 500s were `{ code, error: 'a bare string' }`, so `body.error.code` — - * the one position D5 declares — read `undefined` on all six. #7035 - * (PR #7293) had already removed both from this file's `/meta` - * refusals and #7981 (PR #8071) from `registerSecurityEndpoints`, the - * immediately ADJACENT registrar: a client calling `explain` and then - * `suggested-bindings` met two shapes inside one `security` family. - * - * Emitted through the SHARED builder (`sendError` from - * `@objectstack/types`, imported as `sendEnvelopeError` — see the note - * at the import). That is what makes this the reference shape by - * construction rather than a ninth local literal agreeing with the - * eight it replaced, and it types `code` to the closed vocabulary for - * free. - * - * ⛔ Status codes are untouched: only the POSITION of `code` and - * `message` moves. `detail` — the 400 arm's Zod-issue dump — moves to - * `error.details`, the slot `ApiErrorSchema` actually declares for - * structured context; as a top-level sibling it was undeclared. - */ - const respondError = ( - res: any, - status: number, - code: ErrorCode, - message: string, - details?: unknown, - ): void => sendEnvelopeError( - res, status, code, message, - details === undefined ? undefined : { details }, - ); - - 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) { - // The explain surface stays authenticated-only — it is an - // admin diagnosis tool. (Anonymous is already 401ed above.) - return respondError( - res, 401, 'UNAUTHORIZED', - 'The access-explanation endpoint requires an authenticated caller.', - ); - } - - const svc = await resolveService(environmentId, req); - if (!svc || typeof svc.explain !== 'function') { - return respondError( - res, 501, 'NOT_IMPLEMENTED', - '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. - // - // [#6877] The other read point that was already safe, and for the - // structural reason rather than by luck: every field goes through - // `ExplainRequestSchema.safeParse` below, whose members are - // `z.string()`, so an array is refused as `400 VALIDATION_FAILED` - // by the schema itself. A schema at the boundary is the shape the - // refusal gate is imitating, so there is nothing to add here. - const src = req.method === 'GET' ? (req.query ?? {}) : (req.body ?? {}); - const { ExplainRequestSchema } = await import('@objectstack/spec/security'); - // [#8326] GET-transport normalization ONLY: a query string - // cannot spell a one-element array (`?recordIds=a` parses to - // the bare string), so a lone string is wrapped on GET. On - // POST the body is JSON and can say what it means — a string - // where the contract says array stays a 400, not a wrap. - const rawRecordIds = req.method === 'GET' && typeof src.recordIds === 'string' && src.recordIds !== '' - ? [src.recordIds] - : src.recordIds; - const parsed = (ExplainRequestSchema as any).safeParse({ - object: src.object, - operation: src.operation ?? 'read', - ...(src.userId != null && src.userId !== '' ? { userId: src.userId } : {}), - // [C2 / ADR-0095] Optional record id — explains ONE concrete - // row at record granularity; omitted stays object-level. - ...(src.recordId != null && src.recordId !== '' ? { recordId: src.recordId } : {}), - // [#8326] Optional batch of record ids — the schema owns the - // cap (200), the min (1), and recordId/recordIds mutual - // exclusion, so every refusal is the one 400 below. - ...(rawRecordIds != null ? { recordIds: rawRecordIds } : {}), - }); - if (!parsed.success) { - return respondError( - res, 400, 'VALIDATION_FAILED', - 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string, recordIds?: string[] (max 200, exclusive with recordId) }.', - String(parsed.error?.message ?? '').slice(0, 1000), - ); - } - - const { recordIds, ...singularRequest } = parsed.data as { recordIds?: string[] } & Record; - if (!recordIds) { - // Singular / object-level — the pre-#8326 path, byte-identical. - const decision = await svc.explain(parsed.data, context); - return res.json(decision); - } - - // [#8326] Batch form — transport amortization of the SINGULAR - // evaluation, not a new semantic: the object-level trace is one - // object-level explain, and each per-record verdict is the - // singular record-grained explain for that id, relayed - // verbatim. "Batch answer ≡ N singular answers" is therefore a - // property of the construction, and the agreement test pins it - // from staying that way by accident. - const decision = await svc.explain(singularRequest, context); - const verdictById = new Map(); - for (const id of new Set(recordIds)) { - const single = await svc.explain({ ...singularRequest, recordId: id }, context); - // A service without record-grained support answers no - // record verdict; fail CLOSED (a hidden button beats a - // shown-then-403), with no decidedBy fabricated. - verdictById.set(id, single?.record ?? { recordId: id, visible: false }); - } - // Ordering contract: records[i] answers recordIds[i] — same - // order, same length, duplicates answered per position. - res.json({ ...decision, records: recordIds.map((id) => verdictById.get(id)) }); - } catch (error: any) { - const msg = String(error?.message ?? error ?? ''); - if ( - error?.code === 'PERMISSION_DENIED' || - error?.name === 'PermissionDeniedError' || - msg.startsWith('[Security] Access denied') - ) { - return respondError(res, 403, 'PERMISSION_DENIED', msg.slice(0, 1000)); - } - // [#18253] The name asked about is not a declared object - // (`ExplainObjectNotFoundError`, plugin-security `errors.ts`) — - // a REFUSAL, not a fault, so it keeps its declared answer - // instead of falling to the 500 below. 404 `OBJECT_NOT_FOUND` - // is what this package already answers for an unregistered - // object name (`mapDataError`, `error-response.ts`), emitted - // here through this family's ONE refusal emitter so the body - // is the same ADR-0112 D5 envelope every other arm sends. - // Matched by `code`/`name`, exactly as the 403 arm above is: - // `@objectstack/plugin-security` is not a dependency of this - // package, and the thrown shape is the contract (#8016). - if (error?.code === 'OBJECT_NOT_FOUND' || error?.name === 'ExplainObjectNotFoundError') { - return respondError(res, 404, 'OBJECT_NOT_FOUND', msg.slice(0, 1000)); - } - // [#20603] A refusal the SERVICE classified: an ADR-0112 `code` - // with a 4xx `status` (either spelling), or a sandboxed body's - // business `throw`. It is the caller's answer, not this route's - // fault. The measured producer is the explain engine answering - // enforcement's own refusal, `INVALID_FILTER` / 400, for a - // row-level filter the record matcher cannot evaluate: in a - // record-grained explanation, and since #20604 in an - // object-level one and for a `recordId` no row carries. The - // find that explain describes answers 400 for the same filter. - // This arm used to answer `500 EXPLAIN_FAILED`, so a client - // read an outage where the platform meant "this policy cannot - // be evaluated". - // - // The classification is {@link classifiedRefusalAnswer}, the - // `/data` door's own, imported for the same reason the analytics - // and record-share doors import it (#11684): a local list of - // codes here would be a third opinion on a question that file - // owns. It answers `undefined` for everything the 500 below - // still owns: a declared 5xx, half an envelope (a code without - // a status, #5352), a crashed sandbox body, an unclassified - // fault. The 403 and 404 arms above keep running first, - // because they also match refusals that declare no status. - // - // Re-dressed through this family's ONE emitter, as the - // record-share family re-dresses it: `code` is required by the - // nested envelope and the sandbox limb legitimately carries - // none, so the catalog's status floor fills it. The message is - // the classification's, which unwraps a sandbox wrapper and - // applies the `/data` door's bound. - // - // ⛔ Scope: status, code and message. The classification's - // `declaredCode` and `userMessage` siblings are not forwarded, - // because this family's emitter has no slot for them (its fifth - // argument is `details`) and no arm of the family forwards - // them today. Adding that slot is a change to the whole family. - const refusal = classifiedRefusalAnswer(error); - if (refusal) { - const code = typeof refusal.body.code === 'string' - ? refusal.body.code as ErrorCode - : standardErrorCodeForHttpStatus(refusal.status); - return respondError(res, refusal.status, code, String(refusal.body.error ?? '')); - } - logError('[REST] Security explain error:', error); - // [#22764] A sandboxed body the doors answer as a fault (a - // crash, or a refusal declaring a 5xx) answers as the doors do, - // instead of the QuickJS wrapper relayed below. Status, code - // and message only, the refusal arm's scope above. - const fault = sandboxFaultAnswer(error); - if (fault) { - const nested = nestedRefusalEnvelope(fault); - return respondError(res, nested.status, nested.code, nested.message); - } - // The 500 arm keeps its 500-char cap: an unexpected fault's - // message is not a contract, and truncating it stays a - // sanitization step — only the position of the words moves. - respondError(res, 500, 'EXPLAIN_FAILED', 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'] }, - }); - - /** - * [ADR-0090 D12 / ADR-0105 D8] What the CALLER may delegate. - * - * GET {basePath}/security/my-delegable-scope - * - * The read half of the delegated-admin gate, shaped for a picker: the - * business units the caller may place people into and the positions - * they may assign. A scoped-invitation form narrows its options with - * this instead of listing the whole tree and letting the user find the - * boundary by being refused. - * - * Strictly SELF-scoped — no `userId` parameter, by design. The caller's - * own resolved sets are the only input, so it discloses nothing beyond - * the authority they already hold, and there is no "describe someone - * else's authority" surface to authorize (unlike `explain`, which has - * one and gates it). An authenticated caller is still required. - */ - const delegableHandler = 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) { - return respondError( - res, 401, 'UNAUTHORIZED', - 'The delegable-scope endpoint requires an authenticated caller.', - ); - } - - const svc = await resolveService(environmentId, req); - if (!svc || typeof svc.describeDelegableScope !== 'function') { - return respondError( - res, 501, 'NOT_IMPLEMENTED', - 'Delegated administration is not available on this deployment (no security service with describeDelegableScope).', - ); - } - - res.json(await svc.describeDelegableScope(context)); - } catch (error: any) { - const msg = String(error?.message ?? error ?? ''); - logError('[REST] Delegable scope error:', error); - respondError(res, 500, 'DELEGABLE_SCOPE_FAILED', msg.slice(0, 500)); - } + // [#23050 slice 6] The body moved to `security-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `SecurityExplainRouteHost`, and that module's header for why + // `kernelManager` and `securityServiceProvider` are getters). + const readKernelManager = () => this.kernelManager; + const readSecurityServiceProvider = () => this.securityServiceProvider; + const host: SecurityExplainRouteHost = { + routeManager: this.routeManager, + get kernelManager() { return readKernelManager(); }, + get securityServiceProvider() { return readSecurityServiceProvider(); }, + resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), }; - - this.routeManager.register({ - method: 'GET', - path: `${basePath}/security/my-delegable-scope`, - handler: delegableHandler, - metadata: { - summary: "The caller's delegable scope: business units they may place into and positions they may assign (ADR-0090 D12 / ADR-0105 D8)", - tags: ['security'], - }, - }); + registerSecurityExplainEndpoints.call(host, basePath); } /** @@ -10692,149 +10417,18 @@ export class RestServer { * codes a consumer is most likely to branch on. */ private registerSecurityEndpoints(basePath: string): void { - const dataPath = basePath; - const isScoped = basePath.includes('/environments/:environmentId'); - - const resolveService = async (environmentId?: string) => { - if (!this.securityServiceProvider) return undefined; - try { - const svc = await this.securityServiceProvider(environmentId); - return svc && typeof svc.listAudienceBindingSuggestions === 'function' ? svc : undefined; - } catch { return undefined; } - }; - /** - * The ONE refusal emitter for this route family (#7981) — every arm - * goes through it, so "the three answer the same shape" is a property - * of the code rather than of three literals that happen to agree. - * `refuseRepeatedQueryParams` writes the identical body from - * `query-multiplicity.ts`; that helper is shared with the whole file - * and stays the reference point rather than being re-implemented here. - */ - // [#22719] `extra` carries the declared channels of a classified refusal - // (`declaredCode`, `userMessage`) inside the same `error` object; every - // other arm passes none and writes exactly `{ code, message }`. - const respondError = ( - res: any, status: number, code: string, message: string, - extra?: { declaredCode?: string; userMessage?: string }, - ) => res.status(status).json({ error: { code, message, ...extra } }); - const respond501 = (res: any) => respondError( - res, 501, 'NOT_IMPLEMENTED', 'Security service is not configured on this deployment', - ); - const handleError = (err: any, res: any, defaultCode: string) => { - const status = typeof err?.statusCode === 'number' ? err.statusCode : 500; - if (status !== 500) { - // The typed arm: plugin-security's PermissionDeniedError (403), - // SuggestionNotFoundError (404) and SuggestionStateError (409) - // each carry `code` + `statusCode`. The status mapping is - // unchanged — only the position of the code moves. - return respondError(res, status, err?.code ?? defaultCode, String(err?.message ?? err)); - } - // [#22719] A refusal the typed arm cannot read is asked of the - // `/data` door's own classification before the 500 below relays - // `.message`. A sandboxed hook's refusal carries no `statusCode` - // (the sandbox lifts `status` only), so `confirm` and `dismiss` - // answered `500` with the QuickJS debug wrapper - // where every conforming write door answers the hook's own - // sentence; a refusal declaring `status` + `code` is answered at - // its own status too. The typed arm above is untouched, and the - // answer leaves through this family's one emitter. - const refusal = classifiedRefusalAnswer(err); - if (refusal) { - const nested = nestedRefusalEnvelope(refusal); - return respondError(res, nested.status, nested.code, nested.message, nested.extra); - } - logError(`[REST] suggested-bindings ${defaultCode}:`, err); - // [#22764] A sandboxed body the doors answer as a fault (a crash, or - // a refusal declaring a 5xx) answers as the doors do, through this - // family's one emitter, instead of the wrapper relayed below. - const fault = sandboxFaultAnswer(err); - if (fault) { - const nested = nestedRefusalEnvelope(fault); - return respondError(res, nested.status, nested.code, nested.message, nested.extra); - } - // The 500 arm keeps its 500-char cap: an unexpected fault's message - // is not a contract, and truncating it stays a sanitization step. - return respondError(res, 500, defaultCode, String(err?.message ?? err).slice(0, 500)); + // [#23050 slice 6] The body moved to `security-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `SecurityRouteHost`, and that module's header for why + // `securityServiceProvider` is a getter). + const readSecurityServiceProvider = () => this.securityServiceProvider; + const host: SecurityRouteHost = { + routeManager: this.routeManager, + get securityServiceProvider() { return readSecurityServiceProvider(); }, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), }; - - // LIST (reconciles against installed packages / declared sets first) - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/security/suggested-bindings`, - 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; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - // [#6877] Both are `String(array)` joins — `?status=a&status=b` - // filtered on the single status `'a,b'` and returned nothing. - if (refuseRepeatedQueryParams(req, res, ['status', 'packageId'])) return; - // [#7678] …and a single well-formed but UNKNOWN `?status=` - // did the same thing one layer on: the service's contract - // declares exactly three values, anything else matched no - // row, and the caller got 200 with an empty list — which - // reads as "there are no suggestions" rather than "your - // filter was not a status". The runtime dispatcher's twin of - // this route had refused it since #4127; this live route - // never did. Same predicate, imported — not a second copy of - // the vocabulary. - const status = req.query?.status ? String(req.query.status) : undefined; - if (status !== undefined && !isAudienceBindingSuggestionStatus(status)) { - // [#7981] Same body as before — emitted through the - // shared helper now, so this arm is the reference - // point by construction instead of by coincidence. - return respondError( - res, 400, 'VALIDATION_ERROR', - unknownAudienceBindingSuggestionStatusMessage(status), - ); - } - const result = await svc.listAudienceBindingSuggestions(context ?? {}, { - status, - packageId: req.query?.packageId ? String(req.query.packageId) : undefined, - }); - res.json({ data: result }); - } catch (err: any) { handleError(err, res, 'SUGGESTION_LIST_FAILED'); } - }, - metadata: { summary: 'List suggested audience bindings (ADR-0090 D5/D9)', tags: ['security'] }, - }); - - // CONFIRM — the deployment-level acceptance; `everyone` is re-derived - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/security/suggested-bindings/:id/confirm`, - 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; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id)); - res.json({ data: result }); - } catch (err: any) { handleError(err, res, 'SUGGESTION_CONFIRM_FAILED'); } - }, - metadata: { summary: 'Accept a suggested audience binding for the deployment (everyone carries the set)', tags: ['security'] }, - }); - - // DISMISS — declines a pending suggestion, or revokes an accepted one - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/security/suggested-bindings/:id/dismiss`, - 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; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id)); - res.json({ data: result }); - } catch (err: any) { handleError(err, res, 'SUGGESTION_DISMISS_FAILED'); } - }, - metadata: { summary: 'Dismiss a suggested audience binding, or revoke an accepted one', tags: ['security'] }, - }); + registerSecurityEndpoints.call(host, basePath); } /** diff --git a/packages/rest/src/security-routes.ts b/packages/rest/src/security-routes.ts new file mode 100644 index 00000000000..01fe3b195d9 --- /dev/null +++ b/packages/rest/src/security-routes.ts @@ -0,0 +1,554 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 6] The security routes of `RestServer` — the access-explanation + * family (`GET` / `POST {basePath}/security/explain` and + * `GET {basePath}/security/my-delegable-scope`) and the suggested audience + * bindings (`GET {basePath}/security/suggested-bindings`, with its + * `/:id/confirm` and `/:id/dismiss`) — moved out of `rest-server.ts` whole. + * + * A MOVE, not a redesign. The two functions below are the bodies of + * `RestServer#registerSecurityExplainEndpoints` and + * `RestServer#registerSecurityEndpoints` as they stood, each re-indented one + * level and otherwise unedited: the same routes, registered in the same order, + * by the same handlers, answering the same bodies. Each body's local helpers + * moved with it, unchanged — the explain family's `resolveService` and + * `respondError`, the suggested-binding family's `resolveService`, + * `respondError`, `respond501` and `handleError` — and so did the imports only + * these bodies read, each with its comment where it had one. The two + * registrars share ONE module because they are one `security` family (the + * explain family's emitter docblock names the other its immediately adjacent + * registrar); they still share no code, exactly as before. + * + * ## How the moved bodies reach the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps both private methods + * — the published `dist/*.d.ts` lists their names, docblocks and positions — + * and each body is one host literal ({@link SecurityExplainRouteHost}, + * {@link SecurityRouteHost}) plus `.call(host, basePath)`. A method is handed + * over as a closure that calls it on the server at call time, so a test double + * or spy set on the server is still the one the body reaches. + * + * Each field was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE. Both bodies read it only for their + * `register` calls, all of which run while registration runs, and the + * class's one `routeManager` reassignment wraps + * `registerMetadataEndpointsInner`, never these registrars. + * - `kernelManager` (read by the explain body) and `securityServiceProvider` + * (read by both) go as GETTERS. Each body's `resolveService` reads them at + * REQUEST time, and neither field is `readonly`, so a value copied at + * registration could part from it. The getter reads the field the handler + * read, when it read it. + * + * Module-level helpers stay where they were and are imported from the modules + * `rest-server.ts` imports them from; the bodies read nothing declared in + * `rest-server.ts` itself, so no export was added anywhere and the published + * surface does not move. + */ + +// `sendError`, aliased exactly as `rest-server.ts` aliases it: the note at that +// file's import says why the alias names the envelope DIALECT, and the explain +// family's emitter docblock below points at it. +import { sendError as sendEnvelopeError } from '@objectstack/types'; +// [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one +// owner, shared with the runtime dispatcher's `/security` domain. +import { isAudienceBindingSuggestionStatus, unknownAudienceBindingSuggestionStatusMessage } from '@objectstack/core'; +// [#8073] The closed ADR-0112 error vocabulary, so the explain family's single +// refusal emitter types its `code` parameter as the vocabulary rather than as +// `string` — an invented code is a compile error at the call site instead of a +// runtime surprise on whichever arm a test happens to drive. +import type { ErrorCode } from '@objectstack/spec/api'; +// [#11683] The catalog's own floor for "a required `code` and no more specific +// one" — see its use in the explain family's classified-refusal arm below (and +// `respondSharingError`'s in `sharing-routes.ts`), where the nested ADR-0112 +// envelope declares `code` REQUIRED while the flat classification it re-dresses +// legitimately carries none. +import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { classifiedRefusalAnswer, nestedRefusalEnvelope, sandboxFaultAnswer } from './error-response.js'; +import { logError } from './log.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import type { RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerSecurityExplainEndpoints} reads — + * every one of them, and no other. Each is typed AS the class member (an + * indexed access on the class), so a change to a member's signature on the + * class is a change here too, and naming a member the class does not have is a + * compile error. See the module header for why `kernelManager` and + * `securityServiceProvider` are getters. + */ +export interface SecurityExplainRouteHost { + readonly routeManager: RestServer['routeManager']; + readonly kernelManager: RestServer['kernelManager']; + readonly securityServiceProvider: RestServer['securityServiceProvider']; + resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** + * The `RestServer` members {@link registerSecurityEndpoints} reads — every one + * of them, and no other, typed as {@link SecurityExplainRouteHost}'s are. See + * the module header for why `securityServiceProvider` is a getter. + */ +export interface SecurityRouteHost { + readonly routeManager: RestServer['routeManager']; + readonly securityServiceProvider: RestServer['securityServiceProvider']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** + * Register the access-explanation endpoints — + * `RestServer#registerSecurityExplainEndpoints`' body, called by that method + * with the server as `this`. The method's own docblock, which the published + * declaration carries, names the routes and their contract. + */ +export function registerSecurityExplainEndpoints(this: SecurityExplainRouteHost, 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; } + }; + + /** + * [#8073] The ONE refusal emitter for this route family — every arm of + * both handlers goes through it, so "explain and my-delegable-scope + * answer the same shape" is a property of the code rather than of + * eight literals that happen to agree. + * + * Before this, the family carried BOTH dialects ADR-0112 D5 retires: + * the 401/501/400/403 arms were flat `{ code, message }` and the two + * 500s were `{ code, error: 'a bare string' }`, so `body.error.code` — + * the one position D5 declares — read `undefined` on all six. #7035 + * (PR #7293) had already removed both from this file's `/meta` + * refusals and #7981 (PR #8071) from `registerSecurityEndpoints`, the + * immediately ADJACENT registrar: a client calling `explain` and then + * `suggested-bindings` met two shapes inside one `security` family. + * + * Emitted through the SHARED builder (`sendError` from + * `@objectstack/types`, imported as `sendEnvelopeError` — see the note + * at the import). That is what makes this the reference shape by + * construction rather than a ninth local literal agreeing with the + * eight it replaced, and it types `code` to the closed vocabulary for + * free. + * + * ⛔ Status codes are untouched: only the POSITION of `code` and + * `message` moves. `detail` — the 400 arm's Zod-issue dump — moves to + * `error.details`, the slot `ApiErrorSchema` actually declares for + * structured context; as a top-level sibling it was undeclared. + */ + const respondError = ( + res: any, + status: number, + code: ErrorCode, + message: string, + details?: unknown, + ): void => sendEnvelopeError( + res, status, code, message, + details === undefined ? undefined : { details }, + ); + + 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) { + // The explain surface stays authenticated-only — it is an + // admin diagnosis tool. (Anonymous is already 401ed above.) + return respondError( + res, 401, 'UNAUTHORIZED', + 'The access-explanation endpoint requires an authenticated caller.', + ); + } + + const svc = await resolveService(environmentId, req); + if (!svc || typeof svc.explain !== 'function') { + return respondError( + res, 501, 'NOT_IMPLEMENTED', + '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. + // + // [#6877] The other read point that was already safe, and for the + // structural reason rather than by luck: every field goes through + // `ExplainRequestSchema.safeParse` below, whose members are + // `z.string()`, so an array is refused as `400 VALIDATION_FAILED` + // by the schema itself. A schema at the boundary is the shape the + // refusal gate is imitating, so there is nothing to add here. + const src = req.method === 'GET' ? (req.query ?? {}) : (req.body ?? {}); + const { ExplainRequestSchema } = await import('@objectstack/spec/security'); + // [#8326] GET-transport normalization ONLY: a query string + // cannot spell a one-element array (`?recordIds=a` parses to + // the bare string), so a lone string is wrapped on GET. On + // POST the body is JSON and can say what it means — a string + // where the contract says array stays a 400, not a wrap. + const rawRecordIds = req.method === 'GET' && typeof src.recordIds === 'string' && src.recordIds !== '' + ? [src.recordIds] + : src.recordIds; + const parsed = (ExplainRequestSchema as any).safeParse({ + object: src.object, + operation: src.operation ?? 'read', + ...(src.userId != null && src.userId !== '' ? { userId: src.userId } : {}), + // [C2 / ADR-0095] Optional record id — explains ONE concrete + // row at record granularity; omitted stays object-level. + ...(src.recordId != null && src.recordId !== '' ? { recordId: src.recordId } : {}), + // [#8326] Optional batch of record ids — the schema owns the + // cap (200), the min (1), and recordId/recordIds mutual + // exclusion, so every refusal is the one 400 below. + ...(rawRecordIds != null ? { recordIds: rawRecordIds } : {}), + }); + if (!parsed.success) { + return respondError( + res, 400, 'VALIDATION_FAILED', + 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string, recordIds?: string[] (max 200, exclusive with recordId) }.', + String(parsed.error?.message ?? '').slice(0, 1000), + ); + } + + const { recordIds, ...singularRequest } = parsed.data as { recordIds?: string[] } & Record; + if (!recordIds) { + // Singular / object-level — the pre-#8326 path, byte-identical. + const decision = await svc.explain(parsed.data, context); + return res.json(decision); + } + + // [#8326] Batch form — transport amortization of the SINGULAR + // evaluation, not a new semantic: the object-level trace is one + // object-level explain, and each per-record verdict is the + // singular record-grained explain for that id, relayed + // verbatim. "Batch answer ≡ N singular answers" is therefore a + // property of the construction, and the agreement test pins it + // from staying that way by accident. + const decision = await svc.explain(singularRequest, context); + const verdictById = new Map(); + for (const id of new Set(recordIds)) { + const single = await svc.explain({ ...singularRequest, recordId: id }, context); + // A service without record-grained support answers no + // record verdict; fail CLOSED (a hidden button beats a + // shown-then-403), with no decidedBy fabricated. + verdictById.set(id, single?.record ?? { recordId: id, visible: false }); + } + // Ordering contract: records[i] answers recordIds[i] — same + // order, same length, duplicates answered per position. + res.json({ ...decision, records: recordIds.map((id) => verdictById.get(id)) }); + } catch (error: any) { + const msg = String(error?.message ?? error ?? ''); + if ( + error?.code === 'PERMISSION_DENIED' || + error?.name === 'PermissionDeniedError' || + msg.startsWith('[Security] Access denied') + ) { + return respondError(res, 403, 'PERMISSION_DENIED', msg.slice(0, 1000)); + } + // [#18253] The name asked about is not a declared object + // (`ExplainObjectNotFoundError`, plugin-security `errors.ts`) — + // a REFUSAL, not a fault, so it keeps its declared answer + // instead of falling to the 500 below. 404 `OBJECT_NOT_FOUND` + // is what this package already answers for an unregistered + // object name (`mapDataError`, `error-response.ts`), emitted + // here through this family's ONE refusal emitter so the body + // is the same ADR-0112 D5 envelope every other arm sends. + // Matched by `code`/`name`, exactly as the 403 arm above is: + // `@objectstack/plugin-security` is not a dependency of this + // package, and the thrown shape is the contract (#8016). + if (error?.code === 'OBJECT_NOT_FOUND' || error?.name === 'ExplainObjectNotFoundError') { + return respondError(res, 404, 'OBJECT_NOT_FOUND', msg.slice(0, 1000)); + } + // [#20603] A refusal the SERVICE classified: an ADR-0112 `code` + // with a 4xx `status` (either spelling), or a sandboxed body's + // business `throw`. It is the caller's answer, not this route's + // fault. The measured producer is the explain engine answering + // enforcement's own refusal, `INVALID_FILTER` / 400, for a + // row-level filter the record matcher cannot evaluate: in a + // record-grained explanation, and since #20604 in an + // object-level one and for a `recordId` no row carries. The + // find that explain describes answers 400 for the same filter. + // This arm used to answer `500 EXPLAIN_FAILED`, so a client + // read an outage where the platform meant "this policy cannot + // be evaluated". + // + // The classification is {@link classifiedRefusalAnswer}, the + // `/data` door's own, imported for the same reason the analytics + // and record-share doors import it (#11684): a local list of + // codes here would be a third opinion on a question that file + // owns. It answers `undefined` for everything the 500 below + // still owns: a declared 5xx, half an envelope (a code without + // a status, #5352), a crashed sandbox body, an unclassified + // fault. The 403 and 404 arms above keep running first, + // because they also match refusals that declare no status. + // + // Re-dressed through this family's ONE emitter, as the + // record-share family re-dresses it: `code` is required by the + // nested envelope and the sandbox limb legitimately carries + // none, so the catalog's status floor fills it. The message is + // the classification's, which unwraps a sandbox wrapper and + // applies the `/data` door's bound. + // + // ⛔ Scope: status, code and message. The classification's + // `declaredCode` and `userMessage` siblings are not forwarded, + // because this family's emitter has no slot for them (its fifth + // argument is `details`) and no arm of the family forwards + // them today. Adding that slot is a change to the whole family. + const refusal = classifiedRefusalAnswer(error); + if (refusal) { + const code = typeof refusal.body.code === 'string' + ? refusal.body.code as ErrorCode + : standardErrorCodeForHttpStatus(refusal.status); + return respondError(res, refusal.status, code, String(refusal.body.error ?? '')); + } + logError('[REST] Security explain error:', error); + // [#22764] A sandboxed body the doors answer as a fault (a + // crash, or a refusal declaring a 5xx) answers as the doors do, + // instead of the QuickJS wrapper relayed below. Status, code + // and message only, the refusal arm's scope above. + const fault = sandboxFaultAnswer(error); + if (fault) { + const nested = nestedRefusalEnvelope(fault); + return respondError(res, nested.status, nested.code, nested.message); + } + // The 500 arm keeps its 500-char cap: an unexpected fault's + // message is not a contract, and truncating it stays a + // sanitization step — only the position of the words moves. + respondError(res, 500, 'EXPLAIN_FAILED', 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'] }, + }); + + /** + * [ADR-0090 D12 / ADR-0105 D8] What the CALLER may delegate. + * + * GET {basePath}/security/my-delegable-scope + * + * The read half of the delegated-admin gate, shaped for a picker: the + * business units the caller may place people into and the positions + * they may assign. A scoped-invitation form narrows its options with + * this instead of listing the whole tree and letting the user find the + * boundary by being refused. + * + * Strictly SELF-scoped — no `userId` parameter, by design. The caller's + * own resolved sets are the only input, so it discloses nothing beyond + * the authority they already hold, and there is no "describe someone + * else's authority" surface to authorize (unlike `explain`, which has + * one and gates it). An authenticated caller is still required. + */ + const delegableHandler = 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) { + return respondError( + res, 401, 'UNAUTHORIZED', + 'The delegable-scope endpoint requires an authenticated caller.', + ); + } + + const svc = await resolveService(environmentId, req); + if (!svc || typeof svc.describeDelegableScope !== 'function') { + return respondError( + res, 501, 'NOT_IMPLEMENTED', + 'Delegated administration is not available on this deployment (no security service with describeDelegableScope).', + ); + } + + res.json(await svc.describeDelegableScope(context)); + } catch (error: any) { + const msg = String(error?.message ?? error ?? ''); + logError('[REST] Delegable scope error:', error); + respondError(res, 500, 'DELEGABLE_SCOPE_FAILED', msg.slice(0, 500)); + } + }; + + this.routeManager.register({ + method: 'GET', + path: `${basePath}/security/my-delegable-scope`, + handler: delegableHandler, + metadata: { + summary: "The caller's delegable scope: business units they may place into and positions they may assign (ADR-0090 D12 / ADR-0105 D8)", + tags: ['security'], + }, + }); +} + +/** + * Register the suggested-audience-binding endpoints — + * `RestServer#registerSecurityEndpoints`' body, called by that method with the + * server as `this`. The method's own docblock, which the published declaration + * carries, names the routes and their one refusal envelope. + */ +export function registerSecurityEndpoints(this: SecurityRouteHost, basePath: string): void { + const dataPath = basePath; + const isScoped = basePath.includes('/environments/:environmentId'); + + const resolveService = async (environmentId?: string) => { + if (!this.securityServiceProvider) return undefined; + try { + const svc = await this.securityServiceProvider(environmentId); + return svc && typeof svc.listAudienceBindingSuggestions === 'function' ? svc : undefined; + } catch { return undefined; } + }; + /** + * The ONE refusal emitter for this route family (#7981) — every arm + * goes through it, so "the three answer the same shape" is a property + * of the code rather than of three literals that happen to agree. + * `refuseRepeatedQueryParams` writes the identical body from + * `query-multiplicity.ts`; that helper is shared with the whole file + * and stays the reference point rather than being re-implemented here. + */ + // [#22719] `extra` carries the declared channels of a classified refusal + // (`declaredCode`, `userMessage`) inside the same `error` object; every + // other arm passes none and writes exactly `{ code, message }`. + const respondError = ( + res: any, status: number, code: string, message: string, + extra?: { declaredCode?: string; userMessage?: string }, + ) => res.status(status).json({ error: { code, message, ...extra } }); + const respond501 = (res: any) => respondError( + res, 501, 'NOT_IMPLEMENTED', 'Security service is not configured on this deployment', + ); + const handleError = (err: any, res: any, defaultCode: string) => { + const status = typeof err?.statusCode === 'number' ? err.statusCode : 500; + if (status !== 500) { + // The typed arm: plugin-security's PermissionDeniedError (403), + // SuggestionNotFoundError (404) and SuggestionStateError (409) + // each carry `code` + `statusCode`. The status mapping is + // unchanged — only the position of the code moves. + return respondError(res, status, err?.code ?? defaultCode, String(err?.message ?? err)); + } + // [#22719] A refusal the typed arm cannot read is asked of the + // `/data` door's own classification before the 500 below relays + // `.message`. A sandboxed hook's refusal carries no `statusCode` + // (the sandbox lifts `status` only), so `confirm` and `dismiss` + // answered `500` with the QuickJS debug wrapper + // where every conforming write door answers the hook's own + // sentence; a refusal declaring `status` + `code` is answered at + // its own status too. The typed arm above is untouched, and the + // answer leaves through this family's one emitter. + const refusal = classifiedRefusalAnswer(err); + if (refusal) { + const nested = nestedRefusalEnvelope(refusal); + return respondError(res, nested.status, nested.code, nested.message, nested.extra); + } + logError(`[REST] suggested-bindings ${defaultCode}:`, err); + // [#22764] A sandboxed body the doors answer as a fault (a crash, or + // a refusal declaring a 5xx) answers as the doors do, through this + // family's one emitter, instead of the wrapper relayed below. + const fault = sandboxFaultAnswer(err); + if (fault) { + const nested = nestedRefusalEnvelope(fault); + return respondError(res, nested.status, nested.code, nested.message, nested.extra); + } + // The 500 arm keeps its 500-char cap: an unexpected fault's message + // is not a contract, and truncating it stays a sanitization step. + return respondError(res, 500, defaultCode, String(err?.message ?? err).slice(0, 500)); + }; + + // LIST (reconciles against installed packages / declared sets first) + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/security/suggested-bindings`, + 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; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + // [#6877] Both are `String(array)` joins — `?status=a&status=b` + // filtered on the single status `'a,b'` and returned nothing. + if (refuseRepeatedQueryParams(req, res, ['status', 'packageId'])) return; + // [#7678] …and a single well-formed but UNKNOWN `?status=` + // did the same thing one layer on: the service's contract + // declares exactly three values, anything else matched no + // row, and the caller got 200 with an empty list — which + // reads as "there are no suggestions" rather than "your + // filter was not a status". The runtime dispatcher's twin of + // this route had refused it since #4127; this live route + // never did. Same predicate, imported — not a second copy of + // the vocabulary. + const status = req.query?.status ? String(req.query.status) : undefined; + if (status !== undefined && !isAudienceBindingSuggestionStatus(status)) { + // [#7981] Same body as before — emitted through the + // shared helper now, so this arm is the reference + // point by construction instead of by coincidence. + return respondError( + res, 400, 'VALIDATION_ERROR', + unknownAudienceBindingSuggestionStatusMessage(status), + ); + } + const result = await svc.listAudienceBindingSuggestions(context ?? {}, { + status, + packageId: req.query?.packageId ? String(req.query.packageId) : undefined, + }); + res.json({ data: result }); + } catch (err: any) { handleError(err, res, 'SUGGESTION_LIST_FAILED'); } + }, + metadata: { summary: 'List suggested audience bindings (ADR-0090 D5/D9)', tags: ['security'] }, + }); + + // CONFIRM — the deployment-level acceptance; `everyone` is re-derived + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/security/suggested-bindings/:id/confirm`, + 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; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id)); + res.json({ data: result }); + } catch (err: any) { handleError(err, res, 'SUGGESTION_CONFIRM_FAILED'); } + }, + metadata: { summary: 'Accept a suggested audience binding for the deployment (everyone carries the set)', tags: ['security'] }, + }); + + // DISMISS — declines a pending suggestion, or revokes an accepted one + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/security/suggested-bindings/:id/dismiss`, + 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; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id)); + res.json({ data: result }); + } catch (err: any) { handleError(err, res, 'SUGGESTION_DISMISS_FAILED'); } + }, + metadata: { summary: 'Dismiss a suggested audience binding, or revoke an accepted one', tags: ['security'] }, + }); +} diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index b3c6507de00..c9341579e5d 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -346,7 +346,9 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // (50 and 65 before, +1 each per new host-literal forwarder). // [#23050 slice 5] 5 → 6 (`analytics-routes.ts`): 53 and 68, +1 each // for that slice's forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // [#23050 slice 6] 6 → 7 (`security-routes.ts`): 55 and 70, +1 each + // per new host-literal forwarder (two registrars, two forwarders). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index 4c8c2e01936..20f0a4ba523 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1113,7 +1113,10 @@ describe('[#13214] §4 preconditions', () => { // new host-literal forwarder) and the ownership guard stays at 1. // [#23050 slice 5] 5 → 6 (`analytics-routes.ts`): 53 and 68, +1 each // for that slice's forwarder; the ownership guard still 1. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(6); + // [#23050 slice 6] 6 → 7 (`security-routes.ts`): 55 and 70, +1 each + // per new host-literal forwarder (two registrars, two forwarders); the + // ownership guard still 1. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/scripts/adr-anchors/packages__rest__src__security-routes.ts.json b/scripts/adr-anchors/packages__rest__src__security-routes.ts.json new file mode 100644 index 00000000000..bbdba7cee6c --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__security-routes.ts.json @@ -0,0 +1,9 @@ +{ + "file": "packages/rest/src/security-routes.ts", + "adrs": [ + "ADR-0090", + "ADR-0105", + "ADR-0112" + ], + "invariant": "The security REST doors, moved here whole from `rest-server.ts` (#23050 slice 6): the access-explanation family (`GET` / `POST {base}/security/explain`, `GET {base}/security/my-delegable-scope`) and the suggested audience bindings (`GET {base}/security/suggested-bindings`, `POST …/:id/confirm`, `POST …/:id/dismiss`). ADR-0090 — the doors decide no access of their own: the explain door relays the security service's `explain`, the same code paths enforcement runs, and explaining ANOTHER user is authorized in the SERVICE, never at the door (its D6, with D12's delegated scope); the delegable-scope door is strictly self-scoped, the caller's own resolved sets and no `userId` parameter (its D12); the suggested-binding doors surface a package's default-set suggestions and relay the deployment's one decision on each to the service, never binding on their own (its D5 and D9). ADR-0105 — the delegable scope is the read half of the scoped-invitation placement gate (its D8). ADR-0112 — every refusal of each family leaves through that family's ONE emitter at the nested position, where `error.code` carries the semantic code (its D5); the explain family's emitter types it as the closed `ErrorCode` vocabulary. An arm answered in a second dialect, an authorization decision re-implemented at a door, or a door that binds instead of relaying is the drift these records forbid; the bodies' own comments carry each citation." +} diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 87972375954..f02f80e2ee8 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -350,6 +350,12 @@ const MODULES = { // `{ code, error }` arm sends the computed `outward`), so this count does // not move and that module declares 0 below; the sum is unchanged // (17 + 13 + 8 + 1 + 0 + 0 = 39). + // + // 17 → 17 (#23050 slice 6): the security-explain and suggested-binding + // bodies left this file for `security-routes.ts` carrying NO literal string + // `error` body (every arm there emits the nested `{ error: { code, message } }` + // through its family's one emitter), so this count does not move and that + // module declares 0 below; the sum is unchanged (17 + 13 + 8 + 1 + 0 + 0 + 0 = 39). stringError: 17, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared @@ -413,6 +419,12 @@ const MODULES = { // undeclared-fault 500, `ANALYTICS_QUERY_FAILED`) left with that body for // `analytics-routes.ts`, declared below; the sum is unchanged // (17 + 15 + 8 + 9 + 4 + 1 = 54). + // + // 17 → 17 (#23050 slice 6): the security-explain and suggested-binding + // bodies left this file for `security-routes.ts` carrying NO `{ code, error }` + // body — the two arms #7981 and #8073 converted are the ones counted above + // as 77 → 73 — so this count does not move and that module declares 0 below; + // the sum is unchanged (17 + 15 + 8 + 9 + 4 + 1 + 0 = 54). siblingCode: 17, }, @@ -539,6 +551,26 @@ const MODULES = { stringError: 0, siblingCode: 1, }, + + // [#23050 slice 6] `RestServer#registerSecurityExplainEndpoints`' and + // `#registerSecurityEndpoints`' bodies — the access-explanation family + // (`/security/explain` and `/security/my-delegable-scope`) and the + // suggested audience bindings (`/security/suggested-bindings` and its + // confirm / dismiss) — moved out of `rest-server.ts` whole, into ONE module, + // on exactly slice 1's terms above: a MOVE, no wire answer changed, so it + // inherits `dialectOnly` and the two dialect facts, and no write-site count. + // Both counts were measured on the moved file and equal what left + // `rest-server.ts` in the same PR (stringError 17 → 17, siblingCode 17 → 17: + // none), so each sum is unchanged. Both only ever tick down. Discovered by + // the `*-routes.ts` convention and declared in the same PR that created the + // module, so the gap never exists. + 'packages/rest/src/security-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 6 and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 0, + siblingCode: 0, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */