@objectstack/core 17.0.0 → 17.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +538 -0
- package/dist/index.cjs +383 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +402 -13
- package/dist/index.d.ts +402 -13
- package/dist/index.js +372 -49
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,543 @@
|
|
|
1
1
|
# @objectstack/core
|
|
2
2
|
|
|
3
|
+
## 17.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2782805: feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487)
|
|
8
|
+
|
|
9
|
+
Every other REST error family answers `{ error, code }`, with the machine code
|
|
10
|
+
in `code` — the 401 family was the one outlier, answering
|
|
11
|
+
`{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client
|
|
12
|
+
keying on `body.code` (the shape the other families teach, and the first read
|
|
13
|
+
of `@objectstack/client`'s `err.code`) read `undefined` for every
|
|
14
|
+
authentication failure.
|
|
15
|
+
|
|
16
|
+
`ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well.
|
|
17
|
+
**Additive only** (maintainer-ruled): no key is removed or moved — `error`
|
|
18
|
+
keeps holding the same code value it always has, so every existing reader
|
|
19
|
+
keeps working. The wire effect surfaces through `@objectstack/rest`'s
|
|
20
|
+
`enforceAuth`, which writes this constant verbatim on every `/data`, `/meta`
|
|
21
|
+
and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope
|
|
22
|
+
convergence); both declared envelope families are unchanged in kind.
|
|
23
|
+
- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
|
|
24
|
+
|
|
25
|
+
<!-- adr-0087: not-required (no-migration-prescription) One additive column on
|
|
26
|
+
an `isSystem` object declaring `protection: { lock: 'full' }`, which tenants
|
|
27
|
+
cannot author, so there is no consumer metadata to migrate and nothing
|
|
28
|
+
authorable is renamed, retired or tombstoned — no conversion to register. The
|
|
29
|
+
behavioural change is that a minted key now carries an organization, that a key
|
|
30
|
+
which cannot carry one is refused under the posture where it could never read
|
|
31
|
+
anything, and that an ex-member's key stops authenticating. -->
|
|
32
|
+
|
|
33
|
+
On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
|
|
34
|
+
read **nothing at all**. `sys_api_key` carried no organization column, so key
|
|
35
|
+
authentication established a user but no active organization — and the
|
|
36
|
+
`isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
|
|
37
|
+
no active organization matches no row. Every organization-scoped read answered
|
|
38
|
+
`200` with `total 0` while the console went on offering minting, so a tenant
|
|
39
|
+
admin could mint a valid-looking secret and discover only at call time that it
|
|
40
|
+
read nothing. (There was no cross-tenant leak — the failure was in the other
|
|
41
|
+
direction.)
|
|
42
|
+
|
|
43
|
+
**The column was absent by an inherited rule, not by oversight.**
|
|
44
|
+
`resolveInjectedSystemColumns` injects `organization_id` into every registered
|
|
45
|
+
object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
|
|
46
|
+
flag — even though better-auth's `apiKey` plugin is not loaded and the table is
|
|
47
|
+
hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
|
|
48
|
+
extension-field registration to stay consistent. The read side, by contrast,
|
|
49
|
+
was **already wired**: `resolveApiKeyPrincipal` already read an organization
|
|
50
|
+
into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
|
|
51
|
+
column no mint path ever wrote.
|
|
52
|
+
|
|
53
|
+
**What changes**
|
|
54
|
+
|
|
55
|
+
- `sys_api_key` declares `active_organization_id` (+ index, and the column is
|
|
56
|
+
shown in the "My Keys" and "All" list views, because the card's complaint was
|
|
57
|
+
a credential whose reach its owner could not see).
|
|
58
|
+
- `POST /api/v1/keys` **inherits** the caller's active organization — there is
|
|
59
|
+
deliberately no org parameter and no cross-org key — and **re-checks the
|
|
60
|
+
caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
|
|
61
|
+
windows. Under a walled posture it refuses (400) rather than minting a key
|
|
62
|
+
with no organization, and refuses (403) for an organization the caller is not
|
|
63
|
+
a member of. The mint response echoes the organization the key is pinned to.
|
|
64
|
+
- The verifier reads **one spelling** (Prime Directive #12): the
|
|
65
|
+
`row.organization_id ?? row.organizationId` chain it used to carry was a
|
|
66
|
+
consumer-side tolerance for a producer that did not exist.
|
|
67
|
+
- An **ex-member's key fails closed at verify time** — no principal, not a
|
|
68
|
+
degrade to a user-only principal, which would resurrect the same
|
|
69
|
+
`200 + total 0` silent-empty. Checked at verify rather than by revoking on
|
|
70
|
+
membership loss, because membership ends through many paths (better-auth org
|
|
71
|
+
endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
|
|
72
|
+
a hook must catch every one or it silently misses. It costs **zero extra
|
|
73
|
+
queries**: the resolver has already read `sys_member` for this user.
|
|
74
|
+
- **Pre-existing org-less keys are never backfilled** — that would silently
|
|
75
|
+
upgrade credentials minted under a different promise. They keep working under
|
|
76
|
+
`single` (no wall) and under `group` (whose wall derives from the owner's
|
|
77
|
+
memberships independently of the active organization, so they already work
|
|
78
|
+
there), and are **refused under `isolated`**, where they are provably dead
|
|
79
|
+
today.
|
|
80
|
+
|
|
81
|
+
**The column is deliberately named `active_organization_id`, not
|
|
82
|
+
`organization_id`** — the `sys_session` spelling, for the same concept: the
|
|
83
|
+
organization a credential makes *active*. `objectHasOrgIdField` tests for the
|
|
84
|
+
literal `organization_id`, and Layer 0 exempts objects without it, so the other
|
|
85
|
+
name would have made `sys_api_key` itself org-walled. Both walled postures
|
|
86
|
+
exclude NULL, so every pre-existing org-less row would have vanished from its
|
|
87
|
+
**own owner's** "My Keys" list while, under `group`, continuing to
|
|
88
|
+
authenticate — a live credential nobody could see or revoke, which is a fresh
|
|
89
|
+
instance of the very class this change removes.
|
|
90
|
+
- a38408a: fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864)
|
|
91
|
+
|
|
92
|
+
Registering two plugins under the same `name` used to mean two different things
|
|
93
|
+
depending on which kernel was running:
|
|
94
|
+
|
|
95
|
+
| kernel | behaviour before |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `ObjectKernel` (what `os serve` runs) | accepted and overwrote, with **no check and no distinguishing log line** — `Plugin registered: <name>@<version>` printed twice, reading as two plugins running |
|
|
98
|
+
| `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '<name>' already registered` |
|
|
99
|
+
|
|
100
|
+
Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one
|
|
101
|
+
declared contract: **duplicate registration by `name` overwrites — last-one-wins
|
|
102
|
+
— and emits a `warn` naming the plugin and both versions.**
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0)
|
|
106
|
+
REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
|
|
107
|
+
started; the earlier one is discarded without ever running init(). Duplicate
|
|
108
|
+
registration by name is last-one-wins on both kernels by declared contract
|
|
109
|
+
(#9864) — register the plugin once if that is not what you meant.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**This declares and warns about behaviour that already shipped; it does not fix a
|
|
113
|
+
user-visible bug.** The overwrite is load-bearing today — it is exactly what lets
|
|
114
|
+
a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier
|
|
115
|
+
in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before
|
|
116
|
+
works the same way now. What changes is that the behaviour is declared, audible,
|
|
117
|
+
and pinned against **both** kernels
|
|
118
|
+
(`packages/core/src/plugin-registration.contract.test.ts`) rather than being an
|
|
119
|
+
accident of whichever kernel a reader happened to open. This was the fourth
|
|
120
|
+
measured instance of one contract implemented twice across the two kernels
|
|
121
|
+
(#5170, #5282, #8357 adjacent).
|
|
122
|
+
|
|
123
|
+
**What this changes for a caller**
|
|
124
|
+
|
|
125
|
+
- `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch
|
|
126
|
+
`[Kernel] Plugin '<name>' already registered` to detect a double registration.
|
|
127
|
+
TO: there is no throw to catch — a duplicate is a `warn` and the later instance
|
|
128
|
+
wins. Code that registered a plugin twice and relied on the refusal should
|
|
129
|
+
register it once instead.
|
|
130
|
+
- `ObjectKernel` emits one `warn` where it previously emitted nothing, and
|
|
131
|
+
**suppresses** its `Plugin registered:` line for the superseding registration,
|
|
132
|
+
so the count of those lines equals the number of plugins that actually boot.
|
|
133
|
+
- The level is part of the contract: `warn`, never `info`. The CLI's default
|
|
134
|
+
kernel level is `warn`, and its boot-quiet window replays `warn` while
|
|
135
|
+
discarding in-window `info` — an `info` notice would be invisible on exactly
|
|
136
|
+
the boot path where this was measured.
|
|
137
|
+
|
|
138
|
+
**Measured, not assumed:** the displaced instance holds nothing that needs
|
|
139
|
+
teardown. Registration is legal only while the kernel is `idle`, so a supersede
|
|
140
|
+
can only ever displace a plugin that has never been initialized; `init()`,
|
|
141
|
+
`start()` and `destroy()` all run later, over a registry the displaced entry has
|
|
142
|
+
already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is
|
|
143
|
+
pure validation plus a name-keyed map write of its own, and invokes nothing on
|
|
144
|
+
the plugin. Calling `destroy()` on the displaced instance would be the bug, not
|
|
145
|
+
the fix: it is the paired teardown for an `init()` that never ran.
|
|
146
|
+
- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
|
|
147
|
+
|
|
148
|
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
|
|
149
|
+
added, renamed or retired. `active` is a ROW property of a `sys_permission_set`
|
|
150
|
+
/ `sys_position` record, not a key on `PermissionSetSchema` (which is a strict
|
|
151
|
+
object in `packages/spec` and deliberately declares no such key — see
|
|
152
|
+
`permission-set-projection.ts`'s ROW_STATE_COLUMNS). No spec schema, export or
|
|
153
|
+
stored metadata shape changes, so there is no conversion to register and no
|
|
154
|
+
tombstone to write. The change is a runtime predicate at the authorization
|
|
155
|
+
resolution seam; the remedy for an affected deployment is operational
|
|
156
|
+
(re-activate rows that were switched off), not a metadata migration. -->
|
|
157
|
+
|
|
158
|
+
**BREAKING for deployments that already switched a permission set or position
|
|
159
|
+
off.** Both objects ship a Deactivate action whose confirmation dialog promises,
|
|
160
|
+
in all four locales, that access stops:
|
|
161
|
+
|
|
162
|
+
> Deactivate this permission set? Existing assignments stay in place but stop
|
|
163
|
+
> granting access until re-activated.
|
|
164
|
+
> Deactivate this position? Users keep their assignment but the position stops
|
|
165
|
+
> granting permissions until re-activated.
|
|
166
|
+
|
|
167
|
+
Nothing read the column. Measured on the real resolver: a position seeded
|
|
168
|
+
`active: false` still granted its permission sets, and a permission set seeded
|
|
169
|
+
`active: false` still returned `posture: PLATFORM_ADMIN` with its system
|
|
170
|
+
permissions. Deactivation moved a badge in Setup and nothing else — while the
|
|
171
|
+
admin who had just revoked a compromised or over-broad grant was told the
|
|
172
|
+
opposite, and whose likely next step was therefore *not* the action that would
|
|
173
|
+
have worked (delete the set, or remove the assignments).
|
|
174
|
+
|
|
175
|
+
**What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
|
|
176
|
+
(`@objectstack/core`) — the single seam every transport resolves authorization
|
|
177
|
+
through — now drop a deactivated row **before** any derivation:
|
|
178
|
+
|
|
179
|
+
- a deactivated `sys_position` no longer contributes its
|
|
180
|
+
`sys_position_permission_set` grants, and its name leaves `positions` (so the
|
|
181
|
+
name-reuse path cannot resolve the same grant one layer down);
|
|
182
|
+
- a deactivated `sys_permission_set` contributes no name, no
|
|
183
|
+
`system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
|
|
184
|
+
posture** — the flag is applied before the posture is derived, not after;
|
|
185
|
+
- the `plugin-security` DB loader applies the same predicate, which is what
|
|
186
|
+
judges a set reached by NAME through an active position of the same name.
|
|
187
|
+
|
|
188
|
+
Both tables were already read at that seam, so this costs **zero new hot-path
|
|
189
|
+
queries**.
|
|
190
|
+
|
|
191
|
+
**⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
|
|
192
|
+
row currently carrying `active: false` **stops granting the moment this
|
|
193
|
+
lands** — on live data, with no migration step to notice. That is the correct
|
|
194
|
+
direction (it is what the dialog said when someone clicked Deactivate), but on
|
|
195
|
+
an installation that used the switch believing it was inert it is a real
|
|
196
|
+
revocation. Before upgrading, list the deactivated rows and re-activate any that
|
|
197
|
+
are still meant to grant:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
|
|
201
|
+
GET /api/v1/data/sys_position?filters=[["active","=",false]]
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A row whose `active` column is **absent or NULL** is unaffected: the predicate
|
|
205
|
+
is "explicitly deactivated", never "explicitly active", so rows that predate the
|
|
206
|
+
column keep granting exactly as before.
|
|
207
|
+
|
|
208
|
+
**Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
|
|
209
|
+
Enforcing the flag opened a one-click, installation-wide lockout: deactivating
|
|
210
|
+
`admin_full_access` un-makes every platform admin at once, through a payload
|
|
211
|
+
that touches neither `name` nor any identity table, and re-activating requires
|
|
212
|
+
the permission the click just took away (the seeders deliberately never
|
|
213
|
+
reconcile `active`, so no restart restores it). The last-administrator guard now
|
|
214
|
+
judges that write like the delete and rename spellings it already refused, and
|
|
215
|
+
an environment whose break-glass set is *already* off is read as emptied rather
|
|
216
|
+
than as a bootstrap window — so it does not silently disarm the guard for every
|
|
217
|
+
other identity write. Re-activation itself stays permitted, or the refusal would
|
|
218
|
+
have no way out from inside the product.
|
|
219
|
+
- f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
|
|
220
|
+
|
|
221
|
+
`plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
|
|
222
|
+
pending write can empty the administrator population by testing the payload
|
|
223
|
+
against three standing-key lists (`MEMBER_STANDING_KEYS`,
|
|
224
|
+
`GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
|
|
225
|
+
of them is skipped without any reads — so a column `resolveAuthzContext` starts
|
|
226
|
+
reading that a list omits is a write class the guard **silently stops judging**,
|
|
227
|
+
on the one path whose failure mode is an installation-wide administrator lockout
|
|
228
|
+
with no in-product recovery.
|
|
229
|
+
|
|
230
|
+
Nothing bound the two together. The correspondence lived in a comment, and it
|
|
231
|
+
had already gone false once: #6084 wrote — naming `active` explicitly — that
|
|
232
|
+
everything a permission-set write touches other than `name` is invisible to "who
|
|
233
|
+
is an administrator". That was true when written; #8613 made `active` a
|
|
234
|
+
resolution-time predicate and the sentence became false. Nothing mechanical
|
|
235
|
+
would have caught it, because the guard's own tests stay green precisely when
|
|
236
|
+
the guard is never consulted.
|
|
237
|
+
|
|
238
|
+
**The mechanism is two links, and the first one is a measurement.**
|
|
239
|
+
|
|
240
|
+
- `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
|
|
241
|
+
resolver, listing every table the administrator-derivation path reads, each
|
|
242
|
+
classified `derives` or `reads-only` with its reason, and for the deriving
|
|
243
|
+
tables every column read. It is asserted **equal** to what the real
|
|
244
|
+
`resolveAuthzContext` reads, observed at runtime through a recording engine
|
|
245
|
+
that records every property access and every `where` key per table. Observation
|
|
246
|
+
rather than source extraction because the reads that matter have moved into
|
|
247
|
+
helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
|
|
248
|
+
by `isGrantActive(row, now)`, neither named at the resolver's own call site —
|
|
249
|
+
the exact shape #8613 had.
|
|
250
|
+
|
|
251
|
+
- `@objectstack/plugin-auth` now exports its standing-key lists plus
|
|
252
|
+
`STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
|
|
253
|
+
every column of that measured surface to have an answer: it is standing-bearing
|
|
254
|
+
(in a list) or it is excluded with the reason it cannot empty the administrator
|
|
255
|
+
population. There is no third state — the third state is what `active` was
|
|
256
|
+
between #6084 and #8613.
|
|
257
|
+
|
|
258
|
+
So a resolver change that starts reading a new column fails at the first link
|
|
259
|
+
until the declaration is updated, and at the second until the guard has an
|
|
260
|
+
explicit answer for it. Landing #8613 green would have required writing down that
|
|
261
|
+
deactivating `admin_full_access` cannot empty the administrator population —
|
|
262
|
+
which is false, and which is what the old comment asserted by accident.
|
|
263
|
+
|
|
264
|
+
**No guard behaviour changes.** Every list keeps exactly the values it had; the
|
|
265
|
+
gate is one-directional by construction (it can only ever demand that the guard
|
|
266
|
+
judges *more*), because the other direction would put pressure on a break-glass
|
|
267
|
+
guard to fire less often.
|
|
268
|
+
|
|
269
|
+
The table-level half is covered too: a resolver that started deriving
|
|
270
|
+
administrator standing from a **new** table is invisible to any column-set
|
|
271
|
+
comparison, since the table is absent from both sides — so the surface enumerates
|
|
272
|
+
every table the path reads, and an unclassified one fails.
|
|
273
|
+
|
|
274
|
+
### Patch Changes
|
|
275
|
+
|
|
276
|
+
- 7ff3975: feat(spec): `IHttpServer` gains an optional `afterResponse` response-observing
|
|
277
|
+
hook so HTTP metrics are transport-agnostic instead of Hono-only (#9835)
|
|
278
|
+
|
|
279
|
+
The contract addition (additive — a new optional member plus the
|
|
280
|
+
`HttpResponseObservation` / `HttpResponseObserver` types and the reserved
|
|
281
|
+
`UNMATCHED_ROUTE_PATTERN` label): a transport invokes each registered observer
|
|
282
|
+
exactly once per answered request with `{ method, routePattern, status,
|
|
283
|
+
elapsedMs }`, after the response exists — the observation point the `use()`
|
|
284
|
+
middleware contract cannot express (it runs before dispatch and never sees a
|
|
285
|
+
status). `routePattern` is REQUIRED to be the registered route pattern
|
|
286
|
+
(`/api/v1/data/:id`), never the concrete path, so no adapter re-decides metric
|
|
287
|
+
cardinality. Optionality is feature-detected runtime-real
|
|
288
|
+
(`typeof server.afterResponse === 'function'`); a transport that does not
|
|
289
|
+
implement the seam reports **no** HTTP metrics — zero there means "not
|
|
290
|
+
instrumented", never "no traffic".
|
|
291
|
+
|
|
292
|
+
Implementations and consumers in the same change:
|
|
293
|
+
|
|
294
|
+
- `@objectstack/plugin-hono-server`: `HonoHttpServer` implements the seam (the
|
|
295
|
+
ruled #9650 raw-app middleware becomes its delivery path — same reach,
|
|
296
|
+
including `getRawApp()` mounts and middleware-refused 429s); unrouted
|
|
297
|
+
requests are now labelled with the reserved `unmatched` pattern (previously
|
|
298
|
+
they could surface as `/*`).
|
|
299
|
+
- `@objectstack/observability`: new `armHttpRequestCounter(server, metrics)`
|
|
300
|
+
arms the `http_requests_total` counter through the seam at most once per
|
|
301
|
+
server (first caller wins), which is what makes "exactly one counter per
|
|
302
|
+
server" structural.
|
|
303
|
+
- `@objectstack/runtime`: the dispatcher offers its `observability.metrics`
|
|
304
|
+
registry to the seam (a host that wires only the dispatcher now counts every
|
|
305
|
+
inbound surface) and suppresses its own per-route copy of
|
|
306
|
+
`http_requests_total` when the transport implements the seam — retiring the
|
|
307
|
+
#9833 double count. Request-id echo, the duration histogram, the error
|
|
308
|
+
counter and the error reporter are unchanged.
|
|
309
|
+
- `@objectstack/http-conformance`: `NodeHttpServer` implements the seam, and a
|
|
310
|
+
new cross-adapter conformance suite locks the semantics for both adapters.
|
|
311
|
+
- `@objectstack/core`: re-exports the new contract types/constant.
|
|
312
|
+
- 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485)
|
|
313
|
+
|
|
314
|
+
`parseDateCell` ended in `new Date(s)`. A spreadsheet cell like
|
|
315
|
+
`2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the
|
|
316
|
+
**process** timezone, and the instant bulk import stored became a property of
|
|
317
|
+
the deployment host:
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z
|
|
321
|
+
TZ=UTC → 2026-08-01T06:00:00.000Z
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Same file, same tenant, same cell — eight hours apart, decided by a setting
|
|
325
|
+
nobody authoring the spreadsheet can see, and never consulting the business
|
|
326
|
+
timezone the route had already resolved one frame up
|
|
327
|
+
(`ExecutionContext.timezone`, the platform-default → global → tenant cascade).
|
|
328
|
+
|
|
329
|
+
Since the export renders `datetime` cells in that business timezone (#8373), the
|
|
330
|
+
advertised export → edit in a spreadsheet → re-import round trip was lossless
|
|
331
|
+
only where the host `TZ` happened to equal the business zone. `import-coerce.ts`
|
|
332
|
+
opens by calling itself "the inverse of `export-format.ts`"; it now is one, and
|
|
333
|
+
the regression proof asserts inverse-ness on the **pair** — every fixture under
|
|
334
|
+
a host `TZ` deliberately different from the business timezone, because a test
|
|
335
|
+
that runs only under a matching `TZ` cannot fail.
|
|
336
|
+
|
|
337
|
+
**An offset-free datetime cell is now read in the caller's business timezone**,
|
|
338
|
+
through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall
|
|
339
|
+
clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill
|
|
340
|
+
path) is now the midnight special case of. One implementation of zone
|
|
341
|
+
arithmetic, `Intl` offsets from the platform tz database, never hand-rolled;
|
|
342
|
+
generalising the existing one rather than hand-rolling a second in `rest` is
|
|
343
|
+
what keeps the export and import halves of this seam from drifting apart again.
|
|
344
|
+
Two wall clocks are not a bijection with instants, and both degenerate DST
|
|
345
|
+
readings resolve to the earlier candidate instant — a gap reading lands just
|
|
346
|
+
before the gap, an ambiguous reading on its first occurrence (pinned, measured).
|
|
347
|
+
|
|
348
|
+
Three things deliberately do **not** move:
|
|
349
|
+
|
|
350
|
+
- **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one
|
|
351
|
+
instant and is honoured exactly as written. This change affects naive cells
|
|
352
|
+
only.
|
|
353
|
+
- **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and
|
|
354
|
+
a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the
|
|
355
|
+
zoned handling to make the code look uniform would silently re-time every
|
|
356
|
+
date-only import to fix nothing.
|
|
357
|
+
- **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback
|
|
358
|
+
the export's cell path takes in the same case, so the round trip stays exact
|
|
359
|
+
for deployments that configure no zone — and a process-`TZ` fallback would
|
|
360
|
+
preserve the defect for exactly the deployments that cannot see it. This is
|
|
361
|
+
the one **behaviour change for existing deployments**: a host with a non-UTC
|
|
362
|
+
`TZ` and no resolved business timezone previously read naive cells in the host
|
|
363
|
+
clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved
|
|
364
|
+
zone, not a missing one.
|
|
365
|
+
|
|
366
|
+
Two adjacent legs of the same defect, both on the naive-cell path:
|
|
367
|
+
|
|
368
|
+
- **A naive cell landing in a `date` or `time` field** now takes the typed
|
|
369
|
+
components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`).
|
|
370
|
+
Those branches also read the process clock, so a host east of the cell stored
|
|
371
|
+
the *previous calendar day* for a `date` column.
|
|
372
|
+
- **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS
|
|
373
|
+
materialises it as a `Date` whose UTC components are the sheet's wall clock,
|
|
374
|
+
and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the
|
|
375
|
+
file never had. That fabricated offset then outranked the business timezone by
|
|
376
|
+
the very carve-out above, so every real date cell in a user-authored workbook
|
|
377
|
+
imported as UTC whatever the tenant's zone. It now flattens to the same
|
|
378
|
+
offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that
|
|
379
|
+
function's contract already claimed to produce.
|
|
380
|
+
- e1bb0ca: fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)
|
|
381
|
+
|
|
382
|
+
The record-shaped `os test` action types (`create_record`, `read_record`,
|
|
383
|
+
`update_record`, `delete_record`, `query_records`) built their URLs from the
|
|
384
|
+
**defaults** of `RestApiConfigSchema.apiPath` and
|
|
385
|
+
`CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
|
|
386
|
+
and nothing else. A deployment that moved the mount got a 404 that reads like the
|
|
387
|
+
suite author's own URL mistake rather than a platform limitation.
|
|
388
|
+
|
|
389
|
+
The adapter now asks the server, following the `getRoute` precedent in
|
|
390
|
+
`@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
|
|
391
|
+
test` builds one adapter for the whole run), addressing whatever `routes.data`
|
|
392
|
+
advertises, with the schema-derived convention as the fallback. Measured on a
|
|
393
|
+
booted stack (REST route generator + dispatcher bridge), before and after:
|
|
394
|
+
|
|
395
|
+
| deployment | before | after |
|
|
396
|
+
|---|---|---|
|
|
397
|
+
| stock | created | created |
|
|
398
|
+
| `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
|
|
399
|
+
| `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |
|
|
400
|
+
|
|
401
|
+
The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
|
|
402
|
+
moves the base that `/discovery` is itself mounted under, so the document that
|
|
403
|
+
would name the new mount sits behind the prefix that is missing. The one
|
|
404
|
+
discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
|
|
405
|
+
advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
|
|
406
|
+
under all three configs above — so it is deliberately not probed: trusting it
|
|
407
|
+
would attach a false provenance ("discovery told us") to the same 404.
|
|
408
|
+
|
|
409
|
+
Instead that case degrades loudly. Falling back to the convention prints a
|
|
410
|
+
warning naming the mount it will address, the probe that failed and the remedy,
|
|
411
|
+
and every 404/405 from a record action now carries the mount it addressed and
|
|
412
|
+
where that mount came from. `api_call` is unchanged, issues no probe, and remains
|
|
413
|
+
the escape hatch for a host the probe cannot reach.
|
|
414
|
+
- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
|
|
415
|
+
|
|
416
|
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
|
|
417
|
+
renamed, retired or tombstoned — no spec schema is touched at all. The change
|
|
418
|
+
is a new runtime refusal at the engine's filter collection point, plus the
|
|
419
|
+
routing decline that stops the raw-SQL analytics path bypassing it. -->
|
|
420
|
+
|
|
421
|
+
A `datetime` / `date` / `time` field filtered with a bare string the platform
|
|
422
|
+
cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
|
|
423
|
+
all the way to the driver, where the comparison is false for every row. The
|
|
424
|
+
caller received `HTTP 200`, an empty result set, and nothing to indicate the
|
|
425
|
+
filter was meaningless. An unknown `{placeholder}` in the same position was
|
|
426
|
+
already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
|
|
427
|
+
tokens), so one API answered two shapes of unusable comparand two different
|
|
428
|
+
ways.
|
|
429
|
+
|
|
430
|
+
It is concretely reachable rather than theoretical: `last_7_days` /
|
|
431
|
+
`last_30_days` / `last_90_days` are **declared preset names** in the dashboard
|
|
432
|
+
schema. The shipped console lowers them to `{N_days_ago}` macros before they
|
|
433
|
+
reach the API, so the console path was always safe — but a saved report, an
|
|
434
|
+
integration, an MCP client or an AI-authored query sends the preset name itself
|
|
435
|
+
and got a silent zero. An empty chart is the hardest failure to debug: it is
|
|
436
|
+
indistinguishable from "there is genuinely no data".
|
|
437
|
+
|
|
438
|
+
Such a comparand is now refused at the ObjectQL engine's single filter
|
|
439
|
+
collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
|
|
440
|
+
field, the value, the key path and the spellings that would work. That seam is
|
|
441
|
+
the one place holding the caller's comparand and the field's **declared type**
|
|
442
|
+
at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
|
|
443
|
+
/ `update` / `delete`) and both filter spellings (the array sugar and the
|
|
444
|
+
lowered condition) pass through it, so all four backends inherit one answer
|
|
445
|
+
rather than four. `NativeSQLStrategy` additionally **declines** such a query so
|
|
446
|
+
the raw-SQL analytics path falls through to that door instead of binding the
|
|
447
|
+
value into its own statement.
|
|
448
|
+
|
|
449
|
+
Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
|
|
450
|
+
refusal one layer down (the door runs before token resolution and steps around
|
|
451
|
+
them, so `{30_days_ago}` still resolves normally); non-string comparands are
|
|
452
|
+
untouched (a number is epoch milliseconds, a `Date` is an instant); and the
|
|
453
|
+
**empty string** keeps today's behaviour exactly — it binds as `''` and matches
|
|
454
|
+
every non-null row, which is a separate question that remains its own card.
|
|
455
|
+
- Updated dependencies [56656aa]
|
|
456
|
+
- Updated dependencies [07e630e]
|
|
457
|
+
- Updated dependencies [2f65b1b]
|
|
458
|
+
- Updated dependencies [720ee95]
|
|
459
|
+
- Updated dependencies [f287435]
|
|
460
|
+
- Updated dependencies [9aa8890]
|
|
461
|
+
- Updated dependencies [7c9c1dd]
|
|
462
|
+
- Updated dependencies [75b7c24]
|
|
463
|
+
- Updated dependencies [d5552ca]
|
|
464
|
+
- Updated dependencies [d9813a9]
|
|
465
|
+
- Updated dependencies [8640fb2]
|
|
466
|
+
- Updated dependencies [2420641]
|
|
467
|
+
- Updated dependencies [2ad91c3]
|
|
468
|
+
- Updated dependencies [f57fb38]
|
|
469
|
+
- Updated dependencies [00777a0]
|
|
470
|
+
- Updated dependencies [d491625]
|
|
471
|
+
- Updated dependencies [420804d]
|
|
472
|
+
- Updated dependencies [716ac9b]
|
|
473
|
+
- Updated dependencies [62b1427]
|
|
474
|
+
- Updated dependencies [7ea1372]
|
|
475
|
+
- Updated dependencies [23abe27]
|
|
476
|
+
- Updated dependencies [985a9cd]
|
|
477
|
+
- Updated dependencies [a8189ae]
|
|
478
|
+
- Updated dependencies [26e70fb]
|
|
479
|
+
- Updated dependencies [42b05af]
|
|
480
|
+
- Updated dependencies [2b292ce]
|
|
481
|
+
- Updated dependencies [abcf853]
|
|
482
|
+
- Updated dependencies [8b9eba5]
|
|
483
|
+
- Updated dependencies [d575779]
|
|
484
|
+
- Updated dependencies [94f7ef8]
|
|
485
|
+
- Updated dependencies [c5ac5e4]
|
|
486
|
+
- Updated dependencies [a777944]
|
|
487
|
+
- Updated dependencies [dd88e1c]
|
|
488
|
+
- Updated dependencies [856527c]
|
|
489
|
+
- Updated dependencies [870f710]
|
|
490
|
+
- Updated dependencies [79c46da]
|
|
491
|
+
- Updated dependencies [7ff3975]
|
|
492
|
+
- Updated dependencies [29d055b]
|
|
493
|
+
- Updated dependencies [65589d6]
|
|
494
|
+
- Updated dependencies [2c86fe3]
|
|
495
|
+
- Updated dependencies [e196c6a]
|
|
496
|
+
- Updated dependencies [4ab7523]
|
|
497
|
+
- Updated dependencies [19539b4]
|
|
498
|
+
- Updated dependencies [11b779e]
|
|
499
|
+
- Updated dependencies [739fe5b]
|
|
500
|
+
- Updated dependencies [4bfe1a5]
|
|
501
|
+
- Updated dependencies [2065e31]
|
|
502
|
+
- Updated dependencies [b69d0f5]
|
|
503
|
+
- Updated dependencies [4d47afe]
|
|
504
|
+
- Updated dependencies [e4e5c6e]
|
|
505
|
+
- Updated dependencies [9a56784]
|
|
506
|
+
- Updated dependencies [d00d2f6]
|
|
507
|
+
- Updated dependencies [df0c12d]
|
|
508
|
+
- Updated dependencies [d31785f]
|
|
509
|
+
- Updated dependencies [c308a4f]
|
|
510
|
+
- Updated dependencies [e2899f6]
|
|
511
|
+
- Updated dependencies [3851f87]
|
|
512
|
+
- Updated dependencies [2a29caa]
|
|
513
|
+
- Updated dependencies [09a6eee]
|
|
514
|
+
- Updated dependencies [1a7f907]
|
|
515
|
+
- Updated dependencies [cd455c8]
|
|
516
|
+
- Updated dependencies [30d3752]
|
|
517
|
+
- Updated dependencies [c80e7ae]
|
|
518
|
+
- Updated dependencies [09a9a8a]
|
|
519
|
+
- Updated dependencies [07026cf]
|
|
520
|
+
- Updated dependencies [5d4f3d5]
|
|
521
|
+
- Updated dependencies [4d80e8b]
|
|
522
|
+
- Updated dependencies [30b1c63]
|
|
523
|
+
- Updated dependencies [079b457]
|
|
524
|
+
- Updated dependencies [e43b211]
|
|
525
|
+
- Updated dependencies [890b38f]
|
|
526
|
+
- Updated dependencies [8bee54b]
|
|
527
|
+
- Updated dependencies [7a537ce]
|
|
528
|
+
- Updated dependencies [593c4bf]
|
|
529
|
+
- Updated dependencies [ff08691]
|
|
530
|
+
- Updated dependencies [60e0f90]
|
|
531
|
+
- Updated dependencies [90c5285]
|
|
532
|
+
- Updated dependencies [7901b2d]
|
|
533
|
+
- Updated dependencies [56bca91]
|
|
534
|
+
- Updated dependencies [79394d7]
|
|
535
|
+
- Updated dependencies [730fd9a]
|
|
536
|
+
- Updated dependencies [44bc51d]
|
|
537
|
+
- Updated dependencies [73cfddf]
|
|
538
|
+
- Updated dependencies [d634e66]
|
|
539
|
+
- @objectstack/spec@17.1.0
|
|
540
|
+
|
|
3
541
|
## 17.0.0
|
|
4
542
|
|
|
5
543
|
### Major Changes
|