From 344cb18d8920d60cf37dfbda238136060c19d274 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:56:56 +0000 Subject: [PATCH 1/5] refactor(rest): move the /meta per-item writes into meta-item-write-routes.ts Sub-slice 13c, the move commit. The fifth /meta group of RestServer#registerMetadataEndpointsInner, the two registerPerItemRoute statements registering PUT and DELETE {metaPath}/:type/:name (the item save and the reset), moves re-indented to the new module as registerMetaItemWriteRoutes(this: MetaItemWriteRouteHost, scope: MetaRouteScope). The inner method builds the itemWriteHost literal at the statements' old position and passes the one existing scope, through which the inner head's own per-item registrar reaches the module. The host carries no routeManager: the module registers only through that closure, which reads the server's route manager at call time. The one private-static receiver, RestServer.metaSaveVerdict, becomes this.metaSaveVerdict through the host. The four imports only the moved body read (the save's closed set, its request mapping and the two declared request types) leave rest-server.ts. The readers follow in later commits. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- packages/rest/src/meta-item-write-routes.ts | 536 ++++++++++++++++++++ packages/rest/src/rest-server.ts | 437 +--------------- 2 files changed, 563 insertions(+), 410 deletions(-) create mode 100644 packages/rest/src/meta-item-write-routes.ts 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.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" From 9ce268e260909671e084323223c0a93201d11464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:12:32 +0000 Subject: [PATCH 2/5] test(rest): put meta-item-write-routes.ts on the registrar list and the /meta subset Sub-slice 13c, the list commit. REST_SERVER_REGISTRAR_FILES grows 15 to 16 and its named /meta subset REST_SERVER_META_ROUTE_FILES 2 to 3, with the inner method's module calls (3) beside it. The list's readers are re-pointed in the next commit, so this one measures them red at their base text. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- packages/rest/src/rest-server-registrar-files.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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', ]; /** From cbd7d7a4c35817228e2cec4dff8b9574b56bb268 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:16:51 +0000 Subject: [PATCH 3/5] test(rest): re-point the registrar-list readers, the route-envelope table and the row-50 anchor at meta-item-write-routes.ts Sub-slice 13c, the readers commit. Every reader of the registrar list follows it to 16 files with its sum unchanged: the execctx census (62 sites, 141 mentions, sixteen forwarders), the draft census (8, the reset's state=draft now in the module), the query-number census (9), the canonical-query-ast floors (4), the docblock floors (62 over fifteen modules), the ui-view identity and tenancy measurements (60 / 78) and the dogfood tripwire (0 over sixteen). The dogfood blind-spot census stays 70 / 19 / 51 by 13a's rule. Its per-module control counts the helper's call spelling for a module with no direct site (meta-item-write-routes.ts: 2), and the whole-population registerPerItemRoute( control is re-anchored over the population rule's own haystack, where it stays 8. check-route-envelope declares the module at 0 / 0 (rest-server.ts keeps 7 / 3). system-context.mdx row 50 gains the module's anchor, since the reset's isSystem read moved with it. A new ADR anchor restores check-adr-anchors' decision-letter reading, which the move had narrowed from 1783 to 1775 with the gate green. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- content/docs/permissions/system-context.mdx | 2 +- .../qa/dogfood/test/authz-conformance.test.ts | 3 +- .../test/authz-probe-blind-spot.census.ts | 56 ++++++++++++++++--- .../rest/src/execctx-consumer-census.test.ts | 22 +++++++- .../src/meta-draft-read-door-census.test.ts | 6 +- .../rest-server-canonical-query-ast.test.ts | 12 +++- .../src/rest-server-docblock-position.test.ts | 13 ++++- .../rest-server-query-number-census.test.ts | 6 +- ...ui-view-route-identity.measurement.test.ts | 7 ++- .../ui-view-route-tenancy.measurement.test.ts | 8 ++- ..._rest__src__meta-item-write-routes.ts.json | 9 +++ scripts/check-route-envelope.mjs | 18 ++++++ 12 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 scripts/adr-anchors/packages__rest__src__meta-item-write-routes.ts.json 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/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/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..087f5c1cd71 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 are the nested `{ error: { code, message } }`, and its 400 + // the same) 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. */ From 3750ddecc382628d60b32e8c5fab17d2397013a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:18:42 +0000 Subject: [PATCH 4/5] chore(changeset): rest patch for the /meta per-item writes move Sub-slice 13c. The published index.js / index.cjs bytes change (the new module is bundled); the declarations and the export list do not. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/23050-s13c-meta-item-write-routes-move.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/23050-s13c-meta-item-write-routes-move.md 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. From 892a86538dcc0b491942657ec426e06c0e7d4a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:43:55 +0000 Subject: [PATCH 5/5] docs(scripts): say which door builds the 400 in the meta-item-write-routes MODULES note Only the item save builds a 400 body of its own; the reset's 400s come from the shared query guards. Comment only. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- scripts/check-route-envelope.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 087f5c1cd71..eb299219e9d 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -826,8 +826,8 @@ const MODULES = { // 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 are the nested `{ error: { code, message } }`, and its 400 - // the same) and `rest-server.ts` keeps its 7 / 3, so each sum is unchanged. Both + // 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': {