diff --git a/.changeset/23050-s12-crud-liveness-citations.md b/.changeset/23050-s12-crud-liveness-citations.md new file mode 100644 index 00000000000..af91c7eb7d0 --- /dev/null +++ b/.changeset/23050-s12-crud-liveness-citations.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +The liveness ledger's CRUD rows cite the code that reads their keys where it now lives + +Clause-②: no (only the rows' evidence paths move and every status stays `live`: `CrudEndpointsConfigSchema` accepts `operations.*` and `dataPrefix` exactly as before, and no state count moves) + +- `liveness/crud_endpoints.json`: the five `operations.*` rows and the `dataPrefix` row cited `packages/rest/src/rest-server.ts#registerCrudEndpoints` for the code that gates and prefixes the CRUD mounts. `@objectstack/rest` moved that registrar's body to `packages/rest/src/crud-routes.ts` with no behaviour change, so those six path references follow it. +- The quoted code, the producers, the verdicts and the notes are unchanged. diff --git a/.changeset/23050-s12-crud-routes-move.md b/.changeset/23050-s12-crud-routes-move.md new file mode 100644 index 00000000000..d3eca703f1b --- /dev/null +++ b/.changeset/23050-s12-crud-routes-move.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': patch +--- + +The CRUD doors' registrar body lives in its own module, with no route, handler or answer changed + +Clause-②: no (a move: the six CRUD routes, their order, every handler and every response are unchanged, and the published declarations are byte-identical) + +- `RestServer#registerCrudEndpoints` keeps its name, docblock and place. The body that registers `GET`, `POST` and `POST …/query` on `{basePath}{crud.dataPrefix}/:object`, and `GET`, `PATCH` and `DELETE` on `…/:object/:id`, moved unedited to `packages/rest/src/crud-routes.ts`. The five `crud.operations` switches and `crud.dataPrefix` gate and shape 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/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 129ef961c5e..2b2305cba2d 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -468,7 +468,7 @@ "packages/spec/src/data/filter.zod.ts#checkFilterConditionComparands (the request-schema refusal of a scalar list comparand on the POST door, #20116 — the $-object spelling, located at query.where..$nin)", "packages/spec/src/data/data-engine.zod.ts#lowerFoldedTransportValues (the AST/FilterArray spelling on the POST door, refused at query.where when it cannot be lowered)", "packages/spec/src/data/data-engine.zod.ts#QueryTransportParamsSchema (`filter` is a declared transport spelling of `where` — why negative[0] no longer probes with it)", - "packages/rest/src/rest-server.ts (POST /data/:object/query answers a FindDataRequestSchema failure 400 VALIDATION_FAILED with fields[] via zodIssuesToFields)", + "packages/rest/src/crud-routes.ts (POST /data/:object/query answers a FindDataRequestSchema failure 400 VALIDATION_FAILED with fields[] via zodIssuesToFields)", "packages/metadata-protocol/src/protocol.ts#assertProjectionFieldsExist (a dotted fields entry → 400 INVALID_FIELD prescribing expand, #7532)", "packages/metadata-protocol/src/protocol.ts#assertQueryParamsAreFields (an undeclared leftover key → implicit field filter → 400 INVALID_FIELD, #4134)", "packages/rest/src/rest-server-repeated-filter-param.test.ts + packages/rest/src/analytics-filter-refusal-envelope.test.ts (the schema-door 400 VALIDATION_FAILED posture since #20116)", @@ -1858,7 +1858,7 @@ "packages/objectql/src/in-memory-aggregation.ts#bucketDateValue (the six function arms incl. #11152 boolean-as-number and the #10576 per-aggregation filter; bucketDateValue ISO shapes; the #3839 real-null bucket rule at the module head)", "packages/spec/src/data/aggregation-policy.ts#emptyGroupValueFor (emptyGroupValueFor — the ruled empty-set identities; objectui#3136)", "packages/runtime/src/action-execution.ts#engine (the aggregate door: engine-only, at-least-one-aggregation, the raw-rows degradation hazard)", - "packages/rest/src/rest-server.ts#data (POST /data/:object/query — the wire door that accepts groupBy/aggregations)", + "packages/rest/src/crud-routes.ts#data (POST /data/:object/query — the wire door that accepts groupBy/aggregations)", "packages/metadata-protocol/src/protocol.ts#assertAggregationFieldsExist (assertAggregationFieldsExist — the located unknown-field refusal)", "#10576, #11152, #3839, #3773" ], @@ -2199,7 +2199,7 @@ "source": [ "packages/spec/liveness/crud_endpoints.json (the ADR-0049 ledger this item answers: five live `operations.*` switches + `dataPrefix`, two dead keys)", "packages/spec/src/api/rest-server.zod.ts#CrudEndpointsConfigSchema (the declared contract, its defaults and the two retiredKey tombstones)", - "packages/rest/src/rest-server.ts#registerCrudEndpoints (the six mounts and their five gates), (normalizeConfig — the parsed sub-config every consumer reads), (parseDeclaredSubConfig — the construction-time refusal), (getRoutes — the route table the mount clauses read) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.data built from dataPrefix)", + "packages/rest/src/crud-routes.ts#registerCrudEndpoints (the six mounts and their five gates) + packages/rest/src/rest-server.ts (normalizeConfig — the parsed sub-config every consumer reads), (parseDeclaredSubConfig — the construction-time refusal), (getRoutes — the route table the mount clauses read) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.data built from dataPrefix)", "#14691 (the ADR-0049 retirement of patterns/objectParamStyle) · #11984 (parse-and-consume instead of cast) · ADR-0122 (author state) · ADR-0076 D12 (advertise only what is mounted)", "sibling clause: api-backend.api-methods-verb-gate (the per-OBJECT exposure gate this config-level switch must stay distinguishable from) · api-backend.route-ledger-live-parity (the routing-404 control technique)" ], diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 4feb24cdaf7..708559cccab 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1633,7 +1633,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 9] Length 9 → 10 (`search-routes.ts`), still 0 over it. // [#23050 slice 10] Length 10 → 11 (`ui-routes.ts`), still 0 over it. // [#23050 slice 11] Length 11 → 12 (`form-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] Length 12 → 13 (`crud-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); 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 67275d6ccc4..b2de6db2295 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -537,12 +537,12 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' + - '`search-routes.ts`, `ui-routes.ts` and `form-routes.ts`, the modules the bodies of ' + + '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts` and `crud-routes.ts`, the modules the bodies of ' + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + '`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' + - '`registerUiEndpoints` and `registerFormEndpoints` moved to; ' + + '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints` moved to; ' + 'reachable = those inside registerMetadataEndpoints', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -801,10 +801,24 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // the move commit, before the list learned the module: population 68 and // `this.routeManager.register(` 61 over eleven files, the two sites out of // reach. + // [#23050 slice 12] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerCrudEndpoints`' body (six registration sites, the list, read, + // create, QueryAST query, update and delete doors under + // `{base}{crud.dataPrefix}`, all outside `registerMetadataEndpoints`, so + // all six in the blind spot) moved to `crud-routes.ts`, which joins the list + // (length 12 -> 13) with its own control. `this.routeManager.register(` + // stays 63 over the thirteen files + // (12 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1 + 1 + 2 + 6), + // `private register*Endpoints(` stays 16 (the registrar is still declared + // here), and `enforceAuth` 108 -> 112 is RE-ANCHORED, not relaxed: the six + // call sites moved with their routes and are counted in both readings; the + // four new occurrences are this slice's seam, the same two spellings once + // (the host literal's forwarder, two; the host interface's member, two). No + // call site was added. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 12, + 'registrar list length': 13, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -816,6 +830,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'search-routes.ts: this.routeManager.register(': 1, 'ui-routes.ts: this.routeManager.register(': 1, 'form-routes.ts: this.routeManager.register(': 2, + 'crud-routes.ts: this.routeManager.register(': 6, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -823,7 +838,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 108, + enforceAuth: 112, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + diff --git a/packages/rest/src/crud-routes.ts b/packages/rest/src/crud-routes.ts new file mode 100644 index 00000000000..d25749c7535 --- /dev/null +++ b/packages/rest/src/crud-routes.ts @@ -0,0 +1,542 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 12] The CRUD routes of `RestServer` — the six record doors + * under `{basePath}{crud.dataPrefix}`: `GET /:object` (list), `GET + * /:object/:id` (read), `POST /:object` (create), `POST /:object/query` (the + * QueryAST query), `PATCH /:object/:id` (update) and `DELETE /:object/:id` + * (delete) — moved out of `rest-server.ts` whole. `registerRoutes` calls the + * registrar once for each base it mounts while `api.enableCrud` is on, AFTER + * the data-action routes, whose literal action segments the greedy + * `/:object/:id` matchers here would otherwise capture. + * + * A MOVE, not a redesign. The function below is the body of + * `RestServer#registerCrudEndpoints` as it stood, re-indented one level and + * otherwise unedited: the same six routes, registered in the same order under + * the same five `crud.operations` switches (`list` gates two of them), by the + * same handlers, answering the same bodies. + * + * ## How the moved body reaches the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps the private method — + * the published `dist/*.d.ts` lists its name, docblock and position — and the + * body is one host literal ({@link CrudRouteHost}) plus `.call(host, basePath)`. + * A method is handed over as a closure that calls it on the server at call + * time, so a test double or spy set on the server is still the one the body + * reaches. + * + * Each member was checked on its own, not inherited from an earlier slice: + * + * - `config` goes BY VALUE. The body reads it once, in its first statement, + * while registration runs: `crud`, its `operations` and its `dataPrefix` are + * taken from it then, and the handlers close over those. The class assigns + * the field only in its constructor. + * - `routeManager` goes BY VALUE. The body reads it only for its six + * `register` calls, which run while registration runs, and the class's one + * `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never + * this registrar. + * - No member goes as a getter, because the body reads no FIELD at request + * time. The protocol, the caller's execution context, the anonymous-deny + * gate and the per-object API exposure gate are each resolved per request by + * a METHOD of the class, and every method goes as a closure. No provider + * field is read either. + * + * Module-level names stay where they are declared. Two of them are declared in + * `rest-server.ts` itself and are imported from it: `DATA_RECORD_READ_PARAMS`, + * the read door's closed query-parameter set, which was already exported and + * which its pin test imports from there; and `versionTokenRefusal`, the OCC + * token rule, exported there for this module only (as slices 1 and 2 exported + * theirs) because the runtime dispatcher's `/data` twin names that file as the + * home of its full argument. The package entry re-exports neither, so the + * published surface does not move, and both are read only at request time, so + * the two modules importing each other cannot see them uninitialised. The data + * doors' `ServerScopedDataRequest` comes from `data-action-routes.ts`, where + * slice 1 moved it. The five data request types, + * `assertFilterParamSuppliedOnce`, `logUnexpectedRouteError` and + * `applyDroppedFieldsHeader`, which only this body read, are imported here + * instead of there; every other name is imported from the module + * `rest-server.ts` imports it from. + */ + +// [#15866] The DATA doors' declared request types. Every literal these routes +// assemble is compiled against its declared request type through +// `ServerScopedDataRequest`, so a member the contract does not declare is a +// compile error at the call site instead of a payload no schema has seen. +import type { + FindDataRequest, + GetDataRequest, + CreateDataRequest, + UpdateDataRequest, + DeleteDataRequest, +} from '@objectstack/spec/api'; +import { zodIssuesToFields } from '@objectstack/spec/api'; +import { validationFailure } from '@objectstack/types'; +import type { ServerScopedDataRequest } from './data-action-routes.js'; +import { applyDroppedFieldsHeader, logUnexpectedRouteError, mapDataError } from './error-response.js'; +// [#7390] The filter slot is the one arity judgement the shared normalizer +// structurally cannot make (a filter AST IS an array), so the querystring +// ingress — the only layer that knows it is one — makes it instead. +import { assertFilterParamSuppliedOnce, refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { DATA_RECORD_READ_PARAMS, versionTokenRefusal, type RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerCrudEndpoints} 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 module header says why none of them is + * a getter. + */ +export interface CrudRouteHost { + readonly config: RestServer['config']; + readonly routeManager: RestServer['routeManager']; + resolveProtocol: RestServer['resolveProtocol']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; + enforceApiAccess: RestServer['enforceApiAccess']; +} + +/** + * Register the CRUD endpoints for data operations — + * `RestServer#registerCrudEndpoints`' body, called by that method with the + * server as `this`. + */ +export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): void { + const { crud } = this.config; + const dataPath = `${basePath}${crud.dataPrefix}`; + const isScoped = basePath.includes('/environments/:environmentId'); + + const operations = crud.operations; + + // GET /data/:object - List/query records + if (operations.list) { + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/:object`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return; + // [#6877] Still NOT arity-gated as a whole, for the + // reason it never was: this route hands the WHOLE query + // record to `findData`, whose normalizer + // (`metadata-protocol`) owns every parameter's arity — the + // `$`-alias table, the implicit field-equality bucket, and + // the `$select`/`$expand`/`$searchFields` params that are + // legitimately multi-valued. Declaring an arity list here + // would be this file guessing at another package's + // contract. + // + // [#7390] ONE slot is the exception, and structurally so + // rather than by taste. A filter AST *is* an array + // (`['status','=','open']`), so #7386's arity gate cannot + // read `Array.isArray` as evidence of repetition on the + // filter slot: the normalizer serves this querystring and + // `POST /data/:object/query`'s arbitrary-JSON body through + // one door and cannot tell them apart. THIS layer can — on + // a querystring an array is a repeated parameter and can be + // nothing else — so the filter slot's arity is judged here, + // and only here, leaving the normalizer free of a heuristic. + // + // Refused, never resolved (maintainer ruling 2026-08-11 on + // #7390): last-wins and AND-merge are each a silent choice + // between two intents a caller actually expressed. Before + // this line the common shape was answered with the WRONG + // diagnosis — `malformedFilterArrayError`, telling a caller + // whose filters were both well-formed to check their AST + // syntax — and the rarer `?filter=status&filter=%3D&filter=open` + // spelled a valid AST and returned 200 with a filter nobody + // expressed. It throws rather than responding so both keep + // the envelope this route's other filter refusals already + // use; see the helper for why. + assertFilterParamSuppliedOnce(req.query); + const listRequest: ServerScopedDataRequest = { + object: req.params.object, + query: req.query, + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + const result = await p.findData(listRequest); + res.json(result); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Query records', + tags: ['data', 'crud'], + }, + }); + } + + // GET /data/:object/:id - Get single record + if (operations.read) { + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/:object/:id`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + // [#6877] NOT gated, and this is a measured verdict + // rather than a name-based one: `GetDataRequest.select` / + // `.expand` are declared `z.array(z.string())`, and the + // consumer signature is + // `getData({ …, expand?: string | string[], select?: string | string[] })` + // — it splits the comma form itself and passes an array + // straight through. `?select=a&select=b` is therefore + // ALREADY correct end to end, and refusing it (or + // flattening it) would be the regression. The card listed + // this line under "array flows downstream"; measurement + // says the downstream was built for it. + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'get')) return; + // [#7606] Closed parameter set, AFTER the capability + // gates for the same reason #7527 put it there: which + // parameters a route understands is information, and a + // caller who may not read this object should not learn + // the shape of its ingress before being refused. + // + // This gate RESPONDS rather than throwing, which on this + // route is load-bearing and not a style choice: the catch + // below rewrites every 400 into a 404 (a bad id is a + // miss, not a malformed request). A refusal routed + // through it would reach the caller as "no such record" + // — the silent-drop defect wearing a different status. + if (refuseUnknownQueryParams(req, res, DATA_RECORD_READ_PARAMS)) return; + const { select, expand } = req.query || {}; + const getRequest: ServerScopedDataRequest = { + object: req.params.object, + id: req.params.id, + ...(select != null ? { select } : {}), + ...(expand != null ? { expand } : {}), + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + const result = await p.getData(getRequest); + res.json(result); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Get record by ID', + tags: ['data', 'crud'], + }, + }); + } + + // POST /data/:object - Create record + if (operations.create) { + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/:object`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return; + // [#3899] The wire body IS the record. Validate the + // assembled protocol request (`CreateDataRequestSchema`, + // catalog `requestSchema`) so a non-record body — an + // array, a string, a number — answers 400 instead of + // reaching the engine as `data`. Per-field checks stay + // downstream (object metadata / validation rules); this + // gate is about the SHAPE the contract declares. + // + // [#23574] …the body AS THE CALLER SENT IT. It was + // `req.body ?? {}`, so a JSON `null` body parsed as an + // empty record and created an empty row (`201`). `null` + // is not the record `data` declares, so it is refused + // like an array, a string or a number, and the runtime + // dispatcher's twin (`domains/data.ts`) asks this gate + // over the same assembled request. An empty request + // body is not `null`: the HTTP adapter hands it over + // as `{}`, a record. + const { CreateDataRequestSchema } = await import('@objectstack/spec/api'); + const createInput = { object: req.params.object, data: req.body }; + const parsedCreate = (CreateDataRequestSchema as any).safeParse(createInput); + if (!parsedCreate.success) { + res.status(400).json({ + error: 'Invalid create request', + code: 'VALIDATION_FAILED', + fields: zodIssuesToFields(parsedCreate.error?.issues, createInput), + object: req.params?.object, + }); + return; + } + const createRequest: ServerScopedDataRequest = { + object: req.params.object, + data: req.body, + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + const result = await p.createData(createRequest); + // [#3431] Advertise fields the engine's create-side static- + // `readonly` strip dropped (`engine.insert`, relayed by + // `createData` as `droppedFields`) via the response header + // (body also carries `droppedFields`). Status stays 201. + applyDroppedFieldsHeader(res, result); + res.status(201).json(result); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Create record', + tags: ['data', 'crud'], + }, + }); + } + + // POST /data/:object/query — Spec-shape advanced query (QueryAST in body). + // Supports server-side aggregation via { groupBy, aggregations, where, ... } + // per spec/data/query.zod.ts. Mirrors what `client.data.query()` posts. + // Returns FindDataResponse = { object, records, total? }. + if (operations.list) { + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/:object/query`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return; + // [#3899] Validate the QueryAST body against the declared + // contract (`FindDataRequestSchema`, catalog `requestSchema`). + // A malformed body used to be forwarded as-is — and since a + // dropped/mistyped clause does not narrow a query, it WIDENS + // it, `{"filter": …}` degraded into an unfiltered full read + // with a 200. The PATH object is written last (#3946) so a + // body `object` can neither dodge the `enforceApiAccess` + // gate above nor move the read. Validation only: the merged + // ORIGINAL body is forwarded, not the parse output, so the + // schema cannot inject defaults the engine did not receive + // before (the analytics-entry precedent, #3878). + // + // [#23574] …the body AS THE CALLER SENT IT. It was + // `req.body ?? {}`, so a JSON `null` body read as no + // query and answered the full record set (`200`). + // `query` declares a QueryAST or nothing, never `null`, + // so `null` is refused like a string or a number, and + // the runtime dispatcher's twin (`domains/data.ts`) now + // asks this gate over the same assembled request. An + // empty request body is not `null`: the HTTP adapter + // hands it over as `{}`, an empty query. + const { FindDataRequestSchema } = await import('@objectstack/spec/api'); + const rawQuery = req.body; + const query = (rawQuery && typeof rawQuery === 'object' && !Array.isArray(rawQuery)) + ? { ...rawQuery, object: req.params.object } + : rawQuery; // non-object bodies (`null` included) go to the schema as-is and fail with `query: invalid_type` + const findInput = { object: req.params.object, query }; + const parsedFind = (FindDataRequestSchema as any).safeParse(findInput); + if (!parsedFind.success) { + res.status(400).json({ + error: 'Invalid query request', + code: 'VALIDATION_FAILED', + fields: zodIssuesToFields(parsedFind.error?.issues, findInput), + object: req.params?.object, + }); + return; + } + const queryRequest: ServerScopedDataRequest = { + object: req.params.object, + query, + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + const result = await p.findData(queryRequest); + res.json(result); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Advanced query (QueryAST in body)', + tags: ['data', 'crud'], + }, + }); + } + + // PATCH /data/:object/:id - Update record + if (operations.update) { + this.routeManager.register({ + method: 'PATCH', + path: `${dataPath}/:object/:id`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + // OCC: clients opt in by sending either the standard + // `If-Match` header or an `expectedVersion` field in + // the JSON body. Body wins when both are present + // (lets callers override per-request without + // touching headers). See ConcurrentUpdateError in + // packages/objectql/src/protocol.ts. + const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; + const bodyVersion = (req.body && typeof req.body === 'object') + ? (req.body as any).expectedVersion + : undefined; + const expectedVersion = bodyVersion ?? ifMatchHeader; + // Strip the meta field out of the data payload so it + // doesn't get written as a column. + let data = req.body; + if (data && typeof data === 'object' && 'expectedVersion' in (data as any)) { + const { expectedVersion: _drop, ...rest } = data as any; + data = rest; + } + // [#23492] A token that can never carry a version is + // refused here, before anything is asked, and every + // other present token is forwarded below: no token the + // caller sent is read as "no token". THROWN as + // `validationFailure`, so this route's catch answers it + // through `mapDataError`, in the envelope it already + // gives the protocol's own refusal of `""`. + const versionRefusal = versionTokenRefusal( + expectedVersion, + bodyVersion != null ? 'The body\'s `expectedVersion`' : 'The `If-Match` header', + ); + if (versionRefusal) throw validationFailure(versionRefusal, []); + if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return; + // [#3899] Same gate as create: the wire body is the bare + // field patch (`expectedVersion` already stripped above), + // validated as the assembled `UpdateDataRequestSchema` + // request so a non-record body 400s instead of reaching + // the engine. + // + // [#23491] …the patch AS THE CALLER SENT IT. It was + // `data ?? {}`, so a JSON `null` body parsed as an empty + // patch and answered `200`: an update that changed no + // field and still moved the record's version. `null` is + // not the record the contract declares, so it is refused + // like an array, a string or a number, and the runtime + // dispatcher's twin (`domains/data.ts`) asks this gate + // over the same assembled request. + // + // [#23517] …and the token as the caller sent it, never + // its `String()`: this gate parsed `String(5)`, so a + // number `expectedVersion: z.string()` declares no room + // for was forwarded and judged `409`. + // `versionTokenRefusal` above already refuses it, so what + // reaches this gate and the forward below is a non-blank + // string. + const { UpdateDataRequestSchema } = await import('@objectstack/spec/api'); + const updateInput = { + object: req.params.object, + id: req.params.id, + data, + ...(expectedVersion != null ? { expectedVersion } : {}), + }; + const parsedUpdateOne = (UpdateDataRequestSchema as any).safeParse(updateInput); + if (!parsedUpdateOne.success) { + res.status(400).json({ + error: 'Invalid update request', + code: 'VALIDATION_FAILED', + fields: zodIssuesToFields(parsedUpdateOne.error?.issues, updateInput), + object: req.params?.object, + }); + return; + } + const updateRequest: ServerScopedDataRequest = { + object: req.params.object, + id: req.params.id, + data, + ...(expectedVersion != null ? { expectedVersion } : {}), + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + const result = await p.updateData(updateRequest); + // [#3431] Advertise any LEGALLY-stripped write fields via + // the response header before serialising (the body also + // carries `droppedFields`). Status stays 200. + applyDroppedFieldsHeader(res, result); + res.json(result); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Update record', + tags: ['data', 'crud'], + }, + }); + } + + // DELETE /data/:object/:id - Delete record + if (operations.delete) { + this.routeManager.register({ + method: 'DELETE', + path: `${dataPath}/:object/:id`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + // OCC: same opt-in protocol as PATCH (`If-Match` + // header or `expectedVersion` query string). DELETE + // has no JSON body, so we only accept the header + // and a query parameter. + // [#6877] `String(['1','2'])` is `'1,2'` — an OCC token + // no row carries, so a repeated `?expectedVersion=` turned + // an optimistic-concurrency check into a guaranteed + // conflict on a DESTRUCTIVE verb. + if (refuseRepeatedQueryParams(req, res, ['expectedVersion'])) return; + const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; + const queryVersion = (req.query && typeof req.query === 'object') + ? (req.query as any).expectedVersion + : undefined; + const expectedVersion = queryVersion ?? ifMatchHeader; + // [#23492] The `PATCH` above's rule, thrown the same + // way: a token that can never carry a version is + // refused, every other present token is forwarded. + const versionRefusal = versionTokenRefusal( + expectedVersion, + queryVersion != null ? 'The `expectedVersion` query parameter' : 'The `If-Match` header', + ); + if (versionRefusal) throw validationFailure(versionRefusal, []); + if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; + const deleteRequest: ServerScopedDataRequest = { + object: req.params.object, + id: req.params.id, + ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + const result = await p.deleteData(deleteRequest); + res.json(result); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Delete record', + tags: ['data', 'crud'], + }, + }); + } +} diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 627c7600619..6107005c1b2 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -116,6 +116,9 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * [#23050 slice 11] …and `registerFormEndpoints`' body to `form-routes.ts`, * with none: the public form doors resolve no caller (their authorization is * the form's own declaration). + * + * [#23050 slice 12] …and `registerCrudEndpoints`' body to `crud-routes.ts`, + * with its six: one per CRUD door, each bare and guarded on its next line. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -130,7 +133,8 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, * `registerOpenApiEndpoints`, `registerEmailEndpoints`, - * `registerSearchEndpoints`, `registerUiEndpoints`) hands its moved body + * `registerSearchEndpoints`, `registerUiEndpoints`, + * `registerCrudEndpoints`) hands its moved body * forwards each member to the server with `(...a) => this.(...a)`, so * a moved site's stack shows * that forwarder above the site itself. The site is the first censused frame @@ -713,9 +717,20 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // mention moved and `rest-server.ts` stays at 60. Still twelve // forwarders: (12 - 1) + 3 - 2, the module counted in // `MOVED_REGISTRARS_READING_NO_EXECCTX`. - expect(SITE_FILES).toHaveLength(12); + // + // [#23050 slice 12] 62 sites UNCHANGED / 125 → 129 mentions — the same MOVE. + // `registerCrudEndpoints`' body left for `crud-routes.ts` with its six BARE + // sites (each guarded on its next line, as before): + // 16 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 = 62 sites. The four new + // mentions are this slice's seam, the two spellings once (its host + // literal's forwarder, two; its host interface's member, two): + // `rest-server.ts` goes 60 → 56 (six sites out, one forwarder's two + // mentions in). Thirteen forwarders now: (13 - 1) + 3 - 2, the module + // carrying one registrar that reads the seam, so neither map gains an + // entry. + expect(SITE_FILES).toHaveLength(13); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(125); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(129); 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 6e20ba89341..200803e9706 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -259,7 +259,10 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // [#23050 slice 11] Nor do the public form doors (`form-routes.ts`): they // read the environment's view and object lists, never a draft: // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0, length 11 → 12. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] Nor do the six CRUD doors (`crud-routes.ts`): they + // read and write records, never a draft: + // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 12 → 13. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 1069d7d8c0a..a22e2a9f58e 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -164,9 +164,17 @@ interface CensusEntry { * public form doors (`form-routes.ts`) build no `query:` literal either — they * list the environment's views and objects by `type` and insert through * `createData`, never through a `query:` slot. + * + * [#23050 slice 12] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1, a + * MOVE: `rest-server.ts`' one slot, the list door's caller-supplied + * `query: req.query`, left with `registerCrudEndpoints`' body for + * `crud-routes.ts` (the QueryAST door's `query` is a shorthand member, which + * `querySlots` does not read, as before). Each floor equals its file's + * measured count, so `rest-server.ts`' goes 1 → 0 and the moved slot keeps + * its floor of 1 there. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { - 'rest-server.ts': 1, + 'rest-server.ts': 0, 'data-action-routes.ts': 3, 'batch-routes.ts': 0, 'approvals-routes.ts': 0, @@ -178,6 +186,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 9] Length 9 → 10, the sum unchanged. // [#23050 slice 10] Length 10 → 11, the sum unchanged. // [#23050 slice 11] Length 11 → 12, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] Length 12 → 13, the sum unchanged (its one slot moved + // from `rest-server.ts` to `crud-routes.ts`). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); 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 7367fc00e18..7338d8a8389 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -187,6 +187,11 @@ describe('rest-server.ts docblock position', () => { * the module header, the host interface's block and the function's. The public * form body held no TSDoc block of its own, so `rest-server.ts` keeps every * block it had (163). + * + * [#23050 slice 12] `crud-routes.ts` carries three, as the form module does: + * the module header, the host interface's block and the function's. The CRUD + * body held no TSDoc block of its own, so `rest-server.ts` keeps every block it + * had (163). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -200,6 +205,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -235,9 +241,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 9] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 = 40 over nine, length 9 → 10. // [#23050 slice 10] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 = 44 over ten, length 10 → 11. // [#23050 slice 11] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 + 3 = 47 over eleven, length 11 → 12. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 + 3 + 3 = 50 over twelve, length 12 → 13. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); 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(47); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(50); }); }); 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 2ba702856fc..7e625815b97 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -336,7 +336,10 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // [#23050 slice 11] Nor do the public form doors (`form-routes.ts`): they // read only the slug path parameter, as a string: // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 11 → 12. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] Nor do the six CRUD doors (`crud-routes.ts`): they + // hand the query to the protocol whole and coerce no number of it: + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 12 → 13. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); 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 38fc966732f..003db8dc0a0 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -51,6 +51,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'ui-routes.ts', // [#23050 slice 11] `registerFormEndpoints`' body. 'form-routes.ts', + // [#23050 slice 12] `registerCrudEndpoints`' body. + 'crud-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5a0f948c2e8..bc2c2161436 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -99,10 +99,9 @@ import { RouteManager, type RouteEntry } from './route-manager.js'; // read points. Each handler below declares WHICH of its parameters are // single-valued; genuinely multi-valued ones (`select`, `expand`, `objects`, // `fields`, `searchFields`, `approverId`) are deliberately never listed. -// [#7390] The filter slot is the one arity judgement the shared normalizer -// structurally cannot make (a filter AST IS an array), so the querystring -// ingress — the only layer that knows it is one — makes it instead. -import { refuseRepeatedQueryParams, assertFilterParamSuppliedOnce } from './query-multiplicity.js'; +// `assertFilterParamSuppliedOnce` (#7390), the `/data` list's filter-slot arity rule, read only +// by that door, moved with `registerCrudEndpoints`' body to `crud-routes.ts` (#23050 slice 12). +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; // [#7527] The other half of the same discipline: a parameter this route does // not KNOW is refused rather than dropped. See `query-allowlist.ts` for why an // ignored filter is the one wrong answer a caller cannot detect. @@ -151,17 +150,10 @@ import type { } from '@objectstack/spec/api'; // `GetUiViewRequest`, the UI view door's declared request, moved with that door to // `ui-routes.ts` (#23050 slice 10). -// [#15866] The same discipline for the DATA doors. Every literal these routes -// assemble is now compiled against the declared request type through -// `ServerScopedDataRequest` below, so a member the contract does not declare is -// a compile error at the call site instead of a payload no schema has seen. -import type { - FindDataRequest, - GetDataRequest, - CreateDataRequest, - UpdateDataRequest, - DeleteDataRequest, -} from '@objectstack/spec/api'; +// [#15866] The five data request types (`FindDataRequest`, `GetDataRequest`, `CreateDataRequest`, +// `UpdateDataRequest`, `DeleteDataRequest`), which every CRUD door's literal is compiled against +// through `ServerScopedDataRequest`, moved with `registerCrudEndpoints`' body to `crud-routes.ts` +// (#23050 slice 12). // The four bulk request types (`BatchDataRequest`, `CreateManyDataRequest`, // `UpdateManyDataRequest`, `DeleteManyDataRequest`) moved with their doors to // `batch-routes.ts` (#23050 slice 2). @@ -266,8 +258,9 @@ export type RestProtocol = DataProtocol & MetadataProtocol; // copy is not an option. // [#23050 slice 1] `ServerScopedDataRequest`, the data doors' request envelope, moved to -// `data-action-routes.ts` (imported below): exporting it from this file for that module -// changed the published `dist/*.d.ts` by one line, and a second copy is not an option. +// `data-action-routes.ts`: exporting it from this file for that module changed the published +// `dist/*.d.ts` by one line, and a second copy is not an option. Its last reader here, the CRUD +// doors' body, moved to `crud-routes.ts` (#23050 slice 12), which imports it from there. import { buildFieldMetaMap, @@ -277,7 +270,7 @@ import { type ExportFieldMeta, } from './export-format.js'; // [#23050 slice 1] The data-action routes' body moved to its own module — see its header. -import { registerDataActionEndpoints, type DataActionRouteHost, type ServerScopedDataRequest } from './data-action-routes.js'; +import { registerDataActionEndpoints, type DataActionRouteHost } from './data-action-routes.js'; // [#23050 slice 2] The batch routes' body moved to its own module — see its header. import { registerBatchEndpoints, type BatchRouteHost } from './batch-routes.js'; // [#23050 slice 3] The approval routes' body moved to its own module — see its header. @@ -314,6 +307,8 @@ import { registerSearchEndpoints, type SearchRouteHost } from './search-routes.j import { registerUiEndpoints, type TransportScopedMetaRequest, type UiRouteHost } from './ui-routes.js'; // [#23050 slice 11] The public form routes' body moved to its own module — see its header. import { registerFormEndpoints, type FormRouteHost } from './form-routes.js'; +// [#23050 slice 12] The CRUD routes' body moved to its own module — see its header. +import { registerCrudEndpoints, type CrudRouteHost } from './crud-routes.js'; 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. @@ -355,10 +350,11 @@ import { sendFieldVisibilityFault, handleRouteError, thrownAnswerIsBareNotFound, - logUnexpectedRouteError, + // `logUnexpectedRouteError` and `applyDroppedFieldsHeader`, read only by the CRUD doors' catches + // and their two write answers, moved with `registerCrudEndpoints`' body to `crud-routes.ts` + // (#23050 slice 12). // `isExpectedRouteError`, read only by the public form submit door's catch, moved with // `registerFormEndpoints`' body to `form-routes.ts` (#23050 slice 11). - applyDroppedFieldsHeader, } from './error-response.js'; export { mapDataError }; @@ -1766,7 +1762,7 @@ function sendMetaItemAbsent(res: any): void { * token alike; `data-update-expected-version.test.ts` and * `data-delete-expected-version.test.ts` hold them to it. */ -function versionTokenRefusal(token: unknown, spelling: string): string | undefined { +export function versionTokenRefusal(token: unknown, spelling: string): string | undefined { if (token === undefined || token === null) return undefined; if (typeof token === 'string' && token.trim() !== '') return undefined; const shape = typeof token === 'string' ? 'empty' @@ -8321,441 +8317,19 @@ export class RestServer { * Register CRUD endpoints for data operations */ private registerCrudEndpoints(basePath: string): void { - const { crud } = this.config; - const dataPath = `${basePath}${crud.dataPrefix}`; - const isScoped = basePath.includes('/environments/:environmentId'); - - const operations = crud.operations; - - // GET /data/:object - List/query records - if (operations.list) { - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/:object`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return; - // [#6877] Still NOT arity-gated as a whole, for the - // reason it never was: this route hands the WHOLE query - // record to `findData`, whose normalizer - // (`metadata-protocol`) owns every parameter's arity — the - // `$`-alias table, the implicit field-equality bucket, and - // the `$select`/`$expand`/`$searchFields` params that are - // legitimately multi-valued. Declaring an arity list here - // would be this file guessing at another package's - // contract. - // - // [#7390] ONE slot is the exception, and structurally so - // rather than by taste. A filter AST *is* an array - // (`['status','=','open']`), so #7386's arity gate cannot - // read `Array.isArray` as evidence of repetition on the - // filter slot: the normalizer serves this querystring and - // `POST /data/:object/query`'s arbitrary-JSON body through - // one door and cannot tell them apart. THIS layer can — on - // a querystring an array is a repeated parameter and can be - // nothing else — so the filter slot's arity is judged here, - // and only here, leaving the normalizer free of a heuristic. - // - // Refused, never resolved (maintainer ruling 2026-08-11 on - // #7390): last-wins and AND-merge are each a silent choice - // between two intents a caller actually expressed. Before - // this line the common shape was answered with the WRONG - // diagnosis — `malformedFilterArrayError`, telling a caller - // whose filters were both well-formed to check their AST - // syntax — and the rarer `?filter=status&filter=%3D&filter=open` - // spelled a valid AST and returned 200 with a filter nobody - // expressed. It throws rather than responding so both keep - // the envelope this route's other filter refusals already - // use; see the helper for why. - assertFilterParamSuppliedOnce(req.query); - const listRequest: ServerScopedDataRequest = { - object: req.params.object, - query: req.query, - ...(environmentId ? { environmentId } : {}), - ...(context ? { context } : {}), - }; - const result = await p.findData(listRequest); - res.json(result); - } catch (error: any) { - const mapped = mapDataError(error, req.params?.object); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Query records', - tags: ['data', 'crud'], - }, - }); - } - - // GET /data/:object/:id - Get single record - if (operations.read) { - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/:object/:id`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - // [#6877] NOT gated, and this is a measured verdict - // rather than a name-based one: `GetDataRequest.select` / - // `.expand` are declared `z.array(z.string())`, and the - // consumer signature is - // `getData({ …, expand?: string | string[], select?: string | string[] })` - // — it splits the comma form itself and passes an array - // straight through. `?select=a&select=b` is therefore - // ALREADY correct end to end, and refusing it (or - // flattening it) would be the regression. The card listed - // this line under "array flows downstream"; measurement - // says the downstream was built for it. - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'get')) return; - // [#7606] Closed parameter set, AFTER the capability - // gates for the same reason #7527 put it there: which - // parameters a route understands is information, and a - // caller who may not read this object should not learn - // the shape of its ingress before being refused. - // - // This gate RESPONDS rather than throwing, which on this - // route is load-bearing and not a style choice: the catch - // below rewrites every 400 into a 404 (a bad id is a - // miss, not a malformed request). A refusal routed - // through it would reach the caller as "no such record" - // — the silent-drop defect wearing a different status. - if (refuseUnknownQueryParams(req, res, DATA_RECORD_READ_PARAMS)) return; - const { select, expand } = req.query || {}; - const getRequest: ServerScopedDataRequest = { - object: req.params.object, - id: req.params.id, - ...(select != null ? { select } : {}), - ...(expand != null ? { expand } : {}), - ...(environmentId ? { environmentId } : {}), - ...(context ? { context } : {}), - }; - const result = await p.getData(getRequest); - res.json(result); - } catch (error: any) { - const mapped = mapDataError(error, req.params?.object); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Get record by ID', - tags: ['data', 'crud'], - }, - }); - } - - // POST /data/:object - Create record - if (operations.create) { - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/:object`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return; - // [#3899] The wire body IS the record. Validate the - // assembled protocol request (`CreateDataRequestSchema`, - // catalog `requestSchema`) so a non-record body — an - // array, a string, a number — answers 400 instead of - // reaching the engine as `data`. Per-field checks stay - // downstream (object metadata / validation rules); this - // gate is about the SHAPE the contract declares. - // - // [#23574] …the body AS THE CALLER SENT IT. It was - // `req.body ?? {}`, so a JSON `null` body parsed as an - // empty record and created an empty row (`201`). `null` - // is not the record `data` declares, so it is refused - // like an array, a string or a number, and the runtime - // dispatcher's twin (`domains/data.ts`) asks this gate - // over the same assembled request. An empty request - // body is not `null`: the HTTP adapter hands it over - // as `{}`, a record. - const { CreateDataRequestSchema } = await import('@objectstack/spec/api'); - const createInput = { object: req.params.object, data: req.body }; - const parsedCreate = (CreateDataRequestSchema as any).safeParse(createInput); - if (!parsedCreate.success) { - res.status(400).json({ - error: 'Invalid create request', - code: 'VALIDATION_FAILED', - fields: zodIssuesToFields(parsedCreate.error?.issues, createInput), - object: req.params?.object, - }); - return; - } - const createRequest: ServerScopedDataRequest = { - object: req.params.object, - data: req.body, - ...(environmentId ? { environmentId } : {}), - ...(context ? { context } : {}), - }; - const result = await p.createData(createRequest); - // [#3431] Advertise fields the engine's create-side static- - // `readonly` strip dropped (`engine.insert`, relayed by - // `createData` as `droppedFields`) via the response header - // (body also carries `droppedFields`). Status stays 201. - applyDroppedFieldsHeader(res, result); - res.status(201).json(result); - } catch (error: any) { - const mapped = mapDataError(error, req.params?.object); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Create record', - tags: ['data', 'crud'], - }, - }); - } - - // POST /data/:object/query — Spec-shape advanced query (QueryAST in body). - // Supports server-side aggregation via { groupBy, aggregations, where, ... } - // per spec/data/query.zod.ts. Mirrors what `client.data.query()` posts. - // Returns FindDataResponse = { object, records, total? }. - if (operations.list) { - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/:object/query`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return; - // [#3899] Validate the QueryAST body against the declared - // contract (`FindDataRequestSchema`, catalog `requestSchema`). - // A malformed body used to be forwarded as-is — and since a - // dropped/mistyped clause does not narrow a query, it WIDENS - // it, `{"filter": …}` degraded into an unfiltered full read - // with a 200. The PATH object is written last (#3946) so a - // body `object` can neither dodge the `enforceApiAccess` - // gate above nor move the read. Validation only: the merged - // ORIGINAL body is forwarded, not the parse output, so the - // schema cannot inject defaults the engine did not receive - // before (the analytics-entry precedent, #3878). - // - // [#23574] …the body AS THE CALLER SENT IT. It was - // `req.body ?? {}`, so a JSON `null` body read as no - // query and answered the full record set (`200`). - // `query` declares a QueryAST or nothing, never `null`, - // so `null` is refused like a string or a number, and - // the runtime dispatcher's twin (`domains/data.ts`) now - // asks this gate over the same assembled request. An - // empty request body is not `null`: the HTTP adapter - // hands it over as `{}`, an empty query. - const { FindDataRequestSchema } = await import('@objectstack/spec/api'); - const rawQuery = req.body; - const query = (rawQuery && typeof rawQuery === 'object' && !Array.isArray(rawQuery)) - ? { ...rawQuery, object: req.params.object } - : rawQuery; // non-object bodies (`null` included) go to the schema as-is and fail with `query: invalid_type` - const findInput = { object: req.params.object, query }; - const parsedFind = (FindDataRequestSchema as any).safeParse(findInput); - if (!parsedFind.success) { - res.status(400).json({ - error: 'Invalid query request', - code: 'VALIDATION_FAILED', - fields: zodIssuesToFields(parsedFind.error?.issues, findInput), - object: req.params?.object, - }); - return; - } - const queryRequest: ServerScopedDataRequest = { - object: req.params.object, - query, - ...(environmentId ? { environmentId } : {}), - ...(context ? { context } : {}), - }; - const result = await p.findData(queryRequest); - res.json(result); - } catch (error: any) { - const mapped = mapDataError(error, req.params?.object); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Advanced query (QueryAST in body)', - tags: ['data', 'crud'], - }, - }); - } - - // PATCH /data/:object/:id - Update record - if (operations.update) { - this.routeManager.register({ - method: 'PATCH', - path: `${dataPath}/:object/:id`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - // OCC: clients opt in by sending either the standard - // `If-Match` header or an `expectedVersion` field in - // the JSON body. Body wins when both are present - // (lets callers override per-request without - // touching headers). See ConcurrentUpdateError in - // packages/objectql/src/protocol.ts. - const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; - const bodyVersion = (req.body && typeof req.body === 'object') - ? (req.body as any).expectedVersion - : undefined; - const expectedVersion = bodyVersion ?? ifMatchHeader; - // Strip the meta field out of the data payload so it - // doesn't get written as a column. - let data = req.body; - if (data && typeof data === 'object' && 'expectedVersion' in (data as any)) { - const { expectedVersion: _drop, ...rest } = data as any; - data = rest; - } - // [#23492] A token that can never carry a version is - // refused here, before anything is asked, and every - // other present token is forwarded below: no token the - // caller sent is read as "no token". THROWN as - // `validationFailure`, so this route's catch answers it - // through `mapDataError`, in the envelope it already - // gives the protocol's own refusal of `""`. - const versionRefusal = versionTokenRefusal( - expectedVersion, - bodyVersion != null ? 'The body\'s `expectedVersion`' : 'The `If-Match` header', - ); - if (versionRefusal) throw validationFailure(versionRefusal, []); - if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return; - // [#3899] Same gate as create: the wire body is the bare - // field patch (`expectedVersion` already stripped above), - // validated as the assembled `UpdateDataRequestSchema` - // request so a non-record body 400s instead of reaching - // the engine. - // - // [#23491] …the patch AS THE CALLER SENT IT. It was - // `data ?? {}`, so a JSON `null` body parsed as an empty - // patch and answered `200`: an update that changed no - // field and still moved the record's version. `null` is - // not the record the contract declares, so it is refused - // like an array, a string or a number, and the runtime - // dispatcher's twin (`domains/data.ts`) asks this gate - // over the same assembled request. - // - // [#23517] …and the token as the caller sent it, never - // its `String()`: this gate parsed `String(5)`, so a - // number `expectedVersion: z.string()` declares no room - // for was forwarded and judged `409`. - // `versionTokenRefusal` above already refuses it, so what - // reaches this gate and the forward below is a non-blank - // string. - const { UpdateDataRequestSchema } = await import('@objectstack/spec/api'); - const updateInput = { - object: req.params.object, - id: req.params.id, - data, - ...(expectedVersion != null ? { expectedVersion } : {}), - }; - const parsedUpdateOne = (UpdateDataRequestSchema as any).safeParse(updateInput); - if (!parsedUpdateOne.success) { - res.status(400).json({ - error: 'Invalid update request', - code: 'VALIDATION_FAILED', - fields: zodIssuesToFields(parsedUpdateOne.error?.issues, updateInput), - object: req.params?.object, - }); - return; - } - const updateRequest: ServerScopedDataRequest = { - object: req.params.object, - id: req.params.id, - data, - ...(expectedVersion != null ? { expectedVersion } : {}), - ...(environmentId ? { environmentId } : {}), - ...(context ? { context } : {}), - }; - const result = await p.updateData(updateRequest); - // [#3431] Advertise any LEGALLY-stripped write fields via - // the response header before serialising (the body also - // carries `droppedFields`). Status stays 200. - applyDroppedFieldsHeader(res, result); - res.json(result); - } catch (error: any) { - const mapped = mapDataError(error, req.params?.object); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Update record', - tags: ['data', 'crud'], - }, - }); - } - - // DELETE /data/:object/:id - Delete record - if (operations.delete) { - this.routeManager.register({ - method: 'DELETE', - path: `${dataPath}/:object/:id`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - // OCC: same opt-in protocol as PATCH (`If-Match` - // header or `expectedVersion` query string). DELETE - // has no JSON body, so we only accept the header - // and a query parameter. - // [#6877] `String(['1','2'])` is `'1,2'` — an OCC token - // no row carries, so a repeated `?expectedVersion=` turned - // an optimistic-concurrency check into a guaranteed - // conflict on a DESTRUCTIVE verb. - if (refuseRepeatedQueryParams(req, res, ['expectedVersion'])) return; - const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; - const queryVersion = (req.query && typeof req.query === 'object') - ? (req.query as any).expectedVersion - : undefined; - const expectedVersion = queryVersion ?? ifMatchHeader; - // [#23492] The `PATCH` above's rule, thrown the same - // way: a token that can never carry a version is - // refused, every other present token is forwarded. - const versionRefusal = versionTokenRefusal( - expectedVersion, - queryVersion != null ? 'The `expectedVersion` query parameter' : 'The `If-Match` header', - ); - if (versionRefusal) throw validationFailure(versionRefusal, []); - if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; - const deleteRequest: ServerScopedDataRequest = { - object: req.params.object, - id: req.params.id, - ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), - ...(environmentId ? { environmentId } : {}), - ...(context ? { context } : {}), - }; - const result = await p.deleteData(deleteRequest); - res.json(result); - } catch (error: any) { - const mapped = mapDataError(error, req.params?.object); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Delete record', - tags: ['data', 'crud'], - }, - }); - } + // [#23050 slice 12] The body moved to `crud-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `CrudRouteHost`, and that module's header for why no + // member is a getter). + const host: CrudRouteHost = { + config: this.config, + routeManager: this.routeManager, + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + enforceApiAccess: (...a) => this.enforceApiAccess(...a), + }; + registerCrudEndpoints.call(host, basePath); } /** 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 7395376cee6..4d09ac2f200 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -372,7 +372,10 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // [#23050 slice 11] 11 → 12 (`form-routes.ts`): 59 and 74 unchanged — // the public form body reads neither term, so its literal forwards // neither. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] 12 → 13 (`crud-routes.ts`): 60 and 75, +1 each for + // that slice's forwarder (the six CRUD doors' call sites moved with the + // body and are counted in both readings). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); 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 91b6637bafd..7df8fa3251e 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1142,7 +1142,11 @@ describe('[#13214] §4 preconditions', () => { // [#23050 slice 11] 11 → 12 (`form-routes.ts`): 59 and 74 unchanged — // the public form body reads neither term, so its literal forwards // neither; the ownership guard still 1 call site and 1 forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); + // [#23050 slice 12] 12 → 13 (`crud-routes.ts`): 60 and 75, +1 each for + // that slice's forwarder (the six CRUD doors' call sites moved with the + // body and are counted in both readings); the CRUD body never calls the + // ownership guard, so it is still 1 call site and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(13); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/packages/spec/liveness/crud_endpoints.json b/packages/spec/liveness/crud_endpoints.json index 246f02e1f69..8707e7a9fe4 100644 --- a/packages/spec/liveness/crud_endpoints.json +++ b/packages/spec/liveness/crud_endpoints.json @@ -8,7 +8,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerCrudEndpoints (`if (operations.create)` gates the POST data route mount — an author who sets it false gets no create route at all)", + "evidence": "packages/rest/src/crud-routes.ts#registerCrudEndpoints (`if (operations.create)` gates the POST data route mount — an author who sets it false gets no create route at all)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Per key, not per object: since ADR-0122 `crud.operations` is the author state, so the parse applies each `.default(true)` whenever the object is present and normalizeConfig covers the one case the schema leaves open (an absent `operations` object). REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -16,7 +16,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerCrudEndpoints (`if (operations.read)` gates the GET-by-id route mount)", + "evidence": "packages/rest/src/crud-routes.ts#registerCrudEndpoints (`if (operations.read)` gates the GET-by-id route mount)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Same shape as `create`. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -24,7 +24,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerCrudEndpoints (`if (operations.update)` gates the PATCH route mount)", + "evidence": "packages/rest/src/crud-routes.ts#registerCrudEndpoints (`if (operations.update)` gates the PATCH route mount)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Same shape as `create`. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -32,7 +32,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerCrudEndpoints (`if (operations.delete)` gates the DELETE route mount)", + "evidence": "packages/rest/src/crud-routes.ts#registerCrudEndpoints (`if (operations.delete)` gates the DELETE route mount)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Same shape as `create`. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -40,7 +40,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerCrudEndpoints (`if (operations.list)` gates the list route mounts)", + "evidence": "packages/rest/src/crud-routes.ts#registerCrudEndpoints (`if (operations.list)` gates the list route mounts)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Same shape as `create`. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." } @@ -56,7 +56,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerCrudEndpoints (`const dataPath = \\`${basePath}${crud.dataPrefix}\\`` — the base every data route is mounted under); also packages/rest/src/rest-server.ts#registerDataActionEndpoints, packages/rest/src/rest-server.ts#registerSharingEndpoints and packages/rest/src/rest-server.ts#registerBatchEndpoints (each builds its own dataPath the same way), and packages/rest/src/rest-server.ts#registerDiscoveryEndpoints (advertises it as `routes.data`)", + "evidence": "packages/rest/src/crud-routes.ts#registerCrudEndpoints (`const dataPath = \\`${basePath}${crud.dataPrefix}\\`` — the base every data route is mounted under); also packages/rest/src/rest-server.ts#registerDataActionEndpoints, packages/rest/src/rest-server.ts#registerSharingEndpoints and packages/rest/src/rest-server.ts#registerBatchEndpoints (each builds its own dataPath the same way), and packages/rest/src/rest-server.ts#registerDiscoveryEndpoints (advertises it as `routes.data`)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Five independent consumers, so this key is live in the strong sense: changing it moves the mounted paths AND the advertised discovery document together. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 47685a11e7f..06de9c98eb1 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -391,7 +391,15 @@ const MODULES = { // `FORM_NOT_FOUND`) left this file with `registerFormEndpoints`' body for // `form-routes.ts`, declared below at the count they had here; the sum is // unchanged (11 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 = 39). - stringError: 11, + // + // 11 → 8 (#23050 slice 12): NOT progress — the same MOVE as slices 1–3, 8, + // 10 and 11. The CRUD doors' three literal string `error` bodies (the 400 + // `VALIDATION_FAILED` answers of the create, QueryAST query and update + // request gates: `'Invalid create request'`, `'Invalid query request'`, + // `'Invalid update request'`) left this file with `registerCrudEndpoints`' + // body for `crud-routes.ts`, declared below at the count they had here; the + // sum is unchanged (8 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 = 39). + stringError: 8, // 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. @@ -488,7 +496,12 @@ const MODULES = { // `FORM_RESOLVE_FAILED` and the submit door's 400 `VALIDATION_ERROR`) left // with that body for `form-routes.ts`, declared below; the sum is unchanged // (7 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 = 54). - siblingCode: 7, + // + // 7 → 4 (#23050 slice 12): NOT progress — the same MOVE as `stringError` + // above. Those same three `{ error, code }` bodies (each request gate's 400 + // `VALIDATION_FAILED`) left with that body for `crud-routes.ts`, declared + // below; the sum is unchanged (4 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 = 54). + siblingCode: 4, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -721,6 +734,24 @@ const MODULES = { stringError: 4, siblingCode: 6, }, + + // [#23050 slice 12] `RestServer#registerCrudEndpoints`' body — the six record + // doors under `{basePath}{crud.dataPrefix}` (list, read, create, the QueryAST + // query, update, delete) — moved out of `rest-server.ts` whole, 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 11 → 8, siblingCode 7 → 4), so + // each sum is unchanged. Both only ever tick down. Discovered by the + // `*-routes.ts` convention and declared in the same PR that created the + // module, so the gap never exists. + 'packages/rest/src/crud-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 12 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: 3, + siblingCode: 3, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */