Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/invalid-date-total-arm-metadata-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/metadata-protocol': patch
---

Serve an Invalid `Date` from a driver instead of raising `RangeError` at two metadata read seams.

`canonicalIsoInstant` (`sys-metadata-repository.ts`) and the `occurredAt` arm inside `auditMetaItem` (`protocol.ts`) both reached `value.toISOString()` for any `Date`. That call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`, so a single bad row answered **500** on a read path — where the spelling these repairs replaced, `String(value)`, had served a visibly-wrong field the caller could see and report.

The shape is measured, not hypothetical: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276 — a range Postgres itself stores. Legacy imports, hand migrations and a MySQL database shared with another application are all ordinary ways such a row arrives.

Both arms now guard on `Number.isNaN(value.getTime())`, and the terminal value is chosen per call site rather than uniformly:

- `canonicalIsoInstant` answers `undefined`, so each caller's existing `?? <default>` chain — the branch an absent column already takes — keeps its meaning. Its consumers are machines, and one forwards into a `z.string().datetime()` field that visible text would fail.
- `auditMetaItem`'s `occurredAt` falls into the `String(...)` arm already beside it, which renders exactly `"Invalid Date"`. `AuditMetaItemResponseSchema.events[].occurredAt` is a required plain `z.string()` read by an operator in Studio's audit tab, so the text satisfies the contract and one bad row no longer blanks the page.

Neither answer is a blank: a silent empty value is the shape that hides the producer's bug.
11 changes: 11 additions & 0 deletions .changeset/invalid-date-total-arm-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/metadata': patch
---

Serve an Invalid `Date` from a driver instead of raising `RangeError` in `DatabaseLoader.stat`.

`canonicalIsoInstant` reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`. `stat()` is a hot read path — REST `/meta/*`, ObjectQL plan resolution, runtime overlay merges — so one legacy `sys_metadata` row answered **500** where the spelling this repair replaced had served a visibly-wrong value.

The shape is measured: mysql2 3.23.1 hands back a constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, which Postgres itself stores.

The `Date` arm now guards on `Number.isNaN(value.getTime())` and answers `undefined`, so `stat()`'s own `?? new Date().toISOString()` — the branch an absent column already takes — publishes a parseable `MetadataStats.mtime`. `undefined` rather than visible text is deliberate here: `mtime` is declared `z.string().datetime()`, so the text `"Invalid Date"` would not produce a readable cell, it would produce a zod refusal at the consumer, moving the failure instead of removing it. A blank is excluded for the opposite reason — it hides the producer's bug.
13 changes: 13 additions & 0 deletions .changeset/invalid-date-total-arm-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/rest': patch
---

Serve an Invalid `Date` from a driver as visible text instead of raising `RangeError` in the import-job DTO and the CSV export.

`canonicalIsoStamp` and `formatCsvCell` both reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN` — so one bad timestamp column answered **500** on `GET /api/v1/data/import/jobs/:jobId` and aborted a CSV export mid-stream.

The shape is measured: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, a range Postgres itself stores.

Both arms now guard on `Number.isNaN(value.getTime())` and render the visible text `"Invalid Date"` — the rendering the spelling they replaced produced. Both are read by a human, and the declared contracts allow it: the four import-job stamps are plain `z.string()` (not `z.string().datetime()`), and a CSV cell has no schema at all. The operator sees a wrong-looking field they can report, rather than an error naming no row.

The CSV arm needs its own guard rather than a fall-through, because the branch below it is `JSON.stringify` and `Date.prototype.toJSON` answers `null` for an Invalid `Date` — the silent blank this change exists to avoid. Both CSV paths land on the guarded arm: with field metadata, `formatDate` rejects an Invalid `Date` and passes the value through unchanged.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1749`, `:1778`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1788`, `:1817`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
written by internal callers only, as an option on the engine call:

Expand Down Expand Up @@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1781` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1820` |

### 2. Write pipeline and data integrity

Expand Down Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5411`, `:6868`, `:7116`, `:7547`, `:7740` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5450`, `:6907`, `:7155`, `:7586`, `:7779` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down Expand Up @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1749`, `:1778`; `domains/actions.ts:414` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1788`, `:1817`; `domains/actions.ts:414` |

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@
* did NOT adopt `canonicalIsoInstant` (`sys-metadata-repository.ts` /
* `database-loader.ts`), because #14078 measured an Invalid `Date` reachable
* on BOTH live dialects (a MySQL zero datetime; any Postgres year in
* 275760..294276) where that spelling's `value.toISOString()` raises
* `RangeError`, and #13973 is `pm:blocked` on that ruling. This card follows
* #14037's precedent: `isoFromValidDate` in `protocol.ts` converts the ONE
* measured shape (a valid `Date`) and returns every other shape — including
* an Invalid `Date` — UNCHANGED. §D below is the pin that this card does not
* decide #14078: it goes red the moment anyone swaps the contested spelling
* into this site.
* 275760..294276) where that spelling's `value.toISOString()` raised
* `RangeError`. #14078 has since ruled (option B, 2026-09-02) and that arm is
* now total, answering `undefined` for the shape. This card's route is
* unchanged: `isoFromValidDate` in `protocol.ts` converts the ONE measured
* shape (a valid `Date`) and returns every other shape — including an Invalid
* `Date` — UNCHANGED, which is what `listCommits` promises its callers. §D
* below stays the pin on that promise: it goes red the moment anyone swaps
* the other spelling into this site, now the separately-tracked consolidation
* decision #16422.
*
* ## Reverse verification, direction predicted BEFORE running
*
Expand Down Expand Up @@ -160,12 +162,13 @@ describe('[#14038] listCommits emits the ISO-8601 string createdAt is declared a
* Postgres year in 275760..294276), and whether the shared
* canonical-ISO spelling (`canonicalIsoInstant`) should throw on it
* (option A) or fall back to a rendering (option B) is a maintainer
* call across four packages. Until it is ruled, this site hands that
* one shape through exactly as it does today — no new throw, no
* call across four packages; it was ruled B on 2026-09-02 for the
* five arms that THREW, and this site was not one of them. It hands
* that one shape through exactly as it does today — no new throw, no
* invented rendering. This case is what makes that a PIN rather than
* a claim: it goes red the moment `canonicalIsoInstant` (or any
* spelling that reaches `.toISOString()` unconditionally) is swapped
* into `listCommits`.
* into `listCommits`. The consolidation is #16422.
*/
it('hands the value through unchanged instead of raising RangeError', async () => {
const invalid = new Date(NaN);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14078] `auditMetaItem`'s `occurredAt` arm is TOTAL — an Invalid `Date`
* renders as the visible text `"Invalid Date"`, never as a `RangeError`.
*
* ## The defect
*
* The mapping was a ternary whose middle arm ran `r.occurred_at.toISOString()`
* for ANY `Date`. `toISOString()` raises `RangeError: Invalid time value` on a
* `Date` whose time value is `NaN`, so ONE bad `sys_metadata_audit` row turned
* `GET /api/v1/meta/:type/:name/audit` — the read behind Studio's 审计日志 tab —
* into a 500 for the whole page, on a row the error does not name. This is a
* COMPLIANCE surface: the trail going dark is the failure mode it exists to
* prevent.
*
* ## Reachability is measured, not argued
*
* PR #14409 (landed `3ecb7dc1a`): mysql2 3.23.1 returns a module constant
* literally named `INVALID_DATE` for a zero `DATETIME`; postgres-date 1.0.7
* builds `new Date(NaN)` for every year in 275760..294276, a range Postgres
* itself stores. The maintainer ruled option B on 2026-09-02, on all five arms
* of the shared spelling at once.
*
* ## Why the terminal value here is the TEXT, not `undefined`
*
* The ruling sets it per call site: visible text where the field is required
* and an operator reads it. `AuditMetaItemResponseSchema.events[].occurredAt`
* is a REQUIRED plain `z.string()` — not `z.string().datetime()` — so the text
* satisfies the declared contract and arrives in the tab where a human can see
* and report it. `undefined` would fail the required field, and a blank `''`
* is the silent shape the ruling forbids by name.
*
* The value is reached by letting the guard fail into the `String(...)` arm
* that was already there, so the rendering is literally the one the pre-repair
* spelling produced. §A pins that identity rather than only the literal.
*
* ## What makes these cases non-vacuous
*
* Every case proves its planted value is a `Date` with a `NaN` time value and
* evaluates the OLD arm's expression on that same object, asserting it raises
* `RangeError`. §B is the discrimination limb: a valid `Date` is still
* canonicalised and a canonical string is still a fixed point, so the guard
* cannot pass by having disabled the arm it guards.
*/

import { describe, it, expect } from 'vitest';
import { AuditMetaItemResponseSchema } from '@objectstack/spec/api';
import { ObjectStackProtocolImplementation } from './protocol.js';

/** Canonical ISO-8601 UTC with milliseconds. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/** Non-zero milliseconds, so a truncating regression stays observable. */
const PG_INSTANT = new Date('2026-03-04T05:06:07.089Z');
const SQLITE_TEXT = '2026-03-04T05:06:07.089Z';

/**
* The removed guard, reproduced: the OLD arm's expression on the very object
* the case plants. Red here means the fixture is no longer the contested shape.
*/
function assertOldSpellingWouldThrow(value: Date): void {
expect(value, 'fixture degraded — not a Date').toBeInstanceOf(Date);
expect(Number.isNaN(value.getTime()), 'fixture is a VALID Date — case is vacuous').toBe(true);
expect(() => value.toISOString()).toThrow(RangeError);
}

function auditRow(stamp: unknown): Record<string, unknown> {
return {
id: 'aud_1',
occurred_at: stamp,
actor: 'usr_1',
source: 'protocol.saveMetaItem',
operation: 'save',
outcome: 'allowed',
code: 'ok',
lock_state: null,
lock_overridden: false,
request_id: 'req_1',
note: null,
};
}

/** The real `auditMetaItem`, over an engine whose read door returns `rows`. */
function protocolOver(rows: Array<Record<string, unknown>>) {
const engine = { registry: { getObject: () => undefined }, find: async () => rows };
return new ObjectStackProtocolImplementation(engine as never);
}

const REQ = { type: 'views', name: 'case_grid' };

describe('[#14078] §A an Invalid Date is served as visible text, not a 500', () => {
it('renders `Invalid Date` and satisfies the declared response contract', async () => {
const bad = new Date(NaN);
assertOldSpellingWouldThrow(bad);

const res = await protocolOver([auditRow(bad)]);
const body = await res.auditMetaItem(REQ);

expect(body.events).toHaveLength(1);
expect(body.events[0]!.occurredAt).toBe('Invalid Date');

// The rendering is the pre-repair spelling's own, not a literal invented
// here: `String(new Date(NaN))` is `"Invalid Date"` by ECMA-262.
expect(body.events[0]!.occurredAt).toBe(String(bad));

// ⛔ The blank the ruling forbids by name.
expect(body.events[0]!.occurredAt).not.toBe('');

// The contract itself — a REQUIRED plain `z.string()`, so the text passes
// and reaches the operator's tab.
const parsed = AuditMetaItemResponseSchema.safeParse(body);
expect(parsed.success, JSON.stringify((parsed as { error?: { issues: unknown } }).error?.issues)).toBe(true);
});

it('keeps the REST of the trail readable — one bad row does not blank the page', async () => {
const bad = new Date(NaN);
assertOldSpellingWouldThrow(bad);

const body = await protocolOver([auditRow(bad), auditRow(PG_INSTANT)]).auditMetaItem(REQ);

// The whole point of the ruling: the good rows survive the bad one.
expect(body.events.map((e) => e.occurredAt)).toEqual(['Invalid Date', PG_INSTANT.toISOString()]);
expect(AuditMetaItemResponseSchema.safeParse(body).success).toBe(true);
});
});

describe('[#14078] §B the guard discriminates — the arm it guards still works', () => {
it('canonicalises a VALID Date byte-exactly', async () => {
const body = await protocolOver([auditRow(PG_INSTANT)]).auditMetaItem(REQ);
expect(body.events[0]!.occurredAt).toBe(PG_INSTANT.toISOString());
expect(body.events[0]!.occurredAt).toMatch(ISO_Z);
});

it('leaves an already-canonical SQLite string byte-identical', async () => {
const body = await protocolOver([auditRow(SQLITE_TEXT)]).auditMetaItem(REQ);
expect(body.events[0]!.occurredAt).toBe(SQLITE_TEXT);
});

it('still renders an absent column as the empty string it always did', async () => {
const row = auditRow(null);
delete row.occurred_at;
const body = await protocolOver([row]).auditMetaItem(REQ);
// Unchanged by this card — the nullish arm's meaning is not the ruling's
// subject, and moving it would be a behaviour change nobody asked for.
expect(body.events[0]!.occurredAt).toBe('');
});
});
Loading
Loading