Skip to content

Commit bb11218

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22725-admin-rescue-queue-arm
2 parents 8d7b565 + 996aa86 commit bb11218

54 files changed

Lines changed: 1773 additions & 2721 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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/lint': patch
7+
---
8+
9+
feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted
10+
11+
Clause-②: no
12+
13+
`SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served.
14+
15+
A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run.
16+
17+
In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged.
18+
19+
Removed from `@objectstack/plugin-security`'s public exports, with no replacement:
20+
21+
- `CAPABILITY_NAME_COLLISION`
22+
- `capabilityNameCollisionDiagnostic`
23+
- `formatCapabilityNameCollisionDiagnostic`
24+
- `reportCapabilityNameCollisions`
25+
- `CapabilityNameCollisionDiagnostic` (type)
26+
27+
FROM → TO:
28+
29+
- FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`.
30+
- FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the dashboard-action, empty-filter, list-view-field and translation findings print one verdict line, and `os explain <rule-id>` carries their reasoning
6+
7+
Clause-②: no
8+
9+
- **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence (followed by `Did you mean "…"?` where the rule offers the nearest declared name). Every finding the rules' own test suites fire is at most 199 characters, where the longest of each id ran from 226 to 553 before. The ids:
10+
- dashboard header actions (`dashboards[].header.actions[]`): `dashboard-action-target-undefined` (the `script` and `modal` arms), `dashboard-action-route-unresolved` (one finding per unregistered `COLLECTION/NAME` segment of a `url` target);
11+
- authored filters: `filter-empty-combinator` (`$and: []`, `$or: []`, `$not: {}`), `filter-empty-node` (`{}` as the whole filter, as a `$or` branch, as a `$and` branch);
12+
- list-view field references: `list-view-field-unknown`, `list-view-field-dotted` (the projection door and the filter door's three refused head classes);
13+
- translation bundles (`translations[]`): `translation-target-unknown` (every leg: objects and their fields, views, sections, tabs, validations and actions; global actions; apps and navigation; dashboards, widgets and header actions; flows, toasts, screens, screen descriptions, screen fields and refusals; action params, outcomes and result dialogs), `translation-option-key-unknown` (field, action-param and screen-field options), `translation-section-name-missing`.
14+
15+
The empty-filter verdicts still take their row-set words ("matches EVERY row" / "matches NO row") from the shared filter reduction. `list-view-field-unknown` keeps the shared field-path sentence and, for a dotted reference, which written name it judged. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead.
16+
- **`os explain <rule-id>` takes these 9 ids**, for example `os explain translation-target-unknown`. It prints the reasoning the verdicts no longer carry: what a `script` or `modal` header-action target must name and how a `url` route is resolved and skipped; the empty-filter identity table, what each shape does on a read scope or beside other branches, and which filters are judged; what a dangling list-view field costs at each position, why only the head of a dotted name is judged, and which positions reach which query door; how the translation resolver reads a key, why an orphan key is an error while a mis-keyed option is a warning, what every bundle leg may name, and why a section with no `name` can never be translated. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain <rule-id>` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries.
17+
- **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and the scaffold check `os init` runs print the new `message` on the text face for all 9 ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` or `report` write, `filter-empty-combinator` and `filter-empty-node` change the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; on the write types the reference-integrity suite dispatches the list-view rule on (`view`, `object` and `flow`), `list-view-field-dotted` and the error-tier positions of `list-view-field-unknown` change the 422 issue's `message` and the refusal log line, and its warning-tier positions ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged.
18+
- **Never at the runtime gate:** the two dashboard-action ids (a CLI-only rule: a single published item cannot see the actions and pages it resolves against) and the three translation ids (the rules run there only on a `flow` write, whose snapshot carries no translation bundles).

‎.changeset/22565-flow-cel-unbound-root-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The user spellings are told to write `current_user`, with the guard for a flow t
3131
- a `type: 'flow'` action launching the flow on an undeclared object;
3232
- a `map` node feeding the flow whose `itemObject` is undeclared or absent;
3333
- a `subflow` or `map` parent that is itself such a flow.
34-
- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.
34+
- **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.
3535

3636
A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's.
3737

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
A flow whose `record-*` trigger names no start `config.objectName` is no longer judged for unbound flow CEL roots by `objectstack validate`. With no object named, the record-change trigger registers its hook with no object filter, so it fires on a write to any object, one the stack does not declare included, and hands that row to the run as `record`, its fields flattened beside it. The build door cannot know that record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an object the stack does not declare. The trigger shape itself is not refused.
6+
7+
Clause-②: yes (widening: a flow whose `record-*` trigger names no start `config.objectName` opens, so `validate` no longer reports an unbound flow CEL root there)
8+
9+
**What changes.** On such a flow, `expression-invalid` no longer reports a root the flow does not bind (`user.id`, `foo.bar`, a field name no declared object has). The same flow with a declared `config.objectName` is judged exactly as before.
10+
11+
**The runtime publish gate, by design.** That gate does not judge unbound flow CEL roots on a flow write, and is not going to: who launches a flow and what record it is handed are read from other items of the stack, which makes this a whole-stack judgment that belongs to `objectstack validate`, and the gate's per-write snapshot is not widened to carry flows and actions. The stand-down there is the intended boundary, not a gap waiting on a wider snapshot.
12+
13+
**Who is affected.** Every shipped `record-*` trigger names its object, so no example app changes: `objectstack validate` over `app-crm`, `app-todo`, `app-multi-package` and `app-showcase` reports the same findings before and after.

‎.changeset/22677-flow-cel-record-entrance.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Clause-②: no (narrowing)
1717

1818
A start `config.objectName` alone is no entrance: with no `record-*` trigger nothing hands the run a row. A parent's own `record` variable is not handed on either: a child gets its parent's context, not its variables. Everywhere else `record.X` is refused as `expression-invalid`, `error`, with the remedy below.
1919

20-
**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record` until that snapshot carries them.
20+
**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record`, by design (rulings `5791822697` item 3 and `6104584601`).
2121

2222
## FROM → TO
2323

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): twenty more REST write routes answer a sandboxed hook's refusal in the hook's own words (#22719)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** When a sandboxed hook refuses a write (`throw new Error('Locked rows cannot be changed here.')`), the error that reaches the route carries the QuickJS debug wrapper on `.message` (`hook 'guard' threw: Error: …`) and the sentence on `.innerMessage`. Twenty write routes built their error answer by hand from `.message`, so they shipped the wrapper, while the other write doors answer the sentence. Sixteen of them also answered the refusal as a server fault (`500`).
10+
11+
**What changes on the wire, for a sandboxed hook's refusal.**
12+
13+
| routes | before | after |
14+
| --- | --- | --- |
15+
| `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested `{ success: false, error: { code, message } }`, the sentence |
16+
| `POST /security/suggested-bindings/:id/confirm` and `/dismiss`, `POST /security/permission-sets/:id/discard-overlay` | `500 SUGGESTION_*_FAILED` / `500 INTERNAL`, the wrapper | `400 VALIDATION_ERROR`, same nested `{ error: { code, message } }` shape, the sentence |
17+
| the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested, the sentence |
18+
| `POST /packages/publish` | `500 INTERNAL_ERROR`, the wrapper | `400 VALIDATION_ERROR`, the sentence |
19+
| `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper | the same status and code, the sentence |
20+
21+
A refusal that declares its own `code` keeps it (an unregistered spelling rides `declaredCode`), a refusal that declares its own `status` is answered at that status, and a `userMessage` the hook set rides the answer, as on every other write door.
22+
23+
**Also moved, on the sharing-rule, suggested-binding and approval routes.** These routes now ask the same classification as the `/data` door before their `500` arm. So a refusal from the service that declares its own `status` (or `statusCode`) and `code`, and that no route-specific arm reads, is answered at that status, in the nested envelope, instead of `500` with the route's own code. The external-datasource routes keep answering every refusal at `400` with their own code, and `POST /packages/publish` already answered a declared status.
24+
25+
**Unchanged.** A plain fault keeps each route's own `500` answer and code. The route-specific refusals (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND`, `SHARING_NOT_ENABLED`, the approval prefixes, the typed security errors) answer exactly as before.
26+
27+
**Upgrading.** A client that reads both envelopes, as `@objectstack/client` does, needs no change. A client of the sharing-rule or approval routes that reads a string `body.error` reads `body.error.message` for these answers.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -195,20 +195,19 @@ export default defineStack({
195195
});
196196
```
197197

198-
At boot `bootstrapDeclaredCapabilities`
199-
(`packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`) seeds
200-
each declaration into `sys_capability` with **`managed_by: 'package'` + `package_id`**
201-
provenance — idempotent, re-seeded on upgrade, and it **never clobbers**
202-
admin-authored rows, refuses to hijack a curated platform capability, and refuses
203-
to write into another package's capability.
198+
At boot each declaration becomes a registry item owned by its package — the
199+
capability's one home (ADR-0131 D3). No `sys_capability` row is written for it.
200+
The registry's one-holder rule refuses a second holder of a name at boot with a
201+
`422`, so a package can neither take a curated platform capability nor take
202+
over another package's.
204203

205204
This **replaces the implicit back-door** where a capability existed only as an
206205
untitled placeholder derived from whatever a permission set happened to reference
207206
in `systemPermissions[]`. That derivation is retired: a reference with no
208207
declaration still matches by name at runtime but gets no `sys_capability` row.
209-
An explicit `defineCapability` takes precedence and — for a derived placeholder
210-
an older database still holds — **claims** it, upgrading the row to package
211-
provenance with the authored label/description/scope. This is the ADR-0066 D1
208+
An explicit `defineCapability` is the attributable form: its label,
209+
description, scope and owning package live on the registry item, and it gets
210+
no row either. This is the ADR-0066 D1
212211
direction: retire the implicit `managed_by`-guessing back-doors in favour of
213212
explicit, attributable declarations.
214213

@@ -557,8 +556,8 @@ The complete, prioritized gap map lives in issue **#2561** (the production
557556
`sys_capability` row.
558557
A **package** now DEFINES its own capabilities explicitly via
559558
[`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1),
560-
seeded with `managed_by:'package'` + `package_id` provenance by
561-
`bootstrap-declared-capabilities.ts` — replacing the implicit
559+
served by the registry with owning-package provenance (no row is written for
560+
it) — replacing the implicit
562561
derive-from-`systemPermissions` back-door (now retired).
563562
The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences`
564563
(`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a

0 commit comments

Comments
 (0)