diff --git a/.changeset/23050-s13a-meta-collection-liveness-citations.md b/.changeset/23050-s13a-meta-collection-liveness-citations.md new file mode 100644 index 00000000000..446b444ce43 --- /dev/null +++ b/.changeset/23050-s13a-meta-collection-liveness-citations.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +The liveness ledger's `/meta` type-list, per-type-list and maintenance switch rows cite the code that reads their keys where it now lives + +Clause-②: no (only the rows' evidence paths move and every status stays `live`: `MetadataEndpointsConfigSchema` accepts `endpoints.types`, `endpoints.items` and `endpoints.maintenance` exactly as before, and no state count moves) + +- `liveness/metadata_endpoints.json`: the `endpoints.types`, `endpoints.items` and `endpoints.maintenance` rows cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` for the gates on their mounts. `@objectstack/rest` moved those three route groups to `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` with no behaviour change, so those three path references follow them. +- The quoted code, the producers, the verdicts and the notes are unchanged. The `prefix` row keeps its citation: `metaPath` is still built in the inner method's head. diff --git a/.changeset/23050-s13a-meta-collection-routes-move.md b/.changeset/23050-s13a-meta-collection-routes-move.md new file mode 100644 index 00000000000..726a958726b --- /dev/null +++ b/.changeset/23050-s13a-meta-collection-routes-move.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': patch +--- + +The `/meta` type list, whole-store maintenance and per-type list routes live in their own module, with no route, handler or answer changed + +Clause-②: no (a move: the seven routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + +- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statements that register `GET {metaPath}` and `GET {metaPath}/types`, `GET {metaPath}/diagnostics`, `GET {metaPath}/_drafts`, `POST {metaPath}/_migrate-stored` and `GET {metaPath}/:type` moved, re-indented, to `packages/rest/src/meta-collection-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. The `metadata.endpoints.types`, `.maintenance` and `.items` switches gate the same mounts. +- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 1f6313279cb..c4d227039c7 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -181,7 +181,7 @@ One of the largest consumers of the flag; |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes. The gate is `refuseUnexposedDataAction`, which `callData` asks first and the `/data` domain asks before its request gate; `callData` reads the same bypass once more for each nested-relation condition's target | `packages/runtime/src/action-execution.ts#callData`, `#refuseUnexposedDataAction` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 2b2305cba2d..fb8038ecd4a 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -2446,7 +2446,7 @@ "source": [ "packages/spec/liveness/metadata_endpoints.json (the ADR-0049 ledger this item answers: prefix, enableCache, maskObjectFields and three endpoints.* live, cacheTtl and endpoints.schema dead)", "packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema (the declared contract — four endpoints.* switches whose describe() strings enumerate every mount they gate since #15542/#15854, the ADR-0106 D8 docblock and the two retiredKey tombstones)", - "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath, the four endpoint gates and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (getRoutes) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.metadata built from prefix)", + "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath, the item endpoint gate and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (getRoutes) + packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (the types, items and maintenance endpoint gates) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.metadata built from prefix)", "ADR-0106 D8 (per-caller field masking of served object schemas) · #14691 (the two retirements) · #7526 (the /meta/types ordering fix the `types` switch takes with it) · ADR-0076 D12 · #15542 / #15854 (the switch-radius ruling that created `maintenance` and gave `item` its own writes) · #15543 (why the measured blast radius of that move is zero: no shipped boot path authors a RestServerConfig)", "sibling clause: platform-core.metadata-registry-serving (it drives the served REGISTRY on the default config; this item covers the config that decides what is served and to whom) · platform-core.docs-audience-gate (the audience-gated types that are a cache carve-out here)" ], diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index cd048b2fd48..2277700ae1c 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -1394,7 +1394,7 @@ "ref": "packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts — pins the outage clause ONLY (503 rethrown, never '0 problems'); the live sweep, scoping parity, refusals, and both browser surfaces still need the run" }, "source": [ - "packages/rest/src/rest-server.ts#route (the route: registered BEFORE /meta/:type so 'diagnostics' is not captured as a type; severity defaults 'error'; ?type/?severity/?package each single-valued via refuseRepeatedQueryParams #6877; 501 NOT_IMPLEMENTED when the kernel's protocol lacks getMetaDiagnostics; answers res.json(result) BARE)", + "packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (the route: registered BEFORE /meta/:type so 'diagnostics' is not captured as a type; severity defaults 'error'; ?type/?severity/?package each single-valued via refuseRepeatedQueryParams #6877; 501 NOT_IMPLEMENTED when the kernel's protocol lacks getMetaDiagnostics; answers res.json(result) BARE)", "packages/rest/src/rest-route-ledger.ts#REST (REST-only route, bare body = GetMetaDiagnosticsResponseSchema, client meta.getDiagnostics, #12038)", "packages/metadata-protocol/src/protocol.ts#getMetaDiagnostics (getMetaDiagnostics: registry-derived type set filtered to registered schemas; reuses the _diagnostics read decoration; stats { count, locked, packages } computed in the same sweep 'so the Studio directory page can render tile counts and a package filter in one round-trip'; 503 outage rethrown #8855 / ADR-0110 D3; 400 unrecognised-spelling rethrown #8924; warnings 'reserved for a future lint layer')", "packages/metadata-protocol/src/metadata-diagnostics.ts#computeMetadataDiagnostics (computeMetadataDiagnostics — errors only, undefined = no opinion for schema-less types; zodIssuesToMetadataIssues names union branches #5598 so Studio has a path to highlight)", diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 708559cccab..c10ed35721b 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1634,7 +1634,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 10] Length 10 → 11 (`ui-routes.ts`), still 0 over it. // [#23050 slice 11] Length 11 → 12 (`form-routes.ts`), still 0 over it. // [#23050 slice 12] Length 12 → 13 (`crud-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] Length 13 → 14 (`meta-collection-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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 b2de6db2295..627ee6c5ecc 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -240,7 +240,10 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; // [#23050 slice 3] THE list of the files `RestServer`'s registrars live in — // see the `rest-server.ts` row's population rule. +// [#23050 slice 13a] …and its named subset of the modules that hold a moved +// `/meta` group — see the same row's `reachable` rule. import { + REST_SERVER_META_ROUTE_FILES, REST_SERVER_REGISTRAR_DIR, REST_SERVER_REGISTRAR_FILES, } from '../../../rest/src/rest-server-registrar-files.js'; @@ -537,13 +540,17 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ '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`, ' + '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' + - '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts` and `crud-routes.ts`, the modules the bodies of ' + + '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts`, `crud-routes.ts` and `meta-collection-routes.ts`, ' + + 'the modules the bodies of ' + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + '`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' + - '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints` moved to; ' + - 'reachable = those inside registerMetadataEndpoints', + '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first three `/meta` ' + + 'groups of `registerMetadataEndpointsInner`, moved to; ' + + 'reachable = those inside registerMetadataEndpoints, PLUS every site in the registrar list\'s named `/meta` ' + + 'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`), whose registrations the inner method ' + + 'runs inside the umbrella\'s swap', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. // @@ -815,10 +822,37 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // 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 13a] 70 / 19 / 51 UNCHANGED, and this time `reachable` is the + // reading that had to be taught: the first three `/meta` groups of + // `registerMetadataEndpointsInner` (six direct registration sites: `GET + // {metaPath}` and `/types`, `/diagnostics`, `/_drafts`, `POST + // /_migrate-stored` and `GET /:type`, all REACHABLE through the umbrella key + // `meta:rest-server.ts:registerMetadataEndpoints`) moved to + // `meta-collection-routes.ts`, which joins the list (length 13 -> 14) with + // its own control. The six still register inside the umbrella's swap — the + // inner method builds their host literal, capturing the wrapping registrar + // by value, and calls the module at their old position — but no line of + // them is lexically inside `registerMetadataEndpoints` any more, so the + // lexical rule alone read 70 / 13 / 57: six guarded routes counted as blind + // spots, a false reading. Measured, not argued, before the rule widened: an + // anonymous request at each of the six (both bases) answers `401` with the + // module in place, and hoisting the route manager's capture above the swap + // makes every one of them answer without the gate. The rule therefore adds + // every site in `REST_SERVER_META_ROUTE_FILES`, the list's NAMED `/meta` + // subset — ⛔ never a file-name convention — and the new control + // `/meta subset ↔ .call( inside registerMetadataEndpointsInner` holds that + // subset's length against the module calls inside the inner method, so a + // `/meta` module called without joining the subset (or a subset entry the + // inner method no longer calls) moves it. `this.routeManager.register(` + // stays 63 over the fourteen files + // (6 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1 + 1 + 2 + 6 + 6), + // `private register*Endpoints(` stays 16, `registerPerItemRoute(` stays 8 and + // `enforceAuth` stays 112: the moved groups spell neither term, and the seam + // spells neither (the umbrella's wrapper is what gates them). controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 13, + 'registrar list length': 14, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -831,6 +865,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'ui-routes.ts: this.routeManager.register(': 1, 'form-routes.ts: this.routeManager.register(': 2, 'crud-routes.ts: this.routeManager.register(': 6, + 'meta-collection-routes.ts: this.routeManager.register(': 6, + // [#23050 slice 13a] Recorded AS the subset's length, not as a literal: the + // measured side is the module calls inside the inner method, so the two + // can only move together. + '/meta subset ↔ .call( inside registerMetadataEndpointsInner': REST_SERVER_META_ROUTE_FILES.length, // 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 @@ -1324,9 +1363,21 @@ export function deriveProbeFileCensus(): { const metaIdx = decls.findIndex((d) => d.text.includes('registerMetadataEndpoints')); const start = decls[metaIdx]?.at ?? 0; const end = decls[metaIdx + 1]?.at ?? src.length; + // [#23050 slice 13a] ONE REACH, NOW TWO PLACES. A `/meta` group moved out of + // `registerMetadataEndpointsInner` still registers inside the umbrella's + // swap — the inner method builds its host literal (the wrapping registrar + // captured by value) and calls its module from inside its own body — so its + // sites are as reachable as the ones still lexically inside the slice above. + // They are read off the registrar list's NAMED `/meta` subset, never off a + // file-name convention, and its control below holds that subset against the + // module calls inside the inner method (that method's own extent: from its + // declaration to the umbrella slice's end). + const metaModules = movedModules.filter(([file]) => REST_SERVER_META_ROUTE_FILES.includes(file)); + const innerAt = src.search(/private\s+registerMetadataEndpointsInner\s*\(/); + const inner = innerAt < start || innerAt >= end ? '' : src.slice(innerAt, end); files.set('packages/rest/src/rest-server.ts', { population: sites(all), - reachable: sites(src.slice(start, end)), + reachable: sites(src.slice(start, end)) + metaModules.reduce((n, [, text]) => n + sites(text), 0), controls: { 'private register*Endpoints(': occurrences(src, /private\s+register[A-Za-z]*Endpoints\s*\(/g), 'this.routeManager.register(': occurrences(all, /this\.routeManager\.register\(/g), @@ -1346,6 +1397,9 @@ export function deriveProbeFileCensus(): { // real population drop. The four counts around it cannot — all four are // green while the slice is running 3873 lines long. 'forwarder slice: this.routeManager.register(': occurrences(forwarderSlice(src), mountRe), + // [#23050 slice 13a] The module calls inside the inner method — one per + // moved `/meta` group's module — recorded against the subset's length. + '/meta subset ↔ .call( inside registerMetadataEndpointsInner': occurrences(inner, /\.call\(/g), enforceAuth: occurrences(all, /enforceAuth/g), }, }); diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 6107005c1b2..835f8398c0d 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -119,6 +119,11 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * * [#23050 slice 12] …and `registerCrudEndpoints`' body to `crud-routes.ts`, * with its six: one per CRUD door, each bare and guarded on its next line. + * + * [#23050 slice 13a] …and the first three `/meta` groups of + * `registerMetadataEndpointsInner`' body to `meta-collection-routes.ts`, with + * their three: the `_drafts` and `_migrate-stored` doors' and the per-type + * list's, each locally caught (the list's on its continuation line), as before. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -134,7 +139,9 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, * `registerOpenApiEndpoints`, `registerEmailEndpoints`, * `registerSearchEndpoints`, `registerUiEndpoints`, - * `registerCrudEndpoints`) hands its moved body + * `registerCrudEndpoints`) hands its moved body — and the one + * `registerMetadataEndpointsInner` builds for its moved `/meta` groups (#23050 + * slice 13a), counted as the moved module's registrar — * 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 @@ -728,9 +735,22 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // mentions in). Thirteen forwarders now: (13 - 1) + 3 - 2, the module // carrying one registrar that reads the seam, so neither map gains an // entry. - expect(SITE_FILES).toHaveLength(13); + // + // [#23050 slice 13a] 62 sites UNCHANGED / 129 → 133 mentions — the same MOVE. + // The first three `/meta` groups of `registerMetadataEndpointsInner` left for + // `meta-collection-routes.ts` with their three LOCALLY CAUGHT sites (`_drafts` + // and `_migrate-stored` on the invocation line, the per-type list on its + // continuation line, as before): + // 13 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 + 3 = 62 sites. The four + // new mentions are this slice's seam, the two spellings once (the host + // literal the inner method builds, its forwarder, two; the host interface's + // member, two): `rest-server.ts` goes 56 → 55 (three sites out, one + // forwarder's two mentions in). Fourteen forwarders now: (14 - 1) + 3 - 2, + // the module carrying one host literal that reads the seam, so neither map + // gains an entry. + expect(SITE_FILES).toHaveLength(14); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(129); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(133); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); expect(extraMovedRegistrars).toBe(3); const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index e5f50db0a75..43166e1840c 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -56,8 +56,9 @@ * body that changed. * * Module-level names stay where they are declared. The meta doors' request - * envelope `TransportScopedMetaRequest` is imported from `ui-routes.ts`, where - * slice 10 moved it, and the data doors' `ServerScopedDataRequest` from + * envelope `TransportScopedMetaRequest` is imported from + * `meta-collection-routes.ts`, where slice 13a moved it on from `ui-routes.ts` + * (slice 10's home for it), and the data doors' `ServerScopedDataRequest` from * `data-action-routes.ts`, where slice 1 moved it. `anonymousFormIntakeWithdrawnIn`, * `PUBLIC_FORM_SERVER_MANAGED_FIELDS` and `isExpectedRouteError`, which only * this body read, are imported here instead of there. No export was added @@ -79,8 +80,8 @@ import { validationFailure } from '@objectstack/types'; import type { ServerScopedDataRequest } from './data-action-routes.js'; import { isExpectedRouteError, mapDataError } from './error-response.js'; import { logError } from './log.js'; +import type { TransportScopedMetaRequest } from './meta-collection-routes.js'; import type { RestServer } from './rest-server.js'; -import type { TransportScopedMetaRequest } from './ui-routes.js'; /** * The `RestServer` members {@link registerFormEndpoints} reads — every one of diff --git a/packages/rest/src/meta-collection-routes.ts b/packages/rest/src/meta-collection-routes.ts new file mode 100644 index 00000000000..3e38c8ff50b --- /dev/null +++ b/packages/rest/src/meta-collection-routes.ts @@ -0,0 +1,646 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 13a] The first three `/meta` route groups of `RestServer` — + * the type list (`GET {metaPath}` and its `GET {metaPath}/types` spelling, + * one handler on two paths), the whole-store maintenance family + * (`GET {metaPath}/diagnostics`, `GET {metaPath}/_drafts` and the + * `POST {metaPath}/_migrate-stored` write door) and the per-type list + * (`GET {metaPath}/:type`) — moved out of `rest-server.ts`. They were the first + * three groups of `RestServer#registerMetadataEndpointsInner`'s body, and that + * method now calls the function below at their old position, so the `/meta` + * routes still register in one order, the literal segments still above the + * `:type` parameter that would otherwise capture them. + * + * A MOVE, not a redesign. The function below is those groups' statements as + * they stood, re-indented one level: the same seven routes per base, under the + * same three switches (`metadata.endpoints.types`, `.maintenance` and + * `.items`), by the same handlers, answering the same bodies. The rest of the + * inner method — its head and the per-item, history, object-state and + * published groups — stays where it is. + * + * ## How the moved body reaches the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps the private method, + * and the method keeps its head; at the position of the moved statements it + * builds one host literal ({@link MetaCollectionRouteHost}) and calls + * `registerMetaCollectionRoutes.call(host, scope)`. 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 member was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE, and the literal that carries it is built + * INSIDE `registerMetadataEndpointsInner`, at the position of the + * statements it replaces. That placement is the load-bearing part. + * `registerMetadataEndpoints`, the umbrella, swaps the server's route + * manager for a wrapping registrar for exactly the duration of the inner + * call and restores it in a `finally`; the wrapper is what puts every + * `/meta` route behind the anonymous-deny gate (#3963) and the type-level + * read and write admissions (#21087, #21124). The body reads the manager + * only for its six `register` calls, all of which run during that call, and + * nothing inside the inner method reassigns it, so the value captured there + * is the object every one of those reads returned. ⛔ A host built anywhere + * else — in `registerRoutes`, in the constructor, cached on the instance, or + * in the umbrella above the swap — captures the REAL manager, and these + * seven routes would mount outside all three gates. + * - No member goes as a getter, because the body reads no FIELD at request + * time. The protocol, the caller, the walled refusal, the write actor, the + * list's type check, its answer sources and the type-list translation are + * each resolved per request by a METHOD of the class, and every method goes + * as a closure. No provider field is read. + * + * The inner method's head declares four bindings every `/meta` group reads + * (`metadata`, `metaPath`, `isScoped` and the per-item registrar). They are + * computed once per base and shared by every group, so they stay in that head + * and reach a moved group as ONE parameter, {@link MetaRouteScope}, built + * once there; the function destructures the three its body reads. The host + * stays class members only. + * + * ## The one token that is not byte-identical + * + * The per-type list names the requested type to the list chain through the + * class's PRIVATE STATIC `metaTypeSingular`, which it used to call as + * `RestServer.metaTypeSingular(…)`. A private static is reachable by name only + * inside the class, so that spelling does not compile here, and making the + * member public, or moving it, changes the published declaration the move must + * keep byte-identical. So the call reaches it the way every other class member + * this body reads is reached: through the host, as `this.metaTypeSingular(…)`, + * a closure on {@link MetaCollectionRouteHost} that calls + * `RestServer.metaTypeSingular` with the same argument. The static reads + * nothing but its argument, so the receiver is the whole difference, as it was + * for slice 11's `translateOptionsFor`. That receiver is the only token of the + * body that changed. + * + * Module-level names stay where they are declared. `metaItemPackageBinding` + * (on the package entry) and `mayReadPendingDrafts` (which the analytics module + * imports too) are declared in `rest-server.ts`, stay there and are imported + * from it; both are read only at request time, so the two modules importing + * each other cannot see them uninitialised. Every other name is imported from + * the module `rest-server.ts` imports it from, and every one of them is still + * read there too. Two declarations live here: {@link MetaRouteScope}, new, and + * the meta doors' request envelope `TransportScopedMetaRequest`, moved here + * from `ui-routes.ts` because this module holds the first `/meta` door outside + * `rest-server.ts` (see the note above its block). The package entry + * re-exports neither, and the published surface does not move. + */ + +import { rethrowAuthzStoreUnavailable } from '@objectstack/core'; +import type { GetMetaItemsRequest } from '@objectstack/spec/api'; +import { handleRouteError, sendFieldVisibilityFault } from './error-response.js'; +import * as metaReadGate from './meta-item-read-gate.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { mayReadPendingDrafts, metaItemPackageBinding, type RestServer } from './rest-server.js'; +import type { RouteManager } from './route-manager.js'; + +// [#23050 slice 13a] Moved here from `ui-routes.ts`, verbatim. Slice 10 had moved it +// there from `rest-server.ts` with the UI view door, its first reader outside that +// file; it is the `/meta` doors' envelope, and this module holds the first `/meta` +// door outside that file, so every `/meta` module the split adds imports it from its +// home. A second copy is not an option. The UI view door, the public form doors and +// the meta doors and listing helpers still in `rest-server.ts` import it from here, +// and the block's "here" and "this file" still mean `rest-server.ts`, where they live. +/** + * [commit 2a29caa53] Typed TRANSPORT envelope for the meta-read doors. + * + * `environmentId` is the multi-kernel routing key, and it is OUT of the + * protocol request shape **by explicit maintainer decision** (ruling recorded + * 2026-08-18, landed as commit 2a29caa53): `resolveProtocol(environmentId)` selects the target + * kernel *before* the protocol call, and the implementation's parameter types + * (`@objectstack/metadata-protocol`) never read it off the request — the spec + * schemas (`protocol.zod.ts`) record the same exclusion schema-side. The doors + * here still spread it into the outgoing payload (long-standing wire shape, + * deliberately unchanged by the ruling), so this alias declares that one + * transport-level member on top of the declared request type. The point is + * what it makes the compiler do: every OTHER key in a door's request literal + * is now checked against the spec contract — an undeclared member is a compile + * error at the call site, not a cast-and-hope. Never add protocol members + * here; a key that belongs to the request belongs in the spec schema. + * + * [commit 45862a53d] The same typing now covers the NON-door `getMetaItems` helper call + * sites in this file (the object, book, doc, view and dataset listings). Two + * spellings those sites carry deliberately SURVIVE the tightening, because + * retiring either would change behaviour rather than typing: + * + * - The OPTIONAL CALL (`getMetaItems?.(…)`) and the + * `typeof … === 'function'` guards. `getMetaItems` is a REQUIRED + * `MetadataProtocol` member, so these are not feature detection in the + * type sense — but a host may occupy the protocol slot with an object + * that does not implement the whole surface, which is the measured reason + * they exist (`metaTypeIsLive` documents the same deliberate spelling for + * `getMetaTypes`). Retiring one turns a tolerated absence into a + * `TypeError`. + * - The RESULT handling. `getMetaItems` is declared to return + * `{ type, items }`, while these sites also tolerate the bare-array shape + * older hosts and stubs return (see `metaItemsArray`), so the response + * stays runtime-shaped on purpose. + * + * The REQUEST is what is fully typeable today, and the request is what is + * typed — which is the whole point: an undeclared key in one of these + * literals is now a compile error instead of a cast-and-hope. + */ +export type TransportScopedMetaRequest = R & { environmentId?: string }; + +/** + * [#23050 slice 13a] The four bindings `RestServer#registerMetadataEndpointsInner` + * declares in its head, handed to each moved `/meta` group as ONE parameter. + * They are computed once per base and every group reads them, so they stay in + * that head rather than moving with any one group: + * + * - `metadata` — the normalized `config.metadata`, whose `endpoints.*` + * switches gate each family; + * - `metaPath` — `{basePath}{metadata.prefix}`, the mount every route is under; + * - `isScoped` — whether this base is the environment-scoped one, which alone + * decides whether a handler reads `req.params.environmentId`; + * - `registerPerItemRoute` — the per-item switch's registrar. It is the inner + * method's OWN closure, handed over as it is, and it reads the server's route + * manager at CALL time; ⛔ never a module-local copy, which would be a second + * copy of a helper and would read a host's field instead. + * + * Each module destructures only the bindings its body reads. + */ +export interface MetaRouteScope { + readonly metadata: RestServer['config']['metadata']; + readonly metaPath: string; + readonly isScoped: boolean; + readonly registerPerItemRoute: (entry: Parameters[0]) => void; +} + +/** + * The `RestServer` members {@link registerMetaCollectionRoutes} reads — every + * one of them, and no other, in the order the body first reads them. 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. `metaTypeSingular` is the class's + * private STATIC, typed as the static it is. The module header says why + * `routeManager` is captured inside the swap and by value, why the body reaches + * the static through the host, and why none of the members is a getter. + */ +export interface MetaCollectionRouteHost { + resolveProtocol: RestServer['resolveProtocol']; + translateMetaTypesResponse: RestServer['translateMetaTypesResponse']; + readonly routeManager: RestServer['routeManager']; + resolveExecCtx: RestServer['resolveExecCtx']; + walledCatalogRefusal: RestServer['walledCatalogRefusal']; + resolveMetaWriteActor: RestServer['resolveMetaWriteActor']; + refuseUnknownMetaListType: RestServer['refuseUnknownMetaListType']; + metaListAnswerSources: RestServer['metaListAnswerSources']; + metaTypeSingular: (typeof RestServer)['metaTypeSingular']; +} + +/** + * Register the `/meta` type list, the whole-store maintenance family and the + * per-type list — the first three groups of + * `RestServer#registerMetadataEndpointsInner`' body, called by that method at + * their position in its registration order, with the server's members as + * `this` and the method's head bindings as `scope`. The method's own docblock, + * which the published declaration carries, names the families, their switches + * and the ordering facts the routes below keep. + */ +export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scope: MetaRouteScope): void { + const { metadata, metaPath, isScoped } = scope; + + // GET /meta - List all metadata types + // + // Also mounted at `/meta/types`, the spelling the dispatcher's `/meta` + // branch has always implemented (`parts[0] === 'types'`) and the + // spelling `route-ledger.ts` has always declared. ONE handler, two + // paths, deliberately: the dispatcher's two branches return the same + // `protocol.getMetaTypes()` body, so a second REST handler would be a + // second thing to keep true. + if (metadata.endpoints.types !== false) { + const listMetaTypes = async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const types = await p.getMetaTypes(); + const translated = await this.translateMetaTypesResponse(req, environmentId, types); + res.header('Vary', 'Accept-Language'); + res.json(translated); + } catch (error: any) { + handleRouteError(res, error); + } + }; + this.routeManager.register({ + method: 'GET', + path: metaPath, + handler: listMetaTypes, + metadata: { + summary: 'List all metadata types', + tags: ['metadata'], + }, + }); + + // GET /meta/types — REGISTERED BEFORE `/meta/:type`, and that is + // the entire fix (#7526). + // + // The branch existed in the dispatcher and the row existed in the + // ledger; the REST mount is a THIRD place and nobody wrote it here. + // So `/meta/types` fell into the `:type` catch-all below and + // answered `{"type":"types","items":[]}` — byte-shaped like + // `/meta/zzz_not_a_type`, a 200 no client can tell from "that type + // is empty". Hono is first-match-wins (MEASURED, not assumed — + // `plugin-hono-server`'s `mounted-route-introspection.test.ts` + // registers a literal and a `:param` sibling in both orders and + // pins that the later one never runs), so moving this below + // `/meta/:type` silently re-breaks it — the same shape that already + // put `diagnostics` / `_drafts` / `_migrate-stored` above it. The + // order is pinned by `meta-route-registration-order.test.ts`. + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/types`, + handler: listMetaTypes, + metadata: { + summary: 'List all metadata types (explicit `/types` spelling)', + tags: ['metadata'], + }, + }); + } + + // GET /meta/diagnostics - Cross-type spec-validation sweep + // + // Returns every metadata entry that fails its registered Zod + // schema, scoped to the environment (and optionally org / + // package) of the request. Powers the Studio governance + // dashboard and `os doctor`-style CLI checks. + // + // Registered BEFORE `/meta/:type` so the `diagnostics` segment + // is not captured as a `:type` parameter. + // + // [#15542] First of the three WHOLE-STORE operations, and they share + // one switch of their own — `metadata.endpoints.maintenance`. They + // used to ride `endpoints.items`, whose declared meaning is the + // per-type list, so closing a listing read silently took this sweep, + // `/_drafts` and the `POST /_migrate-stored` write door with it. + if (metadata.endpoints.maintenance !== false) { + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/diagnostics`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).getMetaDiagnostics !== 'function') { + res.status(501).json({ + error: { code: 'NOT_IMPLEMENTED', message: 'protocol.getMetaDiagnostics() is not available in this kernel' }, + }); + return; + } + // [#6877] All three narrow the diagnostics query to ONE + // value each; the `as string` casts are exactly the + // laundering that kept `tsc` silent about the array arm. + if (refuseRepeatedQueryParams(req, res, ['severity', 'type', 'package'])) return; + const severityParam = (req.query?.severity as string | undefined) ?? 'error'; + const severity = severityParam === 'warning' ? 'warning' : 'error'; + const diagnosticsType = (req.query?.type as string | undefined) || undefined; + // [ADR-0131 D6] No organization: the sweep reads each + // type environment → code, the partition every `/meta` + // write now lands in. A legacy organization-scoped row + // is not swept; its promotion is ADR-0131 C7's. + const result = await (p as any).getMetaDiagnostics({ + type: diagnosticsType, + severity, + // [#22188] The list's reading ({@link metaItemPackageBinding}): + // each swept type is a list read, so `all` names no package. + packageId: metaItemPackageBinding(req.query?.package), + }); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List metadata entries that fail spec validation', + tags: ['metadata'], + }, + }); + } + + // GET /meta/_drafts - Pending DRAFT items (ADR-0033) + // + // Surfaces draft-state metadata that the active-only `/meta/:type` + // list hides, so the console can show a "pending changes" view and + // draft-aware package contents (a just-built app package no longer + // looks empty). Optionally narrowed by `?packageId=` and/or `?type=`. + // + // Registered BEFORE `/meta/:type` so the `_drafts` segment is not + // captured as a `:type` parameter. + // + // [#15542] Whole-store operation — `metadata.endpoints.maintenance`. + if (metadata.endpoints.maintenance !== false) { + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/_drafts`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [ADR-0106 D5(4) / #6599] `_drafts` is an AUTHORING + // surface — the console's pending-changes view and + // draft-aware package reads — not a general read. A + // pending object draft carries its full `fields` map, so + // serving it unfiltered leaks every hidden field's + // label, type, options, formula and `requiredPermissions` + // to any authenticated caller, which is the disclosure + // ADR-0106 closes one route over. The other `/meta` + // exits MASK per field; this one GATES per caller, on the + // SAME `systemPermissions` judgement D4 uses for its + // read exemption (`isObjectSchemaMaskExempt`) — a caller + // who could not see a field on `/meta/object` has no + // authoring reason to see the draft that carries it. The + // gate is intentionally independent of the D8 field-mask + // escape hatch: opting out of per-field masking is not + // consent to expose pending drafts to non-authors. + // + // Gate FIRST — before resolving the protocol — so an + // unauthorized caller cannot use the 501-vs-200 answer to + // probe which kernels support drafts (same posture as + // `_migrate-stored` below). + // + // [#20338] Asked through {@link mayReadPendingDrafts}, + // the one question every draft door in this file asks. + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + if (!mayReadPendingDrafts(ctx)) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Reading pending metadata drafts requires an authoring capability (studio.access, setup.access or manage_metadata).', + }, + }); + return; + } + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).listDrafts !== 'function') { + res.status(501).json({ + error: { code: 'NOT_IMPLEMENTED', message: 'protocol.listDrafts() is not available in this kernel' }, + }); + return; + } + // [#6877] Both narrow the draft list to one package / + // one type; an array reached `listDrafts` untouched. + // [#23214] The list lives beside the shared decision, so the + // runtime dispatcher's twin refuses the same names. + if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.drafts)) return; + // [ADR-0131 D6] No organization: a draft is saved + // environment-wide, so the list reads the environment + // partition, symmetric with the save door. A legacy + // organization-scoped draft is not listed; its + // promotion is ADR-0131 C7's. + const result = await (p as any).listDrafts({ + packageId: (req.query?.packageId as string | undefined) || undefined, + type: (req.query?.type as string | undefined) || undefined, + }); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List pending draft metadata items', + tags: ['metadata'], + }, + }); + } + + // POST /meta/_migrate-stored — rewrite stored sys_metadata rows into + // today's canonical shape (ADR-0087; #4327 / #4454 / #4498). + // + // The server-side form of `os migrate meta --stored`. The CLI form + // needs shell access to the deployment's database, which a hosted + // operator does not have — so without this route the stored-metadata + // chain has no finish line on a managed deployment, only the per-read + // conversion that runs forever. Flow rows are covered here for free: + // `migrateStoredMetadata` resolves the automation engine from the + // services registry (#4498), and a server always has a live one. + // + // Registered BEFORE `/meta/:type` so the leading-underscore segment is + // not captured as a `:type` parameter (same reason as `_drafts`). + // + // [#15542] Whole-store operation — `metadata.endpoints.maintenance`. + // This is the WRITE door the card was filed about: it used to be + // unmounted by `endpoints.items: false`, a switch declared as "list + // items of type". + if (metadata.endpoints.maintenance !== false) { + this.routeManager.register({ + method: 'POST', + path: `${metaPath}/_migrate-stored`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // Gate FIRST — before resolving the protocol — so an + // unauthorized caller cannot use the 501 vs 200 answer + // to probe which kernels can be migrated. + // + // This rewrites every eligible row in the deployment, + // so unlike the single-item `PUT /meta/:type/:name` it + // demands an explicit capability rather than only a + // session. `manage_metadata` is ADR-0066 D1's authoring + // capability, and a canonicalization rewrite is + // authoring; `isSystem` bypasses, matching every other + // capability gate on the platform. + // + // [#12702] Deliberately NOT `metaWriteCapabilityVerdict`: + // an install-wide stored-metadata rewrite is env-wide by + // definition, so `manage_org_presentation`'s "org-scoped + // to the caller's own active organization" condition can + // never hold here. `manage_metadata`-only, unchanged — + // do not copy the item doors' acceptance in. + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + const held = new Set( + Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], + ); + if (!ctx?.isSystem && !held.has('manage_metadata')) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Rewriting stored metadata requires the `manage_metadata` capability.', + }, + }); + return; + } + // [ADR-0131 D12 items 7–8] The walled refusal, after the + // capability gate and before the protocol is resolved or + // a row is read: this rewrites every stored row of the + // environment's metadata, which under `isolated` / + // `group` is the platform operator's (ruling A on + // #23231). A preview is refused too: it is the same + // install-wide pass, without the write. Inert under + // `single`. See {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal('sys_metadata', ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).migrateStoredMetadata !== 'function') { + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'protocol.migrateStoredMetadata() is not available in this kernel', + }, + }); + return; + } + const rawTypes = (req.body as any)?.types; + const types = Array.isArray(rawTypes) + ? rawTypes.filter((t: unknown): t is string => typeof t === 'string' && t.length > 0) + : []; + // Preview by default — `apply` must be explicitly true, + // the same posture the CLI takes. A caller who sends an + // empty body gets a report and no writes. + // Attributed to the caller: this writes history rows, + // and "who ran the migration" is the question they + // exist to answer. [#23068] By user id alone — the + // identity this request was authorized as, read by the + // producer every `/meta` write door shares — or by + // nobody. It lands in `recorded_by` / `updated_by`, + // `lookup('sys_user')` columns, so ⛔ never the route's + // name: the pass records its origin on `source`. + const actor = await this.resolveMetaWriteActor(environmentId, req); + const report = await (p as any).migrateStoredMetadata({ + apply: (req.body as any)?.apply === true, + ...(types.length > 0 ? { types } : {}), + ...(actor ? { actor } : {}), + }); + res.json(report); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Rewrite stored metadata rows into the canonical protocol shape', + tags: ['metadata'], + }, + }); + } + + // GET /meta/:type - List items of a type + // + // [#15542] The whole of `metadata.endpoints.items` — this one mount, + // and nothing else, which is what its `describe()` has always said. + if (metadata.endpoints.items !== false) { + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/:type`, + handler: async (req: any, res: any) => { + try { + // [#6877] Five single-valued parameters on this list + // route, declared together at the top so the gate cannot + // be missed by whichever branch reads its parameter + // several hundred lines down: `?object=` (the view + // switcher's `String(req.query.object)`, which turned + // `['a','b']` into the object name `'a,b'` — a name no + // view has, so the switcher silently emptied) and + // `?include=` (repeated, it stopped equalling + // `'content'`, so a caller who asked for doc bodies got + // the slimmed list back with a 200). + // + // [#7566] `?id=` joined them when the app branch below + // started honouring it. It is declared HERE, with the + // rest, rather than beside the filter that reads it, for + // the reason this block exists: a filter that arrives as + // `['crm','account']` and is compared against one app + // name matches nothing, and an empty app list is exactly + // the plausible-looking wrong answer #7566 was filed + // against. Refused, not resolved — see + // `query-multiplicity.ts` for why picking one of two + // conflicting intents is worse than a 400. + // [#23214] The list lives beside the shared decision, so the + // runtime dispatcher's twin refuses the same names. + if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.list)) return; + // [#22188] Read through {@link metaItemPackageBinding}: + // `all`, this list's "show everything" scope, names no package. + const packageId = metaItemPackageBinding(req.query?.package); + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + // [#9488] …and BEFORE any listing work: a `:type` that + // names no metadata type is refused here rather than + // served as a real-but-empty collection, which the write + // door has refused since #8421. See + // {@link refuseUnknownMetaListType} for the union it + // consults, why the static verdict alone is the wrong + // rule on a READ door, and why it throws instead of + // building a body. + await this.refuseUnknownMetaListType(p, req.params?.type); + // [#20338] The caller, for the draft-preview admission + // below. The list names no organization (ADR-0131 D6): + // it reads environment → code. + const listCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + // ADR-0033/0037 draft-overlay preview: `?preview=draft` + // overlays pending drafts on the active list, exactly as + // the runtime dispatcher's /metadata/:type route does — + // the console's draft preview (Live Canvas) reads THIS + // route, so dropping the flag here silently renders the + // published-only world. + // + // [#20338] …for a caller who may read drafts. Anyone + // else is answered the list as if the switch were + // absent — {@link mayReadPendingDrafts} says why that, + // and not a refusal. Declared WITH its admission so no + // later branch of this handler can read the switch past it. + const previewDrafts = typeof req.query?.preview === 'string' + && req.query.preview.toLowerCase() === 'draft' + && mayReadPendingDrafts(listCtx); + // [commit 2a29caa53] Typed against the spec request shape plus the + // transport-level `environmentId` — the `as any` this + // literal used to carry is retired now that the spec + // declares `previewDrafts`. + const listRequest: TransportScopedMetaRequest = { + type: req.params.type, + packageId, + ...(previewDrafts ? { previewDrafts: true } : {}), + ...(environmentId ? { environmentId } : {}), + }; + const items = await p.getMetaItems(listRequest); + + // [#20320] Everything this route does to the list after + // the read — the #5224 `api` served-set face, THE + // per-caller list gate (#20237), `?id=` for apps (#7566), + // `?object=` for views, the ADR-0046 doc locale collapse + // and content slim, the ADR-0106 object mask and the + // translation step — is ONE chain, + // `createMetaListAnswer` in `./meta-item-read-gate.ts`, + // which the runtime dispatcher's `/meta` list branch + // calls too. Its docblock carries each step's rule and + // their order; ⛔ a list step is added there, never here + // (`meta-list-projection-parity.test.ts` in + // `@objectstack/runtime` derives its census from this + // handler and fails on one added here). `getMetaItems` + // answers a bare array or the `{ type, items }` envelope; + // the chain answers the same shape. + // + // `previewDrafts` is handed in ADMITTED — the one + // declaration above — and the chain never re-reads it. + const answer = await metaReadGate.createMetaListAnswer( + this.metaListAnswerSources(environmentId, req, p), + { metaType: this.metaTypeSingular(req.params.type), query: req.query, previewDrafts }, + )(items); + if (!answer.ok) { + sendFieldVisibilityFault(res, answer.object); + return; + } + // [ADR-0106 D6 tier 2] The chain's cache posture — an + // undetermined field visibility serves the list + // `private, no-store`, exactly as this route always has. + if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); + res.header('Vary', 'Accept-Language'); + res.json(answer.data); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List metadata items of a type', + tags: ['metadata'], + }, + }); + } +} 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 200803e9706..738979c86db 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -262,7 +262,13 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // [#23050 slice 12] Nor do the six CRUD doors (`crud-routes.ts`): they // read and write records, never a draft: // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 12 → 13. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] The first three `/meta` groups moved to + // `meta-collection-routes.ts` with three of `rest-server.ts`' six switches: + // the `_drafts` door's two (the 501 probe and the listing) and the + // per-type list's `?preview=draft`. A key names its route, never its file, + // so no ledger row changed: + // 8 = 3 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3, length 13 → 14. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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 a22e2a9f58e..b6bd1965f96 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -172,6 +172,12 @@ interface CensusEntry { * `querySlots` does not read, as before). Each floor equals its file's * measured count, so `rest-server.ts`' goes 1 → 0 and the moved slot keeps * its floor of 1 there. + * + * [#23050 slice 13a] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0, + * unchanged: the first three `/meta` groups (`meta-collection-routes.ts`) build + * no `query:` slot. The per-type list's one `query` key sits mid-line in the + * list chain's options literal, which `querySlots` (a line-initial `query:`) + * does not read, as before the move, so the moved module's floor is 0. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -187,6 +193,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 11] Length 11 → 12, the sum unchanged. // [#23050 slice 12] Length 12 → 13, the sum unchanged (its one slot moved // from `rest-server.ts` to `crud-routes.ts`). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] Length 13 → 14, the sum unchanged (the moved `/meta` + // groups carry no slot). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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 7338d8a8389..d1f438e20e7 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -192,6 +192,14 @@ describe('rest-server.ts docblock position', () => { * the module header, the host interface's block and the function's. The CRUD * body held no TSDoc block of its own, so `rest-server.ts` keeps every block it * had (163). + * + * [#23050 slice 13a] `meta-collection-routes.ts` carries five: the module + * header, the moved alias's block (`TransportScopedMetaRequest` moved on to it + * from `ui-routes.ts`, the `/meta` doors' envelope joining the first `/meta` + * module), `MetaRouteScope`'s block, the host interface's block and the + * function's. `ui-routes.ts` therefore gives up that block (4 → 3). The moved + * `/meta` groups held no TSDoc block of their own, so `rest-server.ts` keeps + * every block it had (163). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -203,9 +211,10 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -242,9 +251,11 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 10] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 = 44 over ten, length 10 → 11. // [#23050 slice 11] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 + 3 = 47 over eleven, length 11 → 12. // [#23050 slice 12] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 + 3 + 3 = 50 over twelve, length 12 → 13. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 = 54 over + // thirteen, length 13 → 14 (`ui-routes.ts` 4 → 3: the alias's block moved on). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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(50); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(54); }); }); 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 7e625815b97..5ccde7c00e1 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -339,7 +339,11 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // [#23050 slice 12] Nor do the six CRUD doors (`crud-routes.ts`): they // hand the query to the protocol whole and coerce no number of it: // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 12 → 13. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] Nor do the first three `/meta` groups + // (`meta-collection-routes.ts`): the type list, the whole-store family and + // the per-type list coerce no number of the query: + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 13 → 14. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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 003db8dc0a0..c08d594b569 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -53,11 +53,36 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'form-routes.ts', // [#23050 slice 12] `registerCrudEndpoints`' body. 'crud-routes.ts', + // [#23050 slice 13a] The first three `/meta` groups of + // `registerMetadataEndpointsInner`' body — see {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-collection-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ export type RestServerRegistrarFile = (typeof REST_SERVER_REGISTRAR_FILES)[number]; +/** + * [#23050 slice 13a] The files on {@link REST_SERVER_REGISTRAR_FILES} that hold a + * moved `/meta` group: a module `RestServer#registerMetadataEndpointsInner` calls + * from inside its own body, with the host literal built there. + * + * A route registered from one of these runs inside the umbrella + * `registerMetadataEndpoints`' swap, behind the same wrapping registrar as the + * routes still in that method, even though no line of it is lexically inside + * the method. A reader that judges "inside the umbrella" by position in + * `rest-server.ts` reads these files as well; the dogfood blind-spot census is + * the one that does today. + * + * Named file by file, ⛔ never by a naming convention: a module joins this + * subset in the slice that moves a `/meta` group into it, and a reader that + * depends on it pins it against the module calls inside the inner method. + */ +export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = [ + // [#23050 slice 13a] The type list, the whole-store maintenance family and the + // per-type list. + 'meta-collection-routes.ts', +]; + /** * The repo-relative directory of {@link REST_SERVER_REGISTRAR_FILES}, for a * reader outside this package (the dogfood suite reads them by repo path). diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bc2c2161436..43a35f01634 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -253,9 +253,10 @@ import { sendError as sendEnvelopeError } from '@objectstack/types'; export type RestProtocol = DataProtocol & MetadataProtocol; // [#23050 slice 10] `TransportScopedMetaRequest`, the meta doors' request envelope, moved to -// `ui-routes.ts` (imported below) with the UI view door's body, its first reader outside this -// file: exporting it from here changed the published `dist/*.d.ts` by one line, and a second -// copy is not an option. +// `ui-routes.ts` with the UI view door's body, its first reader outside this file: exporting +// it from here changed the published `dist/*.d.ts` by one line, and a second copy is not an +// option. [#23050 slice 13a] It moved on, verbatim, to `meta-collection-routes.ts` (imported +// below), the module of the first `/meta` groups the split took out of this file. // [#23050 slice 1] `ServerScopedDataRequest`, the data doors' request envelope, moved to // `data-action-routes.ts`: exporting it from this file for that module changed the published @@ -304,11 +305,20 @@ import { registerEmailEndpoints, type EmailRouteHost } from './email-routes.js'; // [#23050 slice 9] The global search route's body moved to its own module — see its header. import { registerSearchEndpoints, type SearchRouteHost } from './search-routes.js'; // [#23050 slice 10] The UI view route's body moved to its own module — see its header. -import { registerUiEndpoints, type TransportScopedMetaRequest, type UiRouteHost } from './ui-routes.js'; +import { registerUiEndpoints, type UiRouteHost } from './ui-routes.js'; // [#23050 slice 11] The public form routes' body moved to its own module — see its header. import { registerFormEndpoints, type FormRouteHost } from './form-routes.js'; // [#23050 slice 12] The CRUD routes' body moved to its own module — see its header. import { registerCrudEndpoints, type CrudRouteHost } from './crud-routes.js'; +// [#23050 slice 13a] The `/meta` type list, whole-store maintenance and per-type list +// groups moved to their own module — see its header. That module is also the home of the +// meta doors' request envelope `TransportScopedMetaRequest` and of `MetaRouteScope`. +import { + registerMetaCollectionRoutes, + type MetaCollectionRouteHost, + type MetaRouteScope, + type TransportScopedMetaRequest, +} from './meta-collection-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. @@ -5296,448 +5306,33 @@ export class RestServer { this.routeManager.register(entry); }; - // GET /meta - List all metadata types - // - // Also mounted at `/meta/types`, the spelling the dispatcher's `/meta` - // branch has always implemented (`parts[0] === 'types'`) and the - // spelling `route-ledger.ts` has always declared. ONE handler, two - // paths, deliberately: the dispatcher's two branches return the same - // `protocol.getMetaTypes()` body, so a second REST handler would be a - // second thing to keep true. - if (metadata.endpoints.types !== false) { - const listMetaTypes = async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const types = await p.getMetaTypes(); - const translated = await this.translateMetaTypesResponse(req, environmentId, types); - res.header('Vary', 'Accept-Language'); - res.json(translated); - } catch (error: any) { - handleRouteError(res, error); - } - }; - this.routeManager.register({ - method: 'GET', - path: metaPath, - handler: listMetaTypes, - metadata: { - summary: 'List all metadata types', - tags: ['metadata'], - }, - }); - - // GET /meta/types — REGISTERED BEFORE `/meta/:type`, and that is - // the entire fix (#7526). - // - // The branch existed in the dispatcher and the row existed in the - // ledger; the REST mount is a THIRD place and nobody wrote it here. - // So `/meta/types` fell into the `:type` catch-all below and - // answered `{"type":"types","items":[]}` — byte-shaped like - // `/meta/zzz_not_a_type`, a 200 no client can tell from "that type - // is empty". Hono is first-match-wins (MEASURED, not assumed — - // `plugin-hono-server`'s `mounted-route-introspection.test.ts` - // registers a literal and a `:param` sibling in both orders and - // pins that the later one never runs), so moving this below - // `/meta/:type` silently re-breaks it — the same shape that already - // put `diagnostics` / `_drafts` / `_migrate-stored` above it. The - // order is pinned by `meta-route-registration-order.test.ts`. - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/types`, - handler: listMetaTypes, - metadata: { - summary: 'List all metadata types (explicit `/types` spelling)', - tags: ['metadata'], - }, - }); - } - - // GET /meta/diagnostics - Cross-type spec-validation sweep - // - // Returns every metadata entry that fails its registered Zod - // schema, scoped to the environment (and optionally org / - // package) of the request. Powers the Studio governance - // dashboard and `os doctor`-style CLI checks. - // - // Registered BEFORE `/meta/:type` so the `diagnostics` segment - // is not captured as a `:type` parameter. - // - // [#15542] First of the three WHOLE-STORE operations, and they share - // one switch of their own — `metadata.endpoints.maintenance`. They - // used to ride `endpoints.items`, whose declared meaning is the - // per-type list, so closing a listing read silently took this sweep, - // `/_drafts` and the `POST /_migrate-stored` write door with it. - if (metadata.endpoints.maintenance !== false) { - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/diagnostics`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - if (typeof (p as any).getMetaDiagnostics !== 'function') { - res.status(501).json({ - error: { code: 'NOT_IMPLEMENTED', message: 'protocol.getMetaDiagnostics() is not available in this kernel' }, - }); - return; - } - // [#6877] All three narrow the diagnostics query to ONE - // value each; the `as string` casts are exactly the - // laundering that kept `tsc` silent about the array arm. - if (refuseRepeatedQueryParams(req, res, ['severity', 'type', 'package'])) return; - const severityParam = (req.query?.severity as string | undefined) ?? 'error'; - const severity = severityParam === 'warning' ? 'warning' : 'error'; - const diagnosticsType = (req.query?.type as string | undefined) || undefined; - // [ADR-0131 D6] No organization: the sweep reads each - // type environment → code, the partition every `/meta` - // write now lands in. A legacy organization-scoped row - // is not swept; its promotion is ADR-0131 C7's. - const result = await (p as any).getMetaDiagnostics({ - type: diagnosticsType, - severity, - // [#22188] The list's reading ({@link metaItemPackageBinding}): - // each swept type is a list read, so `all` names no package. - packageId: metaItemPackageBinding(req.query?.package), - }); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'List metadata entries that fail spec validation', - tags: ['metadata'], - }, - }); - } - - // GET /meta/_drafts - Pending DRAFT items (ADR-0033) - // - // Surfaces draft-state metadata that the active-only `/meta/:type` - // list hides, so the console can show a "pending changes" view and - // draft-aware package contents (a just-built app package no longer - // looks empty). Optionally narrowed by `?packageId=` and/or `?type=`. - // - // Registered BEFORE `/meta/:type` so the `_drafts` segment is not - // captured as a `:type` parameter. - // - // [#15542] Whole-store operation — `metadata.endpoints.maintenance`. - if (metadata.endpoints.maintenance !== false) { - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/_drafts`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [ADR-0106 D5(4) / #6599] `_drafts` is an AUTHORING - // surface — the console's pending-changes view and - // draft-aware package reads — not a general read. A - // pending object draft carries its full `fields` map, so - // serving it unfiltered leaks every hidden field's - // label, type, options, formula and `requiredPermissions` - // to any authenticated caller, which is the disclosure - // ADR-0106 closes one route over. The other `/meta` - // exits MASK per field; this one GATES per caller, on the - // SAME `systemPermissions` judgement D4 uses for its - // read exemption (`isObjectSchemaMaskExempt`) — a caller - // who could not see a field on `/meta/object` has no - // authoring reason to see the draft that carries it. The - // gate is intentionally independent of the D8 field-mask - // escape hatch: opting out of per-field masking is not - // consent to expose pending drafts to non-authors. - // - // Gate FIRST — before resolving the protocol — so an - // unauthorized caller cannot use the 501-vs-200 answer to - // probe which kernels support drafts (same posture as - // `_migrate-stored` below). - // - // [#20338] Asked through {@link mayReadPendingDrafts}, - // the one question every draft door in this file asks. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - if (!mayReadPendingDrafts(ctx)) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Reading pending metadata drafts requires an authoring capability (studio.access, setup.access or manage_metadata).', - }, - }); - return; - } - const p = await this.resolveProtocol(environmentId, req); - if (typeof (p as any).listDrafts !== 'function') { - res.status(501).json({ - error: { code: 'NOT_IMPLEMENTED', message: 'protocol.listDrafts() is not available in this kernel' }, - }); - return; - } - // [#6877] Both narrow the draft list to one package / - // one type; an array reached `listDrafts` untouched. - // [#23214] The list lives beside the shared decision, so the - // runtime dispatcher's twin refuses the same names. - if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.drafts)) return; - // [ADR-0131 D6] No organization: a draft is saved - // environment-wide, so the list reads the environment - // partition, symmetric with the save door. A legacy - // organization-scoped draft is not listed; its - // promotion is ADR-0131 C7's. - const result = await (p as any).listDrafts({ - packageId: (req.query?.packageId as string | undefined) || undefined, - type: (req.query?.type as string | undefined) || undefined, - }); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'List pending draft metadata items', - tags: ['metadata'], - }, - }); - } - - // POST /meta/_migrate-stored — rewrite stored sys_metadata rows into - // today's canonical shape (ADR-0087; #4327 / #4454 / #4498). - // - // The server-side form of `os migrate meta --stored`. The CLI form - // needs shell access to the deployment's database, which a hosted - // operator does not have — so without this route the stored-metadata - // chain has no finish line on a managed deployment, only the per-read - // conversion that runs forever. Flow rows are covered here for free: - // `migrateStoredMetadata` resolves the automation engine from the - // services registry (#4498), and a server always has a live one. - // - // Registered BEFORE `/meta/:type` so the leading-underscore segment is - // not captured as a `:type` parameter (same reason as `_drafts`). - // - // [#15542] Whole-store operation — `metadata.endpoints.maintenance`. - // This is the WRITE door the card was filed about: it used to be - // unmounted by `endpoints.items: false`, a switch declared as "list - // items of type". - if (metadata.endpoints.maintenance !== false) { - this.routeManager.register({ - method: 'POST', - path: `${metaPath}/_migrate-stored`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // Gate FIRST — before resolving the protocol — so an - // unauthorized caller cannot use the 501 vs 200 answer - // to probe which kernels can be migrated. - // - // This rewrites every eligible row in the deployment, - // so unlike the single-item `PUT /meta/:type/:name` it - // demands an explicit capability rather than only a - // session. `manage_metadata` is ADR-0066 D1's authoring - // capability, and a canonicalization rewrite is - // authoring; `isSystem` bypasses, matching every other - // capability gate on the platform. - // - // [#12702] Deliberately NOT `metaWriteCapabilityVerdict`: - // an install-wide stored-metadata rewrite is env-wide by - // definition, so `manage_org_presentation`'s "org-scoped - // to the caller's own active organization" condition can - // never hold here. `manage_metadata`-only, unchanged — - // do not copy the item doors' acceptance in. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - const held = new Set( - Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], - ); - if (!ctx?.isSystem && !held.has('manage_metadata')) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Rewriting stored metadata requires the `manage_metadata` capability.', - }, - }); - return; - } - // [ADR-0131 D12 items 7–8] The walled refusal, after the - // capability gate and before the protocol is resolved or - // a row is read: this rewrites every stored row of the - // environment's metadata, which under `isolated` / - // `group` is the platform operator's (ruling A on - // #23231). A preview is refused too: it is the same - // install-wide pass, without the write. Inert under - // `single`. See {@link walledCatalogRefusal}. - { - const walled = await this.walledCatalogRefusal('sys_metadata', ctx, environmentId, req); - if (!walled.allowed) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: walled.message, - }, - }); - return; - } - } - const p = await this.resolveProtocol(environmentId, req); - if (typeof (p as any).migrateStoredMetadata !== 'function') { - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'protocol.migrateStoredMetadata() is not available in this kernel', - }, - }); - return; - } - const rawTypes = (req.body as any)?.types; - const types = Array.isArray(rawTypes) - ? rawTypes.filter((t: unknown): t is string => typeof t === 'string' && t.length > 0) - : []; - // Preview by default — `apply` must be explicitly true, - // the same posture the CLI takes. A caller who sends an - // empty body gets a report and no writes. - // Attributed to the caller: this writes history rows, - // and "who ran the migration" is the question they - // exist to answer. [#23068] By user id alone — the - // identity this request was authorized as, read by the - // producer every `/meta` write door shares — or by - // nobody. It lands in `recorded_by` / `updated_by`, - // `lookup('sys_user')` columns, so ⛔ never the route's - // name: the pass records its origin on `source`. - const actor = await this.resolveMetaWriteActor(environmentId, req); - const report = await (p as any).migrateStoredMetadata({ - apply: (req.body as any)?.apply === true, - ...(types.length > 0 ? { types } : {}), - ...(actor ? { actor } : {}), - }); - res.json(report); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Rewrite stored metadata rows into the canonical protocol shape', - tags: ['metadata'], - }, - }); - } - - // GET /meta/:type - List items of a type - // - // [#15542] The whole of `metadata.endpoints.items` — this one mount, - // and nothing else, which is what its `describe()` has always said. - if (metadata.endpoints.items !== false) { - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/:type`, - handler: async (req: any, res: any) => { - try { - // [#6877] Five single-valued parameters on this list - // route, declared together at the top so the gate cannot - // be missed by whichever branch reads its parameter - // several hundred lines down: `?object=` (the view - // switcher's `String(req.query.object)`, which turned - // `['a','b']` into the object name `'a,b'` — a name no - // view has, so the switcher silently emptied) and - // `?include=` (repeated, it stopped equalling - // `'content'`, so a caller who asked for doc bodies got - // the slimmed list back with a 200). - // - // [#7566] `?id=` joined them when the app branch below - // started honouring it. It is declared HERE, with the - // rest, rather than beside the filter that reads it, for - // the reason this block exists: a filter that arrives as - // `['crm','account']` and is compared against one app - // name matches nothing, and an empty app list is exactly - // the plausible-looking wrong answer #7566 was filed - // against. Refused, not resolved — see - // `query-multiplicity.ts` for why picking one of two - // conflicting intents is worse than a 400. - // [#23214] The list lives beside the shared decision, so the - // runtime dispatcher's twin refuses the same names. - if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.list)) return; - // [#22188] Read through {@link metaItemPackageBinding}: - // `all`, this list's "show everything" scope, names no package. - const packageId = metaItemPackageBinding(req.query?.package); - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - // [#9488] …and BEFORE any listing work: a `:type` that - // names no metadata type is refused here rather than - // served as a real-but-empty collection, which the write - // door has refused since #8421. See - // {@link refuseUnknownMetaListType} for the union it - // consults, why the static verdict alone is the wrong - // rule on a READ door, and why it throws instead of - // building a body. - await this.refuseUnknownMetaListType(p, req.params?.type); - // [#20338] The caller, for the draft-preview admission - // below. The list names no organization (ADR-0131 D6): - // it reads environment → code. - const listCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - // ADR-0033/0037 draft-overlay preview: `?preview=draft` - // overlays pending drafts on the active list, exactly as - // the runtime dispatcher's /metadata/:type route does — - // the console's draft preview (Live Canvas) reads THIS - // route, so dropping the flag here silently renders the - // published-only world. - // - // [#20338] …for a caller who may read drafts. Anyone - // else is answered the list as if the switch were - // absent — {@link mayReadPendingDrafts} says why that, - // and not a refusal. Declared WITH its admission so no - // later branch of this handler can read the switch past it. - const previewDrafts = typeof req.query?.preview === 'string' - && req.query.preview.toLowerCase() === 'draft' - && mayReadPendingDrafts(listCtx); - // [commit 2a29caa53] Typed against the spec request shape plus the - // transport-level `environmentId` — the `as any` this - // literal used to carry is retired now that the spec - // declares `previewDrafts`. - const listRequest: TransportScopedMetaRequest = { - type: req.params.type, - packageId, - ...(previewDrafts ? { previewDrafts: true } : {}), - ...(environmentId ? { environmentId } : {}), - }; - const items = await p.getMetaItems(listRequest); - - // [#20320] Everything this route does to the list after - // the read — the #5224 `api` served-set face, THE - // per-caller list gate (#20237), `?id=` for apps (#7566), - // `?object=` for views, the ADR-0046 doc locale collapse - // and content slim, the ADR-0106 object mask and the - // translation step — is ONE chain, - // `createMetaListAnswer` in `./meta-item-read-gate.ts`, - // which the runtime dispatcher's `/meta` list branch - // calls too. Its docblock carries each step's rule and - // their order; ⛔ a list step is added there, never here - // (`meta-list-projection-parity.test.ts` in - // `@objectstack/runtime` derives its census from this - // handler and fails on one added here). `getMetaItems` - // answers a bare array or the `{ type, items }` envelope; - // the chain answers the same shape. - // - // `previewDrafts` is handed in ADMITTED — the one - // declaration above — and the chain never re-reads it. - const answer = await metaReadGate.createMetaListAnswer( - this.metaListAnswerSources(environmentId, req, p), - { metaType: RestServer.metaTypeSingular(req.params.type), query: req.query, previewDrafts }, - )(items); - if (!answer.ok) { - sendFieldVisibilityFault(res, answer.object); - return; - } - // [ADR-0106 D6 tier 2] The chain's cache posture — an - // undetermined field visibility serves the list - // `private, no-store`, exactly as this route always has. - if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); - res.header('Vary', 'Accept-Language'); - res.json(answer.data); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'List metadata items of a type', - tags: ['metadata'], - }, - }); - } + // [#23050 slice 13a] The four head bindings above, handed to each moved + // `/meta` group as ONE parameter, built once here per base. See + // `MetaRouteScope` (`meta-collection-routes.ts`) for what each binding is + // and why all four stay in this head. + const scope: MetaRouteScope = { metadata, metaPath, isScoped, registerPerItemRoute }; + + // [#23050 slice 13a] The type list, the whole-store maintenance family and + // the per-type list (`GET {metaPath}`, `/types`, `/diagnostics`, `/_drafts`, + // `POST /_migrate-stored` and `GET /:type`) moved to + // `meta-collection-routes.ts`, and are registered HERE so they keep their + // place in the order. This literal hands that body every member it reads + // (see `MetaCollectionRouteHost`). It is built inside this method on + // purpose: the umbrella has swapped the route manager for its wrapping + // registrar for exactly the duration of this call, so the value captured + // here is the gated one. Read that module's header before moving it. + const collectionHost: MetaCollectionRouteHost = { + resolveProtocol: (...a) => this.resolveProtocol(...a), + translateMetaTypesResponse: (...a) => this.translateMetaTypesResponse(...a), + routeManager: this.routeManager, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + walledCatalogRefusal: (...a) => this.walledCatalogRefusal(...a), + resolveMetaWriteActor: (...a) => this.resolveMetaWriteActor(...a), + refuseUnknownMetaListType: (...a) => this.refuseUnknownMetaListType(...a), + metaListAnswerSources: (...a) => this.metaListAnswerSources(...a), + metaTypeSingular: (...a) => RestServer.metaTypeSingular(...a), + }; + registerMetaCollectionRoutes.call(collectionHost, scope); // GET /meta/:type/:name - Get specific item // diff --git a/packages/rest/src/ui-routes.ts b/packages/rest/src/ui-routes.ts index 2eab547e5bb..2fd2f00015d 100644 --- a/packages/rest/src/ui-routes.ts +++ b/packages/rest/src/ui-routes.ts @@ -38,59 +38,20 @@ * `handleRouteError` is imported from the module `rest-server.ts` imports it * from. `GetUiViewRequest`, which only this body read, is imported here from * `@objectstack/spec/api` instead of there. One module-level declaration moved - * here with the body: the meta doors' request envelope, below. No export was - * added to `rest-server.ts`, so the published surface does not move. + * here with the body, the meta doors' request envelope; it moved on to + * `meta-collection-routes.ts` with the first `/meta` groups (#23050 slice 13a), + * and this body imports it from there. No export was added to `rest-server.ts`, + * so the published surface does not move. */ import type { GetUiViewRequest } from '@objectstack/spec/api'; import { handleRouteError } from './error-response.js'; +import type { TransportScopedMetaRequest } from './meta-collection-routes.js'; import type { RestServer } from './rest-server.js'; -// [#23050 slice 10] Moved here from `rest-server.ts`, verbatim, because this module is -// the first one outside that file to need it: exporting it from there changed the -// published `dist/*.d.ts` by one line, and a second copy is not an option. The meta -// doors and listing helpers still in `rest-server.ts` import it from here, and the -// block's "here" and "this file" still mean `rest-server.ts`, where they live. -/** - * [commit 2a29caa53] Typed TRANSPORT envelope for the meta-read doors. - * - * `environmentId` is the multi-kernel routing key, and it is OUT of the - * protocol request shape **by explicit maintainer decision** (ruling recorded - * 2026-08-18, landed as commit 2a29caa53): `resolveProtocol(environmentId)` selects the target - * kernel *before* the protocol call, and the implementation's parameter types - * (`@objectstack/metadata-protocol`) never read it off the request — the spec - * schemas (`protocol.zod.ts`) record the same exclusion schema-side. The doors - * here still spread it into the outgoing payload (long-standing wire shape, - * deliberately unchanged by the ruling), so this alias declares that one - * transport-level member on top of the declared request type. The point is - * what it makes the compiler do: every OTHER key in a door's request literal - * is now checked against the spec contract — an undeclared member is a compile - * error at the call site, not a cast-and-hope. Never add protocol members - * here; a key that belongs to the request belongs in the spec schema. - * - * [commit 45862a53d] The same typing now covers the NON-door `getMetaItems` helper call - * sites in this file (the object, book, doc, view and dataset listings). Two - * spellings those sites carry deliberately SURVIVE the tightening, because - * retiring either would change behaviour rather than typing: - * - * - The OPTIONAL CALL (`getMetaItems?.(…)`) and the - * `typeof … === 'function'` guards. `getMetaItems` is a REQUIRED - * `MetadataProtocol` member, so these are not feature detection in the - * type sense — but a host may occupy the protocol slot with an object - * that does not implement the whole surface, which is the measured reason - * they exist (`metaTypeIsLive` documents the same deliberate spelling for - * `getMetaTypes`). Retiring one turns a tolerated absence into a - * `TypeError`. - * - The RESULT handling. `getMetaItems` is declared to return - * `{ type, items }`, while these sites also tolerate the bare-array shape - * older hosts and stubs return (see `metaItemsArray`), so the response - * stays runtime-shaped on purpose. - * - * The REQUEST is what is fully typeable today, and the request is what is - * typed — which is the whole point: an undeclared key in one of these - * literals is now a compile error instead of a cast-and-hope. - */ -export type TransportScopedMetaRequest = R & { environmentId?: string }; +// [#23050 slice 13a] `TransportScopedMetaRequest`, the meta doors' request envelope that slice 10 +// moved here with this body, moved on, verbatim, to `meta-collection-routes.ts` (imported above), +// the module of the first `/meta` groups out of `rest-server.ts`. This body still reads it. /** * The `RestServer` members {@link registerUiEndpoints} reads — every one of 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 4d09ac2f200..7ac629b5c19 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -375,7 +375,12 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // [#23050 slice 12] 12 → 13 (`crud-routes.ts`): 60 and 75, +1 each for // that slice's forwarder (the six CRUD doors' call sites moved with the // body and are counted in both readings). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] 13 → 14 (`meta-collection-routes.ts`): 60 unchanged + // and 75 → 76. The moved `/meta` groups call `resolveExecCtx` at three + // sites and `enforceAuth` at none (the umbrella's wrapper gates them), so + // the host literal the inner method builds forwards the first only; the + // three call sites moved and are counted in both readings. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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 7df8fa3251e..2aae98faa64 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1146,7 +1146,14 @@ describe('[#13214] §4 preconditions', () => { // that slice's forwarder (the six CRUD doors' call sites moved with the // body and are counted in both readings); the CRUD body never calls the // ownership guard, so it is still 1 call site and 1 forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); + // [#23050 slice 13a] 13 → 14 (`meta-collection-routes.ts`): 60 unchanged + // and 75 → 76. The moved `/meta` groups call `resolveExecCtx` at three + // sites and `enforceAuth` at none (the umbrella's wrapper gates them), so + // the host literal the inner method builds forwards the first only; the + // three call sites moved and are counted in both readings. The + // moved groups never call the ownership guard, so it is still 1 call site + // and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); 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/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index 7f24f561f5a..a47913246c5 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -507,6 +507,11 @@ const TYPE_AXIS = new Set(TYPE_CELLS.map(singular)); const HERE = dirname(fileURLToPath(import.meta.url)); const REST_SERVER_SOURCE = resolve(HERE, '../../../rest/src/rest-server.ts'); +// [#23050 slice 13a] `RestServer`'s `GET ${metaPath}/:type` handler moved, with the +// type list and the whole-store family, from `registerMetadataEndpointsInner` to +// `meta-collection-routes.ts`; the list derivation reads it there. The item, tree +// and layers handlers are still in `rest-server.ts`. +const META_COLLECTION_ROUTES_SOURCE = resolve(HERE, '../../../rest/src/meta-collection-routes.ts'); const SHARED_CHAIN_SOURCE = resolve(HERE, '../../../rest/src/meta-item-read-gate.ts'); interface Derived { params: Set; types: Set; found: boolean } @@ -551,7 +556,7 @@ function propertyInitializer(sf: ts.SourceFile, literal: ts.ObjectLiteralExpress /** `RestServer`'s `GET ${metaPath}/:type` handler, and the shared list chain it hands its answer to. */ function deriveListReads(): Derived { const derived: Derived = { params: new Set(), types: new Set(), found: false }; - const rest = parse(REST_SERVER_SOURCE); + const rest = parse(META_COLLECTION_ROUTES_SOURCE); const visit = (n: ts.Node): void => { if (ts.isObjectLiteralExpression(n)) { const method = propertyInitializer(rest, n, 'method'); @@ -578,7 +583,7 @@ describe('[#20320] the census axes cover every read of RestServer\'s list handle const derived = deriveListReads(); it('the handler is found where the census looks for it', () => { - expect(derived.found, `GET \${metaPath}/:type in ${REST_SERVER_SOURCE}`).toBe(true); + expect(derived.found, `GET \${metaPath}/:type in ${META_COLLECTION_ROUTES_SOURCE}`).toBe(true); }); it('every query parameter read is a census cell', () => { diff --git a/packages/spec/liveness/metadata_endpoints.json b/packages/spec/liveness/metadata_endpoints.json index b338d614818..807be041856 100644 --- a/packages/spec/liveness/metadata_endpoints.json +++ b/packages/spec/liveness/metadata_endpoints.json @@ -38,7 +38,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.types !== false)` gates the GET /meta route mount)", + "evidence": "packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (`if (metadata.endpoints.types !== false)` gates the GET /meta route mount)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Read as `!== false`, so an author who omits the key gets the route; setting it false removes the mount. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -46,7 +46,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.items !== false)` gates the GET /meta/:type per-type list mount)", + "evidence": "packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (`if (metadata.endpoints.items !== false)` gates the GET /meta/:type per-type list mount)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Same `!== false` shape as `types`. RADIUS NARROWED 2026-09-06 (#15542): it used to gate four mounts \u2014 the per-type list plus the whole-store family (`/diagnostics`, `/_drafts` and the `POST /_migrate-stored` write door) \u2014 while its describe() named the list alone, so an operator who closed a listing read silently disarmed a migration door. The whole-store family now answers to its own key, `maintenance`, and this key gates exactly the one mount it names. Liveness is unchanged by that move (it was live before and is live now); what changed is the radius, which is the axis this ledger structurally cannot see \u2014 the mount table pin (packages/rest/src/rest-config-mount-table.pin.test.ts, the #15544 shape) is the instrument that does, and it asserts route ABSENCE per switch. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -62,7 +62,7 @@ "status": "live", "verifiedAt": "2026-09-06", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (three `if (metadata.endpoints.maintenance !== false)` gates \u2014 `GET /meta/diagnostics`, `GET /meta/_drafts` and `POST /meta/_migrate-stored`)", + "evidence": "packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (three `if (metadata.endpoints.maintenance !== false)` gates \u2014 `GET /meta/diagnostics`, `GET /meta/_drafts` and `POST /meta/_migrate-stored`)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "ADDED 2026-09-06 (#15542) as the whole-store family's own switch \u2014 the three mounts used to ride `endpoints.items`, whose declared meaning is the per-type list. Live from its first commit: it is a re-partition of an existing gated surface, not a newly declared key waiting for a consumer, and the mount table pin asserts its three routes leave when it is false. Same `!== false` shape as its siblings, default true, so an embedder who never authors it keeps every route it gates. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index efb01c92cc4..fc24f016847 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -405,6 +405,14 @@ const MODULES = { // `'Invalid update request'`) left this file with `registerCrudEndpoints`' // body for `crud-routes.ts`, declared below at the count they had here; the // sum is unchanged (8 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 = 39). + // + // 8 → 8 (#23050 slice 13a): the first three `/meta` groups of + // `registerMetadataEndpointsInner` left this file for + // `meta-collection-routes.ts` carrying NO literal string `error` body (their + // refusals are nested `{ error: { code, message } }` bodies and their catches + // go through `handleRouteError`), so this count does not move and that module + // declares 0 below; the sum is unchanged + // (8 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 + 0 = 39). stringError: 8, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared @@ -507,6 +515,11 @@ const MODULES = { // above. Those same three `{ error, code }` bodies (each request gate's 400 // `VALIDATION_FAILED`) left with that body for `crud-routes.ts`, declared // below; the sum is unchanged (4 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 = 54). + // + // 4 → 4 (#23050 slice 13a): those same three `/meta` groups carried NO + // `{ code, error }` body either, so this count does not move and + // `meta-collection-routes.ts` declares 0 below; the sum is unchanged + // (4 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 + 0 = 54). siblingCode: 4, }, @@ -758,6 +771,23 @@ const MODULES = { stringError: 3, siblingCode: 3, }, + + // [#23050 slice 13a] The first three `/meta` groups of + // `RestServer#registerMetadataEndpointsInner`' body — the type list, the + // whole-store maintenance family and the per-type list — moved out of + // `rest-server.ts` 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 at 0 and + // `rest-server.ts` keeps its 8 / 4, 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/meta-collection-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 13a 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. */