Skip to content

Commit 5259a35

Browse files
fix(objectql): droppedFields on a create names only keys the caller sent, never a middleware fill (#21682) (#21701)
Fixes #21682 Clause-②: no ## What changes This applies on a walled posture with the real `@objectstack/organizations` runtime mounted: - **Before:** a create that named no `organization_id` answered 201 with `droppedFields: [{ object, fields: ['organization_id'], reason: 'readonly' }]`. The caller never sent that key. - **After:** the same create answers 201 with no `droppedFields`. The row is still stored in the active organization. The fix sits where triage routed it: `ObjectQL.insert`'s caller-payload snapshot in `packages/objectql/src/engine.ts`. - At `insert`'s entry, before the middleware chain runs, the engine records the keys each row carries. The record is keyed by the row object (`callerKeySets`, line 2094; called at line 12879). - The snapshot `suppliedPerRow` (line 12983) now keeps only those keys (`callerSuppliedRow`, line 2113). - Values are unchanged, so every key the caller does send is judged as before. - No field is named anywhere. A key counts as the platform's because of WHEN it appeared on the payload, not because of its name. ## The console reading (H4), measured first: the console warns, so this is the p2 raise reading objectui at its pin `ab187972159583b595facdcae3c73b50f6f312e9` (`.objectui-sha`), read from a shallow clone that has since been deleted: - **Every adapter create reports.** `packages/data-objectstack/src/index.ts`: `ObjectStackAdapter.create` calls `notifyDroppedFields('create', resource, result, id, data)` on every create. - **The filter keeps a field the caller never sent.** `notifyDroppedFields` (line 3767) runs the entries through `withoutNoOpDrops(valid, sent, stored)` (line 2794). That filter keeps such a field: `if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;`. It drops only a field whose sent value equals the stored value. - **The shell turns it into a toast.** `packages/app-shell/src/providers/AdapterProvider.tsx` line 80 subscribes `onWriteWarning` and passes sonner's `toast` to `emitWriteWarning`. For any non-empty list, `emitWriteWarning` (`writeWarningToast.ts`) calls `sink.warning` with the title "Saved — but some fields did not take effect" and the line "Read-only, so it did not take effect: " followed by the field's label. - **The record forms go through that adapter.** These call `dataSource.create`: `plugin-form`'s `ObjectForm.tsx:1281`, `ModalForm.tsx:660`, `DrawerForm.tsx:574`, `SplitForm.tsx:399`, `TabbedForm.tsx:501` and `WizardForm.tsx:978`, plus `ObjectCalendar.tsx:1190` and the grid's `ImportWizard.tsx:1931`. - **Measured as well as read.** I extracted the pinned `withoutNoOpDrops`, `sameWireValue` and `stableStringify` verbatim and ran them on the card's wire shape: - entries: `[{ object: 'qa_ledger', fields: ['organization_id'], reason: 'readonly' }]`; - sent: `{ name }`; - stored: a record carrying `organization_id`. - Output: the entry is kept (`toast fires: true`). - **One console path does not toast.** `apps/console/src/components/FormPage.tsx` posts its internal submit with raw `fetch` (line 1375) and never reads `droppedFields`. So an ordinary walled create from the console's record forms shows the amber "Saved — but some fields did not take effect" toast, naming Organization. That is #8093's symptom, on create. The seat raises the grade on this reading. ## Mechanism, measured (Partition 2) - **H1, confirmed by measurement.** - At `251a7dd4b4`, `insert` took `suppliedPerRow` (line 12905) inside the callback it hands `executeWithMiddleware` (line 12828). By then every middleware had already run. - Middleware A (`organizations-plugin.ts:344`) fills an absent or empty `organization_id` in place, so the snapshot recorded the fill as a caller key. - The static-readonly strip took it, because the injected column is `readonly: true`, and reported it. Further down, the driver's `injectTenantOnInsert` filled the column again from the context. That is why no stored data was wrong. - The new pins, run against the unmodified engine, returned `[{ fields: ['organization_id'], reason: 'readonly' }]` for both a member and a platform administrator. - **H2, the precedent applied.** - #8093 (commit `a0fdc564eb`) narrowed the update report by a provenance fact (a payload `id` equal to the bound row), not by a field name. - This change takes the first arm of triage's direction: the caller key set is recorded before any platform middleware runs. - The update path already takes its `suppliedValues` snapshot before its chain (`engine.ts:14054`, chain at line 14200). Insert now matches it for keys. - I did not take the other arm (marking each stamped key). It would have needed a recording Proxy handed to every middleware, and to every batch row inside it. - **H3, explained.** - Middleware A fills only a non-array payload (`!Array.isArray(opCtx.data)`). So a `createMany` row carries no fill and never reported one. The driver fills the column for it. - Without the runtime (`single`) there is no fill at all. - Both controls are pinned. They are green before and after. - **H5, measured.** - On insert, the in-tree middlewares that write a value into `opCtx.data` are two: - `@objectstack/organizations` Middleware A, for `organization_id` (single row only); - `@objectstack/plugin-security` step 3.5, for `owner_id` (every row, `security-plugin.ts:3230`). - The rest filter reads, act after `next()`, or (the anonymous public-form branch) delete keys rather than fill them. - `owner_id` is not `readonly`, so it never reached `droppedFields`. It did reach the referential-integrity check (see Behaviour notes). - The walled pins assert `droppedFields` is empty, which covers both fills. The engine pins use an arbitrary author column, so no name is involved. ## Pins These are triage's three, on a walled boot built from the real `@objectstack/organizations` runtime, the real `SecurityPlugin`, a real `ObjectQL` and `SqliteWasmDriver`. The block is added to #21666's harness in `packages/plugins/organizations/src/create-explicit-organization-wall.test.ts`. The drops are collected through `onFieldsDropped`, the listener the REST create doors (`createData`, `createManyData`) answer `droppedFields` from. 1. A walled create with no `organization_id` reports no dropped field and is stored in the active organization. Run for a member and for a platform administrator. 2. A caller that sends a readonly key still sees it reported: - `organization_id` itself, sent explicitly, is reported; - `created_by`, sent beside the fill, is reported alone. 3. `createMany` and the `single` posture are unchanged: - an array row naming no organization reports nothing; - under `single`, booted without the runtime, a create naming no organization reports nothing, and one naming an organization still reports it. There are three engine pins in `packages/objectql/src/engine-insert-static-readonly-strip.test.ts`, beside the existing exemption "a beforeInsert hook's OWN stamp survives": - a middleware fill on an author `readonly` column lands and is not reported; - the same holds per row on a batch, beside a key the caller did send, which is still taken and reported; - a key the caller sent is judged as before even when a middleware rewrote its value. ## Reverse verification Run from committed state, with HEAD `81ad21efc1` carrying the fix: - **The mutation.** `scripts/ablation-replace.mjs` changed `callerSuppliedRow(row, callerKeysPerRow[i])` to `callerSuppliedRow(row, undefined)`. That restores the full post-middleware copy, which is the pre-fix snapshot. - **It landed on disk.** The anchor count went 1 to 0 and the replacement count 0 to 1. The blob went from `16159cd42cbf` to `9fae7a2c1837`. - **No build was needed.** Both suites resolve the subject from source: the organizations config aliases `@objectstack/objectql` to `src`, and the engine test imports `./engine.js`. - **Predicted, then observed:** - organizations file: 3 failed, 18 passed. The failures are pin 1 for both callers, plus pin 2's "beside the fill" case, which also asserts pin 1's absence. Pin 2's explicit case and every pin 3 case stayed green. - engine file: 2 failed, 19 passed. The failures are the two fill cases (`expected undefined to be 'stamp'`; `expected [ undefined, undefined ] to deeply equal [ 'stamp', 'stamp' ]`). The rewritten-value case stayed green. - **Restored.** `git checkout HEAD -- packages/objectql/src/engine.ts`, run by the tool's trap. Blob after restore `16159cd42cbf` equals HEAD, and `git diff HEAD` is empty. ## Behaviour notes - **The referential-integrity check moves with the snapshot.** - `assertReferencesResolve` reads `suppliedPerRow` to decide what the caller sent. Its docblock already scopes it to caller-supplied keys and names `owner_id` and `organization_id` as stamps it must not judge. - A middleware fill used to be judged anyway. I measured this once with a scratch probe (deleted, not committed): a middleware fills a `lookup` with a dangling id on a create that did not name it. - Before: refused `VALIDATION_FAILED`. After: created. - The in-tree fill this reaches is plugin-security's `owner_id` fill: its stamp was checked as if the caller had sent it. I did not measure a principal that the check actually refused for it. - **Values are unchanged for keys the caller sent.** That includes a caller-sent empty `organization_id` that Middleware A fills. It is still reported, as before. The update path's snapshot also captures values before its chain; this change aligns the KEY set only. ## Tests (at `47cbb883b3`) The test runs were taken at `47cbb883b3`, whose code equals the fix commit `81ad21efc1`. The merge of `origin/main` (`994ec65025`) brought in only a `service-analytics` test and `scripts/pm/git-history.mjs`, neither in `objectql` nor `organizations`, so the suites were not rerun after it. Every run went through `scripts/pm/os-verify-lock.sh` with `--maxWorkers=2`. - **Before the fix**, with the pins at `e95548845c` over the unmodified engine: `pnpm --filter @objectstack/organizations exec vitest run src/create-explicit-organization-wall.test.ts` gave 3 failed and 18 passed. The received value was `[{ fields: ['organization_id'], object: 'qa_ledger', reason: 'readonly' }]`. - **After the fix:** - the same file: 21 passed; - `pnpm --filter @objectstack/objectql exec vitest run src/engine-insert-static-readonly-strip.test.ts`: 21 passed (18 existing, 3 new); - `pnpm --filter @objectstack/objectql exec vitest run --project local` (the package's `test` script): 371 files, 7450 tests passed; - `pnpm --filter @objectstack/organizations test`: 9 files, 130 passed (123 existing, 7 new); - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `check:test-typecheck` reports OK, with 40 files, 234 errors and 65 signatures held in the ledger, unchanged; - `pnpm --filter @objectstack/organizations typecheck`: exit 0, test layer 0 errors. - **Builds:** - the dependency closure, `turbo run build --filter='@objectstack/organizations^...'`: 27 of 27 tasks; - `objectql` and `organizations`, rebuilt for the gates that read `dist/` (the fix is present in `dist/index.mjs`); - a full `--filter='!@objectstack/docs'` build, 72 of 72 tasks (71 cached), for `check:dual-build-cjs-loads`. - **Lint, a declared narrowing.** I ran `eslint --no-inline-config --format json` on the 3 TypeScript files this diff touches: 3 files linted, 0 errors, 0 warnings, none ignored (`--print-config` exits 0 for each). Why the narrowing excludes nothing: - `eslint.config.mjs` enables no type-aware linting (its own note at lines 327-328: no `parserOptions.project`, no typed rules); - its plugins are local per-file AST rules; - its only external reads are `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json`, which this diff does not touch. So this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` stays with CI. ## Gates The final run was at `994ec65025`, after the merge. `node scripts/pm/dispatch-gates.mjs --commands` was run with no paths. It derives 70 commands from the 4-path change set against merge base `38bef8cf9`, the same 70 as before the merge. - **The 70 derived:** all exit 0. `dispatch-gates --ran` reports "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN". - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, 39 packages without `dist`) in the first run at `47cbb883b3`. After the full build it exited 0, and it exited 0 again at `994ec65025`. - Some verdict lines: `check:nul-bytes` OK (10060 files, no control bytes); `check:engine-double-contract` OK (936 pinned); `check:test-source-alias` OK; `check:cross-package-test-inputs` OK; `check-adr-0087-registration` (1 non-breaking changeset); `check-changeset-no-major` (no major); `check-empty-changeset` OK; `check:type-check-coverage` OK. - **The artifact-roster block printed outside the total:** 54 families, 53 of them not in the derived set. 51 exit 0. - 3 exit 2 NOT WIRED, because they need pull-request context: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. - `check-partof-closing-keyword` was also run on this body with `PR_BODY` set, and exited 0. - The other two can only run once this PR exists. Their readings are in the os-dev report on the card. ## Acceptance notes These are noted, not filed: none is a reproducible defect, a contract violation or an authoring trap that this card measured. - **Values still differ from the update path.** Insert now matches update for the caller KEY set. Update also captures VALUES before its chain; insert keeps the value the chain handed on. So a caller-sent key whose value a middleware rewrote is judged on the middleware's value on insert, and on the caller's value on update. No in-tree insert middleware rewrites a non-empty caller value since #21666: Middleware A only fills an absent or empty value, and plugin-security refuses a forged owner rather than rewriting it. This is unexercised drift, and no PR or person will carry it. - **Middleware A's fill now reaches the driver.** On a single-row create, Middleware A's fill and the driver's `injectTenantOnInsert` give the same value. Before, the fill was stripped and the driver filled the column again. Now the fill is kept. - **objectui's `FormPage.tsx` drops the report.** Its internal submit posts with raw `fetch` and never reads `droppedFields`, so a strip on that path is silent. That is at objectui pin `ab187972`; no PR or person will carry it. - **objectui keeps never-sent fields.** objectui's `withoutNoOpDrops` keeps a reported field the caller never sent. With this change the server no longer reports a middleware fill, so that branch no longer fires for one. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ac2ba1 commit 5259a35

4 files changed

Lines changed: 256 additions & 11 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`droppedFields` on a create names only keys the caller sent, never a value a write middleware filled in
6+
7+
Clause-②: no
8+
9+
With `@objectstack/organizations` mounted (the walled tenancy postures), a non-system create that names no `organization_id` has that column filled with the caller's active organization by the organizations write middleware. `ObjectQL.insert` took its record of what the caller sent after that middleware had run. So the static `readonly` strip treated the fill as a caller write, took it, and reported it:
10+
11+
- Before: `POST /api/v1/data/<object>` with a body that names no `organization_id` answered 201 with `droppedFields: [{ object, fields: ['organization_id'], reason: 'readonly' }]`. `onFieldsDropped` fired with the same event. The console showed it as a warning toast on every such create.
12+
- After: the same create answers 201 with no `droppedFields`, and `onFieldsDropped` does not fire. The row is stored in the active organization, as before.
13+
14+
`insert` now records which keys each row carries before any write middleware runs. Only those keys count as sent by the caller. No field is exempted by name, so this covers every write middleware fill, including the `owner_id` fill of `@objectstack/plugin-security`. The referential-integrity check reads the same record, so it no longer checks a middleware-filled reference as if the caller had sent it.
15+
16+
Unchanged:
17+
18+
- A key the caller does send is judged and reported as before. That includes `organization_id` itself, and a key whose value a middleware rewrote.
19+
- The array insert (`createMany`) and the `single` posture already reported nothing for an absent `organization_id`, and they still do.
20+
- The stored row is the same. Before, the filled column was stripped and then filled again from the same active organization further down. Now the fill is kept.
21+
22+
No export, type, error code or status changes.

‎packages/objectql/src/engine-insert-static-readonly-strip.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,53 @@ describe('#14147 — the exemptions, each one load-bearing', () => {
228228
expect(o.created?.completed_at, 'the hook wrote it — provenance, not value equality').toBe('2026-01-01T00:00:00Z');
229229
});
230230

231+
// [#21682] A write MIDDLEWARE runs before the caller snapshot is taken, so
232+
// its fill used to read as a key the caller sent: stripped, and reported in
233+
// `droppedFields` (`@objectstack/organizations`' `organization_id` fill, on
234+
// every walled create naming no organization). The column here is an
235+
// arbitrary author-declared one: what makes a key the platform's is WHEN it
236+
// appeared, never its name.
237+
const fillAbsent = (key: string, value: unknown) => (engine: ObjectQL) => {
238+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
239+
if (opCtx.operation === 'insert') {
240+
for (const row of Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]) {
241+
if (row && !(key in row)) row[key] = value;
242+
}
243+
}
244+
await next();
245+
});
246+
};
247+
248+
it('[#21682] a write middleware’s fill is not caller-supplied either: it lands, and nothing is reported', async () => {
249+
const o = await observeInsert({ title: 'T' }, { context: { userId: 'u1' } }, fillAbsent('completed_at', '2026-01-01T00:00:00Z'));
250+
expect(o.created?.completed_at, 'the platform’s value reaches the driver').toBe('2026-01-01T00:00:00Z');
251+
expect(o.dropped, 'droppedFields names only keys the caller sent').toEqual([]);
252+
});
253+
254+
it('[#21682] …per row on the batch path, beside a key the caller DID send, which is still taken and reported', async () => {
255+
const { engine, creates } = await makeEngine();
256+
fillAbsent('locked_note', 'stamp')(engine);
257+
const dropped: DroppedFieldsEvent[] = [];
258+
await engine.insert('duly_task', [
259+
{ title: 'A' },
260+
{ title: 'B', completed_at: 'forged' },
261+
] as any, { context: { userId: 'u1' }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } } as any);
262+
expect(creates.map((c) => c.locked_note), 'the fill lands on every row').toEqual(['stamp', 'stamp']);
263+
expect(creates[1]).not.toHaveProperty('completed_at');
264+
expect(dropped).toEqual([{ object: 'duly_task', fields: ['completed_at'], reason: 'readonly' }]);
265+
});
266+
267+
it('[#21682] a key the caller sent is judged as before even when a middleware rewrote its value', async () => {
268+
const o = await observeInsert({ title: 'T', completed_at: 'forged' }, { context: { userId: 'u1' } }, (engine) => {
269+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
270+
if (opCtx.operation === 'insert') opCtx.data.completed_at = 'rewritten';
271+
await next();
272+
});
273+
});
274+
expect(o.created, 'the caller named the key, so the strip still owns it').not.toHaveProperty('completed_at');
275+
expect(o.dropped).toEqual([{ object: 'duly_task', fields: ['completed_at'], reason: 'readonly' }]);
276+
});
277+
231278
it('a PLATFORM-INTERNAL object is left to its own 403 guard (ADR-0086 / #3004, carried over)', async () => {
232279
// The reserved `sys_` namespace, and a `managedBy` bucket whose columns
233280
// carry their own fail-closed refusal, have dedicated write governance —

‎packages/objectql/src/engine.ts‎

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,6 +2063,63 @@ function droppedFieldEvents(
20632063
return events;
20642064
}
20652065

2066+
/**
2067+
* [#21682] The keys the CALLER sent on each row of an insert, recorded at
2068+
* `insert`'s entry, BEFORE the middleware chain runs, and keyed by the row
2069+
* OBJECT.
2070+
*
2071+
* `insert` takes its caller snapshot (`suppliedPerRow`) inside the middleware
2072+
* chain's innermost step, so by then a write middleware may already have
2073+
* filled the payload: `@objectstack/organizations` fills an absent
2074+
* `organization_id` with the active organization, and
2075+
* `@objectstack/plugin-security` fills an absent `owner_id` with the acting
2076+
* user. Both write IN PLACE, onto the very row objects recorded here. Without
2077+
* this record the snapshot reads those fills as keys the caller sent, so the
2078+
* static-`readonly` strip took the platform's own `organization_id` and
2079+
* `droppedFields` reported it, on every walled create that named no
2080+
* organization. The console announces every non-empty `droppedFields` as a
2081+
* warning toast. This is the insert-side twin of the update path's #8093
2082+
* (ADDRESSING IS NOT PAYLOAD): a value the platform put on the payload is
2083+
* not one the caller lost.
2084+
*
2085+
* Keyed by IDENTITY, not by index, so the answer survives a middleware that
2086+
* reorders a batch. A row a middleware REPLACED wholesale has no entry, and
2087+
* {@link callerSuppliedRow} then keeps every key it carries: the verdict from
2088+
* before this record existed. That is the over-reporting direction, never the
2089+
* under-stripping one.
2090+
*
2091+
* ⛔ No name list. Which keys are the platform's is answered by WHEN they
2092+
* appeared, so a new stamping middleware is covered without being named here.
2093+
*/
2094+
function callerKeySets(data: unknown): WeakMap<object, ReadonlySet<string>> {
2095+
const sets = new WeakMap<object, ReadonlySet<string>>();
2096+
for (const row of Array.isArray(data) ? data : [data]) {
2097+
if (row !== null && typeof row === 'object' && !sets.has(row)) {
2098+
sets.set(row, new Set(Object.keys(row)));
2099+
}
2100+
}
2101+
return sets;
2102+
}
2103+
2104+
/**
2105+
* One row of `insert`'s caller snapshot: a shallow COPY of the row as the
2106+
* middleware chain handed it on, keeping only the keys the caller sent
2107+
* (`sent`, from {@link callerKeySets}).
2108+
*
2109+
* Only WHICH keys is narrowed. A kept key keeps the value the chain handed
2110+
* on, so every key the caller did send is judged exactly as it was before
2111+
* #21682. `sent` undefined means "this row has no record" and keeps every key.
2112+
*/
2113+
function callerSuppliedRow(row: unknown, sent: ReadonlySet<string> | undefined): Record<string, unknown> {
2114+
const copy: Record<string, unknown> = { ...((row ?? {}) as Record<string, unknown>) };
2115+
if (sent) {
2116+
for (const key of Object.keys(copy)) {
2117+
if (!sent.has(key)) delete copy[key];
2118+
}
2119+
}
2120+
return copy;
2121+
}
2122+
20662123
/**
20672124
* Evaluate formula virtual fields against the raw rows a driver handed back —
20682125
* the read path (`find` / `findOne`) and, since #5504, the write path's
@@ -12831,6 +12888,10 @@ export class ObjectQL implements IObjectQLEngine {
1283112888
data = normalizeBlankTypedValues(this._registry.getObject(object), data);
1283212889
data = normalizeNumericStringValues(this._registry.getObject(object), data);
1283312890

12891+
// [#21682] What the CALLER sent, recorded before any write middleware
12892+
// fills the payload. See `callerKeySets`.
12893+
const callerKeys = callerKeySets(data);
12894+
1283412895
const opCtx: OperationContext = {
1283512896
object,
1283612897
operation: 'insert',
@@ -12858,6 +12919,14 @@ export class ObjectQL implements IObjectQLEngine {
1285812919
// untouched, hooks run after and may override.
1285912920
const nowSnap = new Date();
1286012921
const isBatch = Array.isArray(opCtx.data);
12922+
// [#21682] Each row's caller key set, looked up NOW, on the row objects
12923+
// the middleware chain handed on. The computed-field door below may
12924+
// replace a row with a copy. Index-aligned from here on: every pass
12925+
// between here and the snapshot keeps the rows' order and count.
12926+
const callerKeysPerRow: Array<ReadonlySet<string> | undefined> =
12927+
(isBatch ? (opCtx.data as unknown[]) : [opCtx.data]).map(
12928+
(row) => (row !== null && typeof row === 'object' ? callerKeys.get(row) : undefined),
12929+
);
1286112930
// [#8682] The declared-field door — see `undeclaredWriteFieldErrors` for
1286212931
// what used to run below it for a request that was already refused.
1286312932
// FIRST, so nothing downstream (defaults, summary seeding, the hooks, the
@@ -12893,10 +12962,17 @@ export class ObjectQL implements IObjectQLEngine {
1289312962
}
1289412963
// [#4441] The RAW caller payload per row — before `applyFieldDefaults`
1289512964
// resolves any `defaultValue` / `current_user` token and before the
12896-
// beforeInsert hooks stamp `owner_id` / `organization_id` /
12897-
// `created_by`. The reference check consults it to decide WHAT THE
12898-
// CALLER ACTUALLY SENT, so neither a platform stamp nor a backfilled
12899-
// default is ever reported as the caller's bad reference.
12965+
// beforeInsert hooks stamp `created_by`. The reference check consults it
12966+
// to decide WHAT THE CALLER ACTUALLY SENT, so neither a platform stamp
12967+
// nor a backfilled default is ever reported as the caller's bad
12968+
// reference.
12969+
//
12970+
// [#21682] ...and without the keys a write MIDDLEWARE filled, which ran
12971+
// before this step: `organization_id` (`@objectstack/organizations`) and
12972+
// `owner_id` (`@objectstack/plugin-security`) are filled there, not by a
12973+
// hook. Each row keeps only the keys the caller sent (`callerKeySets`,
12974+
// recorded at entry), so the strips below report and take only those.
12975+
// The values stay as the chain handed them on.
1290012976
//
1290112977
// [#6339] It carries the caller's VALUES, and it is taken HERE — ahead of
1290212978
// the hooks — as an explicit shallow COPY. Both halves are load-bearing:
@@ -12918,7 +12994,7 @@ export class ObjectQL implements IObjectQLEngine {
1291812994
// (#5591).
1291912995
const suppliedPerRow: Array<Record<string, unknown>> =
1292012996
(isBatch ? (opCtx.data as any[]) : [opCtx.data]).map(
12921-
(row) => ({ ...((row ?? {}) as Record<string, unknown>) }),
12997+
(row, i) => callerSuppliedRow(row, callerKeysPerRow[i]),
1292212998
);
1292312999
// [#20082] The write's ONE permission resolution, shared by every consumer
1292413000
// below that needs the map: the CEL defaults here, the re-default after

‎packages/plugins/organizations/src/create-explicit-organization-wall.test.ts‎

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,23 @@
4242
* every app object. The engine strips that column from a non-system payload
4343
* as `readonly` AFTER the wall has judged it, so here an admitted value is
4444
* re-derived, and a refused one never reaches the strip.
45+
*
46+
* ## [#21682] The stamp is not a caller write
47+
*
48+
* The last block pins the report half of the same seam: a create that names
49+
* no organization is filled by Middleware A, and `droppedFields` (the REST
50+
* create doors collect it through the `onFieldsDropped` listener driven here)
51+
* must not name that fill as a key the caller sent. A key the caller DID send
52+
* is still reported, and the array insert and the `single` posture answer
53+
* as they did before.
4554
*/
4655

4756
import { describe, it, expect, afterEach, vi } from 'vitest';
4857
import { ObjectQL } from '@objectstack/objectql';
4958
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
5059
import { SecurityPlugin } from '@objectstack/plugin-security';
5160
import type { PermissionSet } from '@objectstack/spec/security';
61+
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
5262
import { OrganizationsPlugin } from './organizations-plugin.js';
5363

5464
/** The caller's active organization. */
@@ -108,7 +118,7 @@ const ADMIN_CTX = {
108118
posture: 'PLATFORM_ADMIN',
109119
};
110120

111-
type Posture = 'isolated' | 'group';
121+
type Posture = 'isolated' | 'group' | 'single';
112122

113123
const engines: ObjectQL[] = [];
114124
afterEach(async () => {
@@ -125,7 +135,11 @@ interface Booted {
125135
table: (name: string) => Promise<Array<Record<string, unknown>>>;
126136
}
127137

128-
async function boot(posture: Posture = 'isolated'): Promise<Booted> {
138+
/**
139+
* `organizations: false` boots without this package, the way `serve` boots the
140+
* `single` posture — the control for the [#21682] block.
141+
*/
142+
async function boot(posture: Posture = 'isolated', { organizations: mountOrganizations = true } = {}): Promise<Booted> {
129143
const engine = new ObjectQL();
130144
engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true);
131145
await engine.init();
@@ -170,14 +184,18 @@ async function boot(posture: Posture = 'isolated'): Promise<Booted> {
170184
// not to the insert path under test.
171185
hook: vi.fn(),
172186
};
173-
const organizations = new OrganizationsPlugin({ ensureDefaultOrganization: false });
187+
const organizations = mountOrganizations ? new OrganizationsPlugin({ ensureDefaultOrganization: false }) : null;
174188
const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
175189
// Kernel order: every init, then every start — organizations first, as `serve` mounts it.
176-
await organizations.init(ctx as never);
190+
await organizations?.init(ctx as never);
177191
await security.init(ctx as never);
178-
await organizations.start(ctx as never);
192+
await organizations?.start(ctx as never);
179193
await security.start(ctx as never);
180-
expect(services['org-scoping'], 'the real runtime is the org-scoping service').toBe(organizations);
194+
if (organizations) {
195+
expect(services['org-scoping'], 'the real runtime is the org-scoping service').toBe(organizations);
196+
} else {
197+
expect('org-scoping' in services, 'the control boots without the runtime').toBe(false);
198+
}
181199
// The expected refusals log at WARN through the engine's own logger.
182200
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
183201

@@ -326,3 +344,85 @@ describe('[#21666] a create naming an organization meets the Layer 0 write wall,
326344
});
327345
});
328346
});
347+
348+
/**
349+
* One create with the listener the REST create doors wire
350+
* (`ObjectStackProtocolImplementation.createData` / `createManyData` hand
351+
* `onFieldsDropped` to `engine.insert` and answer what it collected as
352+
* `droppedFields`), so `dropped` is the response's `droppedFields`.
353+
*/
354+
const createReporting = async (b: Booted, object: string, data: unknown, context: object) => {
355+
const dropped: DroppedFieldsEvent[] = [];
356+
const outcome = await attempt(() => b.engine.insert(object, data as never, {
357+
context,
358+
onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); },
359+
} as never));
360+
return { outcome, dropped };
361+
};
362+
363+
describe('[#21682] droppedFields names only keys the caller sent, so the organization stamp is not one', () => {
364+
for (const [who, context] of CALLERS) {
365+
it(`${who}: a walled create naming no organization reports no dropped field, and is stored in the active organization`, async () => {
366+
const b = await boot();
367+
368+
const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new' }, context);
369+
370+
expect(outcome.ok, outcome.message).toBe(true);
371+
expect(dropped, 'the fill is the platform\'s write, not a key the caller lost').toEqual([]);
372+
expect(b.seenByHooks, 'the beforeInsert chain still sees the stamp').toEqual([OWN_ORG]);
373+
expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]);
374+
});
375+
}
376+
377+
it('a caller that sends the readonly key itself still sees it reported, the same key the stamp fills', async () => {
378+
const b = await boot();
379+
380+
const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: OWN_ORG }, MEMBER_CTX);
381+
382+
expect(outcome.ok, outcome.message).toBe(true);
383+
expect(dropped).toEqual([{ object: INJECTED, fields: ['organization_id'], reason: 'readonly' }]);
384+
expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]);
385+
});
386+
387+
it('a readonly key the caller sends beside the fill is reported alone', async () => {
388+
// `created_by` is an injected readonly column. No audit hook stamps it in
389+
// this composition, so the caller's value is the one the strip takes.
390+
const b = await boot();
391+
392+
const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', created_by: 'usr_forged' }, MEMBER_CTX);
393+
394+
expect(outcome.ok, outcome.message).toBe(true);
395+
expect(dropped).toEqual([{ object: INJECTED, fields: ['created_by'], reason: 'readonly' }]);
396+
});
397+
398+
it('the array insert is unchanged: a row naming no organization reports nothing', async () => {
399+
const b = await boot();
400+
401+
const { outcome, dropped } = await createReporting(b, INJECTED, [{ id: 'r2', name: 'new' }], MEMBER_CTX);
402+
403+
expect(outcome.ok, outcome.message).toBe(true);
404+
expect(dropped).toEqual([]);
405+
expect(await b.table(INJECTED)).toEqual([...SEEDED, { id: 'r2', organization_id: OWN_ORG }]);
406+
});
407+
408+
describe('the `single` posture, booted without this package, is unchanged', () => {
409+
it('a create naming no organization reports nothing', async () => {
410+
const b = await boot('single', { organizations: false });
411+
412+
const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new' }, MEMBER_CTX);
413+
414+
expect(outcome.ok, outcome.message).toBe(true);
415+
expect(dropped).toEqual([]);
416+
expect(b.seenByHooks, 'no runtime, so no stamp').toEqual([undefined]);
417+
});
418+
419+
it('a create naming an organization still reports it as readonly', async () => {
420+
const b = await boot('single', { organizations: false });
421+
422+
const { outcome, dropped } = await createReporting(b, INJECTED, { id: 'r2', name: 'new', organization_id: OWN_ORG }, MEMBER_CTX);
423+
424+
expect(outcome.ok, outcome.message).toBe(true);
425+
expect(dropped).toEqual([{ object: INJECTED, fields: ['organization_id'], reason: 'readonly' }]);
426+
});
427+
});
428+
});

0 commit comments

Comments
 (0)