Skip to content

Commit 569b54a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/objectql-protocol-layering-s2sn67
# Conflicts: # packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts
2 parents 0e970c1 + 4f8c2d1 commit 569b54a

126 files changed

Lines changed: 5054 additions & 351 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/connector-mcp': minor
5+
---
6+
7+
feat(connectors): degrade + retry declarative instances whose upstream is unreachable (#3017)
8+
9+
ADR-0097 kept every declarative-connector materialization failure fatal at
10+
boot. That is right for configuration faults (unknown provider, invalid
11+
`providerConfig`, unresolvable `credentialRef`, name conflict) but wrong for
12+
*operational* ones: a `provider: 'mcp'` instance must contact its MCP server
13+
(`tools/list`) to materialize, and a transient network blip aborted the whole
14+
app boot.
15+
16+
- **spec**: a provider factory can now throw
17+
`ConnectorUpstreamUnavailableError` (code `CONNECTOR_UPSTREAM_UNAVAILABLE`,
18+
structural guard `isConnectorUpstreamUnavailable`) to mark a failure as
19+
"upstream temporarily unreachable — degrade and retry" instead of fatal.
20+
- **service-automation**: the reconcile degrades such an instance in both boot
21+
and reload modes: it registers an action-less husk (`state: 'degraded'` +
22+
`degradedReason` on the `GET /connectors` descriptor) so the instance is
23+
visible instead of silently missing — or, on a changed-config
24+
re-materialization, keeps the old connector serving. A `connector_action`
25+
against a degraded instance fails with the reason and a "retries
26+
automatically" pointer. Degraded instances retry on an exponential backoff
27+
(5s → 5min, reset by config edits) and on every `metadata:reloaded`
28+
reconcile; recovery swaps the husk for the live connector atomically.
29+
Reconcile runs (boot / reload / retry timer) are now serialized.
30+
- **connector-mcp**: the `mcp` provider classifies connect / `tools/list`
31+
failures as upstream-unavailable; transport-shape validation stays a plain
32+
(fatal) throw.
33+
34+
Configuration faults remain loud boot failures — the carve-out is only for the
35+
unavailable marker.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(security): enforce `readonlyWhen` on the multi-row UPDATE path (#3042)
6+
7+
Conditional `readonlyWhen` field locks were stripped only on the single-id
8+
UPDATE path; the bulk `update({ multi: true, where })` path enforced static
9+
`readonly` (#2948) but never `readonlyWhen`. A programmatic/embedded caller (or
10+
a plugin) issuing a multi-row update in a user context could therefore write a
11+
field its own `readonlyWhen` predicate should have locked — the conditional
12+
lock held for a `PATCH /data/:object/:id` but not for a bulk where-predicate
13+
update. (The external REST/SDK `updateMany` endpoint was unaffected: it loops
14+
single-id `engine.update` calls, which already strip `readonlyWhen`.)
15+
16+
`engine.update` now, on the multi-row path and only when the payload actually
17+
writes a `readonlyWhen` field, reads the row-scoped match set with the same
18+
composed AST the write binds (one query) and drops any field whose predicate is
19+
TRUE for at least one matched row — a single bulk payload cannot keep a field
20+
for some rows and drop it for others, so a field locked in any target row is
21+
fail-safe-dropped for the batch (narrow the `where` to reach the rows where it
22+
is unlocked). A conditional field NO matched row locks is written normally, so a
23+
legitimate bulk edit is unaffected. Symmetric with the single-id
24+
`stripReadonlyWhenFields` and with the static-`readonly` bulk strip; INSERT
25+
stays exempt. No change for any single-id update or any object without
26+
`readonlyWhen` fields.

.changeset/console-fb35e4828fdb.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `fb35e4828fdb`. Frontend changes in this range:
6+
7+
- fix(data-objectstack): emit MutationEvents from batchTransaction and bulk so master-detail saves refresh bound views (#2584)
8+
- feat(dashboard-filters): #2578 item-5 enhancements — nested variable merging, metadata-aware default bindings, server-side optionsFrom distinct (#2590)
9+
- feat(fields+form+detail): file/image upload cells in inline line-item grids (#2360) (#2585)
10+
- feat(app-shell): visual filterBindings editor in the dashboard widget inspector (#2578) (#2586)
11+
- fix(detail): highlight strip lookup editor honors ObjectStack `reference` key (#2407) (#2587)
12+
- fix(app-shell): guard Studio Access pillar against silently discarding unsaved matrix edits (#2588)
13+
- feat(dashboard-filters): #2578 follow-ups — catalog examples, guide tutorial, i18n entries, spec-alignment cleanup (#2581)
14+
- fix(detail+fields+app-shell): ADR-0085 #2548 follow-ups — strip title dedupe, group icon/description, currency channel, approvals Bearer (#2577)
15+
- feat(dashboard): dashboard-level filters driving multiple charts (framework#2501) (#2576)
16+
- feat(page-header): metadata-driven multi-button record header (#2361) (#2574)
17+
18+
objectui range: `092bd859934f...fb35e4828fdb`
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Dashboard-level filters spec pairing (framework#2501, objectui#2578) — land the
6+
two properties the objectui runtime already ships (objectui#2576) so the
7+
protocol and the renderer agree:
8+
9+
- **`GlobalFilterSchema.name`** (optional string) — stable filter name used as
10+
the dashboard-variable key (readable in widget expressions as `page.<name>`)
11+
and as the key widgets reference in `filterBindings`. Defaults to `field`;
12+
`"dateRange"` is reserved for the built-in dashboard date range.
13+
- **`DashboardWidgetSchema.filterBindings`** (optional
14+
`Record<string, string | false>`) — per-widget binding from a dashboard
15+
filter name to one of THIS widget's fields: a string re-targets the filter to
16+
that field, `false` opts the widget out, absent falls back to the filter's
17+
own `field`.
18+
19+
Purely additive — existing dashboards parse unchanged. The metadata-admin
20+
dashboard inspector (objectui `dashboard-schema.ts`) derives its form from this
21+
schema via `z.toJSONSchema`, so both properties surface there automatically
22+
once objectui picks up this spec version.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): structured `buttons` + `defaults` config on `FormViewSchema` (#2998)
6+
7+
`FormViewSchema` gains two optional top-level keys — the spec home for the flat
8+
renderer-invented form config ObjectUI's `ObjectForm` reads today
9+
(`showSubmit`/`submitText`/`showCancel`/`cancelText`/`showReset`/`initialValues`,
10+
objectui#2545), which the strip-mode container silently discards:
11+
12+
- **`buttons`** — structured action-button config: per-button `{ show, label }`
13+
for `submit` / `cancel` / `reset` (new exported leaf `FormButtonConfigSchema`,
14+
`.strict()` per ADR-0089 D3a so typo'd keys error loudly).
15+
- **`defaults`** — initial field values for create-mode forms, keyed by field
16+
machine name (absorbs ObjectUI's `initialValues`).
17+
18+
Both are marked `[EXPERIMENTAL — NOT ENFORCED]` per ADR-0078's escape hatch
19+
until the ObjectUI renderer reads them (tracked in objectui#2545); authoring
20+
them today is declared, not yet honored. Purely additive — no existing key
21+
changes shape, no tombstone needed.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(cli): `os i18n extract` now emits action param keys (`o.<object>._actions.<action>.params.<param>.*`) so action-dialog forms are translatable (#3030)
7+
8+
The console client already resolves param labels, help text, placeholders and
9+
option labels from `o.<object>._actions.<action>.params.*`, but the extractor
10+
never walked `actions[].params`, so those keys were absent from generated
11+
bundles and dialogs like Setup → Create User rendered raw English under any
12+
locale. The extractor now emits:
13+
14+
- inline params → `label` / `helpText` / `placeholder` / `options.<value>`;
15+
- field-backed params (`{ field: '…' }`) → only when they carry a literal
16+
override (field translations already cover them at runtime);
17+
- both object actions and top-level (global) actions.
18+
19+
`@objectstack/platform-objects` regenerates its en/zh-CN/ja-JP/es-ES bundles
20+
with the new keys filled (user admin actions, sys_jwks fields, page variable
21+
forms). Re-running extract with `--merge` stays idempotent.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): keep `lazySchema` proxies identity-compatible with `z.toJSONSchema` (objectui#2561)
6+
7+
zod's `toJSONSchema` keys its `seen` map on the node object it traverses — the `lazySchema` Proxy wherever a schema is referenced lazily (`z.lazy(() => X)` recursion getters, direct conversion roots) — while its wrapper-type processors (pipe/lazy/optional/default/…) look themselves up via the REAL instance captured at construction (`inst._zod.processJSONSchema = (ctx, …) => pipeProcessor(inst, …)`). The identity mismatch crashed conversion with `Cannot set properties of undefined (setting 'ref')`.
8+
9+
This stayed latent while lazy-referenced schemas were plain objects (the object processor never looks itself up); ADR-0089 D3a turned `PageComponentSchema` / `FormFieldSchema` into `.strict().transform(…)` **pipes**, which broke ObjectUI Studio's spec-derived Page/View inspector JSONSchema derivation under spec 15.
10+
11+
Fix: the proxy now serves a memoised `_zod` facade that prototype-delegates to the real internals and wraps only `processJSONSchema` to alias the proxy's `seen` entry onto the real instance before delegating. Parse behavior is unchanged; `OS_EAGER_SCHEMAS=1` remains the bypass. Regression tests cover the D3a pipe shape, recursion through `z.lazy(() => proxy)`, mixed proxy+real traversal, and the full `PageSchema` / `ViewSchema` Studio derivation paths.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
OWD posture is now enforced on the runtime write path (#3050). `metadata-protocol` gains the ADR-0094-addendum `registerAuthoringGate(type, gate)` seam — an awaited, throwing pre-persistence hook inside `saveMetaItem` (draft and publish-mode saves; environment writes only). `plugin-security` registers the `object` posture gate on it: an environment overlay of a packaged object may only TIGHTEN `sharingModel`/`externalSharingModel` (ADR-0086 D1 — closes the `OS_METADATA_WRITABLE=object` unvalidated-widening hole), and `externalSharingModel ≤ sharingModel` (ADR-0090 D11) is now rejected at save time instead of only by CLI lint. Write-path only — stored metadata keeps loading unchanged.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(security): exempt engine referential FK clears from the owner_id transfer guard (#3023)
7+
8+
Follow-up to the #3004 ownership-anchor guard. `owner_id` is a lookup to `sys_user`
9+
with the default `deleteBehavior: 'set_null'`, so deleting a `sys_user` makes
10+
`cascadeDeleteRelations` null `owner_id` on every dependent row. That cascade write
11+
re-entered the write middleware under the deleter's context, where the #3004 guard
12+
read the `owner_id = null` as a user-initiated disown and denied it — aborting the
13+
cascade mid-way (no transaction, so partial state) for any deleter without the
14+
transfer grant on the child object (e.g. a member clearing a `public_read_write`
15+
child that RLS would otherwise have allowed).
16+
17+
The cascade FK clear is engine-mandated referential integrity consequent to an
18+
already-authorized parent delete, not a user ownership change. `cascadeDeleteRelations`
19+
now tags the `set_null` write with a server-derived `__referentialFieldClear` context
20+
marker (set by the engine, never built from a request — same trust model as
21+
`__expandRead`), and the ownership-anchor guard skips when that marker is present.
22+
Ordinary user writes are unaffected; the marker cannot be forged from client input,
23+
so it can never slip a real ownership transfer past the guard.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/plugin-sharing': patch
5+
---
6+
7+
fix(security): guard the `owner_id` ownership anchor and scope bulk writes to owner-visible rows (#3004, #2982)
8+
9+
Two write-path holes on the row-ownership anchor (`owner_id`), the column OWD
10+
row-level scoping keys off to decide who may update/delete a record.
11+
12+
- **#3004 — client-writable, unguarded `owner_id`.** The anchor is deliberately
13+
not `readonly` (ownership is transferable), so the static-readonly strip never
14+
covered it and FLS doesn't gate it by default. A non-privileged writer could
15+
therefore `insert` a record under someone else's name (forge) or `update` one
16+
to a new owner (transfer / disown), evading the owner gate that governs
17+
update/delete. The security middleware (plugin-security step 3.5) now treats
18+
`owner_id` as system-managed for non-privileged writers: on insert an empty
19+
value is auto-stamped to the acting user (batch rows too — previously only the
20+
single-record path stamped, leaving bulk-inserted rows NULL-owned and
21+
invisible to their creator), and a supplied foreign owner is denied; on update
22+
a supplied `owner_id` is a transfer/disown and is denied — the unchanged no-op
23+
echo of a form save is tolerated via a pre-image compare, and a bulk
24+
change-set carrying `owner_id` fails closed. A non-scalar `owner_id`
25+
(array/object) is rejected outright rather than string-coerced, and the
26+
change-set membership test uses own-property semantics so a polluted
27+
prototype cannot spoof an ownership write. Both require the transfer grant
28+
(`allowTransfer`, or `modifyAllRecords` which implies it) to proceed. System
29+
context (`ctx.isSystem`) stays fully exempt (OAuth provisioning / cron
30+
snapshots / seed claims / migrations), and under delegation both principals
31+
must hold the grant (ADR-0090 D10 intersection). Note a REST **import** runs
32+
under the importer's own context (not `isSystem`), so a non-privileged user
33+
importing a CSV whose `owner_id` column names other users is correctly denied
34+
unless they hold the transfer grant — administrators (who carry
35+
`modifyAllRecords`) are unaffected.
36+
37+
- **#2982 — bulk writes skipped owner scoping on OWD-`private` objects.** A
38+
`update({ multi: true })` / bulk delete rebuilt the driver AST from
39+
`options.where` AFTER the middleware chain, discarding the owner/RLS write
40+
filter that plugin-sharing (`buildWriteFilter`) and plugin-security compose
41+
onto `opCtx.ast` — so a member's bulk write hit every matching row, including
42+
peers'. The engine now seeds `opCtx.ast` from the caller's predicate BEFORE the
43+
chain (the same seam reads use) and hands the middleware-composed AST to
44+
`driver.updateMany` / `driver.deleteMany`, so bulk writes are constrained to the
45+
rows the caller may edit — matching single-id write behavior. `delete` now
46+
applies the same scalar-`id` guard `update` already had, so an id-list bulk
47+
delete (`where: { id: { $in: […] } }, multi: true`) is owner-scoped too, and
48+
both multi branches fail CLOSED (throw) rather than silently rebuilding an
49+
unscoped predicate if the row-scoping AST is ever absent.
50+
51+
Consequences of routing bulk writes through the AST: the anti-oracle
52+
predicate guard now also applies to bulk `update`/`delete` (a bulk write
53+
filtering on an FLS-unreadable field is rejected, as reads already are), and a
54+
principal-less (no-`userId`, non-system) bulk write on an owner-scoped object
55+
now correctly affects zero rows instead of all of them.
56+
57+
Proven end-to-end on the real showcase app
58+
(`packages/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts`) and pinned
59+
in the ADR-0096 authz-conformance ledger (`ownership-anchor-guard`,
60+
`bulk-write-owner-scoping`).

0 commit comments

Comments
 (0)