Skip to content

Commit a3fa4e2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16645-analytics-native-sql-authz
2 parents 97d0ea0 + ce8bfc9 commit a3fa4e2

69 files changed

Lines changed: 4514 additions & 592 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the audit binder stamps `created_by` from the session on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#16311)
6+
7+
The `beforeInsert` audit stamp was `record.created_by = record.created_by ?? session.userId` — client-preferred on every insert, with no flag and no privilege required — while its sibling one line down was already the `preserveAudit` ternary. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: an authenticated `POST /api/v1/data/OBJECT` carrying `created_by: 'forged_user'` stored exactly that, on an object whose `created_by` is the registry-injected `AUDIT_FIELD_DEFS` shape (`readonly: true`), while `updated_by` in the same payload was correctly overwritten with the session user. A row could claim it was created by a user who did not create it — audit integrity, not privilege escalation.
8+
9+
The stamp now takes the same shape as `updated_by`, one field over, and the same shape #15964 landed for `created_at`:
10+
11+
```ts
12+
record.created_by = preserveAudit ? (record.created_by ?? session.userId) : session.userId;
13+
```
14+
15+
**What changes for a caller.** An ordinary create no longer preserves a supplied `created_by` — the value is overwritten with the session user rather than deleted, so the column is still a real attribution stamp. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves.
16+
17+
**The session-less insert is deliberately unchanged, and that is load-bearing.** Both audit-user assignments stay inside `if (session?.userId)`. With no session the hook assigns nothing and the engine's readonly strip takes the caller's value, so the key is absent — already the correct outcome today, reached by a different path. A shape that assigned `session.userId` unconditionally would write `undefined` into the key, making it one the hook "wrote", and the strip would then spare it: a branch that is correct today would become a new hole. That row is pinned.
18+
19+
**The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_by`, exactly as it has for `updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= session.userId`.
20+
21+
**A creator that supplied a non-session `created_by` under an authenticated session must now ask for it** via `preserveAudit: true`. Creators that write an arbitrary `created_by` through a session-less system context (`{ isSystem: true }` with no `userId`) are untouched: the hook never entered that branch before this change either, and the `isSystem` strip exemption is what carries their value.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/client': minor
5+
---
6+
7+
`cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone.
8+
9+
A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way.
10+
11+
- **`@objectstack/spec`**`CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference.
12+
- **`@objectstack/metadata-protocol`**`cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log.
13+
- **`@objectstack/client`**`CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event.
14+
15+
Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
Dispatcher domain routes stop claiming their lexical neighbours: `DomainRoute.match` now defaults to `'segment'`, and the project-membership skip list gained the same boundary.
6+
7+
Ten shipped routes — `/actions`, `/ai`, `/analytics`, `/automation`, `/data`, `/i18n`, `/meta`, `/notifications`, `/packages`, `/ui` — carried the implicit `'prefix'` default, a bare `path.startsWith(prefix)` with no segment boundary. So `/datax`, `/metaxyz`, `/uifoo`, `/aixx` and `/packagesomething` were each claimed by a domain that does not own them, and a package mounting one of those namespaces later would have been shadowed by a domain that never wanted it. `/auth` was the eleventh member of the family and was repaired on its own; this closes the rest at the seam rather than one route at a time, so the eleventh domain someone adds is boundary-correct without having to remember anything.
8+
9+
- **The default moved, the mode did not go away.** `match: 'segment'` (the prefix exactly, plus everything under `prefix + '/'`) is the default; `match: 'prefix'` still buys the bare `startsWith` claim for a route that asks for it in writing. One shape genuinely needs it and now declares it: a prefix ending in `'?'` (`/keys?`, `/mcp?`, `/mcp/skill?`), which reproduces the legacy branch's query-string form for adapters that pass the query through in the path. There is no `/` after that `'?'`, so a segment match cannot express it — those three routes match exactly what they always did.
10+
- **What each narrowed claim used to answer, measured per domain rather than assumed.** They were not uniform: `/actionsx`, `/aixx`, `/automationx`, `/metaxyz` and `/packagesomething` answered `401`; `/i18nxx` and `/notificationsx` answered `501`; `/analyticsx` and `/uifoo` fell through unhandled. `/data` was the worst and the reason per-domain measurement was owed — its handler reads the sub-path as an OBJECT NAME, so `GET /datax` answered a **success envelope for a fabricated object** and `GET /datax/foo` **threw** `Record foo not found in x`. Each of the ten now answers the dispatcher's `ROUTE_NOT_FOUND` envelope, which is what they should always have answered. No caller depended on any of these: nothing in the repo builds a dispatch path by concatenating a domain prefix without a separator, and no route-ledger row or SDK method addresses a shape of this kind.
11+
- **The membership skip list, which was the same predicate with a worse consequence.** `enforceProjectMembership` skipped the control plane with `skipPaths.some(p => path.startsWith(p))` and `'/auth'` in the list, so `/authentication/foo` was waved **past the membership check** rather than merely routed somewhere wrong. It was latent — nothing claims `/authentication/*`, so such a request 404s first — and it would have gone live the day any domain claimed a path of that shape. The skip list stops at `'/'`, `'?'` or end-of-string now; the `'?'` form is part of the boundary on purpose, so `/auth?redirect=…` keeps the exemption it has today and the control plane is not newly gated.
12+
13+
Every domain still claims itself and every path under it, `/auth/me/permissions` included; the registry header comment that described the old rough edges as deliberate no longer describes code that has them.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(platform-objects): nine es-ES and ja-JP metadata-form leaves say what their source says
6+
7+
Nine leaves of `metadataForms` served a superseded English source revision in both es-ES
8+
and ja-JP. Each was a faithful translation of the sentence the source carried when it was
9+
extracted; the English moved afterwards and bundle merge fills gaps only, so a
10+
present-but-stale leaf is never refreshed by re-extraction.
11+
12+
The nine, by the test the census applies — does the string assert something the source
13+
does not, or drop a distinct concept the source names:
14+
15+
- `object.fields.fields.trackHistory.helpText` said "keep change history" in both. The
16+
source says `Summarize this field on the record activity timeline` — a different
17+
feature, not a loose translation.
18+
- `object.fields.isSystem.helpText` dropped `defaults sharing to public`.
19+
- `view.fields.filter.helpText` dropped the whole clause after the dash — the shared
20+
visual builder and its field-type-aware operators and value inputs.
21+
- `action.fields.body.helpText` said "JavaScript code to run", losing the L1-expression /
22+
L2-sandboxed-body distinction. It now reads as the sibling leaf
23+
`hook.fields.body.helpText` already renders that same source sentence in both locales.
24+
- `action.sections.advanced.description` asserted bulk operations, which the source does
25+
not name.
26+
- `page.fields.type.helpText` asserted the page-kind enum the source stopped listing and
27+
dropped the "List / Interface binds a source view into a curated surface" sentence.
28+
- `report.sections.basics.description` said "data source" where the source says report
29+
type.
30+
- `report.fields.columns.helpText` said "columns to show in the report", losing both
31+
`Dimension names across` and `matrix only`.
32+
- `email_template.fields.variables.helpText` described a list of variable names; the
33+
source is a JSON shape example, which is language-neutral and is now carried verbatim.
34+
35+
Values only — the key set is unchanged at 773 leaves, identical across all four bundles.
36+
The recorded-source-hash table is untouched and needs no entry: it records a digest only
37+
while a leaf is still a byte copy of its source, so all nine, being real translations,
38+
carry no entry and are LEGACY-TRUSTED by construction.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/formula": patch
3+
"@objectstack/lint": patch
4+
---
5+
6+
`firstUndeclaredReference` now documents the side of its contract it was silent about: it can false-NEGATIVE, and a `null` is "nothing was reported", not "every reference is rooted".
7+
8+
The existing sentence — "Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive on arithmetic/comparison overloads" — is true, and stays. What it never said is what that narrowing costs. cel-js's checker returns exactly ONE error, so when the first one is of another class every undeclared reference behind it in the same source goes unjudged and the helper answers `null` — the same value that means the source is clean. A contract that declares only which error it cannot make reads as making neither.
9+
10+
No behaviour changes. This is the contract text, and it ships: the amended block is JSDoc on a published export, so it is emitted into `@objectstack/formula`'s `dist/index.d.ts` and `dist/index.d.mts` (measured — the declaration file grew 53.45 KB to 55.99 KB) and is what a consumer reads on hover.
11+
12+
What the amendment adds, all of it measured rather than reasoned:
13+
14+
- **The masking is positional, not name-keyed.** The masked name is not the one that triggered the first error, so excluding the trigger's own name does not reach it. `data == 'x' && status == 'q'` answers `null`; the same two names in the other order answer `"status"`.
15+
- **`celEngine.compile()` is not a gate against it.** `compile` type-checks in the permissive environment, where every unlisted name is `dyn`. The strict environment here declares `SCOPE_ROOTS` as `map`, so a root — or an object field sharing one of those names (`data`, `config`, `result`, `item`, `event`, `input`, `user`, …) — used as the operand of an operator with no `map` overload faults HERE and nowhere else. A caller that only reaches the helper on a clean compile is therefore not protected by its own gate.
16+
- **The CEL type-name class is the same shape.** `type == 'grid'` is already pinned as a blind spot in `@objectstack/lint`'s `visibility-bare-identifier` suite, but pinned per NAME; the masking it causes is source-wide.
17+
- **What closing it would take, and why that is not this change.** Widening the regex onto the overload message is the false positive the narrowing buys off (`type(record.x) == string` is legitimate CEL). Reporting past the first error needs a re-check loop over a neutralised source, or a checker entry returning more than one error — cel-js 8.0.0 has neither; its `TypeCheckResult` carries a single `error`. Both change what every consuming rule reports, so the oracle's shape is a design decision.
18+
19+
`@objectstack/lint` carries a second comment-only correction, to `flow-variable-scope`'s account of the same oracle. Its "known, deliberate blind spot" note bounded the under-report to a flow variable named after a `SCOPE_ROOTS` member; measured, the bound does not hold — such a name in an operand position terminates the discovery loop on iteration 0 and every shadow in that source is lost, whatever it is named. That block sits on an internal function, so unlike the `formula` half it reaches no published declaration file; the entry is here because the package is touched and published.

0 commit comments

Comments
 (0)