diff --git a/.changeset/23050-s13c-meta-item-write-routes-move.md b/.changeset/23050-s13c-meta-item-write-routes-move.md new file mode 100644 index 00000000000..169f98249d3 --- /dev/null +++ b/.changeset/23050-s13c-meta-item-write-routes-move.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': patch +--- + +The `/meta` per-item write routes live in their own module, with no route, handler or answer changed + +Clause-②: no (a move: the two 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, its per-item registrar included. The two statements that register `PUT {metaPath}/:type/:name` (the item save) and `DELETE {metaPath}/:type/:name` (the reset to the artifact default) moved, re-indented, to `packages/rest/src/meta-item-write-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. They still register through that per-item registrar, so the `metadata.endpoints.item` switch gates 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 c4d227039c7..41723d40427 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/meta-collection-routes.ts#registerMetaCollectionRoutes`, `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/meta-item-write-routes.ts#registerMetaItemWriteRoutes`, `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/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 8c22f627505..1a35f054130 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1636,7 +1636,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 12] Length 12 → 13 (`crud-routes.ts`), still 0 over it. // [#23050 slice 13a] Length 13 → 14 (`meta-collection-routes.ts`), still 0 over it. // [#23050 slice 13b] Length 14 → 15 (`meta-item-read-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] Length 15 → 16 (`meta-item-write-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); 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 7f93f030b02..794c05718b2 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -540,17 +540,18 @@ 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`, `crud-routes.ts`, `meta-collection-routes.ts` and ' + - '`meta-item-read-routes.ts`, ' + + '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts`, `crud-routes.ts`, `meta-collection-routes.ts`, ' + + '`meta-item-read-routes.ts` and `meta-item-write-routes.ts`, ' + 'the modules the bodies of ' + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + '`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' + - '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first four `/meta` ' + + '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first five `/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` and `meta-item-read-routes.ts`), whose ' + + 'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`, `meta-item-read-routes.ts` and ' + + '`meta-item-write-routes.ts`), whose ' + 'registrations the inner method ' + 'runs inside the umbrella\'s swap', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and @@ -866,10 +867,34 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // 6 + 6 + 4), `private register*Endpoints(` stays 16, `registerPerItemRoute(` // stays 8 and `enforceAuth` stays 112: the moved group spells neither term, // and neither does the seam. + // [#23050 slice 13c] 70 / 19 / 51 UNCHANGED, `reachable` again held by 13a's + // rule with no rule change, and TWO controls re-keyed, as ruled for this + // slice. The fifth `/meta` group, the per-item writes (`PUT` and `DELETE + // {metaPath}/:type/:name`, two helper-routed registration sites, REACHABLE + // through the same umbrella key), moved to `meta-item-write-routes.ts`, which + // joins the list (length 15 -> 16) and the named `/meta` subset (2 -> 3). It + // is the first module on the list with NO direct site: both of its routes + // register through the inner method's per-item registrar, which reaches it in + // the `/meta` scope as the one closure the inner head declares and which + // still reads the route manager at call time, inside the swap. So (1) its own + // control counts `registerPerItemRoute(` (2), the population rule's second + // spelling, instead of `this.routeManager.register(`, which reads 0 there and + // would be a zero read as a reading; and (2) the `registerPerItemRoute(` + // control is RE-ANCHORED, not relaxed, over the same haystack as + // `this.routeManager.register(` and the population rule (this file plus every + // module on the list), where it stays 8 (6 here + 2 in the module); over this + // file alone it would read 6. The subset's length and the module calls + // inside the inner method move together (both 3). Measured at the move + // commit, before the list learned the module: population 68, the two sites + // out of reach, and the `.call(` control at 3 against a subset of 2. + // `this.routeManager.register(` stays 63 over the sixteen files (the module + // spells it nowhere), `private register*Endpoints(` stays 16, + // `const registerPerItemRoute =` stays 1 and `enforceAuth` stays 112: the + // moved group spells none of them, and neither does the seam. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 15, + 'registrar list length': 16, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -884,6 +909,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'crud-routes.ts: this.routeManager.register(': 6, 'meta-collection-routes.ts: this.routeManager.register(': 6, 'meta-item-read-routes.ts: this.routeManager.register(': 4, + // [#23050 slice 13c] Controlled on the helper's call spelling: this module + // has no direct site (see above). + 'meta-item-write-routes.ts: registerPerItemRoute(': 2, // [#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. @@ -1405,9 +1433,21 @@ export function deriveProbeFileCensus(): { // [#23050 slice 1] The moved module's own share, so a reading that // silently stopped appending it cannot hide behind the total above. // [#23050 slice 3] One such control per module on the list. - ...Object.fromEntries(movedModules.map(([file, text]) => - [`${file}: this.routeManager.register(`, occurrences(text, /this\.routeManager\.register\(/g)])), - 'registerPerItemRoute(': occurrences(src, /registerPerItemRoute\(/g), + // [#23050 slice 13c] …one per SPELLING the module registers through, as + // the population rule counts both: a module whose routes all go through + // the per-item registrar has no direct site, and a control that read 0 + // there would be instrument failure by this census's own rule. A + // spelling a module does not spell mints no control, so a module that + // stops spelling the one its control was recorded on makes that control + // VANISH, which is as loud as moving it. + ...Object.fromEntries(movedModules.flatMap(([file, text]) => [ + [`${file}: this.routeManager.register(`, occurrences(text, /this\.routeManager\.register\(/g)] as const, + [`${file}: registerPerItemRoute(`, occurrences(text, /registerPerItemRoute\(/g)] as const, + ].filter(([, count]) => count > 0))), + // [#23050 slice 13c] Over `all`, the population rule's own haystack, since + // a `/meta` module now carries helper call sites too (re-anchored, not + // relaxed: 6 here + 2 in `meta-item-write-routes.ts` = 8). + 'registerPerItemRoute(': occurrences(all, /registerPerItemRoute\(/g), 'const registerPerItemRoute =': occurrences(src, /const\s+registerPerItemRoute\s*=/g), // ⭐ The one control here that is NOT a whole-file count, deliberately: // it is the SHAPE of the slice the population rule subtracts, and it is diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 4b1649f70d6..09de373a87d 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -129,6 +129,10 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * `meta-item-read-routes.ts`, with its one: the item read's, locally caught on * its continuation line, as before — one of §7's and §8's four continuation * sites, which those sections find there because they read every file here. + * + * [#23050 slice 13c] …and the fifth, the per-item writes, to + * `meta-item-write-routes.ts`, with their two: the item save's and the reset's, + * each locally caught on its invocation line, as before. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -146,7 +150,7 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerSearchEndpoints`, `registerUiEndpoints`, * `registerCrudEndpoints`) hands its moved body — and each one * `registerMetadataEndpointsInner` builds for a moved `/meta` group's module - * (#23050 slices 13a and 13b), counted as that module's registrar — + * (#23050 slices 13a, 13b and 13c), counted as that 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 @@ -766,9 +770,21 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // out, one forwarder's two mentions in). Fifteen forwarders now: // (15 - 1) + 3 - 2, the module carrying one host literal that reads the // seam, so neither map gains an entry. - expect(SITE_FILES).toHaveLength(15); + // + // [#23050 slice 13c] 62 sites UNCHANGED / 137 → 141 mentions — the same MOVE. + // The fifth `/meta` group, the per-item writes, left for + // `meta-item-write-routes.ts` with its two LOCALLY CAUGHT sites (the item + // save's and the reset's, each on its invocation line, as before): + // 10 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 + 3 + 1 + 2 = 62 sites. The + // four new mentions are this slice's seam, the two spellings once (the third + // host literal the inner method builds, its forwarder, two; the host + // interface's member, two): `rest-server.ts` stays at 55 (the two sites out, + // one forwarder's two mentions in). Sixteen forwarders now: (16 - 1) + 3 - 2, + // the module carrying one host literal that reads the seam, so neither map + // gains an entry. + expect(SITE_FILES).toHaveLength(16); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(137); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(141); 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/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 83a8190ceb4..691386101db 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -272,7 +272,11 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // with two more: the item read's `?state=draft` and `?preview=draft`. Keys // name the route, so again no ledger row changed: // 8 = 1 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3 + 2, length 14 → 15. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] The per-item writes moved to `meta-item-write-routes.ts` + // with the last one `rest-server.ts` held: the reset's `?state=draft` (a + // `write` row). Its key names the route, so again no ledger row changed: + // 8 = 0 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3 + 2 + 1, length 15 → 16. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/meta-item-write-routes.ts b/packages/rest/src/meta-item-write-routes.ts new file mode 100644 index 00000000000..395d5e6ec2c --- /dev/null +++ b/packages/rest/src/meta-item-write-routes.ts @@ -0,0 +1,536 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 13c] The `/meta` per-item writes of `RestServer` — the item save + * (`PUT {metaPath}/:type/:name`) and the reset to the artifact default + * (`DELETE {metaPath}/:type/:name`) — moved out of `rest-server.ts`. They were + * the fifth group of `RestServer#registerMetadataEndpointsInner`'s body, two + * statements, and that method now calls the function below at their old + * position, so the `/meta` routes still register in one order: after the + * per-item reads, and above the history family. + * + * A MOVE, not a redesign. The function below is those two statements as they + * stood, re-indented one level: the same two routes per base, by the same + * handlers, answering the same bodies, each still registered through the + * per-item switch's registrar. The moved comments are moved text too: where one + * says "the `PUT` twins", "next door" or "a few hundred lines up", it still + * describes the inner method's order as it was, in which `POST + * {metaPath}/_migrate-stored` (now in `meta-collection-routes.ts`) sat above + * these two doors. + * + * ## How the moved body reaches the class + * + * The shape slice 13a set for `/meta` (`meta-collection-routes.ts`' header + * carries the full reasoning). 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 MetaItemWriteRouteHost}) and hands it, with the head's + * one {@link MetaRouteScope}, to the function below as its `this` and its + * argument. 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: + * + * - There is NO `routeManager` member, and that absence is the load-bearing + * part. This is the first `/meta` module that registers nothing on the + * server's route manager itself: both statements register through the inner + * method's per-item registrar, which reaches this module in the scope as the + * ONE closure the inner head declares. That closure reads the server's route + * manager at CALL time, and every call below runs during the inner call, + * while `registerMetadataEndpoints`, the umbrella, has swapped that manager + * for its wrapping registrar; so these two routes still mount behind the + * anonymous-deny gate (#3963) and the type-level write admission (#21124), + * and still answer to the per-item switch (`metadata.endpoints.item`), which + * the same closure reads. ⛔ Never a module-local registrar: a copy here would + * be a second copy of a helper, it would read a host field instead of the + * server's manager, and a manager captured anywhere but inside the swap would + * mount both writes outside those gates. + * - No member goes as a getter, because the body reads no instance FIELD at + * request time. The caller, the walled refusal, the protocol and the write + * actor are each resolved per request by a METHOD of the class, and every + * method goes as a closure. + * + * The inner method's head declares the bindings every `/meta` group reads and + * hands them over as ONE parameter, {@link MetaRouteScope}, built once there; + * the function destructures the three its body reads (`metaPath`, `isScoped` + * and the per-item registrar). It does not read `metadata`: the per-item + * switch is read inside that registrar, where it always was. The host stays + * class members only. + * + * ## The one token that is not byte-identical + * + * The save door asked the class's PRIVATE STATIC `metaSaveVerdict`, which it + * used to call as `RestServer.metaSaveVerdict(…)`. 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 (the stored-version read doors ask the same + * static from the class head, which is why it is spelled once there). So the + * call reaches it the way every other class member this body reads is reached: + * through the host, as `this.metaSaveVerdict(…)`, a closure on + * {@link MetaItemWriteRouteHost} that calls the static with the same argument. + * The static reads nothing but its argument, so the receiver is the whole + * difference, as it was for slice 13a's `metaTypeSingular`. That receiver is + * the only token of the body that changed. + * + * ## Module-level names + * + * Two names declared in `rest-server.ts` stay there and are imported from it: + * the reset's closed query set (its member-selector tests import it from + * there) and the package binding (on the package entry). Both are read only at + * request time, so the two modules importing each other cannot see one + * uninitialised. The imports only this body read left `rest-server.ts` with + * it: the save's closed query set (whose re-export there stays), the save's + * request mapping and the two declared request types. Every other name is + * imported from the module `rest-server.ts` imports it from. The package entry + * re-exports nothing from here, and the published surface does not move. + */ + +import { rethrowAuthzStoreUnavailable } from '@objectstack/core'; +// [#12702 · #20156] The `/meta` item write doors' capability predicate — see +// `rest-server.ts`' import of the same name for why it is shared. +import { metaWriteCapabilityVerdict } from '@objectstack/metadata-core'; +import type { DeleteMetaItemRequest, SaveMetaItemRequest } from '@objectstack/spec/api'; +import { handleRouteError } from './error-response.js'; +import type { MetaRouteScope, TransportScopedMetaRequest } from './meta-collection-routes.js'; +import { META_ITEM_MEMBER_PARAM, META_ITEM_SAVE_PARAMS, metaMemberSelector } from './meta-item-read-gate.js'; +// [#22141] The save door's precondition and lifecycle, read through the ONE +// mapping the runtime dispatcher's `/meta` domain asks too. +import { metaSaveRequestOptions } from './meta-save-request.js'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { META_ITEM_RESET_PARAMS, metaItemPackageBinding, type RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerMetaItemWriteRoutes} 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. `metaSaveVerdict` is the class's + * private STATIC, typed as the static it is. The module header says why there + * is no `routeManager` member, why the body reaches the static through the + * host, and why none of the members is a getter. + */ +export interface MetaItemWriteRouteHost { + resolveExecCtx: RestServer['resolveExecCtx']; + metaSaveVerdict: (typeof RestServer)['metaSaveVerdict']; + walledCatalogRefusal: RestServer['walledCatalogRefusal']; + resolveProtocol: RestServer['resolveProtocol']; + resolveMetaWriteActor: RestServer['resolveMetaWriteActor']; +} + +/** + * Register the `/meta` per-item writes — the item save and the reset, the fifth + * group of `RestServer#registerMetadataEndpointsInner`' body, called by that + * method at its 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 registerMetaItemWriteRoutes(this: MetaItemWriteRouteHost, scope: MetaRouteScope): void { + const { metaPath, isScoped, registerPerItemRoute } = scope; + + // PUT /meta/:type/:name - Save metadata item + // We always register this route, but return 501 if protocol doesn't support it + // This makes it discoverable even if not implemented + registerPerItemRoute({ + method: 'PUT', + path: `${metaPath}/:type/:name`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#6603] Authoring capability gate — the SAME mechanism + // `POST /meta/_migrate-stored` uses next door, deliberately + // not a second way of demanding the same capability. + // + // Two independent reasons, either sufficient: + // + // 1. **The ADR-0106 round-trip.** D1 removes an unreadable + // field WHOLE from a served object schema, and this + // route persists the body it is handed. So a non-exempt + // caller's ordinary GET → edit a label → PUT used to + // store the schema back MINUS the fields masked out of + // their own read — silent deletion of fields they were + // never allowed to see, with nothing in the exchange + // saying so. Refusing the write is the write-side answer + // the masking needs: it makes "whoever may write a + // schema is whoever sees all of it" an enforced + // invariant instead of a coincidence, rather than + // teaching `saveMetaItem` that absent means keep (which + // would make field DELETION inexpressible for everyone). + // 2. It closes a hole that predates masking entirely: any + // authenticated session could clobber any metadata item. + // + // Gate FIRST — before the protocol is resolved — so an + // unauthorized caller cannot use the 501-vs-200 answer to + // probe which kernels implement saving, and so nothing is + // written before the refusal. `manage_metadata` is + // ADR-0066 D1's authoring capability and saving a metadata + // item is authoring; `isSystem` bypasses, matching every + // other capability gate on the platform. + // + // [#12702 · ADR-0131 D6] The gate is the shared + // `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`): + // `manage_metadata` or `isSystem`. The org-scoped + // `manage_org_presentation` arm retired with the + // per-organization overlay axis — this door threads no + // organization, so that arm would have admitted its holders + // to environment-wide authoring. + // + // [#20156] Asked through {@link metaSaveVerdict}, the ONE + // spelling the stored-version read doors' author exemption + // asks too (ruling 5856774816): whoever this door admits + // reads the stored version whole there, whatever the plain + // read withholds from them — so what they save back is + // everything that was stored. + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + { + const verdict = this.metaSaveVerdict(ctx); + if (!verdict.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: verdict.message, + }, + }); + return; + } + } + // [ADR-0131 D3 · D12 items 7–8] The walled refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` every organization shares the + // environment's metadata, so only the platform operator rung + // (or the engine itself) writes it, whatever the type. Inert + // under `single`. See {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, 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 (!p.saveMetaItem) { + // [#7035] ADR-0112 envelope: the semantic code lives at + // `error.code`, NOT as a sibling of `error`. This site + // used to answer `{ error: '', code: 'NOT_IMPLEMENTED' }` + // while `POST /meta/_migrate-stored` a few hundred lines + // up answered the nested shape for the same condition, + // so a client reading `err.error.code` got `undefined` + // here — and `undefined` takes the "no code" branch, not + // an error branch. `NOT_IMPLEMENTED` is unchanged: it is + // already the standard-catalog code ADR-0112 maps 501 to + // (`spec/src/api/errors.zod.ts` — the catalog member and + // `standardErrorCodeForHttpStatus(501)`). + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'Save operation not supported by protocol implementation', + }, + }); + return; + } + + // Accept both `{ ...itemFields }` (bare) and `{ metadata: {...} }` + // / `{ item: {...} }` envelope shapes. Studio and direct API + // callers historically use either; ADR-0005 settles on + // unwrapping to a single payload before persistence. + const body = req.body ?? {}; + const item = (body && typeof body === 'object' && 'metadata' in body) + ? (body as any).metadata + : (body && typeof body === 'object' && 'item' in body) + ? (body as any).item + : body; + + // Opt-in OCC under ADR-0008 PR-10d.3: callers (Studio, + // CLI) may set `If-Match` to the version token a receipt + // served, to enforce that the overlay row has not advanced + // since they last read it. A `null`/empty body or no header + // preserves the legacy last-write-wins behaviour. [#21207] + // The token is the crypto provider's keyed digest of the + // stored content hash, never the hash itself; the protocol + // compares it in that form, so it passes through here as sent. + // [#22114] The item read serves the same token as `version`, + // and `If-None-Match: *` pins a save that expects no row. + // [#22141] The pin and `?mode=draft` are read through + // {@link metaSaveRequestOptions} — the ONE mapping the + // runtime dispatcher's `/meta` door asks too, so the + // `@objectstack/hono` catch-all cannot answer this request + // differently. Its `request` members are spread into the + // save below, each present only when the caller asked. + // + // The multiplicity guard runs FIRST: it refuses a repeated + // `force`, `package` or `mode` (see the `force` read below + // for why that one is sharp) and UNWRAPS one occurrence + // encoded as an array, so `?mode=` reaches the mapping as + // the string it reads. + // + // [#23072] …and RECOGNITION before it, over the measured + // closed set: a name this door does not read — `member` + // first among them, the save being body-addressed — is a + // located 400 here, before anything is written, never + // dropped while the body's own member is saved. + if (refuseUnknownQueryParams(req, res, META_ITEM_SAVE_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; + const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query }); + if (!saveOptions.ok) { + res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } }); + return; + } + // [#7749 producer, #7941 precedence] The request's authenticated + // identity — one producer, shared by every `/meta` write (see + // resolveMetaWriteActor). `X-Actor` is not consulted. + const actor = await this.resolveMetaWriteActor(environmentId, req); + // Phase 3a-destructive: `?force=true` opts past the + // destructive-change safety check. Accept any truthy + // string ('true', '1', 'yes') for resilience. + // + // [#6877] THE sharp one on this surface. The `typeof` ternary + // below falls to `!!forceRaw` for anything that is not a + // string, and a non-empty array is truthy — so + // `?force=false&force=false`, a caller repeating an explicit + // OPT-OUT, turned the destructive-change guard ON. An + // inversion, on a destructive verb, reported as 200. The + // multiplicity guard above refuses it before this read. + const forceRaw = req.query?.force; + const force = typeof forceRaw === 'string' + ? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase()) + : !!forceRaw; + + // Software-package binding (Studio package authoring). + // `?package=` binds the saved row to that package + // (sys_metadata.package_id). 'all'/empty = env-local overlay + // ({@link metaItemPackageBinding}, the item read's too). + const packageId = metaItemPackageBinding(req.query?.package); + + // [ADR-0131 D6] THE WRITE NAMES NO ORGANIZATION. The + // per-organization overlay axis is retired: every write this + // door admits lands environment-wide (`organization_id` + // NULL), its audit row included, whatever the caller's + // active organization. Until this stage the door threaded + // the active organization for the five types whose registry + // flag opened an overlay (then spelled `allowOrgOverride`, + // `allowEnvironmentOverlay` since 18, which decides no + // organization); a single-posture Studio save + // of those types therefore sits under the Default + // Organization, and is not served again until ADR-0131 C7 + // promotes it to the environment layer. + // [#12004] The `as any` cast this call carried came off + // when `SaveMetaItemRequestSchema` caught up with the + // members this door sends. `saveMetaItem` is a REQUIRED + // protocol member, so unlike the publish door's old cast + // (member existence, TS2339) this one was load-bearing on + // REQUEST SHAPE alone: the schema declared only + // `{ type, name, item }`, and removing the cast surfaced + // TS2353 on every other key. The literal is now compiled + // against the spec contract through the commit 2a29caa53 + // `TransportScopedMetaRequest` wrapper — `environmentId` + // is the transport-level routing key that wrapper layers + // on, ⛔ never a protocol key; every other key here is + // checked against the declared request, so an undeclared + // member is a compile error instead of a payload member no + // contract has ever seen. + const saveRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: req.params.name, + item, + // [commit d806081dd] This door answers with an ADR-0112 error + // envelope that carries the refusal's `issues[]` + // structurally beside the message (`sendError` threads a + // top-level `issues`), so `saveMetaItem`'s 422 renders + // its findings as a headline here instead of restating + // the per-key prose a console would then show twice. + // Server-stated: this object is built field by field + // from named `req` values and never spreads the body, so + // a client cannot smuggle a face in. + writeFace: 'meta-envelope', + ...(environmentId ? { environmentId } : {}), + // `parentVersion` and `mode`, each only when asked — + // a closed two-key object read off the headers and the + // query, never the body. + ...saveOptions.request, + ...(actor ? { actor } : {}), + ...(force ? { force: true } : {}), + ...(packageId ? { packageId } : {}), + }; + const result = await p.saveMetaItem(saveRequest); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Save specific metadata item', + tags: ['metadata'], + }, + }); + + // DELETE /meta/:type/:name - Reset metadata item to artifact default + // Removes a customization overlay row from sys_metadata (ADR-0005). + // Returns 200 even when no overlay existed (idempotent reset). + registerPerItemRoute({ + method: 'DELETE', + path: `${metaPath}/:type/:name`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#7019] Same gate, same mechanism as the `PUT` twins — + // but the argument for it is NOT the ADR-0106 round trip, + // and saying so matters. Nothing is masked here and nothing + // is round-tripped: this route discards a customization + // overlay outright, so before this gate an authenticated + // session holding no authoring capability at all could + // reset any customized metadata item in the deployment to + // its artifact default — and with `?dropStorage=true`, drop + // the object's physical table with it. + // + // It belongs with the two PUTs because deleting a + // customization is authoring it (ADR-0066 D1), and because + // the fix is the same four lines — not because it is the + // same argument. + // + // Gate FIRST — before the protocol is resolved — so the + // 501-vs-200 answer leaks no kernel capability, and, the + // point here, so the refusal happens with the overlay row + // still intact. A gate that answers 403 after + // `deleteMetaItem` has run would still be the bug. + // `isSystem` bypasses, as everywhere else. + // + // [#12702 · ADR-0131 D6] Same shared verdict as the PUT + // door. The reset names no organization either, so it + // discards the environment-wide row. + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + operation: 'reset', + }); + if (!verdict.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: verdict.message, + }, + }); + return; + } + } + // [ADR-0131 D3 · D12 items 7–8] The walled refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` every organization shares the + // environment's metadata, so only the platform operator rung + // (or the engine itself) writes it, whatever the type. Inert + // under `single`. See {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, 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 (!p.deleteMetaItem) { + // [#7035] ADR-0112 envelope. This site was the worst of + // the three shapes: a BARE STRING `error`, with no code + // at all — so neither `err.error.code` nor `err.code` + // resolved, and `err.error.message` read `undefined` + // too. `NOT_IMPLEMENTED` is the standard-catalog code + // for 501 (ADR-0112; `standardErrorCodeForHttpStatus`). + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'Reset operation not supported by protocol implementation', + }, + }); + return; + } + // Mirror saveMetaItem's OCC + actor plumbing (ADR-0008 + // PR-10d wiring): `If-Match` pins the expected current + // version so concurrent edits get a 409 instead of a + // silent reset; the request's authenticated identity flows + // into the history tombstone row (#7749 producer; #7941 + // dropped the `X-Actor` limb that used to outrank it). + const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; + const parentVersion = typeof ifMatchHeader === 'string' + ? ifMatchHeader.replace(/^"|"$/g, '') + : undefined; + // [#7749 producer, #7941 precedence] The request's authenticated + // identity — one producer, shared by every `/meta` write (see + // resolveMetaWriteActor). `X-Actor` is not consulted. + const actor = await this.resolveMetaWriteActor(environmentId, req); + + // [#6877] `?state=` and the destructive `?dropStorage=` + // both fail SAFE on an array today (the comparisons stop + // matching), but "the wrong answer happens to be the + // conservative one" is not a rule a caller can rely on and + // is not what the request asked for. + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_RESET_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', 'package', META_ITEM_MEMBER_PARAM])) return; + const stateParam = typeof req.query?.state === 'string' + && req.query.state.toLowerCase() === 'draft' + ? 'draft' as const + : undefined; + + // `?dropStorage=true` also tears down the object's physical + // table (object + active only). Used by the "discard a + // previewed object" flow so a publish-to-preview leaves no + // orphan table. Destructive — opt-in, defaults off. + const dropStorage = req.query?.dropStorage === 'true' || req.query?.dropStorage === '1'; + const deletePackageId = metaItemPackageBinding(req.query?.package); + + // [ADR-0131 D6] No organization, as on the `PUT` twin: the + // reset reaches the environment-wide row. + // [#11679] The `(p as any)` cast this call carried came off + // when `DeleteMetaItemRequestSchema` caught up with the + // eight members this door sends. Unlike the publish door's + // cast (member existence, TS2339), this one was load-bearing + // on REQUEST SHAPE: the member was declared all along, but + // the schema declared only `{ type, name }`, so removing the + // cast surfaced TS2353 on six keys. The literal is now + // compiled against the spec contract through the commit 2a29caa53 + // `TransportScopedMetaRequest` wrapper — `environmentId` is + // the transport-level routing key that wrapper layers on, + // ⛔ never a protocol key; every other key here is checked + // against the declared request, so an undeclared member is a + // compile error instead of a payload member no contract has + // ever seen. The 501 guard above stays: the member is + // declared OPTIONAL, and the guard is what narrows it to + // callable here. + const deleteRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: req.params.name, + ...(environmentId ? { environmentId } : {}), + ...(parentVersion !== undefined ? { parentVersion } : {}), + ...(actor ? { actor } : {}), + ...(stateParam ? { state: stateParam } : {}), + ...(dropStorage ? { dropStorage: true } : {}), + // [#22877] Resets ONE member of a bundled type. + ...metaMemberSelector(req.query), + // [#23165, ADR-0048] The package whose row the delete + // removes; `all` and an empty value name none, as on + // the save door, and the protocol then resolves it. + ...(deletePackageId ? { packageId: deletePackageId } : {}), + }; + const result = await p.deleteMetaItem(deleteRequest); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Reset metadata item to artifact default (deletes customization overlay)', + tags: ['metadata'], + }, + }); +} 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 03a14c156cb..df0bec0b642 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -182,6 +182,13 @@ interface CensusEntry { * [#23050 slice 13b] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0, * unchanged: the per-item reads (`meta-item-read-routes.ts`) build no `query:` * slot either, so the moved module's floor is 0. + * + * [#23050 slice 13c] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0, + * unchanged: the per-item writes (`meta-item-write-routes.ts`) build no `query:` + * slot either. The item save's one `query` key sits mid-line in the argument of + * its request mapping (`metaSaveRequestOptions`), 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, @@ -199,6 +206,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // groups carry no slot). // [#23050 slice 13b] Length 14 → 15, the sum unchanged (the per-item reads // carry no slot). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] Length 15 → 16, the sum unchanged (the per-item writes + // carry no slot). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); 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 ee8b76d5023..bf828a4b8df 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -208,6 +208,12 @@ describe('rest-server.ts docblock position', () => { * ETag dimension). The moved `/meta` group held no TSDoc block of its own, so * `rest-server.ts` gives up exactly those two (163 → 161), still far above its * floor. + * + * [#23050 slice 13c] `meta-item-write-routes.ts` carries three, as the form and + * CRUD modules do: the module header, the host interface's block and the + * function's. The moved per-item writes held no TSDoc block of their own and + * took no module-level helper with them, so `rest-server.ts` keeps every block + * it had (161). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -224,6 +230,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -264,9 +271,11 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // thirteen, length 13 → 14 (`ui-routes.ts` 4 → 3: the alias's block moved on). // [#23050 slice 13b] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 + 5 = 59 over // fourteen, length 14 → 15. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 + 5 + 3 = 62 + // over fifteen, length 15 → 16. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); 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(59); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(62); }); }); 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 99411f4ad4d..c3370df72af 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -347,7 +347,11 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // the reference sweep, the layered read, the book tree and the item read // coerce no number of the query: // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 14 → 15. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] Nor do the per-item writes (`meta-item-write-routes.ts`): + // the item save and the reset read their switches as strings and coerce no + // number of the query: + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 15 → 16. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); 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 bbff5b71826..752c6d008f0 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -59,6 +59,9 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#23050 slice 13b] The fourth `/meta` group of `registerMetadataEndpointsInner`' // body, the per-item reads — see {@link REST_SERVER_META_ROUTE_FILES}. 'meta-item-read-routes.ts', + // [#23050 slice 13c] The fifth `/meta` group of `registerMetadataEndpointsInner`' + // body, the per-item writes — see {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-item-write-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -87,6 +90,10 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // [#23050 slice 13b] The per-item reads: `/references`, `/layers`, the book tree // and the item read. 'meta-item-read-routes.ts', + // [#23050 slice 13c] The per-item writes: the item save and the reset. The first + // module here that registers nothing on the route manager itself: both routes go + // through the inner method's per-item registrar, handed over in its scope. + 'meta-item-write-routes.ts', ]; /** diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8876294348c..2816281785c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -147,8 +147,9 @@ import type { PublishMetaItemRequest, AuditMetaItemRequest, HistoryMetaItemRequest, - SaveMetaItemRequest, - DeleteMetaItemRequest, + // `SaveMetaItemRequest` and `DeleteMetaItemRequest`, the item save's and the reset's + // declared requests, moved with those doors to `meta-item-write-routes.ts` (#23050 + // slice 13c). } from '@objectstack/spec/api'; // `GetUiViewRequest`, the UI view door's declared request, moved with that door to // `ui-routes.ts` (#23050 slice 10). @@ -210,14 +211,14 @@ export { filterAppForUser } from './meta-item-read-gate.js'; import { // `META_ITEM_LAYERS_PARAMS` is read only by the `/layers` door, which moved to // `meta-item-read-routes.ts` (#23050 slice 13b); the re-export below still names it. + // `META_ITEM_SAVE_PARAMS` is read only by the item save, which moved to + // `meta-item-write-routes.ts` (#23050 slice 13c); the re-export below still names it. META_ITEM_MEMBER_PARAM, META_ITEM_PUBLISHED_PARAMS, - META_ITEM_SAVE_PARAMS, metaMemberSelector, } from './meta-item-read-gate.js'; -// [#22141] The `PUT /meta/:type/:name` door's precondition and lifecycle, read -// through the ONE mapping the runtime dispatcher's `/meta` domain asks too. -import { metaSaveRequestOptions } from './meta-save-request.js'; +// [#22141] `metaSaveRequestOptions`, the `PUT /meta/:type/:name` door's precondition and +// lifecycle mapping, moved with that door to `meta-item-write-routes.ts` (#23050 slice 13c). import type { AuthSessionApi, ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, @@ -328,6 +329,9 @@ import { // [#23050 slice 13b] The `/meta` per-item reads (`/references`, `/layers`, the book tree // and the item read) moved to their own module — see its header. import { registerMetaItemReadRoutes, type MetaItemReadRouteHost } from './meta-item-read-routes.js'; +// [#23050 slice 13c] The `/meta` per-item writes (the item save and the reset) moved to +// their own module — see its header. +import { registerMetaItemWriteRoutes, type MetaItemWriteRouteHost } from './meta-item-write-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. @@ -5282,410 +5286,23 @@ export class RestServer { }; registerMetaItemReadRoutes.call(itemReadHost, scope); - // PUT /meta/:type/:name - Save metadata item - // We always register this route, but return 501 if protocol doesn't support it - // This makes it discoverable even if not implemented - registerPerItemRoute({ - method: 'PUT', - path: `${metaPath}/:type/:name`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#6603] Authoring capability gate — the SAME mechanism - // `POST /meta/_migrate-stored` uses next door, deliberately - // not a second way of demanding the same capability. - // - // Two independent reasons, either sufficient: - // - // 1. **The ADR-0106 round-trip.** D1 removes an unreadable - // field WHOLE from a served object schema, and this - // route persists the body it is handed. So a non-exempt - // caller's ordinary GET → edit a label → PUT used to - // store the schema back MINUS the fields masked out of - // their own read — silent deletion of fields they were - // never allowed to see, with nothing in the exchange - // saying so. Refusing the write is the write-side answer - // the masking needs: it makes "whoever may write a - // schema is whoever sees all of it" an enforced - // invariant instead of a coincidence, rather than - // teaching `saveMetaItem` that absent means keep (which - // would make field DELETION inexpressible for everyone). - // 2. It closes a hole that predates masking entirely: any - // authenticated session could clobber any metadata item. - // - // Gate FIRST — before the protocol is resolved — so an - // unauthorized caller cannot use the 501-vs-200 answer to - // probe which kernels implement saving, and so nothing is - // written before the refusal. `manage_metadata` is - // ADR-0066 D1's authoring capability and saving a metadata - // item is authoring; `isSystem` bypasses, matching every - // other capability gate on the platform. - // - // [#12702 · ADR-0131 D6] The gate is the shared - // `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`): - // `manage_metadata` or `isSystem`. The org-scoped - // `manage_org_presentation` arm retired with the - // per-organization overlay axis — this door threads no - // organization, so that arm would have admitted its holders - // to environment-wide authoring. - // - // [#20156] Asked through {@link metaSaveVerdict}, the ONE - // spelling the stored-version read doors' author exemption - // asks too (ruling 5856774816): whoever this door admits - // reads the stored version whole there, whatever the plain - // read withholds from them — so what they save back is - // everything that was stored. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - { - const verdict = RestServer.metaSaveVerdict(ctx); - if (!verdict.allowed) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: verdict.message, - }, - }); - return; - } - } - // [ADR-0131 D3 · D12 items 7–8] The walled refusal, after the - // capability gate and before the protocol is resolved: under - // `isolated` / `group` every organization shares the - // environment's metadata, so only the platform operator rung - // (or the engine itself) writes it, whatever the type. Inert - // under `single`. See {@link walledCatalogRefusal}. - { - const walled = await this.walledCatalogRefusal(req.params.type, 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 (!p.saveMetaItem) { - // [#7035] ADR-0112 envelope: the semantic code lives at - // `error.code`, NOT as a sibling of `error`. This site - // used to answer `{ error: '', code: 'NOT_IMPLEMENTED' }` - // while `POST /meta/_migrate-stored` a few hundred lines - // up answered the nested shape for the same condition, - // so a client reading `err.error.code` got `undefined` - // here — and `undefined` takes the "no code" branch, not - // an error branch. `NOT_IMPLEMENTED` is unchanged: it is - // already the standard-catalog code ADR-0112 maps 501 to - // (`spec/src/api/errors.zod.ts` — the catalog member and - // `standardErrorCodeForHttpStatus(501)`). - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'Save operation not supported by protocol implementation', - }, - }); - return; - } - - // Accept both `{ ...itemFields }` (bare) and `{ metadata: {...} }` - // / `{ item: {...} }` envelope shapes. Studio and direct API - // callers historically use either; ADR-0005 settles on - // unwrapping to a single payload before persistence. - const body = req.body ?? {}; - const item = (body && typeof body === 'object' && 'metadata' in body) - ? (body as any).metadata - : (body && typeof body === 'object' && 'item' in body) - ? (body as any).item - : body; - - // Opt-in OCC under ADR-0008 PR-10d.3: callers (Studio, - // CLI) may set `If-Match` to the version token a receipt - // served, to enforce that the overlay row has not advanced - // since they last read it. A `null`/empty body or no header - // preserves the legacy last-write-wins behaviour. [#21207] - // The token is the crypto provider's keyed digest of the - // stored content hash, never the hash itself; the protocol - // compares it in that form, so it passes through here as sent. - // [#22114] The item read serves the same token as `version`, - // and `If-None-Match: *` pins a save that expects no row. - // [#22141] The pin and `?mode=draft` are read through - // {@link metaSaveRequestOptions} — the ONE mapping the - // runtime dispatcher's `/meta` door asks too, so the - // `@objectstack/hono` catch-all cannot answer this request - // differently. Its `request` members are spread into the - // save below, each present only when the caller asked. - // - // The multiplicity guard runs FIRST: it refuses a repeated - // `force`, `package` or `mode` (see the `force` read below - // for why that one is sharp) and UNWRAPS one occurrence - // encoded as an array, so `?mode=` reaches the mapping as - // the string it reads. - // - // [#23072] …and RECOGNITION before it, over the measured - // closed set: a name this door does not read — `member` - // first among them, the save being body-addressed — is a - // located 400 here, before anything is written, never - // dropped while the body's own member is saved. - if (refuseUnknownQueryParams(req, res, META_ITEM_SAVE_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; - const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query }); - if (!saveOptions.ok) { - res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } }); - return; - } - // [#7749 producer, #7941 precedence] The request's authenticated - // identity — one producer, shared by every `/meta` write (see - // resolveMetaWriteActor). `X-Actor` is not consulted. - const actor = await this.resolveMetaWriteActor(environmentId, req); - // Phase 3a-destructive: `?force=true` opts past the - // destructive-change safety check. Accept any truthy - // string ('true', '1', 'yes') for resilience. - // - // [#6877] THE sharp one on this surface. The `typeof` ternary - // below falls to `!!forceRaw` for anything that is not a - // string, and a non-empty array is truthy — so - // `?force=false&force=false`, a caller repeating an explicit - // OPT-OUT, turned the destructive-change guard ON. An - // inversion, on a destructive verb, reported as 200. The - // multiplicity guard above refuses it before this read. - const forceRaw = req.query?.force; - const force = typeof forceRaw === 'string' - ? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase()) - : !!forceRaw; - - // Software-package binding (Studio package authoring). - // `?package=` binds the saved row to that package - // (sys_metadata.package_id). 'all'/empty = env-local overlay - // ({@link metaItemPackageBinding}, the item read's too). - const packageId = metaItemPackageBinding(req.query?.package); - - // [ADR-0131 D6] THE WRITE NAMES NO ORGANIZATION. The - // per-organization overlay axis is retired: every write this - // door admits lands environment-wide (`organization_id` - // NULL), its audit row included, whatever the caller's - // active organization. Until this stage the door threaded - // the active organization for the five types whose registry - // flag opened an overlay (then spelled `allowOrgOverride`, - // `allowEnvironmentOverlay` since 18, which decides no - // organization); a single-posture Studio save - // of those types therefore sits under the Default - // Organization, and is not served again until ADR-0131 C7 - // promotes it to the environment layer. - // [#12004] The `as any` cast this call carried came off - // when `SaveMetaItemRequestSchema` caught up with the - // members this door sends. `saveMetaItem` is a REQUIRED - // protocol member, so unlike the publish door's old cast - // (member existence, TS2339) this one was load-bearing on - // REQUEST SHAPE alone: the schema declared only - // `{ type, name, item }`, and removing the cast surfaced - // TS2353 on every other key. The literal is now compiled - // against the spec contract through the commit 2a29caa53 - // `TransportScopedMetaRequest` wrapper — `environmentId` - // is the transport-level routing key that wrapper layers - // on, ⛔ never a protocol key; every other key here is - // checked against the declared request, so an undeclared - // member is a compile error instead of a payload member no - // contract has ever seen. - const saveRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - item, - // [commit d806081dd] This door answers with an ADR-0112 error - // envelope that carries the refusal's `issues[]` - // structurally beside the message (`sendError` threads a - // top-level `issues`), so `saveMetaItem`'s 422 renders - // its findings as a headline here instead of restating - // the per-key prose a console would then show twice. - // Server-stated: this object is built field by field - // from named `req` values and never spreads the body, so - // a client cannot smuggle a face in. - writeFace: 'meta-envelope', - ...(environmentId ? { environmentId } : {}), - // `parentVersion` and `mode`, each only when asked — - // a closed two-key object read off the headers and the - // query, never the body. - ...saveOptions.request, - ...(actor ? { actor } : {}), - ...(force ? { force: true } : {}), - ...(packageId ? { packageId } : {}), - }; - const result = await p.saveMetaItem(saveRequest); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Save specific metadata item', - tags: ['metadata'], - }, - }); - - // DELETE /meta/:type/:name - Reset metadata item to artifact default - // Removes a customization overlay row from sys_metadata (ADR-0005). - // Returns 200 even when no overlay existed (idempotent reset). - registerPerItemRoute({ - method: 'DELETE', - path: `${metaPath}/:type/:name`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#7019] Same gate, same mechanism as the `PUT` twins — - // but the argument for it is NOT the ADR-0106 round trip, - // and saying so matters. Nothing is masked here and nothing - // is round-tripped: this route discards a customization - // overlay outright, so before this gate an authenticated - // session holding no authoring capability at all could - // reset any customized metadata item in the deployment to - // its artifact default — and with `?dropStorage=true`, drop - // the object's physical table with it. - // - // It belongs with the two PUTs because deleting a - // customization is authoring it (ADR-0066 D1), and because - // the fix is the same four lines — not because it is the - // same argument. - // - // Gate FIRST — before the protocol is resolved — so the - // 501-vs-200 answer leaks no kernel capability, and, the - // point here, so the refusal happens with the overlay row - // still intact. A gate that answers 403 after - // `deleteMetaItem` has run would still be the bug. - // `isSystem` bypasses, as everywhere else. - // - // [#12702 · ADR-0131 D6] Same shared verdict as the PUT - // door. The reset names no organization either, so it - // discards the environment-wide row. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - { - const verdict = metaWriteCapabilityVerdict({ - isSystem: ctx?.isSystem === true, - systemPermissions: ctx?.systemPermissions, - operation: 'reset', - }); - if (!verdict.allowed) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: verdict.message, - }, - }); - return; - } - } - // [ADR-0131 D3 · D12 items 7–8] The walled refusal, after the - // capability gate and before the protocol is resolved: under - // `isolated` / `group` every organization shares the - // environment's metadata, so only the platform operator rung - // (or the engine itself) writes it, whatever the type. Inert - // under `single`. See {@link walledCatalogRefusal}. - { - const walled = await this.walledCatalogRefusal(req.params.type, 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 (!p.deleteMetaItem) { - // [#7035] ADR-0112 envelope. This site was the worst of - // the three shapes: a BARE STRING `error`, with no code - // at all — so neither `err.error.code` nor `err.code` - // resolved, and `err.error.message` read `undefined` - // too. `NOT_IMPLEMENTED` is the standard-catalog code - // for 501 (ADR-0112; `standardErrorCodeForHttpStatus`). - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'Reset operation not supported by protocol implementation', - }, - }); - return; - } - // Mirror saveMetaItem's OCC + actor plumbing (ADR-0008 - // PR-10d wiring): `If-Match` pins the expected current - // version so concurrent edits get a 409 instead of a - // silent reset; the request's authenticated identity flows - // into the history tombstone row (#7749 producer; #7941 - // dropped the `X-Actor` limb that used to outrank it). - const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; - const parentVersion = typeof ifMatchHeader === 'string' - ? ifMatchHeader.replace(/^"|"$/g, '') - : undefined; - // [#7749 producer, #7941 precedence] The request's authenticated - // identity — one producer, shared by every `/meta` write (see - // resolveMetaWriteActor). `X-Actor` is not consulted. - const actor = await this.resolveMetaWriteActor(environmentId, req); - - // [#6877] `?state=` and the destructive `?dropStorage=` - // both fail SAFE on an array today (the comparisons stop - // matching), but "the wrong answer happens to be the - // conservative one" is not a rule a caller can rely on and - // is not what the request asked for. - // [#22877] Recognition first, over the measured closed set. - if (refuseUnknownQueryParams(req, res, META_ITEM_RESET_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', 'package', META_ITEM_MEMBER_PARAM])) return; - const stateParam = typeof req.query?.state === 'string' - && req.query.state.toLowerCase() === 'draft' - ? 'draft' as const - : undefined; - - // `?dropStorage=true` also tears down the object's physical - // table (object + active only). Used by the "discard a - // previewed object" flow so a publish-to-preview leaves no - // orphan table. Destructive — opt-in, defaults off. - const dropStorage = req.query?.dropStorage === 'true' || req.query?.dropStorage === '1'; - const deletePackageId = metaItemPackageBinding(req.query?.package); - - // [ADR-0131 D6] No organization, as on the `PUT` twin: the - // reset reaches the environment-wide row. - // [#11679] The `(p as any)` cast this call carried came off - // when `DeleteMetaItemRequestSchema` caught up with the - // eight members this door sends. Unlike the publish door's - // cast (member existence, TS2339), this one was load-bearing - // on REQUEST SHAPE: the member was declared all along, but - // the schema declared only `{ type, name }`, so removing the - // cast surfaced TS2353 on six keys. The literal is now - // compiled against the spec contract through the commit 2a29caa53 - // `TransportScopedMetaRequest` wrapper — `environmentId` is - // the transport-level routing key that wrapper layers on, - // ⛔ never a protocol key; every other key here is checked - // against the declared request, so an undeclared member is a - // compile error instead of a payload member no contract has - // ever seen. The 501 guard above stays: the member is - // declared OPTIONAL, and the guard is what narrows it to - // callable here. - const deleteRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - ...(environmentId ? { environmentId } : {}), - ...(parentVersion !== undefined ? { parentVersion } : {}), - ...(actor ? { actor } : {}), - ...(stateParam ? { state: stateParam } : {}), - ...(dropStorage ? { dropStorage: true } : {}), - // [#22877] Resets ONE member of a bundled type. - ...metaMemberSelector(req.query), - // [#23165, ADR-0048] The package whose row the delete - // removes; `all` and an empty value name none, as on - // the save door, and the protocol then resolves it. - ...(deletePackageId ? { packageId: deletePackageId } : {}), - }; - const result = await p.deleteMetaItem(deleteRequest); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Reset metadata item to artifact default (deletes customization overlay)', - tags: ['metadata'], - }, - }); + // [#23050 slice 13c] The per-item writes (`PUT` and `DELETE {metaPath}/:type/:name`, + // the item save and the reset) moved to `meta-item-write-routes.ts`, and are + // registered HERE so they keep their place in the order: after the per-item reads, + // above the history family below. They still register through the per-item + // registrar this head declares, which reaches that module in `scope` as the one + // closure it is and reads the route manager when it is called, during this call. + // This literal hands that body every other member it reads (see + // `MetaItemWriteRouteHost`); it carries no route manager, because that body + // registers nothing on one itself. Read that module's header before moving it. + const itemWriteHost: MetaItemWriteRouteHost = { + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + metaSaveVerdict: (...a) => RestServer.metaSaveVerdict(...a), + walledCatalogRefusal: (...a) => this.walledCatalogRefusal(...a), + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveMetaWriteActor: (...a) => this.resolveMetaWriteActor(...a), + }; + registerMetaItemWriteRoutes.call(itemWriteHost, scope); // GET /meta/:type/:name/history — durable change-log for one item. // Returns the sys_metadata_history events that the Studio "History" 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 fd247bd0b52..2ff527a918c 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -385,7 +385,12 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // one site and `enforceAuth` at none, so the second host literal the inner // method builds forwards the first only; the call site moved and is // counted in both readings. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] 15 → 16 (`meta-item-write-routes.ts`): 60 unchanged + // and 77 → 78, the same shape: the per-item writes call `resolveExecCtx` at + // two sites and `enforceAuth` at none, so the third host literal the inner + // method builds forwards the first only; the two call sites moved and are + // counted in both readings. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); 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 1c2a900e594..17e7a04e868 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1159,7 +1159,13 @@ describe('[#13214] §4 preconditions', () => { // method builds forwards the first only; the call site moved and is // counted in both readings. The per-item reads never call the ownership // guard either, so it is still 1 call site and 1 forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); + // [#23050 slice 13c] 15 → 16 (`meta-item-write-routes.ts`): 60 unchanged + // and 77 → 78, the same shape: the per-item writes call `resolveExecCtx` at + // two sites and `enforceAuth` at none, so the third host literal the inner + // method builds forwards the first only; the two call sites moved and are + // counted in both readings. The per-item writes never call the ownership + // guard either, so it is still 1 call site and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/scripts/adr-anchors/packages__rest__src__meta-item-write-routes.ts.json b/scripts/adr-anchors/packages__rest__src__meta-item-write-routes.ts.json new file mode 100644 index 00000000000..8fea3cb26d0 --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__meta-item-write-routes.ts.json @@ -0,0 +1,9 @@ +{ + "file": "packages/rest/src/meta-item-write-routes.ts", + "adrs": [ + "ADR-0066", + "ADR-0131", + "ADR-0106" + ], + "invariant": "The `/meta` per-item write doors, moved here whole from `rest-server.ts` (#23050 slice 13c): the item save (`PUT {metaPath}/:type/:name`) and the reset to the artifact default (`DELETE {metaPath}/:type/:name`), registered through the inner method's per-item registrar, under the per-item switch and inside the umbrella's guarded registration. ADR-0066 — saving or resetting a metadata item is authoring, so each door asks the shared write-capability verdict FIRST, before the protocol is resolved, so nothing is written or discarded before a refusal and no kernel capability leaks to a caller it refuses. ADR-0131 — neither door names an organization: every write it admits lands environment-wide, and each asks the walled refusal after its capability gate and before the protocol is resolved. ADR-0106 — the save's capability gate is the write-side half of the masked schema read: whoever may save an object schema reads all of it, so a save never stores back a schema minus the fields its caller could not see. A door that resolves the protocol or writes before its capability gate, threads an organization into a write, or runs past the walled refusal is the drift these records forbid; the bodies' own comments carry each citation." +} diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 7811c41313d..eb299219e9d 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -819,6 +819,24 @@ const MODULES = { stringError: 1, siblingCode: 1, }, + + // [#23050 slice 13c] The fifth `/meta` group of + // `RestServer#registerMetadataEndpointsInner`' body — the per-item writes (the + // item save and the reset, `PUT` and `DELETE {metaPath}/:type/:name`) — 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 (each door's + // 403 and 501 bodies, and the save's 400, are the nested `{ error: { code, + // message } }`) and `rest-server.ts` keeps its 7 / 3, 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-item-write-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 13c 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. */