diff --git a/.changeset/23050-s13d-meta-item-history-routes-move.md b/.changeset/23050-s13d-meta-item-history-routes-move.md new file mode 100644 index 00000000000..b3155182593 --- /dev/null +++ b/.changeset/23050-s13d-meta-item-history-routes-move.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': patch +--- + +The `/meta` history family routes live in their own module, with no route, handler or answer changed + +Clause-②: no (a move: the five 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 five statements that register `GET {metaPath}/:type/:name/history` (the change log), `GET …/audit` (the audit trail), `POST …/publish` (the promotion of a pending draft), `POST …/rollback` (the restore of an earlier version) and `GET …/diff` (the comparison of two stored versions) moved, re-indented, to `packages/rest/src/meta-item-history-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 87f6ce64bad..1c28c86fa89 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/meta-item-write-routes.ts#registerMetaItemWriteRoutes`, `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/meta-collection-routes.ts#registerMetaCollectionRoutes`, `packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes`, `packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index 2277700ae1c..2d581013ba5 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -456,7 +456,7 @@ "packages/spec/src/api/protocol.zod.ts#SaveMetaItemResponse (SaveMetaItemResponse: state draft|active, version as If-Match/409 OCC token, 'staged only — not served until published')", "packages/runtime/src/domains/packages.ts#packages (POST /packages/:id/publish-drafts, ADR-0033/ADR-0045 visibility flip + its failure warning)", "packages/objectql/src/protocol-publish-package-drafts.test.ts (atomic namespace gate; #4463 author-time rules gate the draft→active transition)", - "packages/rest/src/rest-server.ts#toVersion (GET /meta/:type/:name/{history,audit,diff} + POST .../rollback: overlay-type history vs { events: [] } for non-overlay; rollback body { toVersion }, 400 on a missing/invalid toVersion; actor from resolveMetaWriteActor — the authenticated session identity ONLY, [#7941] — X-Actor is ignored outright, not read as a fallback either)", + "packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes (GET /meta/:type/:name/{history,audit,diff} + POST .../rollback: overlay-type history vs { events: [] } for non-overlay; rollback body { toVersion }, 400 on a missing/invalid toVersion; actor from resolveMetaWriteActor — the authenticated session identity ONLY, [#7941] — X-Actor is ignored outright, not read as a fallback either)", "packages/rest/src/meta-write-actor-identity.test.ts#Actor (unit pin: an explicit X-Actor does NOT outrank the authenticated identity, and is inert on the machine-write path too)", "packages/rest/src/rest-route-ledger.ts#getHistory (client bindings meta.getHistory / getAudit / diffItem / rollbackItem; the 'dispatcher /meta swallows /history as a compound name and 404s' note — routes hunter #12)", "docs/audits/2026-07-studio-package-create-ux-dogfood.md ('Unpublished draft' badge, 'Changes · n', 'one atomic release' toast)" diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 1a35f054130..e952963b09b 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1637,7 +1637,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#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. // [#23050 slice 13c] Length 15 → 16 (`meta-item-write-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); + // [#23050 slice 13d] Length 16 → 17 (`meta-item-history-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); 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 794c05718b2..104a7117f10 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -541,17 +541,17 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ '`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`, ' + - '`meta-item-read-routes.ts` and `meta-item-write-routes.ts`, ' + + '`meta-item-read-routes.ts`, `meta-item-write-routes.ts` and `meta-item-history-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 five `/meta` ' + + '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first six `/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`, `meta-item-read-routes.ts` and ' + - '`meta-item-write-routes.ts`), whose ' + + 'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`, `meta-item-read-routes.ts`, ' + + '`meta-item-write-routes.ts` and `meta-item-history-routes.ts`), whose ' + 'registrations the inner method ' + 'runs inside the umbrella\'s swap', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and @@ -891,10 +891,29 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // 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. + // [#23050 slice 13d] 70 / 19 / 51 UNCHANGED, `reachable` held by 13a's rule + // and every control keyed as 13c keyed them, with no rule change. The sixth + // `/meta` group, the history family (`/history`, `/audit`, `/publish`, + // `/rollback` and `/diff`, five helper-routed registration sites, REACHABLE + // through the same umbrella key), moved to `meta-item-history-routes.ts`, + // which joins the list (length 16 -> 17) and the named `/meta` subset + // (3 -> 4). Like the per-item writes' module it has NO direct site, so its + // own control counts `registerPerItemRoute(` (5), and the whole-population + // `registerPerItemRoute(` control stays 8 over this file plus every module on + // the list (1 here, the published read's, + 2 + 5); over this file alone it + // would now read 1. The subset's length and the module calls inside the + // inner method move together (both 4). Measured at the move commit, before + // the list learned the module: population 65, the five sites out of reach, + // and the `.call(` control at 4 against a subset of 3. + // `this.routeManager.register(` stays 63 over the seventeen files (the module + // spells it nowhere), `private register*Endpoints(` stays 16, + // `const registerPerItemRoute =` stays 1, `forwarder slice:` 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': 16, + 'registrar list length': 17, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -912,6 +931,8 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // [#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 13d] The same: the history family's module has no direct site. + 'meta-item-history-routes.ts: registerPerItemRoute(': 5, // [#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. diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 09de373a87d..1af55c26c9c 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -133,6 +133,13 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * [#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. + * + * [#23050 slice 13d] …and the sixth, the history family, to + * `meta-item-history-routes.ts`, with its five, each locally caught as before: + * the audit trail's, the publish's and the rollback's on their invocation + * lines, the change log's and the comparison's on their continuation lines — + * two of §7's and §8's four continuation sites, which those sections find there + * because they read every file here. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -150,7 +157,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, 13b and 13c), counted as that module's registrar — + * (#23050 slices 13a, 13b, 13c and 13d), 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 @@ -782,9 +789,22 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // 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); + // + // [#23050 slice 13d] 62 sites UNCHANGED / 141 → 145 mentions — the same MOVE. + // The sixth `/meta` group, the history family, left for + // `meta-item-history-routes.ts` with its five LOCALLY CAUGHT sites (the audit + // trail's, the publish's and the rollback's on their invocation lines, the + // change log's and the comparison's on their continuation lines, as before): + // 5 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 + 3 + 1 + 2 + 5 = 62 sites. + // The four new mentions are this slice's seam, the two spellings once (the + // fourth host literal the inner method builds, its forwarder, two; the host + // interface's member, two): `rest-server.ts` goes 55 → 52 (the five sites + // out, one forwarder's two mentions in). Seventeen forwarders now: + // (17 - 1) + 3 - 2, the module carrying one host literal that reads the seam, + // so neither map gains an entry. + expect(SITE_FILES).toHaveLength(17); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(141); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(145); 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 691386101db..063a7cc937a 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -276,7 +276,11 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // 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); + // [#23050 slice 13d] The history family moved to `meta-item-history-routes.ts` + // carrying no draft switch: none of its five doors reads one, so no ledger row + // changed: + // 8 = 0 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3 + 2 + 1 + 0, length 16 → 17. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/meta-item-history-routes.ts b/packages/rest/src/meta-item-history-routes.ts new file mode 100644 index 00000000000..46d6413e328 --- /dev/null +++ b/packages/rest/src/meta-item-history-routes.ts @@ -0,0 +1,997 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 13d] The `/meta` history family of `RestServer` — the item's + * change log (`GET {metaPath}/:type/:name/history`), its audit trail + * (`GET {metaPath}/:type/:name/audit`), the promotion of its pending draft + * (`POST {metaPath}/:type/:name/publish`), the restore of an earlier version + * (`POST {metaPath}/:type/:name/rollback`) and the comparison of two stored + * versions (`GET {metaPath}/:type/:name/diff`) — moved out of `rest-server.ts`. + * They were the sixth group of `RestServer#registerMetadataEndpointsInner`'s + * body, five 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 writes, and above the object FSM read. + * + * A MOVE, not a redesign. The function below is those five statements as they + * stood, re-indented one level: the same five 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 reset door above", "a few hundred lines up" or "this file", it + * still describes `rest-server.ts` and the inner method's order as they were, + * in which the item save and the reset (now in `meta-item-write-routes.ts`) sat + * immediately above these five doors. + * + * ## How the moved body reaches the class + * + * The shape slices 13a and 13c set for `/meta` (`meta-collection-routes.ts`' + * header carries the full reasoning, `meta-item-write-routes.ts`' the + * pass-through through the per-item registrar). 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 MetaItemHistoryRouteHost}) 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. All five 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 five routes still mount + * behind the anonymous-deny gate (#3963) and the type-level read and write + * admissions (#21087, #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 all five doors outside + * those gates. + * - `STORED_VERSION_DOOR_POLICY` goes BY VALUE, as it does for the per-item + * reads. It is the class's `private static readonly` field, assigned once, + * when the class is evaluated, from the constant of the same name in + * `meta-item-read-gate.ts`, and never reassigned, so the value the literal + * captures is the object every read of it returned. + * - No member goes as a getter, because the body reads no instance FIELD at + * request time. The caller, the protocol, the event doors' refusal, the + * walled refusal, the write actor, the masker, the current document, the read + * gate and the schema mask 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 six tokens that are not byte-identical + * + * The comparison door reached six of the class's PRIVATE STATICS as + * `RestServer.…`, once each: the type normalizer `metaTypeSingular`, the two + * type questions `gatesPerCaller` and `gatesOnItsOwnKey`, the side rebuild + * `diffSides`, the field `STORED_VERSION_DOOR_POLICY` and the emitter + * `diffEmittedFrom`. A private static is reachable by name only inside the + * class, so that spelling does not compile here, and making a member public, + * or moving it, changes the published declaration the move must keep + * byte-identical. So each is reached the way every other class member this + * body reads is reached: through the host, as `this.…` — the five methods as + * closures on {@link MetaItemHistoryRouteHost} that call the static with the + * same arguments, the field by value. None of the five methods reads anything + * but its arguments (`gatesPerCaller` still asks `gatesOnItsOwnKey` on the + * class), so the receiver is the whole difference, as it was for slices + * 13a–13c. Those six receivers are the only tokens of the body that changed. + * + * ## Module-level names + * + * Ten names declared in `rest-server.ts` stay there and are imported from it: + * the five doors' closed query sets (their tests import them from there), the + * declared numeric query read and its whole-number schema (the approvals, + * data-action and search modules import both from there too), the package + * binding, the absence answer (the class's event-door refusal sends it too), + * and the authoring-door refusal, which stays beside the predicate it asks + * (that predicate is a docs anchor) and gained `export` there rather than a + * second home. Every one of them is 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 two declared request + * schemas the change log and the audit trail parse their numbers through, the + * three declared request types and the member selector. 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 — and the +// metadata-plane FLS names the comparison door's mask reads. +import { + ObjectSchemaMaskEvaluationError, + metaWriteCapabilityVerdict, + type ObjectSchemaMaskPosture, +} from '@objectstack/metadata-core'; +// [#20061 / #20062 / #20139] The DECLARED request schemas the change log and the +// audit trail parse their numeric parameters through — see +// `readDeclaredQueryNumber` in `rest-server.ts`. +import { AuditMetaItemRequestSchema, HistoryMetaItemRequestSchema } from '@objectstack/spec/api'; +import type { AuditMetaItemRequest, HistoryMetaItemRequest, PublishMetaItemRequest } from '@objectstack/spec/api'; +import { handleRouteError, sendFieldVisibilityFault, thrownAnswerIsBareNotFound } from './error-response.js'; +import type { MetaRouteScope, TransportScopedMetaRequest } from './meta-collection-routes.js'; +import { META_ITEM_MEMBER_PARAM, metaMemberSelector } from './meta-item-read-gate.js'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { + META_ITEM_AUDIT_PARAMS, + META_ITEM_DIFF_PARAMS, + META_ITEM_HISTORY_PARAMS, + META_ITEM_PUBLISH_PARAMS, + META_ITEM_ROLLBACK_PARAMS, + UNDECLARED_WHOLE_NUMBER_PARAM, + metaItemPackageBinding, + readDeclaredQueryNumber, + refuseNonAuthoringCaller, + sendMetaItemAbsent, + type RestServer, +} from './rest-server.js'; + +/** + * The `RestServer` members {@link registerMetaItemHistoryRoutes} 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. The six statics are typed as the + * statics they are. The module header says why there is no `routeManager` + * member, why `STORED_VERSION_DOOR_POLICY` goes by value, why the body reaches + * the statics through the host, and why none of the members is a getter. + */ +export interface MetaItemHistoryRouteHost { + resolveExecCtx: RestServer['resolveExecCtx']; + resolveProtocol: RestServer['resolveProtocol']; + eventDoorRefusal: RestServer['eventDoorRefusal']; + walledCatalogRefusal: RestServer['walledCatalogRefusal']; + resolveMetaWriteActor: RestServer['resolveMetaWriteActor']; + metaTypeSingular: (typeof RestServer)['metaTypeSingular']; + resolveObjectMasker: RestServer['resolveObjectMasker']; + gatesPerCaller: (typeof RestServer)['gatesPerCaller']; + fetchCurrentMetaDocument: RestServer['fetchCurrentMetaDocument']; + gatesOnItsOwnKey: (typeof RestServer)['gatesOnItsOwnKey']; + diffSides: (typeof RestServer)['diffSides']; + metaItemReadGate: RestServer['metaItemReadGate']; + readonly STORED_VERSION_DOOR_POLICY: (typeof RestServer)['STORED_VERSION_DOOR_POLICY']; + diffEmittedFrom: (typeof RestServer)['diffEmittedFrom']; + maskObjectDocument: RestServer['maskObjectDocument']; +} + +/** + * Register the `/meta` history family — the change log, the audit trail, the + * publish, the rollback and the version comparison, the sixth 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 registerMetaItemHistoryRoutes(this: MetaItemHistoryRouteHost, scope: MetaRouteScope): void { + const { metaPath, isScoped, registerPerItemRoute } = scope; + + // GET /meta/:type/:name/history — durable change-log for one item. + // Returns the sys_metadata_history events that the Studio "History" + // tab renders as an audit timeline. Overlay-only metadata types + // (view/dashboard/report/email_template) return real events; + // non-overlay types return `{ events: [] }` (the legacy raw-engine + // path does not record history). + registerPerItemRoute({ + method: 'GET', + path: `${metaPath}/:type/:name/history`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#20378] AN AUTHORING DOOR — ruling 5865708652 (letter B). + // `sys_metadata_history` is the authoring commit log + // (ADR-0067), and a DRAFT save appends a row to it exactly + // as an active save does, with nothing on the row to tell + // the two apart — so this log, served to a caller who may + // not read pending drafts, lists unpublished authoring work + // (ADR-0106 D4: 「draft/preview reads are admin-gated + // upstream」). The caller is asked + // {@link mayReadAuthoringTrail} FIRST, and one it does not + // admit is refused in `GET /meta/_drafts`'s shape + // (403 `FORBIDDEN`, the same nested envelope): before the + // protocol is resolved (no 501-vs-200 probe), before the + // query is parsed, before any item or event is read. The + // answer is therefore one and the same for an item that + // exists, one that does not and a draft-only one — the door + // is no existence oracle — and it carries no item or + // version detail. The message names THIS door, never + // drafts: a refusal worded about drafts would read as + // "this item has one". Whoever it admits reads exactly what + // they read before, the per-caller refusal below included. + // [#23546] `setup.access` alone no longer admits it: under a + // wall it handed every organization administrator the + // environment-wide trail (see AUTHORING_TRAIL_READ_CAPABILITIES). + // + // Ruling 5856774816 (#20156) item 2 is narrowed for this + // door and `/diff` only: `/layers` and `?layers=true` read + // the active row and keep the pruned plain-read answer. + // + // `historyCtx` is this door's one caller resolution. The refusal is + // {@link refuseNonAuthoringCaller}, shared with `/diff` and + // `/audit` (#20441) so the three cannot drift apart. + const historyCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + if (refuseNonAuthoringCaller(historyCtx, res, 'Reading a metadata item\'s version history')) return; + const p = await this.resolveProtocol(environmentId, req); + // The cast came off when `MetadataProtocol` declared + // `historyMetaItem` (#12005 — the commit cccbe51bf pattern, exactly + // as #11678 de-cast the audit twin below). The member is + // declared OPTIONAL, so this truthiness guard is not just + // feature detection: it is what narrows the member to + // callable at the call site. Same guard semantics as + // before, minus the cast. + if (!p.historyMetaItem) { + res.status(501).json({ + error: 'History query not supported by protocol implementation', + }); + return; + } + // [#6877] `Number(['1','2'])` is `NaN`, which the + // `Number.isFinite` spreads below drop — so a repeated + // `?limit=` silently returned the UNLIMITED history instead + // of the page the caller asked for. + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_HISTORY_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['sinceSeq', 'limit', META_ITEM_MEMBER_PARAM])) return; + // [#20139] `sinceSeq` is parsed through its DECLARATION + // (`HistoryMetaItemRequestSchema.sinceSeq`, `z.number().optional()`), + // the way `limit` is just below: `?sinceSeq=abc` used to be `NaN`, + // dropped by the spread below, and answered with the log read from + // the START; `?sinceSeq=` became `Number('')` = 0, a cursor the + // repository applies (`event_seq <= 0` rows skipped) that the + // caller never sent. Both are refused now; any finite number is + // still forwarded exactly as before. + const sinceSeq = readDeclaredQueryNumber(req.query, 'sinceSeq', + HistoryMetaItemRequestSchema.shape.sinceSeq, { emptyIsAbsent: false }); + // [#20062] `limit` is parsed through its DECLARATION + // (`HistoryMetaItemRequestSchema.limit`, `z.number().optional()`), + // not coerced: `?limit=abc` used to be `NaN`, dropped by the + // spread below, and answered with the WHOLE change log; `?limit=` + // became `Number('')` = 0, a zero-event answer. Both are refused + // now. The declaration carries no `int()` and no bounds, so any + // finite number is still forwarded exactly as before. + const limit = readDeclaredQueryNumber(req.query, 'limit', + HistoryMetaItemRequestSchema.shape.limit, { emptyIsAbsent: false }); + // [#20156] The plain read's refusal, where it refuses this + // caller the item whole: a change log of a doc the caller + // may not read, or of an app they may not open, discloses + // who changed it, when and why — and an unpublished app's + // log contradicts ADR-0045 §3's "externally unobservable". + // See {@link eventDoorRefusal}. + { + const refusal = await this.eventDoorRefusal(environmentId, req, p); + if (refusal) { + refusal(res); + return; + } + } + // [#13406 · ADR-0131 D6] The ENV partition, stated by + // omission: `sys_metadata_history` is filtered by strict + // equality on `organization_id`, and since the + // per-organization overlay axis retired every write logs + // there and `historyMetaItem` reads no other partition. A + // legacy organization-scoped log is not served; its + // promotion is ADR-0131 C7's. + // + // Typed through `TransportScopedMetaRequest` like the + // reset door above, NOT as a plain `HistoryMetaItemRequest` + // like the audit door below: this door still spreads the + // transport-level `environmentId` (long-standing wire + // shape, deliberately unchanged — the ruling commit 2a29caa53 landed keeps + // it out of the protocol schema, and the implementation + // never reads it), so the wrapper is what layers that one + // member on. Every OTHER key is compiled against the spec + // contract — an undeclared member here is now TS2353 + // instead of a payload member no contract has ever seen. + const historyRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: req.params.name, + ...(environmentId ? { environmentId } : {}), + // Both already finite or absent — the declared parses above + // refuse anything else, so no `Number.isFinite` drop is left here. + ...(sinceSeq !== undefined ? { sinceSeq } : {}), + ...(limit !== undefined ? { limit } : {}), + // [#22877] ONE member's events of the bundle's lineage. + ...metaMemberSelector(req.query), + }; + const result = await p.historyMetaItem(historyRequest); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List durable history events for a metadata item', + tags: ['metadata'], + }, + }); + + // GET /meta/:type/:name/audit — ADR-0010 §3.6 / Phase 4.1. + // Compliance trail for the metadata-protection layer: returns + // recent sys_metadata_audit rows (save/publish/rollback/delete/ + // reset attempts, both allowed and denied) so Studio's "审计 + // 日志 / Audit log" tab can show who tried what and whether + // a lock blocked it. Empty array on environments where the + // table is not yet provisioned. An AUTHORING door (#20441): a + // caller without an authoring capability is refused 403. + registerPerItemRoute({ + method: 'GET', + path: `${metaPath}/:type/:name/audit`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#20441] AN AUTHORING DOOR — ruling 5865708652 (letter B), + // carried to this door by triage's grade 5871509797. + // `saveMetaItem` appends a success row to + // `sys_metadata_audit` for EVERY save, a draft save + // included, and `auditMetaItem` serves its `note: 'draft'`, + // its actor and its time. So this trail, served to a caller + // who may not read pending drafts, disclosed that an item + // had unpublished authoring work, who saved it and when — + // and for an item with nothing published, that it exists at + // all, where the plain read answers `404` (ADR-0045 §3). + // ADR-0106 D4: 「draft/preview reads are admin-gated + // upstream」. + // + // The trail has no published-only answer to fall back to: + // withholding only the draft-save rows would hand a member + // a pruned log that reads as a true, complete one, the + // shape the ruling measured wrong. So the caller is asked + // {@link mayReadAuthoringTrail} FIRST and refused by + // {@link refuseNonAuthoringCaller} — the refusal `/history` + // and `/diff` give, and `GET /meta/_drafts`'s shape — before + // the protocol is resolved (no 501-vs-200 probe), before the + // query is parsed, and before any item or event is read. + // Whoever it admits reads exactly what they read before, + // the per-caller refusal below included. [#23546] + // `setup.access` alone no longer admits it. + // + // `auditCtx` is this door's one caller resolution. + const auditCtx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + if (refuseNonAuthoringCaller(auditCtx, res, 'Reading a metadata item\'s audit trail')) return; + const p = await this.resolveProtocol(environmentId, req); + if (typeof p.auditMetaItem !== 'function') { + // [#9426 / ADR-0110 D3] A MISS and a FAULT are different + // facts, and this branch is the second one: the resolved + // protocol cannot read an audit trail AT ALL, so the + // question was never asked. Answering `{ events: [] }` + // reported it as the first — "the trail WAS read and this + // item has no entries" — on a COMPLIANCE surface, where + // Studio's 审计日志 / Audit log tab renders the empty case + // as *nobody touched this item*. That is precisely the + // claim a compliance reader must not be given on false + // pretenses. + // + // ⚠️ This is NOT the unprovisioned-table condition this + // route's header comment describes. That one lives one + // layer down, inside + // `ObjectStackProtocolImplementation.auditMetaItem`, + // which catches a failed read and returns `{ events: [] }` + // after a `console.warn` — a path that requires the method + // to EXIST and to be CALLED. The two are separate frames + // in separate packages, and this branch returns BEFORE the + // call, so refusing here leaves the unprovisioned-table + // answer exactly as it was. + // + // Refusing HERE rather than asserting at assembly is + // deliberate, and is the reasoning PR #9425 landed one + // route over. `auditMetaItem` is a declared OPTIONAL + // member of `MetadataProtocol` (the commit-cccbe51bf-pattern + // catch-up that retired this door's `(p as any)` casts; + // it was an undeclared ADR-0076 D9 server-only + // extension before that). A host without the verb is + // therefore a CONFORMING deployment that lands here + // with no type error, and a boot-time assertion would + // promote a declared-optional member into a required + // one — a `packages/spec` contract decision, not a + // route one. The guard is also what narrows the member + // to callable below. + // + // Envelope per #7035: the ADR-0112 NESTED + // `{ error: { code, message } }` the sibling `/meta` 501 + // refusals converged on — never the bare-string or + // sibling-`code` dialects, which make `body.error.code` + // read `undefined` and which `check:route-envelope` + // counts, shrink-only, on this file. + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'protocol.auditMetaItem() is not available in this kernel', + }, + }); + return; + } + // [#6877] Same `Number(...)` → `NaN` → dropped-limit shape as + // the history twin above. + // [#23072] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_AUDIT_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['limit'])) return; + // [#20139] Parsed through its DECLARATION + // (`AuditMetaItemRequestSchema.limit`, `z.number().optional()`), + // the history twin's reading: `?limit=abc` used to be `NaN`, + // dropped by the spread below, and answered with the producer's + // default 100 events; `?limit=` became `Number('')` = 0, which the + // implementation clamps to ONE event. Both are refused now; any + // finite number is still forwarded, and the implementation's own + // `[1, 500]` clamp is unchanged. + const limit = readDeclaredQueryNumber(req.query, 'limit', + AuditMetaItemRequestSchema.shape.limit, { emptyIsAbsent: false }); + // [#20156] The history twin's gate, for the same reason: the + // audit trail of an item the plain read refuses this caller + // is refused with the plain read's answer. See + // {@link eventDoorRefusal}. + { + const refusal = await this.eventDoorRefusal(environmentId, req, p); + if (refusal) { + refusal(res); + return; + } + } + // [#8747 · ADR-0131 D6] SCOPE THE READ: the + // environment-wide rows only (`organization_id: null`), + // never every tenant's. Since the per-organization overlay + // axis retired every `/meta` write audits there; a legacy + // organization-scoped audit row is not served. + // + // `environmentId` is not in this payload: `auditMetaItem`'s + // request type never declared it. Environment scoping comes + // from WHICH protocol `resolveProtocol` hands back. + // (#20441), not a second resolution. + // + // The `(p as any)` casts this door carried came off when + // `MetadataProtocol` declared `auditMetaItem` (the commit cccbe51bf + // pattern, same as the publish door below): the literal is + // now compiled against the spec contract, so an undeclared + // key here is a compile error (TS2353) instead of a payload + // member no contract has ever seen. Plain + // `AuditMetaItemRequest` rather than the + // `TransportScopedMetaRequest` wrapper on purpose: this + // door stopped sending `environmentId` when #8747 scoped + // the read (see the note above), so there is no + // transport-level member left to layer on. + const auditRequest: AuditMetaItemRequest = { + type: req.params.type, + name: req.params.name, + // Already finite or absent — the declared parse above + // refuses anything else. + ...(limit !== undefined ? { limit } : {}), + }; + const result = await p.auditMetaItem(auditRequest); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List protection-audit events for a metadata item', + tags: ['metadata'], + }, + }); + + // POST /meta/:type/:name/publish — promote the pending draft + // overlay to live. 404 `NO_DRAFT` when nothing to publish. + registerPerItemRoute({ + method: 'POST', + path: `${metaPath}/:type/:name/publish`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [commit b5378550e] Authoring capability gate — the SAME four lines the + // `PUT` / `DELETE` / `_migrate-stored` doors carry, deliberately + // not a second way of demanding the same capability. + // + // Promotion is authoring. `promoteDraftForPublish` flips the + // `sys_metadata` row `state: 'draft'` → `'active'`, and + // ADR-0027 (E)(5) defines sealing a publish as exactly that + // flip — so this door decides which body is LIVE. Measured + // before the gate: an authenticated principal holding no + // authoring capability at all reached `publishMetaItem` and + // got 200, i.e. it could take a draft somebody else authored + // and make it the live overlay. The `/meta` umbrella already + // refused ANONYMOUS here (401, `registerMetadataEndpoints`), + // so this closes the authenticated-but-uncapable cohort — the + // one the four sibling doors close and these two did not. + // + // ⛔ Not a publish-specific capability: `manage_metadata` is + // ADR-0066 D1's authoring capability and the same one the save + // door demands, so no caller who can author a draft is newly + // refused (measured: the save→publish loop's own first step is + // already gated on it). Splitting author from publisher would + // need a DIFFERENT declared capability and is a product call. + // + // Gate FIRST — before the protocol is resolved — so an + // unauthorized caller cannot use the 501-vs-200 answer to probe + // which kernels implement publishing, and so nothing is promoted + // before the refusal. `isSystem` bypasses, matching every other + // capability gate on the platform. + // + // [#12702 · ADR-0131 D6] Same shared verdict as the save + // door, because promotion is the second half of the + // save→publish loop. + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + operation: 'publish', + }); + 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.publishMetaItem) { + res.status(501).json({ + error: 'Publish operation not supported by protocol implementation', + }); + 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); + const body = (req.body && typeof req.body === 'object') ? req.body : {}; + const message = typeof body.message === 'string' ? body.message : undefined; + + // [commit 9e04c3e35] Software-package binding for the PROMOTION — + // `?package=`, deliberately the SAME wire spelling and the + // same normalisation the `PUT` door states it with a few + // hundred lines up, not a second dialect for one value. + // + // Why this door needed it at all. #9612 taught the runtime + // publish gate to narrow `objects` to the written item's + // package closure, but only when the caller can NAME the + // package. Three write doors reach that gate; `saveMetaItem` + // and `publishPackageDrafts` both name one, and this one — + // the single-item draft→active promotion — named nothing. So + // every HTTP-driven promotion, which is precisely Studio's + // designer save→publish loop on every edit, handed the gate + // the whole tenant. The protocol half was already built and + // waiting: `promoteDraftForPublish` declares + // `packageId?: string | null` and threads it into both the + // gate and `repo.promoteDraft`. Only the caller was mute. + // + // ⚠️ THE SHARP EDGE, and the reason this is a conditional + // spread rather than a plain key. `promoteDraftForPublish` + // forwards to `repo.promoteDraft` with + // `...('packageId' in request ? { packageId: request.packageId ?? null } : {})` + // — it branches on the KEY BEING PRESENT, not on the value, + // because `null` is a meaningful scope there (pin the lookup + // to the UNBOUND row) while an absent key means "match any + // package", the historical resolution. Writing + // `packageId: packageId` here would therefore put a + // present-and-`undefined` key on every publish that names no + // package, coercing it to `null` downstream and pinning the + // lookup to unbound rows — so a draft authored under a package + // would stop being found and the door would answer `no_draft`. + // That is a silent outage on the untouched path, produced by a + // change that reads like it only ADDS an option. The key must + // be ABSENT when the caller states nothing. + // + // ⛔ Not read off the draft row either — see + // `promoteDraftForPublish`'s own warning: `rowToItem` projects + // `sys_metadata` into a `MetadataItem`, which carries no + // package id, so a read from there is `undefined` on every + // path — narrowing that never fires while looking like it + // does. Widening `MetadataItem` is a `packages/spec` contract + // change and stays filed rather than taken here. + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_PUBLISH_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['package', META_ITEM_MEMBER_PARAM])) return; + const packageId = metaItemPackageBinding(req.query?.package); + + // [ADR-0131 D6] No organization, as on the `PUT` twin: a + // draft is saved environment-wide, so the promotion looks in + // the environment partition. + // [#11145] The `(p as any)` cast this call carried came off + // when `MetadataProtocol` declared `publishMetaItem` (commit cccbe51bf, + // maintainer ruling 2026-08-22, option B). What the cast was + // load-bearing FOR is recorded because it is counter-intuitive + // and was measured, not assumed: deleting it while the member + // was undeclared answered + // `TS2339: Property 'publishMetaItem' does not exist on + // type 'RestProtocol'` + // — NOT a `TS2353` about an unknown key. The cast was feature + // detection for an ADR-0076 D9 server-only extension, so only + // declaring the member could retire it; widening the + // implementation's own request type in + // `@objectstack/metadata-protocol` (which this package + // deliberately does not depend on) never could, and commit 490879ad0 + // measured exactly that. + // + // What replaces it is the point of the exercise, not a + // side effect: the literal below is compiled against the spec + // contract through the commit 2a29caa53 `TransportScopedMetaRequest` + // wrapper, so an undeclared key here is a COMPILE ERROR + // (`TS2353`, measured) instead of a payload member no contract + // has ever seen. `environmentId` is the transport-level + // routing key that wrapper layers on — ⛔ never a protocol + // key; a key that belongs on the request belongs in the spec + // schema. + // + // The 501 feature-detection guard above STAYS. The member is + // declared OPTIONAL (ADR-0076 D9 promotion is additive to a + // shipped contract, and a kernel may not implement the + // promotion door at all), and that same guard is what narrows + // it to callable here. + const publishRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: req.params.name, + ...(environmentId ? { environmentId } : {}), + ...(actor ? { actor } : {}), + ...(message ? { message } : {}), + ...(packageId ? { packageId } : {}), + // [#22877] Publishes ONE member's draft of a bundled type. + ...metaMemberSelector(req.query), + }; + const result = await p.publishMetaItem(publishRequest); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Publish the pending draft overlay (promotes draft → active)', + tags: ['metadata'], + }, + }); + + // POST /meta/:type/:name/rollback — restore a historical version + // as the new live overlay. Body: { toVersion: , message? }. + registerPerItemRoute({ + method: 'POST', + path: `${metaPath}/:type/:name/rollback`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [commit b5378550e] Authoring capability gate — the same four lines as + // the sibling doors, and the sharper half of this pair. + // `rollbackMetaItem` restores a CALLER-SUPPLIED `toVersion` as + // the new live row, so without this gate it is a mechanism for + // reverting security hardening: a permission set as it stood + // before it was tightened, a validation rule from before it + // existed, a layout from before field-level security. Measured + // before the gate: an authenticated principal holding no + // authoring capability reached `rollbackMetaItem` and got 200. + // + // It is also the door with the least behind it. Publish at + // least re-runs `assertRuntimeAuthoringRules` on the promoted + // draft (#4463 D1); rollback runs no content gate at all — its + // only refusals are TYPE-level (`isOverlayAllowed` → + // `NOT_OVERRIDABLE`) and the ADR-0010 lock. Neither reads the + // caller, so nothing downstream was ever answering "may this + // principal press this button". + // + // Gate FIRST — before the protocol is resolved — so 403-vs-501 + // leaks no kernel capability and nothing is restored before the + // refusal. `isSystem` bypasses, as everywhere else. + // + // [#12702 · ADR-0131 D6] Same shared verdict as the + // sibling doors. + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + operation: 'rollback', + }); + 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 as any).rollbackMetaItem) { + res.status(501).json({ + error: 'Rollback operation not supported by protocol implementation', + }); + return; + } + // [#6877] The `Number(...)` below already refuses an array + // — but as `INVALID_REQUEST` "'toVersion' (positive integer) + // is required", which tells a caller who supplied two valid + // integers that they supplied none. Refusing the multiplicity + // by name says what actually happened. + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_ROLLBACK_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['toVersion', META_ITEM_MEMBER_PARAM])) return; + const body = (req.body && typeof req.body === 'object') ? req.body : {}; + const toVersionRaw = body.toVersion ?? body.version ?? req.query?.toVersion; + const toVersion = Number(toVersionRaw); + if (!Number.isFinite(toVersion) || toVersion < 1) { + res.status(400).json({ + error: `'toVersion' (positive integer) is required`, + code: 'INVALID_REQUEST', + }); + 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); + const message = typeof body.message === 'string' ? body.message : undefined; + // [ADR-0131 D6] No organization, as on the `PUT` twin: the + // rollback restores a version of the environment-wide row. + const result = await (p as any).rollbackMetaItem({ + type: req.params.type, + name: req.params.name, + toVersion, + ...(environmentId ? { environmentId } : {}), + ...(actor ? { actor } : {}), + ...(message ? { message } : {}), + // [#22877] Reverts ONE member of a bundled type. + ...metaMemberSelector(req.query), + }); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Restore the body at the given history version as the new live row', + tags: ['metadata'], + }, + }); + + // GET /meta/:type/:name/diff?from=N&to=M — structural diff + // between two historical versions (or one version vs current). + registerPerItemRoute({ + method: 'GET', + path: `${metaPath}/:type/:name/diff`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#20378] AN AUTHORING DOOR — ruling 5865708652 (letter B), + // the `/history` twin's gate on the same log. A diff reads + // stored versions out of `sys_metadata_history`, where a + // DRAFT save is recorded exactly as an active save, so + // `?from=`/`?to=` naming a draft save — or the default + // range, once a draft is pending — served unpublished + // content to a caller who may not read drafts. The version + // store cannot tell a draft version from a published one, + // so there is no exact published-only answer to fall back + // to: the `/meta/_drafts` shape, not the draft switches' + // "answer as if absent". The caller is asked + // {@link mayReadAuthoringTrail} FIRST, and one it does not + // admit is refused in `GET /meta/_drafts`'s shape + // (403 `FORBIDDEN`, the same nested envelope): before the + // protocol is resolved, before the query is parsed, before + // the mask posture, the current document or any version is + // read — one answer for an item that exists, one that does + // not and a draft-only one, with no item or version detail, + // so the door is no existence oracle. The message names THIS + // door, never drafts. Whoever it admits reads exactly what + // they read before: every per-caller gate below, and ruling + // 5856774816's author exemption, still apply to them. + // [#23546] `setup.access` alone no longer admits it. + // + // Ruling 5856774816 (#20156) item 2 is narrowed for this + // door and `/history` only: `/layers` and `?layers=true` + // read the active row and keep the pruned plain-read answer. + // + // `diffCtx` is this door's one caller resolution. The refusal is + // {@link refuseNonAuthoringCaller}, shared with `/history` + // and `/audit` (#20441) so the three cannot drift apart. + const diffCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + if (refuseNonAuthoringCaller(diffCtx, res, 'Comparing a metadata item\'s stored versions')) return; + const p = await this.resolveProtocol(environmentId, req); + if (!(p as any).diffMetaItem) { + res.status(501).json({ + error: 'Diff operation not supported by protocol implementation', + }); + return; + } + // [#6877] A repeated `?from=` became `NaN`, the spreads below + // omitted the bound, and the door quietly diffed a different + // pair of versions and answered 200. + // [#23072] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_DIFF_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['from', 'fromVersion', 'to', 'toVersion'])) return; + // [#20139] The same drop for a SINGLE unreadable value: the + // `parseV` helper that stood here answered `undefined` for + // `?from=abc` (and `Infinity`), so `diffMetaItem` substituted + // "the version before `to`" — or, for `?to=abc`, the CURRENT + // body — and the door answered 200 with a comparison nobody + // asked for; `?from=1.5` was forwarded and diffed against a + // version that cannot exist. No request schema is declared for + // this door, so a version reads as a whole number. The name the + // caller used is the one read (and the one a refusal names): + // `from` / `to` win over `fromVersion` / `toVersion` exactly as + // the old `??` had it, and an empty value stays absent, as + // `parseV('')` already answered. + const fromParam = req.query?.from != null ? 'from' : 'fromVersion'; + const toParam = req.query?.to != null ? 'to' : 'toVersion'; + const fromVersion = readDeclaredQueryNumber(req.query, fromParam, + UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); + const toVersion = readDeclaredQueryNumber(req.query, toParam, + UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); + // [#20156] A diff discloses BOTH versions' values — for a + // doc its `content`, for an app its `navigation`, for an + // object its `fields` — so it owes the plain read's + // per-caller answer for each side. Resolved in the plain + // read's order: the ADR-0106 mask posture BEFORE any fetch + // (D2/D3), then the current document, which a type the gate + // judges here must have — the plain read answers its + // absence, and there is no gate input to judge the versions + // against. + const diffMetaType = this.metaTypeSingular(req.params.type); + let diffMaskPosture: ObjectSchemaMaskPosture; + try { + diffMaskPosture = await (await this.resolveObjectMasker(environmentId, req, diffMetaType))(req.params.name); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, req.params.name); + return; + } + throw maskError; + } + const diffGated = this.gatesPerCaller(diffMetaType); + let diffCurrent: any; + try { + diffCurrent = diffGated + ? await this.fetchCurrentMetaDocument(req, p) + : undefined; + } catch (currentError: any) { + // [#7557, #24210] A withheld item (a disabled package's) + // is answered in the plain read's body, as + // {@link eventDoorRefusal} answers it. + if (thrownAnswerIsBareNotFound(currentError)) { + sendMetaItemAbsent(res); + return; + } + throw currentError; + } + // [#22639] A list view's and a dashboard's per-caller gate + // is the item's OWN `requiredPermissions`. With no + // published row there is no current document to judge, so + // — as the event doors answer for every gated type — the + // diff is served as before, and a never-published view or + // dashboard keeps its builder's diff whether or not it + // names a capability: no key, no change. Its draft is + // judged where it is served whole (`?state=draft`, + // `/layers`). + if (diffGated && diffCurrent == null && !this.gatesOnItsOwnKey(diffMetaType)) { + sendMetaItemAbsent(res); + return; + } + // [#13406 · ADR-0131 D6] The ENV partition, stated by + // omission, as on the history twin: `diffMetaItem` reads + // the environment's `sys_metadata_history` rows, and + // `version` is a per-(org, type, name) lineage counter, so + // the environment lineage is the one every `/meta` write + // now extends. + const result = await (p as any).diffMetaItem({ + type: req.params.type, + name: req.params.name, + ...(environmentId ? { environmentId } : {}), + ...(fromVersion !== undefined ? { fromVersion } : {}), + ...(toVersion !== undefined ? { toVersion } : {}), + }); + // [#20156] Judge the current document and both sides, with + // ONE judge (one books read, one holdings resolution): a + // side the caller may not read is not served beside one + // they may. `per-caller`, the stored-version doors' policy — + // see `MetaReadGatePolicy`. + // + // [#20156] And each side is EMITTED as the gate serves it: + // ruling 5856774816 — a caller who may write an app reads + // both sides whole, and any other caller who may open it + // reads each side pruned, exactly as the plain read prunes + // it ({@link diffEmittedFrom}). + let served: any = result; + if (diffGated && diffCurrent != null) { + const { from, to } = this.diffSides(diffCurrent, result); + const judge = this.metaItemReadGate( + environmentId, req, p, diffMetaType, req.params.name, [diffCurrent, from, to], + this.STORED_VERSION_DOOR_POLICY, + ); + const sides: any[] = []; + for (const side of [diffCurrent, from, to]) { + const verdict = await judge(side); + if (verdict.kind === 'refuse') { + verdict.send(res); + return; + } + sides.push(verdict.document); + } + served = this.diffEmittedFrom(result, sides[1], sides[2]); + } + // [ADR-0106 D5(4)] The object mask on the one key it + // projects, `fields`, in every bucket and on both sides — + // the redaction precedent one frame down (the stored bodies + // are compared raw, the EMITTED values are the projected + // ones), so a field the caller may not read is not served + // as a diff value either. + if (diffMaskPosture.kind === 'project' && served && typeof served === 'object') { + let faulted = false; + const maskFields = (value: unknown): unknown => { + if (faulted || !value || typeof value !== 'object' || Array.isArray(value)) return value; + const masked = this.maskObjectDocument(res, diffMaskPosture, req.params.name, { fields: value }); + if (!masked) { faulted = true; return value; } + return (masked.document as { fields: unknown }).fields; + }; + const onFields = (e: any, keys: readonly string[]) => + (e && e.path === 'fields' + ? { ...e, ...Object.fromEntries(keys.map((k) => [k, maskFields(e[k])])) } + : e); + const bucket = (list: unknown, keys: readonly string[]) => + (Array.isArray(list) ? list.map((e) => onFields(e, keys)) : list); + served = { + ...(served as Record), + added: bucket((served as any).added, ['value']), + removed: bucket((served as any).removed, ['value']), + changed: bucket((served as any).changed, ['from', 'to']), + }; + // `maskObjectDocument` already answered the ADR-0106 D6 + // 5xx: a side projected to NO fields is never served. + if (faulted) return; + } else if (diffMaskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + } + res.json(served); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Diff two metadata versions (from/to query params; to defaults to the active version, from to the nearest earlier version whose body differs)', + 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 df0bec0b642..4af1a4bf9c4 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -189,6 +189,10 @@ interface CensusEntry { * 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. + * + * [#23050 slice 13d] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0, + * unchanged: the history family (`meta-item-history-routes.ts`) builds no + * `query:` slot either, so the moved module's floor is 0. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -207,6 +211,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // carry no slot). // [#23050 slice 13c] Length 15 → 16, the sum unchanged (the per-item writes // carry no slot). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16); + // [#23050 slice 13d] Length 16 → 17, the sum unchanged (the history family + // carries no slot). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); 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 bf828a4b8df..cab97959dee 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -214,6 +214,13 @@ describe('rest-server.ts docblock position', () => { * 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). + * + * [#23050 slice 13d] `meta-item-history-routes.ts` carries three, as the + * per-item writes' module does: the module header, the host interface's block + * and the function's. The moved history family held no TSDoc block of its own + * and took no module-level helper with it (the authoring-door refusal it calls + * stays, exported, and only gained a paragraph in its own block), so + * `rest-server.ts` keeps every block it had (161). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -231,6 +238,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -273,9 +281,11 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // fourteen, length 14 → 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); + // [#23050 slice 13d] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 + 5 + 3 + 3 = 65 + // over sixteen, length 16 → 17. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); 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(62); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(65); }); }); 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 c3370df72af..73a5780926f 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -351,7 +351,12 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // 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); + // [#23050 slice 13d] The history family (`meta-item-history-routes.ts`) moved + // with the one coercion of the query `rest-server.ts` held inside the `/meta` + // block: the rollback's `Number(toVersionRaw)`, an exemption row. Its key names + // the route, never the file, so no ledger row changed: + // 9 = 7 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1, length 16 → 17. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); 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 752c6d008f0..3f740db312e 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -62,6 +62,9 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#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', + // [#23050 slice 13d] The sixth `/meta` group of `registerMetadataEndpointsInner`' + // body, the history family — see {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-item-history-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -94,6 +97,10 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // 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', + // [#23050 slice 13d] The history family: `/history`, `/audit`, `/publish`, + // `/rollback` and `/diff`. Like the per-item writes, all five routes go through the + // inner method's per-item registrar, handed over in its scope. + 'meta-item-history-routes.ts', ]; /** diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d908f0c4a0f..ad320586540 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -124,14 +124,12 @@ import { } from '@objectstack/spec/api'; import { z } from 'zod'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; -// [#20061 / #20062 / #20139] The DECLARED request schemas two of the three +// [#20061 / #20062 / #20139] The DECLARED request schemas the three // query-reading doors parse their numeric parameters through — see -// `readDeclaredQueryNumber` below. The third, `ListImportJobsRequestSchema`, -// moved with its door to `data-action-routes.ts` (#23050 slice 1). -import { - HistoryMetaItemRequestSchema, - AuditMetaItemRequestSchema, -} from '@objectstack/spec/api'; +// `readDeclaredQueryNumber` below — all moved with their doors: the change log's +// and the audit trail's (`HistoryMetaItemRequestSchema`, `AuditMetaItemRequestSchema`) +// to `meta-item-history-routes.ts` (#23050 slice 13d), and `ListImportJobsRequestSchema` +// to `data-action-routes.ts` (#23050 slice 1). // [#23183] The app item read's package-id key, spelled once in the spec — see // `fetchCurrentMetaDocument`, whose doors stay by-name. import { isAppPackageIdKey } from '@objectstack/spec/api'; @@ -144,9 +142,9 @@ import type { // `GetMetaItemCachedRequest`, the item read's cached-path request, moved with that // body to `meta-item-read-routes.ts` (#23050 slice 13b). GetMetaItemLayeredRequest, - PublishMetaItemRequest, - AuditMetaItemRequest, - HistoryMetaItemRequest, + // `PublishMetaItemRequest`, `AuditMetaItemRequest` and `HistoryMetaItemRequest`, the + // publish's, the audit trail's and the change log's declared requests, moved with those + // doors to `meta-item-history-routes.ts` (#23050 slice 13d). // `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). @@ -215,7 +213,9 @@ import { // `meta-item-write-routes.ts` (#23050 slice 13c); the re-export below still names it. META_ITEM_MEMBER_PARAM, META_ITEM_PUBLISHED_PARAMS, - metaMemberSelector, + // `metaMemberSelector`, the member selector's one reading, is read only by the history + // family's `?member=` doors, which moved to `meta-item-history-routes.ts` (#23050 + // slice 13d). } from './meta-item-read-gate.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). @@ -329,6 +329,9 @@ import { registerMetaItemReadRoutes, type MetaItemReadRouteHost } from './meta-i // [#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'; +// [#23050 slice 13d] The `/meta` history family (the change log, the audit trail, the +// publish, the rollback and the version comparison) moved to its own module — see its header. +import { registerMetaItemHistoryRoutes, type MetaItemHistoryRouteHost } from './meta-item-history-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. @@ -371,7 +374,8 @@ import { sendFieldVisibilityFault, handleRouteError, // `thrownAnswerIsBareNotFound`: the item read's catch moved to `meta-item-read-routes.ts` - // (#23050 slice 13b); the event and diff doors here still read it (#24210). + // (#23050 slice 13b) and the diff door's to `meta-item-history-routes.ts` (#23050 + // slice 13d); the event doors' refusal here still reads it (#24210). thrownAnswerIsBareNotFound, // `logUnexpectedRouteError` and `applyDroppedFieldsHeader`, read only by the CRUD doors' catches // and their two write answers, moved with `registerCrudEndpoints`' body to `crud-routes.ts` @@ -1820,8 +1824,13 @@ function mayReadAuthoringTrail(caller: unknown): boolean { * would read as "this item has one". This is ONE function so the three doors * cannot drift apart in their predicate, status, code or envelope; only the * door's own name differs between them. + * + * [#23050 slice 13d] Exported because those three doors moved to + * `meta-item-history-routes.ts`, which imports it from here: it stays beside + * {@link mayReadAuthoringTrail}, the admission it asks, rather than taking a + * second home. */ -function refuseNonAuthoringCaller(caller: unknown, res: any, reading: string): boolean { +export function refuseNonAuthoringCaller(caller: unknown, res: any, reading: string): boolean { if (mayReadAuthoringTrail(caller)) return false; res.status(403).json({ error: { @@ -5245,826 +5254,35 @@ export class RestServer { }; 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" - // tab renders as an audit timeline. Overlay-only metadata types - // (view/dashboard/report/email_template) return real events; - // non-overlay types return `{ events: [] }` (the legacy raw-engine - // path does not record history). - registerPerItemRoute({ - method: 'GET', - path: `${metaPath}/:type/:name/history`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#20378] AN AUTHORING DOOR — ruling 5865708652 (letter B). - // `sys_metadata_history` is the authoring commit log - // (ADR-0067), and a DRAFT save appends a row to it exactly - // as an active save does, with nothing on the row to tell - // the two apart — so this log, served to a caller who may - // not read pending drafts, lists unpublished authoring work - // (ADR-0106 D4: 「draft/preview reads are admin-gated - // upstream」). The caller is asked - // {@link mayReadAuthoringTrail} FIRST, and one it does not - // admit is refused in `GET /meta/_drafts`'s shape - // (403 `FORBIDDEN`, the same nested envelope): before the - // protocol is resolved (no 501-vs-200 probe), before the - // query is parsed, before any item or event is read. The - // answer is therefore one and the same for an item that - // exists, one that does not and a draft-only one — the door - // is no existence oracle — and it carries no item or - // version detail. The message names THIS door, never - // drafts: a refusal worded about drafts would read as - // "this item has one". Whoever it admits reads exactly what - // they read before, the per-caller refusal below included. - // [#23546] `setup.access` alone no longer admits it: under a - // wall it handed every organization administrator the - // environment-wide trail (see AUTHORING_TRAIL_READ_CAPABILITIES). - // - // Ruling 5856774816 (#20156) item 2 is narrowed for this - // door and `/diff` only: `/layers` and `?layers=true` read - // the active row and keep the pruned plain-read answer. - // - // `historyCtx` is this door's one caller resolution. The refusal is - // {@link refuseNonAuthoringCaller}, shared with `/diff` and - // `/audit` (#20441) so the three cannot drift apart. - const historyCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - if (refuseNonAuthoringCaller(historyCtx, res, 'Reading a metadata item\'s version history')) return; - const p = await this.resolveProtocol(environmentId, req); - // The cast came off when `MetadataProtocol` declared - // `historyMetaItem` (#12005 — the commit cccbe51bf pattern, exactly - // as #11678 de-cast the audit twin below). The member is - // declared OPTIONAL, so this truthiness guard is not just - // feature detection: it is what narrows the member to - // callable at the call site. Same guard semantics as - // before, minus the cast. - if (!p.historyMetaItem) { - res.status(501).json({ - error: 'History query not supported by protocol implementation', - }); - return; - } - // [#6877] `Number(['1','2'])` is `NaN`, which the - // `Number.isFinite` spreads below drop — so a repeated - // `?limit=` silently returned the UNLIMITED history instead - // of the page the caller asked for. - // [#22877] Recognition first, over the measured closed set. - if (refuseUnknownQueryParams(req, res, META_ITEM_HISTORY_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['sinceSeq', 'limit', META_ITEM_MEMBER_PARAM])) return; - // [#20139] `sinceSeq` is parsed through its DECLARATION - // (`HistoryMetaItemRequestSchema.sinceSeq`, `z.number().optional()`), - // the way `limit` is just below: `?sinceSeq=abc` used to be `NaN`, - // dropped by the spread below, and answered with the log read from - // the START; `?sinceSeq=` became `Number('')` = 0, a cursor the - // repository applies (`event_seq <= 0` rows skipped) that the - // caller never sent. Both are refused now; any finite number is - // still forwarded exactly as before. - const sinceSeq = readDeclaredQueryNumber(req.query, 'sinceSeq', - HistoryMetaItemRequestSchema.shape.sinceSeq, { emptyIsAbsent: false }); - // [#20062] `limit` is parsed through its DECLARATION - // (`HistoryMetaItemRequestSchema.limit`, `z.number().optional()`), - // not coerced: `?limit=abc` used to be `NaN`, dropped by the - // spread below, and answered with the WHOLE change log; `?limit=` - // became `Number('')` = 0, a zero-event answer. Both are refused - // now. The declaration carries no `int()` and no bounds, so any - // finite number is still forwarded exactly as before. - const limit = readDeclaredQueryNumber(req.query, 'limit', - HistoryMetaItemRequestSchema.shape.limit, { emptyIsAbsent: false }); - // [#20156] The plain read's refusal, where it refuses this - // caller the item whole: a change log of a doc the caller - // may not read, or of an app they may not open, discloses - // who changed it, when and why — and an unpublished app's - // log contradicts ADR-0045 §3's "externally unobservable". - // See {@link eventDoorRefusal}. - { - const refusal = await this.eventDoorRefusal(environmentId, req, p); - if (refusal) { - refusal(res); - return; - } - } - // [#13406 · ADR-0131 D6] The ENV partition, stated by - // omission: `sys_metadata_history` is filtered by strict - // equality on `organization_id`, and since the - // per-organization overlay axis retired every write logs - // there and `historyMetaItem` reads no other partition. A - // legacy organization-scoped log is not served; its - // promotion is ADR-0131 C7's. - // - // Typed through `TransportScopedMetaRequest` like the - // reset door above, NOT as a plain `HistoryMetaItemRequest` - // like the audit door below: this door still spreads the - // transport-level `environmentId` (long-standing wire - // shape, deliberately unchanged — the ruling commit 2a29caa53 landed keeps - // it out of the protocol schema, and the implementation - // never reads it), so the wrapper is what layers that one - // member on. Every OTHER key is compiled against the spec - // contract — an undeclared member here is now TS2353 - // instead of a payload member no contract has ever seen. - const historyRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - ...(environmentId ? { environmentId } : {}), - // Both already finite or absent — the declared parses above - // refuse anything else, so no `Number.isFinite` drop is left here. - ...(sinceSeq !== undefined ? { sinceSeq } : {}), - ...(limit !== undefined ? { limit } : {}), - // [#22877] ONE member's events of the bundle's lineage. - ...metaMemberSelector(req.query), - }; - const result = await p.historyMetaItem(historyRequest); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'List durable history events for a metadata item', - tags: ['metadata'], - }, - }); - - // GET /meta/:type/:name/audit — ADR-0010 §3.6 / Phase 4.1. - // Compliance trail for the metadata-protection layer: returns - // recent sys_metadata_audit rows (save/publish/rollback/delete/ - // reset attempts, both allowed and denied) so Studio's "审计 - // 日志 / Audit log" tab can show who tried what and whether - // a lock blocked it. Empty array on environments where the - // table is not yet provisioned. An AUTHORING door (#20441): a - // caller without an authoring capability is refused 403. - registerPerItemRoute({ - method: 'GET', - path: `${metaPath}/:type/:name/audit`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#20441] AN AUTHORING DOOR — ruling 5865708652 (letter B), - // carried to this door by triage's grade 5871509797. - // `saveMetaItem` appends a success row to - // `sys_metadata_audit` for EVERY save, a draft save - // included, and `auditMetaItem` serves its `note: 'draft'`, - // its actor and its time. So this trail, served to a caller - // who may not read pending drafts, disclosed that an item - // had unpublished authoring work, who saved it and when — - // and for an item with nothing published, that it exists at - // all, where the plain read answers `404` (ADR-0045 §3). - // ADR-0106 D4: 「draft/preview reads are admin-gated - // upstream」. - // - // The trail has no published-only answer to fall back to: - // withholding only the draft-save rows would hand a member - // a pruned log that reads as a true, complete one, the - // shape the ruling measured wrong. So the caller is asked - // {@link mayReadAuthoringTrail} FIRST and refused by - // {@link refuseNonAuthoringCaller} — the refusal `/history` - // and `/diff` give, and `GET /meta/_drafts`'s shape — before - // the protocol is resolved (no 501-vs-200 probe), before the - // query is parsed, and before any item or event is read. - // Whoever it admits reads exactly what they read before, - // the per-caller refusal below included. [#23546] - // `setup.access` alone no longer admits it. - // - // `auditCtx` is this door's one caller resolution. - const auditCtx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - if (refuseNonAuthoringCaller(auditCtx, res, 'Reading a metadata item\'s audit trail')) return; - const p = await this.resolveProtocol(environmentId, req); - if (typeof p.auditMetaItem !== 'function') { - // [#9426 / ADR-0110 D3] A MISS and a FAULT are different - // facts, and this branch is the second one: the resolved - // protocol cannot read an audit trail AT ALL, so the - // question was never asked. Answering `{ events: [] }` - // reported it as the first — "the trail WAS read and this - // item has no entries" — on a COMPLIANCE surface, where - // Studio's 审计日志 / Audit log tab renders the empty case - // as *nobody touched this item*. That is precisely the - // claim a compliance reader must not be given on false - // pretenses. - // - // ⚠️ This is NOT the unprovisioned-table condition this - // route's header comment describes. That one lives one - // layer down, inside - // `ObjectStackProtocolImplementation.auditMetaItem`, - // which catches a failed read and returns `{ events: [] }` - // after a `console.warn` — a path that requires the method - // to EXIST and to be CALLED. The two are separate frames - // in separate packages, and this branch returns BEFORE the - // call, so refusing here leaves the unprovisioned-table - // answer exactly as it was. - // - // Refusing HERE rather than asserting at assembly is - // deliberate, and is the reasoning PR #9425 landed one - // route over. `auditMetaItem` is a declared OPTIONAL - // member of `MetadataProtocol` (the #11006-pattern - // catch-up that retired this door's `(p as any)` casts; - // it was an undeclared ADR-0076 D9 server-only - // extension before that). A host without the verb is - // therefore a CONFORMING deployment that lands here - // with no type error, and a boot-time assertion would - // promote a declared-optional member into a required - // one — a `packages/spec` contract decision, not a - // route one. The guard is also what narrows the member - // to callable below. - // - // Envelope per #7035: the ADR-0112 NESTED - // `{ error: { code, message } }` the sibling `/meta` 501 - // refusals converged on — never the bare-string or - // sibling-`code` dialects, which make `body.error.code` - // read `undefined` and which `check:route-envelope` - // counts, shrink-only, on this file. - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'protocol.auditMetaItem() is not available in this kernel', - }, - }); - return; - } - // [#6877] Same `Number(...)` → `NaN` → dropped-limit shape as - // the history twin above. - // [#23072] Recognition first, over the measured closed set. - if (refuseUnknownQueryParams(req, res, META_ITEM_AUDIT_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['limit'])) return; - // [#20139] Parsed through its DECLARATION - // (`AuditMetaItemRequestSchema.limit`, `z.number().optional()`), - // the history twin's reading: `?limit=abc` used to be `NaN`, - // dropped by the spread below, and answered with the producer's - // default 100 events; `?limit=` became `Number('')` = 0, which the - // implementation clamps to ONE event. Both are refused now; any - // finite number is still forwarded, and the implementation's own - // `[1, 500]` clamp is unchanged. - const limit = readDeclaredQueryNumber(req.query, 'limit', - AuditMetaItemRequestSchema.shape.limit, { emptyIsAbsent: false }); - // [#20156] The history twin's gate, for the same reason: the - // audit trail of an item the plain read refuses this caller - // is refused with the plain read's answer. See - // {@link eventDoorRefusal}. - { - const refusal = await this.eventDoorRefusal(environmentId, req, p); - if (refusal) { - refusal(res); - return; - } - } - // [#8747 · ADR-0131 D6] SCOPE THE READ: the - // environment-wide rows only (`organization_id: null`), - // never every tenant's. Since the per-organization overlay - // axis retired every `/meta` write audits there; a legacy - // organization-scoped audit row is not served. - // - // `environmentId` is not in this payload: `auditMetaItem`'s - // request type never declared it. Environment scoping comes - // from WHICH protocol `resolveProtocol` hands back. - // (#20441), not a second resolution. - // - // The `(p as any)` casts this door carried came off when - // `MetadataProtocol` declared `auditMetaItem` (the commit cccbe51bf - // pattern, same as the publish door below): the literal is - // now compiled against the spec contract, so an undeclared - // key here is a compile error (TS2353) instead of a payload - // member no contract has ever seen. Plain - // `AuditMetaItemRequest` rather than the - // `TransportScopedMetaRequest` wrapper on purpose: this - // door stopped sending `environmentId` when #8747 scoped - // the read (see the note above), so there is no - // transport-level member left to layer on. - const auditRequest: AuditMetaItemRequest = { - type: req.params.type, - name: req.params.name, - // Already finite or absent — the declared parse above - // refuses anything else. - ...(limit !== undefined ? { limit } : {}), - }; - const result = await p.auditMetaItem(auditRequest); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'List protection-audit events for a metadata item', - tags: ['metadata'], - }, - }); - - // POST /meta/:type/:name/publish — promote the pending draft - // overlay to live. 404 `NO_DRAFT` when nothing to publish. - registerPerItemRoute({ - method: 'POST', - path: `${metaPath}/:type/:name/publish`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [commit b5378550e] Authoring capability gate — the SAME four lines the - // `PUT` / `DELETE` / `_migrate-stored` doors carry, deliberately - // not a second way of demanding the same capability. - // - // Promotion is authoring. `promoteDraftForPublish` flips the - // `sys_metadata` row `state: 'draft'` → `'active'`, and - // ADR-0027 (E)(5) defines sealing a publish as exactly that - // flip — so this door decides which body is LIVE. Measured - // before the gate: an authenticated principal holding no - // authoring capability at all reached `publishMetaItem` and - // got 200, i.e. it could take a draft somebody else authored - // and make it the live overlay. The `/meta` umbrella already - // refused ANONYMOUS here (401, `registerMetadataEndpoints`), - // so this closes the authenticated-but-uncapable cohort — the - // one the four sibling doors close and these two did not. - // - // ⛔ Not a publish-specific capability: `manage_metadata` is - // ADR-0066 D1's authoring capability and the same one the save - // door demands, so no caller who can author a draft is newly - // refused (measured: the save→publish loop's own first step is - // already gated on it). Splitting author from publisher would - // need a DIFFERENT declared capability and is a product call. - // - // Gate FIRST — before the protocol is resolved — so an - // unauthorized caller cannot use the 501-vs-200 answer to probe - // which kernels implement publishing, and so nothing is promoted - // before the refusal. `isSystem` bypasses, matching every other - // capability gate on the platform. - // - // [#12702 · ADR-0131 D6] Same shared verdict as the save - // door, because promotion is the second half of the - // save→publish loop. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - { - const verdict = metaWriteCapabilityVerdict({ - isSystem: ctx?.isSystem === true, - systemPermissions: ctx?.systemPermissions, - operation: 'publish', - }); - 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.publishMetaItem) { - res.status(501).json({ - error: 'Publish operation not supported by protocol implementation', - }); - 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); - const body = (req.body && typeof req.body === 'object') ? req.body : {}; - const message = typeof body.message === 'string' ? body.message : undefined; - - // [commit 9e04c3e35] Software-package binding for the PROMOTION — - // `?package=`, deliberately the SAME wire spelling and the - // same normalisation the `PUT` door states it with a few - // hundred lines up, not a second dialect for one value. - // - // Why this door needed it at all. #9612 taught the runtime - // publish gate to narrow `objects` to the written item's - // package closure, but only when the caller can NAME the - // package. Three write doors reach that gate; `saveMetaItem` - // and `publishPackageDrafts` both name one, and this one — - // the single-item draft→active promotion — named nothing. So - // every HTTP-driven promotion, which is precisely Studio's - // designer save→publish loop on every edit, handed the gate - // the whole tenant. The protocol half was already built and - // waiting: `promoteDraftForPublish` declares - // `packageId?: string | null` and threads it into both the - // gate and `repo.promoteDraft`. Only the caller was mute. - // - // ⚠️ THE SHARP EDGE, and the reason this is a conditional - // spread rather than a plain key. `promoteDraftForPublish` - // forwards to `repo.promoteDraft` with - // `...('packageId' in request ? { packageId: request.packageId ?? null } : {})` - // — it branches on the KEY BEING PRESENT, not on the value, - // because `null` is a meaningful scope there (pin the lookup - // to the UNBOUND row) while an absent key means "match any - // package", the historical resolution. Writing - // `packageId: packageId` here would therefore put a - // present-and-`undefined` key on every publish that names no - // package, coercing it to `null` downstream and pinning the - // lookup to unbound rows — so a draft authored under a package - // would stop being found and the door would answer `no_draft`. - // That is a silent outage on the untouched path, produced by a - // change that reads like it only ADDS an option. The key must - // be ABSENT when the caller states nothing. - // - // ⛔ Not read off the draft row either — see - // `promoteDraftForPublish`'s own warning: `rowToItem` projects - // `sys_metadata` into a `MetadataItem`, which carries no - // package id, so a read from there is `undefined` on every - // path — narrowing that never fires while looking like it - // does. Widening `MetadataItem` is a `packages/spec` contract - // change and stays filed rather than taken here. - // [#22877] Recognition first, over the measured closed set. - if (refuseUnknownQueryParams(req, res, META_ITEM_PUBLISH_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['package', META_ITEM_MEMBER_PARAM])) return; - const packageId = metaItemPackageBinding(req.query?.package); - - // [ADR-0131 D6] No organization, as on the `PUT` twin: a - // draft is saved environment-wide, so the promotion looks in - // the environment partition. - // [#11145] The `(p as any)` cast this call carried came off - // when `MetadataProtocol` declared `publishMetaItem` (commit cccbe51bf, - // maintainer ruling 2026-08-22, option B). What the cast was - // load-bearing FOR is recorded because it is counter-intuitive - // and was measured, not assumed: deleting it while the member - // was undeclared answered - // `TS2339: Property 'publishMetaItem' does not exist on - // type 'RestProtocol'` - // — NOT a `TS2353` about an unknown key. The cast was feature - // detection for an ADR-0076 D9 server-only extension, so only - // declaring the member could retire it; widening the - // implementation's own request type in - // `@objectstack/metadata-protocol` (which this package - // deliberately does not depend on) never could, and commit 490879ad0 - // measured exactly that. - // - // What replaces it is the point of the exercise, not a - // side effect: the literal below is compiled against the spec - // contract through the commit 2a29caa53 `TransportScopedMetaRequest` - // wrapper, so an undeclared key here is a COMPILE ERROR - // (`TS2353`, measured) instead of a payload member no contract - // has ever seen. `environmentId` is the transport-level - // routing key that wrapper layers on — ⛔ never a protocol - // key; a key that belongs on the request belongs in the spec - // schema. - // - // The 501 feature-detection guard above STAYS. The member is - // declared OPTIONAL (ADR-0076 D9 promotion is additive to a - // shipped contract, and a kernel may not implement the - // promotion door at all), and that same guard is what narrows - // it to callable here. - const publishRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - ...(environmentId ? { environmentId } : {}), - ...(actor ? { actor } : {}), - ...(message ? { message } : {}), - ...(packageId ? { packageId } : {}), - // [#22877] Publishes ONE member's draft of a bundled type. - ...metaMemberSelector(req.query), - }; - const result = await p.publishMetaItem(publishRequest); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Publish the pending draft overlay (promotes draft → active)', - tags: ['metadata'], - }, - }); - - // POST /meta/:type/:name/rollback — restore a historical version - // as the new live overlay. Body: { toVersion: , message? }. - registerPerItemRoute({ - method: 'POST', - path: `${metaPath}/:type/:name/rollback`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [commit b5378550e] Authoring capability gate — the same four lines as - // the sibling doors, and the sharper half of this pair. - // `rollbackMetaItem` restores a CALLER-SUPPLIED `toVersion` as - // the new live row, so without this gate it is a mechanism for - // reverting security hardening: a permission set as it stood - // before it was tightened, a validation rule from before it - // existed, a layout from before field-level security. Measured - // before the gate: an authenticated principal holding no - // authoring capability reached `rollbackMetaItem` and got 200. - // - // It is also the door with the least behind it. Publish at - // least re-runs `assertRuntimeAuthoringRules` on the promoted - // draft (#4463 D1); rollback runs no content gate at all — its - // only refusals are TYPE-level (`isOverlayAllowed` → - // `NOT_OVERRIDABLE`) and the ADR-0010 lock. Neither reads the - // caller, so nothing downstream was ever answering "may this - // principal press this button". - // - // Gate FIRST — before the protocol is resolved — so 403-vs-501 - // leaks no kernel capability and nothing is restored before the - // refusal. `isSystem` bypasses, as everywhere else. - // - // [#12702 · ADR-0131 D6] Same shared verdict as the - // sibling doors. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - { - const verdict = metaWriteCapabilityVerdict({ - isSystem: ctx?.isSystem === true, - systemPermissions: ctx?.systemPermissions, - operation: 'rollback', - }); - 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 as any).rollbackMetaItem) { - res.status(501).json({ - error: 'Rollback operation not supported by protocol implementation', - }); - return; - } - // [#6877] The `Number(...)` below already refuses an array - // — but as `INVALID_REQUEST` "'toVersion' (positive integer) - // is required", which tells a caller who supplied two valid - // integers that they supplied none. Refusing the multiplicity - // by name says what actually happened. - // [#22877] Recognition first, over the measured closed set. - if (refuseUnknownQueryParams(req, res, META_ITEM_ROLLBACK_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['toVersion', META_ITEM_MEMBER_PARAM])) return; - const body = (req.body && typeof req.body === 'object') ? req.body : {}; - const toVersionRaw = body.toVersion ?? body.version ?? req.query?.toVersion; - const toVersion = Number(toVersionRaw); - if (!Number.isFinite(toVersion) || toVersion < 1) { - res.status(400).json({ - error: `'toVersion' (positive integer) is required`, - code: 'INVALID_REQUEST', - }); - 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); - const message = typeof body.message === 'string' ? body.message : undefined; - // [ADR-0131 D6] No organization, as on the `PUT` twin: the - // rollback restores a version of the environment-wide row. - const result = await (p as any).rollbackMetaItem({ - type: req.params.type, - name: req.params.name, - toVersion, - ...(environmentId ? { environmentId } : {}), - ...(actor ? { actor } : {}), - ...(message ? { message } : {}), - // [#22877] Reverts ONE member of a bundled type. - ...metaMemberSelector(req.query), - }); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Restore the body at the given history version as the new live row', - tags: ['metadata'], - }, - }); - - // GET /meta/:type/:name/diff?from=N&to=M — structural diff - // between two historical versions (or one version vs current). - registerPerItemRoute({ - method: 'GET', - path: `${metaPath}/:type/:name/diff`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#20378] AN AUTHORING DOOR — ruling 5865708652 (letter B), - // the `/history` twin's gate on the same log. A diff reads - // stored versions out of `sys_metadata_history`, where a - // DRAFT save is recorded exactly as an active save, so - // `?from=`/`?to=` naming a draft save — or the default - // range, once a draft is pending — served unpublished - // content to a caller who may not read drafts. The version - // store cannot tell a draft version from a published one, - // so there is no exact published-only answer to fall back - // to: the `/meta/_drafts` shape, not the draft switches' - // "answer as if absent". The caller is asked - // {@link mayReadAuthoringTrail} FIRST, and one it does not - // admit is refused in `GET /meta/_drafts`'s shape - // (403 `FORBIDDEN`, the same nested envelope): before the - // protocol is resolved, before the query is parsed, before - // the mask posture, the current document or any version is - // read — one answer for an item that exists, one that does - // not and a draft-only one, with no item or version detail, - // so the door is no existence oracle. The message names THIS - // door, never drafts. Whoever it admits reads exactly what - // they read before: every per-caller gate below, and ruling - // 5856774816's author exemption, still apply to them. - // [#23546] `setup.access` alone no longer admits it. - // - // Ruling 5856774816 (#20156) item 2 is narrowed for this - // door and `/history` only: `/layers` and `?layers=true` - // read the active row and keep the pruned plain-read answer. - // - // `diffCtx` is this door's one caller resolution. The refusal is - // {@link refuseNonAuthoringCaller}, shared with `/history` - // and `/audit` (#20441) so the three cannot drift apart. - const diffCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - if (refuseNonAuthoringCaller(diffCtx, res, 'Comparing a metadata item\'s stored versions')) return; - const p = await this.resolveProtocol(environmentId, req); - if (!(p as any).diffMetaItem) { - res.status(501).json({ - error: 'Diff operation not supported by protocol implementation', - }); - return; - } - // [#6877] A repeated `?from=` became `NaN`, the spreads below - // omitted the bound, and the door quietly diffed a different - // pair of versions and answered 200. - // [#23072] Recognition first, over the measured closed set. - if (refuseUnknownQueryParams(req, res, META_ITEM_DIFF_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['from', 'fromVersion', 'to', 'toVersion'])) return; - // [#20139] The same drop for a SINGLE unreadable value: the - // `parseV` helper that stood here answered `undefined` for - // `?from=abc` (and `Infinity`), so `diffMetaItem` substituted - // "the version before `to`" — or, for `?to=abc`, the CURRENT - // body — and the door answered 200 with a comparison nobody - // asked for; `?from=1.5` was forwarded and diffed against a - // version that cannot exist. No request schema is declared for - // this door, so a version reads as a whole number. The name the - // caller used is the one read (and the one a refusal names): - // `from` / `to` win over `fromVersion` / `toVersion` exactly as - // the old `??` had it, and an empty value stays absent, as - // `parseV('')` already answered. - const fromParam = req.query?.from != null ? 'from' : 'fromVersion'; - const toParam = req.query?.to != null ? 'to' : 'toVersion'; - const fromVersion = readDeclaredQueryNumber(req.query, fromParam, - UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); - const toVersion = readDeclaredQueryNumber(req.query, toParam, - UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); - // [#20156] A diff discloses BOTH versions' values — for a - // doc its `content`, for an app its `navigation`, for an - // object its `fields` — so it owes the plain read's - // per-caller answer for each side. Resolved in the plain - // read's order: the ADR-0106 mask posture BEFORE any fetch - // (D2/D3), then the current document, which a type the gate - // judges here must have — the plain read answers its - // absence, and there is no gate input to judge the versions - // against. - const diffMetaType = RestServer.metaTypeSingular(req.params.type); - let diffMaskPosture: ObjectSchemaMaskPosture; - try { - diffMaskPosture = await (await this.resolveObjectMasker(environmentId, req, diffMetaType))(req.params.name); - } catch (maskError: any) { - if (maskError instanceof ObjectSchemaMaskEvaluationError) { - sendFieldVisibilityFault(res, req.params.name); - return; - } - throw maskError; - } - const diffGated = RestServer.gatesPerCaller(diffMetaType); - let diffCurrent: any; - try { - diffCurrent = diffGated - ? await this.fetchCurrentMetaDocument(req, p) - : undefined; - } catch (currentError: any) { - // [#7557, #24210] A withheld item (a disabled package's) - // is answered in the plain read's body, as - // {@link eventDoorRefusal} answers it. - if (thrownAnswerIsBareNotFound(currentError)) { - sendMetaItemAbsent(res); - return; - } - throw currentError; - } - // [#22639] A list view's and a dashboard's per-caller gate - // is the item's OWN `requiredPermissions`. With no - // published row there is no current document to judge, so - // — as the event doors answer for every gated type — the - // diff is served as before, and a never-published view or - // dashboard keeps its builder's diff whether or not it - // names a capability: no key, no change. Its draft is - // judged where it is served whole (`?state=draft`, - // `/layers`). - if (diffGated && diffCurrent == null && !RestServer.gatesOnItsOwnKey(diffMetaType)) { - sendMetaItemAbsent(res); - return; - } - // [#13406 · ADR-0131 D6] The ENV partition, stated by - // omission, as on the history twin: `diffMetaItem` reads - // the environment's `sys_metadata_history` rows, and - // `version` is a per-(org, type, name) lineage counter, so - // the environment lineage is the one every `/meta` write - // now extends. - const result = await (p as any).diffMetaItem({ - type: req.params.type, - name: req.params.name, - ...(environmentId ? { environmentId } : {}), - ...(fromVersion !== undefined ? { fromVersion } : {}), - ...(toVersion !== undefined ? { toVersion } : {}), - }); - // [#20156] Judge the current document and both sides, with - // ONE judge (one books read, one holdings resolution): a - // side the caller may not read is not served beside one - // they may. `per-caller`, the stored-version doors' policy — - // see `MetaReadGatePolicy`. - // - // [#20156] And each side is EMITTED as the gate serves it: - // ruling 5856774816 — a caller who may write an app reads - // both sides whole, and any other caller who may open it - // reads each side pruned, exactly as the plain read prunes - // it ({@link diffEmittedFrom}). - let served: any = result; - if (diffGated && diffCurrent != null) { - const { from, to } = RestServer.diffSides(diffCurrent, result); - const judge = this.metaItemReadGate( - environmentId, req, p, diffMetaType, req.params.name, [diffCurrent, from, to], - RestServer.STORED_VERSION_DOOR_POLICY, - ); - const sides: any[] = []; - for (const side of [diffCurrent, from, to]) { - const verdict = await judge(side); - if (verdict.kind === 'refuse') { - verdict.send(res); - return; - } - sides.push(verdict.document); - } - served = RestServer.diffEmittedFrom(result, sides[1], sides[2]); - } - // [ADR-0106 D5(4)] The object mask on the one key it - // projects, `fields`, in every bucket and on both sides — - // the redaction precedent one frame down (the stored bodies - // are compared raw, the EMITTED values are the projected - // ones), so a field the caller may not read is not served - // as a diff value either. - if (diffMaskPosture.kind === 'project' && served && typeof served === 'object') { - let faulted = false; - const maskFields = (value: unknown): unknown => { - if (faulted || !value || typeof value !== 'object' || Array.isArray(value)) return value; - const masked = this.maskObjectDocument(res, diffMaskPosture, req.params.name, { fields: value }); - if (!masked) { faulted = true; return value; } - return (masked.document as { fields: unknown }).fields; - }; - const onFields = (e: any, keys: readonly string[]) => - (e && e.path === 'fields' - ? { ...e, ...Object.fromEntries(keys.map((k) => [k, maskFields(e[k])])) } - : e); - const bucket = (list: unknown, keys: readonly string[]) => - (Array.isArray(list) ? list.map((e) => onFields(e, keys)) : list); - served = { - ...(served as Record), - added: bucket((served as any).added, ['value']), - removed: bucket((served as any).removed, ['value']), - changed: bucket((served as any).changed, ['from', 'to']), - }; - // `maskObjectDocument` already answered the ADR-0106 D6 - // 5xx: a side projected to NO fields is never served. - if (faulted) return; - } else if (diffMaskPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); - } - res.json(served); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Diff two metadata versions (from/to query params; to defaults to the active version, from to the nearest earlier version whose body differs)', - tags: ['metadata'], - }, - }); + // [#23050 slice 13d] The history family (`GET {metaPath}/:type/:name/history`, + // `/audit`, `POST /publish`, `POST /rollback` and `GET /diff`) moved to + // `meta-item-history-routes.ts`, and is registered HERE so it keeps its place in + // the order: after the per-item writes, above the object FSM read below. Like the + // writes, it still registers 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 `MetaItemHistoryRouteHost`): the class's + // methods as closures, its private statics as closures on the class, and + // `STORED_VERSION_DOOR_POLICY` by value; it carries no route manager, because that + // body registers nothing on one itself. Read that module's header before moving it. + const itemHistoryHost: MetaItemHistoryRouteHost = { + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + resolveProtocol: (...a) => this.resolveProtocol(...a), + eventDoorRefusal: (...a) => this.eventDoorRefusal(...a), + walledCatalogRefusal: (...a) => this.walledCatalogRefusal(...a), + resolveMetaWriteActor: (...a) => this.resolveMetaWriteActor(...a), + metaTypeSingular: (...a) => RestServer.metaTypeSingular(...a), + resolveObjectMasker: (...a) => this.resolveObjectMasker(...a), + gatesPerCaller: (...a) => RestServer.gatesPerCaller(...a), + fetchCurrentMetaDocument: (...a) => this.fetchCurrentMetaDocument(...a), + gatesOnItsOwnKey: (...a) => RestServer.gatesOnItsOwnKey(...a), + diffSides: (...a) => RestServer.diffSides(...a), + metaItemReadGate: (...a) => this.metaItemReadGate(...a), + STORED_VERSION_DOOR_POLICY: RestServer.STORED_VERSION_DOOR_POLICY, + diffEmittedFrom: (...a) => RestServer.diffEmittedFrom(...a), + maskObjectDocument: (...a) => this.maskObjectDocument(...a), + }; + registerMetaItemHistoryRoutes.call(itemHistoryHost, scope); // GET /meta/object/:name/state/:field?from=:state — ADR-0020 D3.3 // legal-next-state introspection. [#7526] 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 2ff527a918c..ee8d3f68b48 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -390,7 +390,12 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // 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); + // [#23050 slice 13d] 16 → 17 (`meta-item-history-routes.ts`): 60 unchanged + // and 78 → 79, the same shape: the history family calls `resolveExecCtx` at + // five sites and `enforceAuth` at none, so the fourth host literal the inner + // method builds forwards the first only; the five call sites moved and are + // counted in both readings. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); 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 17e7a04e868..d8ae618b6d3 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1165,7 +1165,13 @@ describe('[#13214] §4 preconditions', () => { // 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); + // [#23050 slice 13d] 16 → 17 (`meta-item-history-routes.ts`): 60 unchanged + // and 78 → 79, the same shape: the history family calls `resolveExecCtx` at + // five sites and `enforceAuth` at none, so the fourth host literal the inner + // method builds forwards the first only; the five call sites moved and are + // counted in both readings. The history family never calls the ownership + // guard either, so it is still 1 call site and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); 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-history-routes.ts.json b/scripts/adr-anchors/packages__rest__src__meta-item-history-routes.ts.json new file mode 100644 index 00000000000..64ad16cac4c --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__meta-item-history-routes.ts.json @@ -0,0 +1,10 @@ +{ + "file": "packages/rest/src/meta-item-history-routes.ts", + "adrs": [ + "ADR-0067", + "ADR-0106", + "ADR-0066", + "ADR-0131" + ], + "invariant": "The `/meta` history family, moved here whole from `rest-server.ts` (#23050 slice 13d): the change log (`GET {metaPath}/:type/:name/history`), the audit trail (`/audit`), the publish (`POST /publish`), the rollback (`POST /rollback`) and the version comparison (`GET /diff`), registered through the inner method's per-item registrar, under the per-item switch and inside the umbrella's guarded registration. ADR-0067 — `sys_metadata_history` is the authoring commit log, and a draft save appends to it exactly as an active save does, so the change log, the audit trail and the comparison are AUTHORING doors: each asks the authoring-trail admission first, before the protocol is resolved, and refuses a caller it does not admit in the drafts list's 403 shape. ADR-0106 — D4 is why those logs are admin-gated (a draft read is an authoring read), and the comparison owes the plain read's mask: the caller's posture is resolved before any fetch and the object mask projects the emitted values on both sides, so a field the caller may not read is not served as a diff value. ADR-0066 — the publish and the rollback are authoring writes: each asks the shared write-capability verdict FIRST, before the protocol is resolved, so nothing is promoted or restored before a refusal and the 501-vs-200 answer is no probe. ADR-0131 — no door names an organization: the change log reads the environment's partition, every write the publish or the rollback admits lands environment-wide, and each of the two asks the walled refusal after its capability gate and before the protocol is resolved. A door that reads or writes before its gate, serves a log entry or a diff value its caller may not read, 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 eb299219e9d..6ed374eed36 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -421,7 +421,16 @@ const MODULES = { // supported by protocol implementation'`), declared below at the count it had // here; the sum is unchanged // (7 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 + 0 + 1 = 39). - stringError: 7, + // + // 7 → 2 (#23050 slice 13d): NOT progress — the same MOVE as slices 1–3, 8, 10–12 + // and 13b. The sixth `/meta` group, the history family, left this file for + // `meta-item-history-routes.ts` with its five literal string `error` bodies (the + // 501 `NOT_IMPLEMENTED` of `/history`, `/publish`, `/rollback` and `/diff`, and + // the rollback's 400 `INVALID_REQUEST` on a missing or invalid `toVersion`), + // declared below at the count they had here. The two left here are class-head + // sites; the sum is unchanged, `meta-item-write-routes.ts`' 0 included + // (2 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 + 0 + 1 + 0 + 5 = 39). + stringError: 2, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared // `respondError` helper, banking that progress per the ratchet's own rule. @@ -533,7 +542,14 @@ const MODULES = { // above. That same `{ error, code }` body left with the per-item reads for // `meta-item-read-routes.ts`, declared below; the sum is unchanged // (3 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 + 0 + 1 = 54). - siblingCode: 3, + // + // 3 → 2 (#23050 slice 13d): NOT progress — the same MOVE as `stringError` + // above. The rollback's one `{ error, code }` body (its 400 `INVALID_REQUEST`) + // left with the history family for `meta-item-history-routes.ts`, declared + // below; the two left here are class-head sites, and the sum is unchanged, + // `meta-item-write-routes.ts`' 0 included + // (2 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 + 0 + 1 + 0 + 1 = 54). + siblingCode: 2, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -837,6 +853,26 @@ const MODULES = { stringError: 0, siblingCode: 0, }, + + // [#23050 slice 13d] The sixth `/meta` group of + // `RestServer#registerMetadataEndpointsInner`' body — the history family (the + // change log, the audit trail, the publish, the rollback and the version + // comparison, `{metaPath}/:type/:name/history`, `/audit`, `/publish`, `/rollback` + // and `/diff`) — 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 and + // equal what left `rest-server.ts` in the same PR (stringError 7 → 2, siblingCode + // 3 → 2: the four 501 bodies and the rollback's 400, which is also the one + // sibling-`code` body), 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-history-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 13d 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: 5, + siblingCode: 1, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */