Skip to content

Commit de2d28e

Browse files
committed
Merge origin/main into claude/project-thread-fyy60n
# Conflicts: # packages/runtime/src/app-plugin.ts # packages/runtime/src/domains/packages.ts
2 parents 239fd9d + 33bc8ae commit de2d28e

138 files changed

Lines changed: 7047 additions & 1564 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/types': patch
3+
---
4+
5+
refactor(types)!: delete the api-types module and its eight names nothing reads
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-export-retired @object-ui/types#HTTPMethod, @object-ui/types#APIRequest, @object-ui/types#APIConfig, @object-ui/types#DataFetchConfig, @object-ui/types#DataFetchableSchema, @object-ui/types#ExpressionContext, @object-ui/types#ExpressionNodeSchema, @object-ui/types#APISchema) the retired operands are TypeScript-only interfaces and unions for a data-fetching and expression-node dialect that no renderer, adapter or host reads; nothing in the workspace or in hotcrm imports any of them from the package entry, they have no zod twin, they are not arms of `AnySchema`, and no packages/spec schema, registered node type, conversion surface or stored shape names them, so there is nothing for `objectstack migrate meta` to rewrite -->
11+
12+
**BREAKING** for a host that imports any of these eight types from `@object-ui/types`. None had an importer anywhere in the workspace or in hotcrm, so the Console and every `@object-ui/*` package are unaffected.
13+
14+
They are **gone from the package, not only from the entry**: the whole `api-types.ts` module (the entry's "API and Events" block) is deleted. The objectui#6497 tombstone for `UIEventHandler` and `EventableSchema` it carried now stands in the entry where the block was; those two stay retired.
15+
16+
What leaves, and what to write instead:
17+
18+
- FROM `HTTPMethod`, `APIRequest`, `APIConfig`, `DataFetchConfig`, `DataFetchableSchema`, `APISchema` TO no replacement in this package. A component reads data through this package's `DataSource` interface; a host that kept its own request shape declares it locally.
19+
- FROM `ExpressionContext`, `ExpressionNodeSchema` TO no replacement in this package. Expressions are `${…}` strings evaluated by `@object-ui/core`'s `ExpressionEvaluator` against its own `ExpressionContext` class.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
refactor(metadata-protocol): nineteen more `protocol.ts` members hand their bodies to their facets
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- Nineteen members of `ObjectStackProtocolImplementation` that the facade gate still let keep a body (the overlay and runtime-create write gates, the code-only refusals, the registry hydration's view-expansion reconcile and four static type tables) now keep their signatures on the class and delegate in one line to their section's facet, which holds the body, moved unchanged. Four static tables moved to a new leaf module, `protocol-write-gates-tables.ts`. No behaviour changes and nothing to do on upgrade.
11+
- The published `index.d.ts` and `index.d.cts` carry the same public surface; only docblocks of private members change, where a moved body took its docblock with it.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
feat(service-messaging): serve the notification topics and channels a preference or template can name
6+
7+
Clause-②: yes (widening) — a new read-only route, `GET /api/v1/messaging/preference-options`, mounted by `MessagingServicePlugin` on the kernel's HTTP server; nothing existing is narrowed or refused.
8+
Metadata-protocol: none
9+
10+
The topic and channel of a notification preference stay free text, `*` is still stored as written, and nothing new is refused. What is new is a list the console can offer beside the text box, so an administrator picks a value instead of typing a machine name from memory.
11+
12+
- **The route.** `GET /api/v1/messaging/preference-options` answers `{ topics, channels }`. `topics` starts with `*` and lists the platform's fixed topics (`approval.requested`, `collab.mention`, …), the topics this environment's flow `notify` steps send under (`notify` for a step that names none), and the topics already stored on this organization's notifications, each once with every source that names it. `channels` starts with `*` and lists every channel the service knows, with `configured: false` on one whose service is not set up yet (email with no email service, …). Any signed-in member may read it, as on `/api/v1/notifications`; an anonymous caller is refused `401`, a held sign-in `403`, and a query parameter `400`. A topic stored in one organization is never listed for another.
13+
- **The fields.** `sys_notification_preference.topic` and `sys_notification_template.topic` declare `widget: 'notification-topic'`, and `sys_notification_preference.channel` declares `widget: 'notification-channel'`, for the console's pick-or-type inputs; until the console registers them, the fields render as the same text boxes. The template's channel keeps its existing refusal.
14+
- **The help.** The preference channel's help now names `push`, available once a push service is configured, in every language.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/mcp': patch
5+
---
6+
7+
fix(platform-objects)!: the platform-admin actions declare their gate, so MCP `list_actions` no longer lists them to a caller who cannot run them, and both action doors refuse that caller themselves
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) nothing an author wrote is retired or renamed: the change adds `requiredPermissions` to fifteen first-party action declarations, so stored metadata needs no rewrite, and the one thing an upgrader may act on (a legacy role-only admin needing the admin_full_access set or OS_PLATFORM_OWNER_EMAIL) is a grant, not a metadata edit -->
13+
14+
**BREAKING.** The 15 first-party actions whose door runs the platform-admin gate (on `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`, `set_user_manager`, `list_platform_admins`; on `sys_member`: `add_member`; on `sys_oauth_application`: `disable_oauth_application`, `enable_oauth_application`; on `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification`, `verify_domain`) now declare `requiredPermissions`: `['manage_users']` for the account and membership doors, `['manage_platform_settings']` for the OAuth application and SSO doors. It is the existing ADR-0066 D4 key, read by one runtime check for MCP `list_actions`, MCP `run_action` and `POST /api/v1/actions/:object/:action`. A platform administrator holds both capabilities on either way of becoming one (the `admin_full_access` grant or `OS_PLATFORM_OWNER_EMAIL`), so nothing changes for them.
15+
16+
What changes, before and now:
17+
18+
- **MCP `list_actions`.** Before: every caller was listed all of these actions (a plain member saw `ban_user` and `set_user_manager`). Now: a caller without the capability is not listed them.
19+
- **The refusal at `POST /actions/:object/:action` and MCP `run_action`.** Before: the door forwarded the call and the target answered 403 `PERMISSION_DENIED` "Admin role required". Now: nothing reaches the target. `POST /actions/...` answers 403 itself, with the capability sentence (`Action 'ban_user' on 'sys_user' requires capability [manage_users] — caller is missing [manage_users]`), and MCP `run_action` answers the same sentence as `PERMISSION_DENIED` with status 403, whether it is called by `actionName` alone or with `objectName`.
20+
- **A caller whose only admin standing is the legacy better-auth `role === 'admin'` scalar** (no `admin_full_access` grant, not in `OS_PLATFORM_OWNER_EMAIL`). Before: the action doors forwarded the call and the target admitted it. Now: the action doors refuse it 403, because that caller holds no `manage_users` / `manage_platform_settings`. The target endpoints themselves (`/api/v1/auth/admin/*`) are unchanged and still admit it; give the account the `admin_full_access` permission set, or list its email in `OS_PLATFORM_OWNER_EMAIL`, to keep it on the action doors.
21+
22+
**MCP `run_action` refusal codes (`@objectstack/runtime`, `@objectstack/mcp`).** The capability gate's refusal at MCP `run_action` used to be a plain message; it now carries `code: 'PERMISSION_DENIED'` and `status: 403`, the REST door's answer, for every action with `requiredPermissions`. With `objectName` on a `sys_*` object, the MCP tools admit an action only where `list_actions` lists it, and answered every action missing from the listing with the system-object sentence (`… is not exposed via MCP …`). An action that IS exposed but whose gate refuses the caller now gets the gate's refusal there too, through a new optional `McpActionBridge.capabilityRefusal(name, objectName)` the runtime bridge implements; an action not exposed to AI keeps the system-object sentence, and a bridge without the member behaves as before.
23+
24+
What does not change: the target endpoints judge the platform-admin standing as before, the console shows and hides these buttons from their `visible` standing as before, and an action on another object is unaffected.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the `objectstack-ai` skill's MCP tools table now teaches that `delete_record` needs `confirm: true` — a call without it is refused with `ACTION_CONFIRMATION_REQUIRED` (428), so the agent asks the human first, then retries. The row said only that `delete_record` is annotated destructive, so an agent following it was refused since the MCP tool started requiring confirmation.
6+
7+
Clause-②: no
8+
Metadata-protocol: none
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/objectql': minor
6+
---
7+
8+
feat(spec, security): a `controlled_by_parent` object may declare an optional master, and a detail row with no master is read by its owner
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: added
12+
Skills-docs: follow-up owed — #30907 (card 3/3 of the ruling: the docs and skills that teach `controlled_by_parent` still say the master is always required); this diff teaches the optional master in ADR-0055 §1 and §2 as amended, in `os explain relationship/master-detail-required` and `os explain security-controlled-by-parent-no-relation`, and in the semantic migration entry `cbp-optional-master-admitted`
13+
14+
A rule like "a rep sees the contacts of the accounts they own; a contact need not have an account" now has a declarative form. Declare the detail's only `master_detail` with `required: false`:
15+
16+
```ts
17+
fields: {
18+
account: Field.masterDetail('crm_account', { label: 'Account', required: false }),
19+
},
20+
sharingModel: 'controlled_by_parent',
21+
```
22+
23+
- **`@objectstack/spec`: the optional master.** A `controlled_by_parent` object whose only `master_detail` is declared `required: false` passes the parse and `ObjectSchema.create()` with that value kept. `controlledByParentMasterOf(fields)` is exported from `@objectstack/spec/data` beside `controlledByParentMasterRefusal`: it answers the one master's field and whether it is optional. `isControlledByParentMasterField` still means the required master. Every other refusal stands: two masters, a required `lookup` only, and a `readonly` or `system` master. `ObjectSchema.create()` still forces an omitted `required` to `true`, and still refuses `required: false` on a `master_detail` beside another one. A stored row the v18 upgrade listed because its master declared `required: false` is valid now and no longer listed (`os migrate meta`); the semantic entry `cbp-optional-master-admitted` records it. The raw parse reads an omitted `required` as `false` and so admits it as the optional master; `os lint` reports the omission (below).
24+
- **`@objectstack/plugin-security`: the null-master read arm.** For an optional master, the controlled-by-parent read filter is the unchanged master arm OR a second arm for rows whose master reference is empty: every such row for a holder of View All on the detail, else the caller's own (`owner_id`), else none (an object without an `owner_id` column). A row with no owner (a queue row) is View All's alone. The two arms never share a row, and View All on the detail does not open the master arm. Every read door composes the one filter, so the data API, analytics, search, export, the parent gates of attachments, comments, activity and approvals, chains and delegated reads follow. A required master's filter is unchanged. `security/explain` names the null-master arm on a record read. Writes are unchanged: an insert or update that leaves the master reference empty is still refused with 422.
25+
- **`@objectstack/lint`.** `relationship/master-detail-required` passes the optional master, still reports `readonly` / `system` on it, and reports an omitted `required` on the only `master_detail` at `error`, naming both spellings (`required: true`, or `required: false` for an optional master). `security-controlled-by-parent-no-relation` and `-ambiguous-relation` resolve the optional master as the parse does.
26+
- **`@objectstack/objectql`.** The boot refusal of a master the engine cannot compose a read over now judges an optional master's detail too.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the `fieldPatches` example relabels HotCRM's `industry` and patches options only on a field that declares its own
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `objectstack-data` skill's one `fieldPatches` example patched an option of `crm_account.industry`. HotCRM binds that field to the shared `industry` picklist, so a verbatim copy was refused at install and by `os validate` (`picklist-option`). The example now sets `industry`'s `label` only and patches an option of `type`, which declares its own `options` (`customer` → `Client`). The added bytes are paid by deleting a restatement in the same file: the `.custom` paragraph's closing clause "a base field changes only through `fieldPatches`", whose facts the relabel paragraph above it already states. No token ceiling moves. No schema, key or behaviour changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `fieldPatches` description's example no longer patches an option of a picklist-bound field
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`ObjectExtensionSchema.fieldPatches`'s description and the `ObjectExtensionSchema` TSDoc taught `industry: { label: 'Sector', options: { technology: { label: 'Tech' } } }`. HotCRM's `crm_account.industry` takes its options from a shared picklist, so the install door and `os validate` refuse that patch (`picklist-option`). The description now says an option of a picklist-bound field is never patchable and only a field declaring its own `options` takes an option patch, and its example sets `industry`'s label and help text and patches `type`'s `customer` option. The generated `references/data/object.mdx` follows. Description text only: no schema, key or behaviour changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/types': patch
4+
---
5+
6+
feat(cli): `os serve` seals secrets with the crypto provider a plugin registers, so a KMS- or Vault-backed provider can hold the keys
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A plugin supplies the host's secret crypto provider by registering an `ICryptoProvider` as the kernel service `crypto-provider` — `CRYPTO_PROVIDER_SERVICE`, new in `@objectstack/types` — with `ctx.registerService` in its `init()`, and declaring it in `providesServices`. `os serve` then hands that one provider to everything that seals a `sys_secret` row: the data engine (secret fields), the datasource secret binder (datasource credentials) and the default settings service (encrypted settings). Before, `os serve` built `LocalCryptoProvider` itself after every plugin hook and overwrote whatever a plugin had set.
12+
13+
Without a registration nothing changes: the host keeps `LocalCryptoProvider`, keyed off `OS_SECRET_KEY` or the persisted key file exactly as before, and a production boot without a stable key still refuses at `kernel:ready` with the same message. The one difference is that the default settings service now shares that local provider instead of building a second one from the same key source; what either sealed, the other opens.
14+
15+
The provider holds every secret's key, so the boot refuses rather than guess:
16+
17+
- two plugins that both declare `providesServices: ['crypto-provider']` refuse before either initializes, both named (an undeclared second `registerService` is refused by the kernel, naming the service);
18+
- a provider registered after plugin init, or the registered one replaced, refuses once the boot is up;
19+
- a value registered under the name that is not an `ICryptoProvider` refuses, naming the missing methods;
20+
- a secret sealed or opened while plugins are still initializing is refused, never sealed by a provider the boot has not chosen yet.
21+
22+
No refusal prints a registered value.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
feat(runtime): a host can supply where module enable/disable state lives — `createPackageStateStorePlugin(store)` — and a store that refuses a write makes the enable or disable door fail with nothing changed
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Package lifecycle state (which packages an operator has disabled) has lived only in a file under `OS_HOME`, which a hosted container loses when it is replaced. `@objectstack/runtime` now exports the seam a host uses to keep it elsewhere:
11+
12+
- **`PackageStateStore`**: `loadDisabledPackageIds(environmentId)` and `setPackageDisabled(environmentId, packageId, disabled)`, either may return a promise.
13+
- **`createPackageStateStorePlugin(store)`** (and the `PackageStateStorePlugin` class it builds): add it to the kernel's plugins. It registers the store as the `PACKAGE_STATE_STORE_SERVICE` (`'package-state-store'`) service under the plugin name `PACKAGE_STATE_STORE_PLUGIN_NAME`, which `AppPlugin` lists in its `optionalDependencies`, so the store is in place before the boot seed reads it whatever slot it is composed in. A store missing either method is refused with a `TypeError`.
14+
15+
With a host store composed, the boot seed reads it instead of the file, and the package doors (install, `PATCH /packages/:id/enable`, `PATCH /packages/:id/disable`, `DELETE /packages/:id`) write to it; the file under `OS_HOME` is neither read nor written. A host store that throws:
16+
17+
- on a write from the enable or disable door: the door changes nothing in memory and answers `503 SERVICE_UNAVAILABLE`, saying the switch did not happen and can be retried. The doors now write the store before they switch the package.
18+
- on a write from install or delete, whose change has already landed: logged at `error`, naming what the next boot will read.
19+
- on the boot read: the boot fails, naming the store, rather than bring every disabled package back enabled.
20+
21+
A store registered under the service name by a plugin of the host's own, which nothing orders ahead of `AppPlugin`, fails `AppPlugin.start()` with the fix (use `createPackageStateStorePlugin`), instead of serving a boot seeded from the file while the doors write to the store.
22+
23+
Without a host store nothing changes: the same file, the same bytes, and a failed file write still only warns.

0 commit comments

Comments
 (0)