Skip to content

Commit 704a58a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20981-agg-flag-comparands
2 parents 9b235f2 + e952cff commit 704a58a

22 files changed

Lines changed: 1306 additions & 147 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-knowledge': patch
3+
---
4+
5+
fix(service-knowledge): the realtime event bridge no longer keeps a `record.created` / `record.updated` / `record.deleted` branch, and its docs name the events ObjectQL really publishes (#20573)
6+
7+
No producer emits a bare `record.*` event: the ObjectQL engine publishes `data.record.created` / `data.record.updated` / `data.record.deleted` for a single-record write and `data.records.updated` / `data.records.deleted` for a predicate write (`multi: true`), and `@objectstack/spec` already dropped the bare names from `RealtimeEventType`. The `KnowledgeServicePlugin` subscription handler still carried a branch for them, with a `payload.record ?? payload` fallback for a shape nothing sends. That branch is removed. The `data.record.*` sync (record body from `after`, id from `recordId`) and the `data.records.*` stale-index warning are unchanged.
8+
9+
The `enableEventSync` option's TSDoc and the `handleRecordUpsert` / `handleRecordDelete` docs now name `data.record.created|updated|deleted` instead of `record.*`.
10+
11+
If a plugin of yours publishes a bare `record.created`, `record.updated` or `record.deleted` event through `IRealtimeService` and relied on the knowledge index following it, publish `data.record.created|updated|deleted` with the `DataEvent` payload instead (the record in `after`, its id in `recordId`).
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `action:button` / `action:icon` refuse `endpoint` with the rename `ActionSchema` already prescribes — `endpoint` → `target` (#21005)
6+
7+
**BREAKING** — `endpoint` on an `action:button` or `action:icon` component (`ActionButtonProps`, `ActionIconProps`) is no longer a declared key. `ActionSchema` has always refused `endpoint` with "Did you mean `endpoint` → `target`?", while these two rows accepted it. objectui's console registers its own `api` handler, which reads `target` and never `endpoint`, so an `api` button written with `endpoint` passed the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table the action and both rows share. Write the endpoint as `target`.
8+
9+
Clause-②: yes (narrowing)
10+
11+
## FROM → TO
12+
13+
| you wrote (17.5 and earlier) | write instead |
14+
| --- | --- |
15+
| `{ type: 'action:button', properties: { actionType: 'api', endpoint: '/api/v1/x' } }` | `{ type: 'action:button', properties: { actionType: 'api', target: '/api/v1/x' } }` |
16+
| `{ type: 'action:icon', properties: { actionType: 'api', endpoint: '/api/v1/x' } }` | `{ type: 'action:icon', properties: { actionType: 'api', target: '/api/v1/x' } }` |
17+
| `endpoint` on a block with no `actionType` | add `actionType: 'api'` and rename `endpoint` to `target` |
18+
19+
**The one-line fix:** rename `endpoint` to `target` in the block's `properties`; the value (the URL the `api` action calls) is unchanged.
20+
21+
**What an author who still writes it sees.** A page is never refused for it: a page component's `properties` is an open bag, so `definePage()`, `defineStack({ pages })` and the page write door accept the page as before. `os validate` / `os build` / `os lint` report `component-props-unknown-key` as a warning at `properties.endpoint`, with the rename "Did you mean `endpoint` → `target`?" — the same clause `ActionSchema` prints. The two rows also stop answering `path` with the edit-distance guess `patch` (the declarative write's field values): `url`, `endpoint`, `path` and `href` all rename to `target`, on the action and on both blocks alike. A typed `ActionButtonProps` / `ActionIconProps` input fails `tsc` at `endpoint`.
22+
23+
## The migration kit
24+
25+
- **The D2 conversion `action-block-endpoint-to-target`** (protocol 18, retired from the load path) renames `endpoint` to `target` on an `action:button` / `action:icon` whose `actionType` is `api`, the one meaning the key declared, with one notice per block. It reaches blocks in regions, nested in a container's `children`, and in a slotted page's named slots, so a stored `page` row or a built artifact that carries the key loads with `target` through the rehydration seams, which replay it. An already-present `target` wins: a twin with the same value is dropped. A block with no `actionType`, another `actionType`, a non-string `endpoint`, or a `target` that names a different endpoint is left as stored and reported as a TODO. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.
26+
- **The D3 entry `action-block-endpoint-spelling-retired`** names what the rename cannot decide: the TODO sites above, and code — a custom action handler that read `endpoint` off the action reads nothing once the block carries `target`.
27+
- **No deprecation window**, per the project's startup-stage posture.
28+
29+
Census at landing: no producer in this repository (examples, templates, platform pages, fixtures) or in objectui's examples authors `endpoint` on either block. ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.
30+
31+
<!-- adr-0087: registered action-block-endpoint-to-target, action-block-endpoint-spelling-retired -->
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
docs(spec)!: the security service contract's field answers for a caller who resolves no permission set exclude the fields that declare `requiredPermissions` (#21063)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Only the prose of the ISecurityService interface moves: no method, parameter, return type, schema key or stored shape changes, so objectstack migrate meta has nothing to rewrite. The answers it now states are the ones every field-level requiredPermissions declaration already implied. -->
10+
11+
**BREAKING for implementers and consumers of `ISecurityService` field answers.**
12+
13+
**What changed.** The contract in `@objectstack/spec/contracts` now states the
14+
field answers for a non-system caller who resolves no permission set. Such a
15+
caller holds no permission-set field grant and no capability. No grant narrows
16+
its answers, and a field's own declarations still apply: a field that declares
17+
`requiredPermissions` is not in its `getReadableFields` answer (unless a
18+
`maskingRule` on the field serves it masked, which keeps it as a served
19+
column), and it is not in its `getWritableFields` answer.
20+
`getMetadataReadableFields` answers the same for that caller when the
21+
deployment's fallback set resolves to nothing. The contract used to say the
22+
data-plane answer for that caller was the full field set, because the engine
23+
middleware skipped its whole field gate for it. The middleware skips only its
24+
permission-set grant gates.
25+
26+
**Who this reaches.** An implementation of `ISecurityService` must answer this
27+
way for that caller. A consumer that relied on the full field set for that
28+
caller now receives the narrower answer from the reference implementation
29+
(`@objectstack/plugin-security`).
30+
31+
**What to do.** An implementation folds each field's `requiredPermissions`
32+
into its answer for this caller exactly as it does for a caller whose
33+
permission sets lack the capability. A consumer needs no change.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: a field that declares `requiredPermissions` is not served to a caller who resolves no permission set, and that caller may not query on it or write it (#21063)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author wrote is rewritten or changes meaning: every field-level requiredPermissions already declared mask on read and deny on write for a caller who does not hold all of it, and this change makes the runtime honour that declaration for the one caller class it skipped. No stored metadata, schema key or published type moves. -->
10+
11+
**BREAKING for callers who resolve no permission set.**
12+
13+
**What changed.** A field that declares `requiredPermissions` is masked on read
14+
and denied on write unless the caller holds all of them (ADR-0066 D3). A caller
15+
who carries a principal but resolves no permission set holds no capability, so
16+
the gate applies to it, but the runtime served that caller the stored value,
17+
let it filter, sort, group and aggregate on the field, and accepted a write
18+
that named it. The explain engine already reported the field hidden for that
19+
caller. Now the field is not served to it (a field that also declares a
20+
`maskingRule` is served masked, as before). A filter, sort key, group key or
21+
aggregate that names the field is refused with `403 PERMISSION_DENIED`, and so
22+
is a write payload that names it, as for any other caller who lacks the
23+
capability.
24+
25+
The published field answers agree with what is served and refused.
26+
`ISecurityService.getReadableFields`, `getQueryableFields` and
27+
`getWritableFields` no longer list such a field for this caller, and neither
28+
does `getMetadataReadableFields` when the deployment's fallback set resolves to
29+
nothing. The write preview answers such a payload as the write path does.
30+
31+
**Who this reaches.** A caller who resolves no permission set but carries a
32+
position, a named permission set or a user id. A caller with none of the three
33+
is handed through untouched, as before, and the field projections say so. A
34+
caller who resolves at least one permission set is unaffected, and so is a
35+
system context. Whether this caller may read or write the object at all is
36+
unchanged.
37+
38+
**What to do.** Nothing, unless such a caller needs the field. A field's
39+
`requiredPermissions` name the capabilities that open it, so give the caller a
40+
permission set that holds all of them, or drop the requirement from the field.

‎content/docs/references/ui/component.mdx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const result = AIChatWindowProps.parse(data);
6262
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
6363
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
6464
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
65-
| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner |
6665
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
6766
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
6867
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
@@ -118,7 +117,6 @@ const result = AIChatWindowProps.parse(data);
118117
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values |
119118
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
120119
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
121-
| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner |
122120
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
123121
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
124122
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |

‎packages/plugins/plugin-security/src/get-writable-fields.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,13 @@ describe('getWritableFields — the answers the contract names', () => {
158158
expect(await plugin.getWritableFields('invoice', { isSystem: true })).toEqual(FIELDS);
159159
});
160160

161-
it('no permission sets resolved: the full field set, as the middleware skips its write gate', async () => {
162-
const { plugin } = await boot([], { noBaseline: true });
163-
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS);
161+
it('no permission sets resolved: the full field set minus the capability-gated field, which the write gate refuses', async () => {
162+
const { plugin, middleware } = await boot([], { noBaseline: true });
163+
// [#21063] The caller holds no capability, so `margin`'s
164+
// `requiredPermissions` refuses it on write; nothing else is refused.
165+
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS.filter((f) => f !== 'margin'));
166+
expect(await middlewareAdmits(middleware, 'update', WRITER_CTX, { margin: PAYLOAD_VALUE.margin })).toBe(false);
167+
expect(await middlewareAdmits(middleware, 'update', WRITER_CTX, { secret: PAYLOAD_VALUE.secret })).toBe(true);
164168
});
165169

166170
it('an unresolvable object is no answer (undefined), not an empty one', async () => {

0 commit comments

Comments
 (0)