Skip to content

Commit 0dfde81

Browse files
committed
fix(objectql): stamp created_at unconditionally on an ordinary create
The audit binder's beforeInsert stamp used `record.created_at ?? now`, which since #15395 launders a caller-supplied value past the engine-side static readonly strip (#14259 reads a key a hook ASSIGNED as the hook's write). It now takes the same shape as `updated_at`: the system clock wins unless `preserveAudit` is set, which is the historical-import channel and stays. Ruled by the maintainer 2026-09-06, decision batch #54, option A. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 7beaaa3 commit 0dfde81

2 files changed

Lines changed: 214 additions & 5 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #15964 — on an ORDINARY create the audit binder stamps `created_at` from the
4+
// system clock, so a caller-supplied value never survives a plain REST `POST`.
5+
//
6+
// ## The hole, and why it needed the three controls below
7+
//
8+
// Measured on a live rig (framework `e581457baaaf`): a normal authenticated
9+
// caller `POST /api/v1/data/OBJECT` with a forged `created_at` kept that value
10+
// on the stored row, on objects that declare `created_at` as `readonly: true`.
11+
// The same request, same path, same object family:
12+
//
13+
// field declaration sent stored verdict
14+
// --------------------- ------------------- ------------ --------- --------
15+
// id readonly: true forged minted stripped
16+
// run_at readonly, datetime 1999-01-01 now stripped
17+
// updated_at readonly, datetime 1999-01-01 now stripped
18+
// created_at readonly, datetime 1999-01-01 1999-01-01 KEPT
19+
//
20+
// The three stripped rows are the reading's in-experiment controls: they prove
21+
// the create-side strip IS running on this path and DOES take other
22+
// author-declared `readonly` datetimes, so `created_at` surviving is "the strip
23+
// ran and spared exactly this one", never "the strip did not run".
24+
//
25+
// ## Mechanism
26+
//
27+
// Since #15395 the static-`readonly` strip runs INSIDE `engine.insert`, AFTER
28+
// the `beforeInsert` hooks, and its #14259 guard treats a key a hook ASSIGNED
29+
// as the hook's write rather than a caller forgery (`rowHookWrittenKeys`). The
30+
// audit binder stamped `record.created_at = record.created_at ?? now`, so on a
31+
// forged payload the hook "wrote" a value whose bytes came entirely from the
32+
// caller — and the strip spared it. `updated_at`'s `preserveAudit ? (… ?? now)
33+
// : now` overwrote the forgery first, which is why it is a control here rather
34+
// than a second symptom.
35+
//
36+
// ## Maintainer ruling, 2026-09-06 (decision batch #54, option A), verbatim
37+
// 「同意」
38+
//
39+
// - the beforeInsert stamp for `created_at` takes the SAME SHAPE as
40+
// `updated_at` — the system clock wins on an ordinary create;
41+
// - the historical-import channel KEEPS working: `treatAsHistorical` sets
42+
// `preserveAudit` on the write context (`packages/rest/src/import-runner.ts`),
43+
// and that branch still reinstates an original `created_at`. That is the
44+
// third case below, and it is the reason the ruled shape is the
45+
// `preserveAudit`-branching one rather than a bare `= now`.
46+
//
47+
// Consistent with the 2026-08-08 ruling that narrowed `preserveAudit` to the
48+
// UPDATE path: `created_at` is preserved here by the audit binder's own
49+
// `preserveAudit` branch, which is where the flag has always been read on this
50+
// path — the create-side strip still does not read it (#14147).
51+
52+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
53+
import { ObjectKernel } from '@objectstack/core';
54+
import { ObjectQLPlugin } from './plugin.js';
55+
import type { ObjectSchema } from '@objectstack/spec/data';
56+
57+
const FORGED_AT = '1999-01-01T00:00:00.000Z';
58+
const FORGED_ID = 'conv_REST_FORGED';
59+
60+
describe('audit binder: create-side `created_at` (#15964)', () => {
61+
let kernel: ObjectKernel;
62+
63+
beforeEach(() => {
64+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
65+
});
66+
67+
afterEach(async () => {
68+
if (kernel.getState() === 'running') await kernel.shutdown();
69+
});
70+
71+
/**
72+
* Boots the REAL ingress this card is about: `ObjectQLPlugin` binds its
73+
* `sys_stamp_audit_insert` hook through `bindHooksToEngine`, and
74+
* `engine.insert` runs the static-readonly strip after it. What reaches the
75+
* driver's `create` IS the stored row, so the payload is the verdict.
76+
*/
77+
async function boot(objectName: string) {
78+
const captured: Record<string, any>[] = [];
79+
const mockDriver = {
80+
name: 'audit-capture', version: '1.0.0',
81+
connect: async () => {}, disconnect: async () => {},
82+
find: async () => [], findOne: async () => null,
83+
create: async (_o: string, d: any) => {
84+
captured.push({ ...d });
85+
return { id: d.id ?? 'minted_id', ...d };
86+
},
87+
update: async (_o: string, i: any, d: any) => ({ id: i, ...d }),
88+
delete: async () => true, syncSchema: async () => {},
89+
};
90+
await kernel.use({
91+
name: 'audit-capture-plugin', type: 'driver', version: '1.0.0',
92+
init: async (ctx: any) => { ctx.registerService('driver.audit-capture', mockDriver); },
93+
} as any);
94+
await kernel.use(new ObjectQLPlugin());
95+
await kernel.bootstrap();
96+
97+
const objectql = kernel.getService('objectql') as any;
98+
// `created_at` / `updated_at` are NOT declared here: the registry injects
99+
// them from `AUDIT_FIELD_DEFS`, where both are `readonly: true` datetimes —
100+
// the same declaration the card's three live objects carry.
101+
const schema: ObjectSchema = {
102+
name: objectName,
103+
label: 'Repro Object',
104+
datasource: 'audit-capture',
105+
fields: {
106+
id: { name: 'id', label: 'Id', type: 'text', readonly: true },
107+
title: { name: 'title', label: 'Title', type: 'text' },
108+
run_at: { name: 'run_at', label: 'Run At', type: 'datetime', readonly: true },
109+
},
110+
} as any;
111+
objectql.registry.registerObject(schema, 'test', 'test');
112+
return { objectql, captured };
113+
}
114+
115+
const forgedPayload = () => ({
116+
id: FORGED_ID,
117+
title: 'x',
118+
run_at: FORGED_AT,
119+
created_at: FORGED_AT,
120+
updated_at: FORGED_AT,
121+
});
122+
123+
/** Prints the card's four-field table for the row that reached the driver. */
124+
function printTable(label: string, row: Record<string, any>) {
125+
const verdict = (v: unknown, forged: unknown) => (v === forged ? 'KEPT (forged)' : 'stripped/overwritten');
126+
// eslint-disable-next-line no-console
127+
console.log(
128+
`\n[#15964 ${label}]\n` +
129+
` id sent=${FORGED_ID} stored=${String(row.id)} -> ${verdict(row.id, FORGED_ID)}\n` +
130+
` run_at sent=${FORGED_AT} stored=${String(row.run_at)} -> ${verdict(row.run_at, FORGED_AT)}\n` +
131+
` updated_at sent=${FORGED_AT} stored=${String(row.updated_at)} -> ${verdict(row.updated_at, FORGED_AT)}\n` +
132+
` created_at sent=${FORGED_AT} stored=${String(row.created_at)} -> ${verdict(row.created_at, FORGED_AT)}\n`,
133+
);
134+
}
135+
136+
it('an ordinary create: the caller-supplied `created_at` does NOT survive, and the three controls stay stripped', async () => {
137+
const { objectql, captured } = await boot('repro_conversations');
138+
139+
await objectql.insert('repro_conversations', forgedPayload(), {
140+
context: { userId: 'user-1' },
141+
});
142+
143+
expect(captured.length).toBe(1);
144+
const row = captured[0];
145+
printTable('after', row);
146+
147+
// The three in-experiment controls — each was already stripped BEFORE this
148+
// change, and a fix that closes `created_at` while opening any of them is a
149+
// regression on a security card.
150+
expect(row.id).not.toBe(FORGED_ID);
151+
expect(row.run_at).not.toBe(FORGED_AT);
152+
expect(row.updated_at).not.toBe(FORGED_AT);
153+
154+
// The card's row. Overwritten by the binder rather than deleted, which is
155+
// why `created_at` is still present and still a real stamp.
156+
expect(row.created_at).not.toBe(FORGED_AT);
157+
expect(typeof row.created_at).toBe('string');
158+
expect(Date.parse(row.created_at)).toBeGreaterThan(Date.parse('2020-01-01T00:00:00.000Z'));
159+
// …and the two audit timestamps agree on a create, as they did before.
160+
expect(row.updated_at).toBe(row.created_at);
161+
});
162+
163+
it('a create that sends no `created_at` is still stamped (the binder keeps doing its job)', async () => {
164+
const { objectql, captured } = await boot('repro_plain');
165+
166+
await objectql.insert('repro_plain', { title: 'x' }, { context: { userId: 'user-1' } });
167+
168+
const row = captured[0];
169+
expect(typeof row.created_at).toBe('string');
170+
expect(Date.parse(row.created_at)).toBeGreaterThan(Date.parse('2020-01-01T00:00:00.000Z'));
171+
});
172+
173+
// The ruled control: the historical-import channel is EXPLICIT and still
174+
// works. `runImport({ treatAsHistorical: true })` puts `preserveAudit: true`
175+
// on the write context (`packages/rest/src/import-runner.ts`), which is
176+
// exactly the context asserted here.
177+
it('`preserveAudit` (what `treatAsHistorical` sets) still reinstates the original `created_at`', async () => {
178+
const { objectql, captured } = await boot('repro_historical');
179+
180+
await objectql.insert('repro_historical', forgedPayload(), {
181+
context: { userId: 'user-1', preserveAudit: true },
182+
});
183+
184+
const row = captured[0];
185+
printTable('preserveAudit control', row);
186+
187+
expect(row.created_at).toBe(FORGED_AT);
188+
// Symmetric with `updated_at`, which has had this branch since #3493.
189+
expect(row.updated_at).toBe(FORGED_AT);
190+
// …and the exemption is the audit binder's, not the strip's: a non-audit
191+
// readonly field is still taken on the create side (2026-08-08 ruling,
192+
// unchanged by this card).
193+
expect(row.run_at).not.toBe(FORGED_AT);
194+
});
195+
});

packages/objectql/src/plugin.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,13 +1100,27 @@ export class ObjectQLPlugin implements Plugin {
11001100
) => {
11011101
const now = stamp();
11021102
// A "historical" import (#3493) reinstates the ORIGINAL timeline, so a
1103-
// client-supplied updated_at/updated_by is CLIENT-PREFERRED here —
1104-
// symmetric with created_at/created_by on insert — instead of being
1105-
// overwritten with the import instant. Opt-in and server-set only; a
1106-
// normal write leaves `preserveAudit` unset and still stamps now.
1103+
// client-supplied created_at/updated_at/updated_by is CLIENT-PREFERRED
1104+
// under `preserveAudit` — instead of being overwritten with the import
1105+
// instant. Opt-in and server-set only; a normal write leaves
1106+
// `preserveAudit` unset and still stamps now.
1107+
//
1108+
// [#15964] `created_at` takes the SAME SHAPE as `updated_at`, on the
1109+
// maintainer ruling of 2026-09-06 (decision batch #54, option A). It was
1110+
// `record.created_at ?? now` on EVERY insert — client-preferred with no
1111+
// flag at all — and since #15395 the static-`readonly` strip runs INSIDE
1112+
// `engine.insert`, AFTER this hook, where #14259's guard reads a key a
1113+
// `beforeInsert` hook ASSIGNED as the hook's write rather than a caller
1114+
// forgery (`rowHookWrittenKeys`). The `??` therefore LAUNDERED the
1115+
// caller's bytes past that strip: an ordinary authenticated POST kept a
1116+
// forged `created_at` on an object declaring it `readonly: true`, while
1117+
// `id`, `updated_at` and every other author-declared readonly datetime in
1118+
// the same payload were taken. Under `preserveAudit` the preservation is
1119+
// DECLARED, so the same keep is the ruled historical-import channel and
1120+
// stays — which is why the fix is this ternary and not a bare `= now`.
11071121
const preserveAudit = session?.preserveAudit === true;
11081122
if (isInsert) {
1109-
record.created_at = record.created_at ?? now;
1123+
record.created_at = preserveAudit ? (record.created_at ?? now) : now;
11101124
}
11111125
record.updated_at = preserveAudit ? (record.updated_at ?? now) : now;
11121126
if (session?.userId) {

0 commit comments

Comments
 (0)