Skip to content

Commit 7af05a0

Browse files
committed
Merge origin/main into claude/public-form-owner-bypass-ds4orf
Integrates PR #3018 (owner_id anchor guard + bulk write scoping, #3004/#2982). Conflict: authz-conformance.matrix.ts — kept both sides' new ledger rows (ownership-anchor-guard, bulk-write-owner-scoping, public-form-managed-anchors). api-surface.json regenerated post-merge (check passes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr7QwD4Fx9KYkLH1xDMwEo
2 parents 47e5377 + ef3fdd7 commit 7af05a0

62 files changed

Lines changed: 1489 additions & 165 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: 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: 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: 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`).

content/docs/data-modeling/queries.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,19 @@ Only `query` and `fields` are implemented. The engine expands `search` into a dr
367367
are always AND-ed regardless of `operator`.
368368
</Callout>
369369

370+
### Pinyin recall (Chinese deployments)
371+
372+
When pinyin search is enabled (`OS_SEARCH_PINYIN_ENABLED` — auto-on when the stack's
373+
i18n config lists any `zh-*` locale, see
374+
[Environment Variables → Search](/docs/deployment/environment-variables#search)), records
375+
with CJK names are also found by typing their **full pinyin** or **initials**: searching
376+
`zhangwei` or `zw` matches a record named `张伟`, alongside the normal CJK and latin
377+
matching. This is transparent to queries and clients — the platform maintains a hidden
378+
`__search` companion column derived from each object's display/name field and ORs it into
379+
the expanded filter; `fields` semantics, `searchableFields`, and drivers are unchanged
380+
(ADR-0097). Relevance ranking and typo tolerance remain Tier-2 (native FTS) and are not
381+
part of this.
382+
370383
---
371384

372385
## Window Functions

content/docs/deployment/environment-variables.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,26 @@ OS_MCP_SERVER_ENABLED=true os start # additionally auto-start the stdio tran
235235

236236
---
237237

238+
## Search
239+
240+
Pinyin recall for `$search` (ADR-0097): with the switch on, every object's
241+
display/name field gets a hidden, platform-maintained companion column storing
242+
full pinyin + initials ("张伟" → "zhangwei zw"), so lookup pickers, list
243+
quick-search and ⌘K match `zhangwei` / `zw` against CJK names — transparently,
244+
with no client or object changes.
245+
246+
```bash
247+
os start # auto-on when the stack's i18n config lists any zh-* locale
248+
OS_SEARCH_PINYIN_ENABLED=true os start # force on regardless of locales
249+
OS_SEARCH_PINYIN_ENABLED=false os start # force off (e.g. a zh-locale stack that doesn't want the extra column)
250+
```
251+
252+
| Variable | Type | Default | Description |
253+
|:---|:---|:---|:---|
254+
| `OS_SEARCH_PINYIN_ENABLED` | boolean | locale-derived | Pinyin search recall. When unset, the default derives from the stack's configured locales (`i18n.defaultLocale` / `supportedLocales` / `fallbackLocale`): any `zh-*` locale turns it on; an explicit value always wins. Gates both the compile-time `__search` companion column and the `plugin-pinyin-search` populate hooks, so there is no half-state. Off ⇒ no extra column, and `pinyin-pro` is never loaded. |
255+
256+
---
257+
238258
## Marketplace & Metadata
239259

240260
| Variable | Type | Default | Description |

content/docs/references/ui/view.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Migrated to shared/http.zod.ts. Re-exported here for backward compatibility.
1616
## TypeScript Usage
1717

1818
```typescript
19-
import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
20-
import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
19+
import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
20+
import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
2121

2222
// Validate data
2323
const result = AddRecordConfig.parse(data);
@@ -88,6 +88,18 @@ Aggregation function for column footer summary
8888
* `max`
8989

9090

91+
---
92+
93+
## FormButtonConfig
94+
95+
### Properties
96+
97+
| Property | Type | Required | Description |
98+
| :--- | :--- | :--- | :--- |
99+
| **show** | `boolean` | optional | Whether the button is rendered (renderer default applies when omitted) |
100+
| **label** | `string` | optional | Button label (i18n-capable; renderer default when omitted) |
101+
102+
91103
---
92104

93105
## FormField
@@ -175,6 +187,8 @@ Aggregation function for column footer summary
175187
| **defaultSort** | `Object[]` | optional | Default sort order for related list views within this form |
176188
| **sharing** | `Object` | optional | Public sharing configuration for this form |
177189
| **submitBehavior** | `Object \| Object \| Object \| Object` | optional | Post-submit behavior |
190+
| **buttons** | `Object` | optional | [EXPERIMENTAL — NOT ENFORCED, #2998] Form action-button visibility & labels. Renderer wiring pending in ObjectUI (objectui#2545). |
191+
| **defaults** | `Record<string, any>` | optional | [EXPERIMENTAL — NOT ENFORCED, #2998] Initial field values for create-mode forms (spec home for ObjectUI `initialValues`). Renderer wiring pending (objectui#2545). |
178192
| **aria** | `Object` | optional | ARIA accessibility attributes for the form view |
179193

180194

content/docs/ui/dashboards.mdx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,42 @@ Add interactive filter controls that apply to all widgets:
302302

303303
```typescript
304304
globalFilters: [
305-
{ field: 'region', label: 'Region', type: 'select' },
305+
{ name: 'region', field: 'region', label: 'Region', type: 'select' },
306306
{ field: 'owner', label: 'Sales Rep', type: 'lookup' },
307307
]
308308
```
309309

310+
Each filter's `name` is its stable identity: the key its value is published
311+
under as a dashboard-level variable (readable in widget expressions as
312+
`page.<name>`) and the key widgets reference in `filterBindings`. It defaults
313+
to `field`; the name `dateRange` is reserved for the built-in date range.
314+
315+
### Per-Widget Filter Bindings
316+
317+
By default a filter applies to its own `field` on every widget (the date
318+
range defaults to `dateRange.field ?? 'created_at'`). When a widget stores
319+
the concept under a different field — or should ignore a filter — declare
320+
`filterBindings` on the widget:
321+
322+
```typescript
323+
widgets: [
324+
// Default binding: dateRange → created_at, region → region.
325+
{ id: 'invoices_by_status', /**/ },
326+
// This widget's own fields differ — map each filter explicitly.
327+
{
328+
id: 'accounts_signed',
329+
filterBindings: { dateRange: 'signed_at', region: 'sales_region' },
330+
/**/
331+
},
332+
// Opt out of a filter with `false`.
333+
{ id: 'total_invoices', filterBindings: { region: false }, /**/ },
334+
]
335+
```
336+
337+
Binding precedence: an explicit `filterBindings` entry (string override or
338+
`false` opt-out) → the filter's legacy `targetWidgets` allow-list → the
339+
filter's own `field`.
340+
310341
## Complete Example
311342

312343
First define the dataset the widgets bind to:

docs/adr/0093-tenancy-mode-and-membership-lifecycle.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle
22

3-
- **Status:** Proposed (implementation in progress)
3+
- **Status:** Accepted (2026-07-13) — implemented: `tenancy` kernel service (`plugin-auth/src/tenancy-service.ts`), membership reconciler (`reconcile-membership.ts`), locked by `dogfood/test/membership-reconciler.dogfood.test.ts`
44
- **Date:** 2026-07-13
55
- **Deciders:** ObjectStack Protocol Architects
66
- **Implementation:** #2882 (Phase 0 — tactical create-user bind, merged) → this PR (Phases 1–3 — `tenancy` service, fail-fast boot guard, membership reconciler, consumer migration, backfill, docs). One revision from the original plan, ratified in D2: the endpoint-level create-user bind **delegates to the shared reconciler** (one implementation, two call sites) instead of being deleted. Runtime verification confirmed the hook fires for `admin.createUser`, but better-auth *defers* `user.create.after` post-commit (#1881), so the endpoint keeps its delegated call to report `organizationId` / `membershipCreated` deterministically in its response. Cloud-host semantics (personal-org hook precedence, multi-org non-binding, D5 blast radius) verified against `objectstack-ai/cloud` — see D2/D3/D5.

docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0095: Authorization Kernel Chain — Tenant Isolation as Layer 0, a Monotonic Posture Ladder, Capability-Derived Posture
22

3-
**Status**: Proposed (2026-07-14)
3+
**Status**: Accepted (2026-07-14) — implemented: D1 tenant Layer 0 (`plugin-security/src/tenant-layer.ts`), D2/D3 posture ladder (`core/src/security/posture-ladder.ts`), locked by `authz-matrix-gate.test.ts` + `posture-ladder.test.ts` (#2920 B1–B4)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0002](./0002-environment-database-isolation.md) (environment-per-database), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (scope depth), [ADR-0066](./0066-unified-authorization-model.md) (unified authz model, superuser bypass), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (permission model v2), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy service)
66
**Scopes**: the "cross-tenant RLS does not exist here" claim in [ADR-0073](./0073-automation-execution-identity.md) (see Context)

0 commit comments

Comments
 (0)