Skip to content

Commit 4b63a5d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-c4vx2u
# Conflicts: # packages/services/service-settings/src/settings-service.ts
2 parents 1f6cbda + b964ea1 commit 4b63a5d

131 files changed

Lines changed: 5707 additions & 1200 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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/lint': patch
3+
'@objectstack/formula': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(lint)!: `os build`, `os validate`, `os lint` and the email template publish door refuse a hole in an HTML body that sits where the mail renderer's encoding does not suit it (#25189)
8+
9+
Clause-②: no (narrowing: an email template whose `bodyHtml` places a hole outside the supported placements is refused at the three CLI commands and at the publish door, where it passed)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the verdict the authoring doors give an email template whose HTML body places a hole outside the supported placements. A stored template keeps rendering and is judged at its next publish, and the remedy is the author moving the hole, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a lint verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
13+
14+
**BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105).
15+
16+
The mail renderer encodes a double-braced hole for HTML text and leaves a triple-braced hole unencoded. The template's author decides where each hole sits, and that one encoding is right for some placements and not for others. A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), classifies every hole in a template's `bodyHtml` by where it sits in the markup and refuses each one outside the supported placements.
17+
18+
- **Supported:** a double-braced hole in element text, or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with `type: 'url'` and forms the whole value of a double-quoted `href`.
19+
- **Refused:** every other placement, by class. These are attributes read as a URL, event-handler attributes, `style` attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. A triple-braced hole outside the one supported shape is refused as well.
20+
- **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200.
21+
- **TO:** the CLI commands report an `error` finding at `emailTemplates[i].bodyHtml`, and the write answers the existing `422 INVALID_METADATA` with the same issue. The message names the hole, its offset and its placement, and the hint names the supported placements. No new error code is added.
22+
- **Unchanged:** `subject` and `bodyText` are not judged, because the renderer writes them as plain text. A draft save is not judged. A stored template is not migrated, not refused on read and not judged at send or at boot. It keeps rendering until its next publish, and that publish is judged.
23+
- **Measured before crossing:** none of the 76 HTML bodies in the HotCRM reference application, and none of the templates this repository ships, is refused. The five bodies the rule did refuse were documentation examples and test fixtures, and each now uses the supported link shape.
24+
- **`@objectstack/formula` (patch):** the classifier the rule reads, `judgeMailHtmlHoles`, is exported with its supported-placement list and labels. `@objectstack/plugin-email`'s built-in template pin reads the same classifier and holds its hole grammar to the renderer's.
25+
- **`@objectstack/spec` (patch):** one conversion's example templates now declare their link variable with `type: 'url'` and place it in an `href`. No schema, conversion behaviour or export changes.
26+
- **The fix for an author:** move the hole into element text or a quoted plain-text attribute. For a link, declare the variable with `type: 'url'` and write it triple-braced as the whole double-quoted `href` value.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/objectql': minor
5+
---
6+
7+
feat(spec,core,objectql): one package entry of a release artifact may state its own `source`, and the registry records it
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
12+
`ArtifactPackageSchema` (one `packages[]` entry of an assembled release artifact) gains an optional `source: 'code' | 'marketplace'` beside `manifest`. The values are the new `ArtifactPackageSourceSchema`, the subset of `InstalledPackageSourceSchema` a load path can register; `authored` and any other value are refused at the entry gate as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422).
13+
14+
The `manifest` service registers each package with the `source` its entry states, so a merged artifact that carries marketplace installs beside the deployment's own code lists them as `marketplace` on `GET /api/v1/packages`. An entry that states nothing takes the registering door's statement as before (`register(artifact, { source })`, `code` when absent), so existing artifacts register unchanged. An entry's own statement wins over the door's.
15+
16+
`@objectstack/core` adds `resolveArtifactPackageEntries(artifact)`, which returns each package to register as `{ manifest, source? }` in dependency order. `resolveArtifactPackageOrder` is unchanged and still returns the bodies alone.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: `rollbackMetaItem` and `revertCommit` refuse a stored version the save door refuses, instead of writing it back live
6+
7+
Clause-②: no (narrowing)
8+
9+
Metadata-protocol: narrowed
10+
11+
<!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused, page-default-record-page-one-per-object-refused, api-endpoint-anonymous-elevated-flow-refused) no rule is added or changed: the restore doors now ask the save door's existing refusals, and the ledger entries of the three that carry one already describe the refused shape and its fix -->
12+
13+
**BREAKING** (what a metadata restore writes narrows), shipped as `minor` under the 18.x convention for breaking changes.
14+
15+
The two restore doors write a stored history body back as the active row: `POST /api/v1/meta/TYPE/NAME/rollback` (`rollbackMetaItem`) and the restore limb of a commit revert (`revertCommit`, which `rollbackToPackageCommit` also uses). They asked only the save door's `name` and permission-set owner judges, and none of the refusals `saveMetaItem` runs after its schema gate. So a version saved before a refusal existed, or one half of a pair whose other half moved after the version was saved, came back live.
16+
17+
- **FROM:** a rollback or a commit revert restored a version that the save door would refuse today:
18+
- a hook with no `body`;
19+
- a record page marked `isDefault` for an object whose default another page now holds;
20+
- a datasource restored to `schemaMode: 'managed'` (or no `schemaMode`) while objects bind it `external`;
21+
- an object restored with `external` on a datasource that is managed now;
22+
- a `runAs: 'system'` flow that an anonymous (`authRequired: false`) endpoint names;
23+
- a body the shared runtime authoring rules refuse;
24+
- a body a domain plugin's authoring gate refuses (plugin-security's object posture gate, for example).
25+
- **TO:** each restore door asks those refusals through the save door's own functions, with the save door's code and status, and stores nothing on a refusal. The active row, the history and the registry stay as they were.
26+
- `rollbackMetaItem` answers the refusal: `400 VALIDATION_ERROR` for the hook, `422 DEFAULT_RECORD_PAGE_CONFLICT` for the page, `422 INVALID_METADATA` for the datasource pair, the flow and the authoring rules, and the plugin gate's own code.
27+
- `revertCommit` reports the refused item in `failed[]` with that code and reverts the commit's other items, as it does for its other per-item refusals.
28+
- **Unchanged:** a version the save door accepts restores as before. The restore does not run the schema parse on the history body, does not apply the read-only-base create refusal (that refusal judges a package a caller names for a new item; a restore names none and writes into the active row's own package binding), and keeps the write-only credential channel's current credential. The runtime authoring rules' advisories are not added to the restore response.
29+
- **The fix for an author:** a refused restore names the same problem the save door names. Restore a different version, or first change the other half of the pair (for example, remove `external` from the bound objects before restoring the datasource as managed), then restore again.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(lint)!: a page or view `visibleWhen` that hands `has()` an argument other than a field selection is refused at publish
7+
8+
Clause-②: no (narrowing)
9+
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: registered visibility-predicate-has-argument-refused -->
13+
14+
**BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes.
15+
16+
CEL defines `has()` in one form, `has(e.f)`, with `f` a field name after a dot. The engine refuses any other argument as `has() invalid argument` on every evaluation, whatever the record holds. Every other predicate surface already refused it through `validateExpression`. The page and view `visibleWhen` door did not, so `has(status)` and `has(record.items[0])` published clean, and the console, which renders a faulting predicate as visible, drew the guarded element for every user.
17+
18+
- **FROM:** a `visibleWhen` on a page component, a form view section or a section field could hand `has()` a name alone (`has(status)`), an index as the last step (`has(record.items[0])`), or any other argument that is not a field selection (`has(record.?a)`, `has(upper(record.x))`, a literal, an operator), and `os validate`, `os build`, `os lint` and the page and view save doors accepted it.
19+
- **TO:** each is refused as `visibility-predicate-has-argument`, an error, at those doors (the save doors answer `422 INVALID_METADATA`). The finding quotes the argument and the engine's wording, and its hint names the spelling to write. `os explain visibility-predicate-has-argument` says why.
20+
- **Unchanged:** `has(e.f)` for any `e`, including a selection over an indexed or called receiver (`has(record.items[0].name)`); every other predicate, and nothing about the types that flow through one is judged. A `has(status) && status == 'x'` keeps its `visibility-bare-identifier` finding on the unguarded `status`, beside the new one. A stored page or view row in the refused shape still loads and is served; its next save is refused, and `os migrate meta --stored` lists each such predicate under `visibility-predicate-has-argument-refused`.
21+
- **The fix for an author:** write `has(record.status)` or `'status' in record` for a field (`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether a list has a first element (`'k' in e` for a bracketed string key).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat(platform-objects): the console hides the six doors that add people while `features.cloudManaged` is on
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A cloud-managed environment's people arrive from the organisation's identity provider, so the console no longer offers the ways to add them by hand there. While `GET /api/v1/auth/config` serves `features.cloudManaged: true`, these actions are hidden:
11+
12+
- `invite_user` on `sys_user`, `sys_member`, `sys_invitation` and `sys_organization`;
13+
- `sys_user.create_user`;
14+
- `sys_user.set_user_password`. This one is hidden in the console only: the server keeps answering it for a local (`env_native`) account, so the break-glass admin stays editable.
15+
16+
Each action keeps the gate it already had (`organization` or `admin`), and the new gate is added beside it, so nothing changes where the flag is off.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): declare the console flag `features.cloudManaged` — a `PUBLIC_AUTH_FEATURES` entry with a new hide-when-on gate semantics, and `AuthFeaturesConfigSchema` declares it
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
The managed (cloud-hosted, SSO-only) mode gets its console flag. People in a managed environment arrive through the organisation's identity provider, so the console hides the doors that would add them any other way.
11+
12+
- **`PublicAuthFeatureSemantics` gains `'hide-when-on'`.** The two existing semantics gate an input on a capability being present (`opt-in` lowers to `features.X == true`, `default-on` to `features.X != false`). The new one gates an input on a mode being absent: `featureGatePredicate` lowers it to `features.X != true`, so the input is hidden when the flag is on and visible when it is off or not served at all.
13+
- **`PUBLIC_AUTH_FEATURES.cloudManaged`** (`surface: 'crud'`, `semantics: 'hide-when-on'`) books six gated inputs: the four `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`, `sys_organization`), `sys_user.actions.create_user` and `sys_user.actions.set_user_password`. `PUBLIC_AUTH_FEATURE_NAMES` lists it, so `requiresFeature: 'cloudManaged'` is now accepted on an action or param and lowers to `visible: features.cloudManaged != true`.
14+
- **`AuthFeaturesConfigSchema.cloudManaged`** is an optional boolean on the `GET /api/v1/auth/config` response's `features`. Absent means not managed.
15+
16+
What to do: nothing. Every key and value accepted before is accepted unchanged, and every existing flag lowers to the same predicate as before.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
feat(plugin-auth): `GET /api/v1/auth/config` serves `features.cloudManaged`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The public auth config's `features` gains `cloudManaged`, a boolean read from `isCloudManaged()` in `@objectstack/core` (true when the process runs with `OS_CLOUD_MANAGED=1`). It is `false` on every other deployment. The console reads it to hide the doors that add people by hand (invite, create user, set password) in a cloud-managed environment, whose people arrive from the organisation's identity provider.
11+
12+
What to do: nothing. The payload only gains a key.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: a flow `get_record` node's `limit` is declared a positive integer, so a fraction, `0` or a negative is refused at every door that parses a flow
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (already-registered flow-builtin-node-config-values-refused) registered before this change, amended in this diff: the entry's surface, replacement and reason now name a get_record limit that is not a positive integer, and the stored-row listing reports such a node under that id -->
11+
12+
**BREAKING** (a value the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage.
13+
14+
The `get_record` executor reads around the Data API's page-window normalizer: with `limit` above 1 it calls `data.find` with that LIMIT as written, and otherwise it calls `findOne`. The spec declared `limit` as any number, so a flow could declare a window the runtime did not honour as written.
15+
16+
- **FROM:** `GetRecordConfigSchema.limit` was `z.number()`. `limit: 2.5` parsed and reached the driver as `LIMIT 2.5`; `limit: 0`, `limit: -1` and `limit: 0.5` parsed and silently read one record through `findOne`.
17+
- **TO:** `limit` is `z.number().int().positive()`. Every door that parses a flow refuses any other number at `nodes.N.config.limit` with the code `node-config-refused-by-contract`: `FlowSchema.parse`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `objectstack validate` and `objectstack compile`, the metadata save door, `AutomationEngine.registerFlow`, and an artifact's parse. The generated JSON Schema declares `type: integer` with an exclusive minimum of 0.
18+
- **Fix:** write the count as a whole number of at least 1. `limit: 1` (or no `limit`) reads one record as `record`; a larger whole number reads a `records` list of at most that many.
19+
- **Unchanged:** every positive whole `limit`, and a node with no `limit`. A string `limit` (`'10'`, `'{page.size}'`) was already refused as the wrong type.
20+
- **Not the Data API's page window.** `QueryPageWindowSchema` admits `0`, which asks a list door for the total alone. A `get_record` node always reads at least one row, so its bound is its own.
21+
22+
Data at rest: a flow already stored with such a `limit` is refused whole when it registers. At boot it is skipped with a `warn` naming it and its trigger is not armed, while the flows beside it register. `objectstack migrate meta` reports it under `flow-builtin-node-config-values-refused`. No conversion rewrites it, because only the author knows the count they meant.
23+
24+
No producer writes such a value: every `get_record` `limit` in this repository's examples and tests, and in HotCRM, is a positive whole number.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/service-settings': patch
6+
---
7+
8+
refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before.
14+
15+
- **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing:
16+
- behind a wall, no grant does;
17+
- under `single`, the grant owned by the Default Organization does;
18+
- an organization-less grant never does, and neither does any grant when no Default Organization can be named.
19+
20+
Three readers call it:
21+
- the authorization resolver passes the memoised Default Organization;
22+
- the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it;
23+
- the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion.
24+
25+
Each reader keeps its own normalizer for a grant row's organization.
26+
- **The organization-reach rule (ADR-0131 D8/D9).** New exports:
27+
- `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`;
28+
- `organizationReachAdmits(reach, rowOrganizationId)`;
29+
- `organizationReachRowOwner(reach)`;
30+
- `organizationIdOf(value)`;
31+
- `UNOWNED_ORGANIZATION_REACH`;
32+
- the types `OrganizationReach` and `OrganizationReachFacts`.
33+
34+
The rule decides whose `sys_setting` rows a caller may draw on:
35+
- the caller's own organization;
36+
- for a caller naming none where no wall stands, the organization D9 derives;
37+
- nothing behind a wall;
38+
- rows with no organization, only in a composition that registers no organization object.
39+
40+
`SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts:
41+
- the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller;
42+
- the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults.
43+
44+
These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader.
45+
46+
No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action.

0 commit comments

Comments
 (0)