Skip to content

Commit acf0e6c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23543-retire-mongo-driver-alias
2 parents 81eb862 + 32e03d2 commit acf0e6c

33 files changed

Lines changed: 1094 additions & 856 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-i18n': minor
3+
---
4+
5+
fix(service-i18n)!: `I18nServicePlugin` no longer mounts its own copy of `/api/v1/i18n/*`; the runtime dispatcher is the one owner, and the `registerRoutes` / `basePath` options are removed
6+
7+
Clause-②: no
8+
9+
<!-- adr-0087: not-required (runtime-interface-only packages/services/service-i18n/src/i18n-service-plugin.ts#I18nServicePluginOptions) One TypeScript-only declaration in one runtime file narrows: the plugin's constructor options interface loses its `registerRoutes` and `basePath` members, which drove the plugin's own HTTP mounts and nothing else. No spec key, authorable metadata spelling, Zod schema or stored shape moves, and no stored row is read or rewritten, so `objectstack migrate meta` has nothing to rewrite. The channel that reaches an affected consumer is the compiler, at the consumer's own constructor call. The other half of the change is runtime routing in compositions that carry no dispatcher, which no ledger entry can describe. -->
10+
11+
**BREAKING** for code that constructs `I18nServicePlugin` with route options, and for a composition that serves HTTP without the runtime dispatcher. It ships as `minor` under the launch-window convention for breaking changes.
12+
13+
**What changed.** The three i18n routes (`GET /api/v1/i18n/locales`, `/translations/:locale`, `/labels/:object/:locale`) had two registrations: the runtime dispatcher's, in its `start()`, and this plugin's, in a `kernel:ready` hook. Measured on `objectstack serve` over the showcase app, the dispatcher's registration answered every request on all three paths, because it registers first and the HTTP adapter answers a duplicate path with its first registration. The plugin's copy was never reached there. Where it could be reached — a composition with an HTTP server and no dispatcher — it did not apply the authenticated-caller floor the dispatcher's i18n domain applies (ADR-0056 D2). It is removed: one route, one owner. The plugin still registers the `i18n` service and still syncs runtime-authored translations; the dispatcher's `/i18n` domain serves the routes from that service, exactly as before on `objectstack serve` and `objectstack dev`.
14+
15+
**If you passed `registerRoutes` or `basePath`.** Delete them from the options object. `registerRoutes: false` is now what always happens. There is no replacement for `basePath`: the routes live where the dispatcher mounts them.
16+
17+
**If you serve HTTP without the dispatcher.** The plugin no longer answers `/api/v1/i18n/*` there. Compose the runtime dispatcher (`createDispatcherPlugin` from `@objectstack/runtime`) to serve the i18n routes, with its authenticated-caller floor.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(plugin-sharing, runtime): a shared conversation's messages open for exactly the callers its share link opens for, on both mounts
7+
8+
Clause-②: no — `/messages` now reaches the answer `/resolve` already gives the same caller for the same link, on the same mount; who can open a share link is unchanged.
9+
10+
A share link to an AI conversation has two public routes: `/resolve` returns the conversation, and `/messages` returns its messages. Both check the link through the same service call. `/resolve` passed it every input an audience is judged by. `/messages` passed fewer, so it refused callers `/resolve` served:
11+
12+
- **A `signed_in` link, a signed-in caller:** plugin-sharing's share-link routes answered `/messages` with `404 NOT_FOUND`, so the console's shared-conversation page showed the conversation with no messages. It now returns the messages, as the runtime dispatcher's share-links domain already did.
13+
- **An `email` link, an allowlisted `?email=`:** both mounts answered `/messages` with `404 NOT_FOUND`. Both now read `?email=` on `/messages` exactly as on `/resolve`, and return the messages.
14+
- **Unchanged:** every caller `/resolve` refuses is still refused on `/messages` with `404 NOT_FOUND`: an anonymous caller of a `signed_in` link, and a caller of an `email` link with no email or an off-list email, signed in or not. A password-protected link still needs its password on both routes. `/resolve` is unchanged.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(spec): the page form offers the list page's own empty state, `interfaceConfig.emptyState`
7+
8+
Clause-②: no
9+
10+
The console's list page draws the page's own `interfaceConfig.emptyState` in place of the source view's, so the page metadata form now offers it.
11+
12+
- **`@objectstack/spec`**: `pageForm`'s Interface section gains an `emptyState` control under `interfaceConfig`, a popover of the list view's `EmptyStateSchema` keys (`title`, `message`, `icon`, `link`), placed after the add-record entry point. No schema changes: `InterfacePageConfigSchema.emptyState` already parsed, so every page that was valid before is valid now, and nothing new is accepted.
13+
- **`@objectstack/platform-objects`**: the metadata-forms translation bundles carry the new control's label and help text, translated in `zh-CN`, `ja-JP` and `es-ES`.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint)!: a permission set's field-permission key with an empty object part (`'.FIELD'`) is refused as unqualified, at `os validate` and at the metadata save door alike (#23549)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is added, removed, renamed or re-shaped, and no stored row is rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a lint verdict, and with it the accept set of the save door that runs the lint: a `fields` key whose object part is empty is now refused where it was accepted. No ledger entry can derive the repair, because an empty object part does not say which object the author meant; the repair is the author's edit of the key. A stored set holding such a key keeps loading until its next ordinary publish-mode save, which is judged; the `os migrate meta --stored --apply` pass, when it re-saves such a row for another conversion, does so unjudged (the gate exempts `source: 'migrate-stored'`), and the row keeps loading. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict and none is added (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A permission set's `fields` map is keyed `OBJECT.FIELD`, and the security plugin applies a set's field rules to an object by the key prefix `OBJECT.` (`PermissionEvaluator.getFieldPermissions`). A key whose object part is empty, such as `'.full_name'`, matches no object, because no object name can be empty, so the mask it declares never enforces: the field stays exactly as readable and editable as the object-level grant leaves it, for every holder of the set.
14+
15+
**What changes for `'.FIELD'`.**
16+
17+
- Before this change, both field-permission rules let the key through. `security-fls-unqualified-key` skipped every key containing a `.`, and `security-fls-unknown-field` read the empty object part as another package's object and did not judge it. `os validate`, `os build` and `os lint` passed the stack, and the metadata save door stored the set with `success: true`.
18+
- Now `security-fls-unqualified-key` reports it, at `error`: a key is object-qualified only when it has a non-empty object part before its first `.`. The finding sits at `permissions[N].fields[".FIELD"]`. Its message says the object part before the first `.` is empty, and its fix line names the `'<object>.<field>'` form, with an example that keeps the remainder as the field when it is one plain name. `security-fls-unknown-field` no longer considers the shape, so each such key earns exactly one finding. The same holds for `'.'`, `'..FIELD'` and `'.FIELD.MORE'`.
19+
- At the save door, a `permission` write in publish mode on the environment authoring channel that carries such a key now answers `422 INVALID_METADATA`, with one `security-fls-unqualified-key` issue. This covers `PUT /api/v1/meta/permission/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`publishMetaItem`), and every other publish-mode save through `saveMetaItem` on that channel: cloning a permission set, `duplicatePackage`, the import of an unmanaged package and the organization-ownership ceremony. A kernel assembled on the `package-author` authoring channel skips this gate; the author-time CLI doors above judge that channel's source instead.
20+
- An `os migrate meta --stored --apply` re-save is not judged: the gate exempts `source: 'migrate-stored'`, so a stored set holding such a key is rewritten into the current dialect unjudged, is not reported `failed`, and keeps loading. Its next ordinary publish-mode save is judged.
21+
22+
**Remedy.** Put the object's name before the `.`, so `'.full_name'` becomes `'crm_account.full_name'` for the object whose field it masks. Saving the set as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
23+
24+
**Unchanged.**
25+
26+
- A qualified key naming a real field (`OBJECT.FIELD`) is accepted as before.
27+
- A key naming an object this stack does not define, such as `no_such_object.x` or `no_such_object.`, is still not judged, because another installed package may declare that object.
28+
- A bare key (`full_name`) is refused under `security-fls-unqualified-key` with the same message and fix line as before; an empty or dangling remainder on an object of this stack (`crm_account.`, `crm_account.owner.name`) is refused under `security-fls-unknown-field` as before.
29+
- Stored rows are not migrated by this change, and they are not refused on read or at boot: the save door judges new writes only. A set stored before this change keeps loading until its next ordinary publish-mode save, which is judged; an `os migrate meta --stored --apply` re-save is not (above).
30+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
31+
- No rule id, error code, export or signature is added or removed. `PermissionSetSchema` is unchanged.
32+
- Measured before this change: no permission set in this repository's examples, templates, skills or fixtures carries a `fields` key that starts with `.`.

‎docs/adr/0076-objectql-core-tiering.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
274274
6. **Data-facade home** — does the `DataProtocol` impl live in the engine-adjacent transport layer / `rest`, or a small `@objectstack/protocol-data`? (It is thin and transport-shaped.)
275275
7. **Metadata package name (post-segmentation)** — **Resolved: keep `@objectstack/metadata-protocol`** (already published; renaming has ~0 benefit and real churn). The `protocol` suffix is a low-cost naming exception — the contract is in `@objectstack/spec/api`; a README note in the package should clarify impl-vs-contract.
276276
8. **Per-domain versioning** — once segmented, do capability protocols get independent version markers / a `getCapabilities()` discovery method?
277-
9. **dispatcher vs rest-server overlap** — **Resolved (audit, #2462): delineate, do not merge.** They partition the API surface by domain rather than duplicating it: in a standard boot REST owns data/meta/batch/import-export/search/email/forms/sharing/reports/approvals/security, the dispatcher bridge owns mcp/ai/graphql/keys/automation/actions/storage/i18n/analytics/health/ready. Only **discovery** and **packages** were genuinely double-mounted (first-registration-wins — the D11 smell), and the dispatcher's `/data`/`/meta`/`/ui`/`/notifications`/`/share-links`/`/security` `dispatch()` branches are **not mounted** in a standard boot. D11 worklist: (1) kill or delegate the dead callable branches; (2) give packages + discovery a single owner; (3) extract per-domain handlers; (4) unify env-resolution on the injected `KernelResolver` seam.
277+
9. **dispatcher vs rest-server overlap** — **Resolved (audit, #2462): delineate, do not merge.** They partition the API surface by domain rather than duplicating it: in a standard boot REST owns data/meta/batch/import-export/search/email/forms/sharing/reports/approvals/security, the dispatcher bridge owns mcp/ai/graphql/keys/automation/actions/storage/i18n/analytics/health/ready. Only **discovery** and **packages** were genuinely double-mounted (first-registration-wins — the D11 smell), and the dispatcher's `/data`/`/meta`/`/ui`/`/notifications`/`/share-links`/`/security` `dispatch()` branches are **not mounted** in a standard boot. D11 worklist: (1) kill or delegate the dead callable branches; (2) give packages + discovery a single owner; (3) extract per-domain handlers; (4) unify env-resolution on the injected `KernelResolver` seam. *(Correction, 2026-10-15, #23347: two entries of the partition above were read from source, and a boot contradicts them. **i18n** was double-mounted too, whenever `I18nServicePlugin` is composed: the plugin registered the same three `/api/v1/i18n/*` patterns at `kernel:ready`, after the bridge's `start()` registrations, so first-registration-wins shadowed it. Measured on `objectstack serve` over `examples/app-showcase`, the bridge answered every request on all three paths. #23347's code change retires the plugin's copy, which leaves the bridge the single owner. **storage** is not the bridge's: it has mounted no storage route since #4087, and `/api/v1/storage/*` is `service-storage`'s own surface on the same `http-server` (`packages/runtime/src/dispatcher-plugin.ts`, the bridge's docblock and its Storage block). The verdict, delineate and do not merge, is unchanged.)*
278278
- **(2) shipped**: `${prefix}/discovery` has a single deterministic owner — the dispatcher bridge cedes it to `com.objectstack.rest.api` when that plugin is registered (`kernel.hasPlugin`, new public API), keeping it only as the fallback in REST-less compositions; `/.well-known/objectstack` stays dispatcher-owned. The bridge's `/packages` family now flows through `dispatch()` (like analytics/i18n/automation/AI) instead of calling `handlePackages()` directly, so both HTTP entries into the domain (explicit mounts + the cloud hosts' catch-all) share one pipeline — the direct path used to skip kernel/identity resolution and drop `req.query` (the documented `?overwrite=true` install flag never worked over the mounted routes).
279279
- **(1) re-scoped (cross-repo finding)**: the "dead" branches are NOT safely killable — the `@objectstack/hono` `createHonoApp()` catch-all delegates every unmatched `${prefix}/*` to `dispatch()`, and both cloud hosts mount it under their plugin routes. In that composition `/share-links` is the **designed primary surface** (per-env kernels register the `shareLinks` service with `registerShareLinkRoutes:false`; the host dispatcher serves the route after kernel swap — documented in cloud's `artifact-kernel-factory` and `default-environment-plugins`), and `/notifications` has **no other HTTP owner anywhere**. `/data`/`/meta`/`/ui`/`/security` are REST-shadowed but still catch REST misses. Killing therefore waits until the catch-all is retired in favor of per-domain handlers (step 3); until then the branches are the cloud fallback fabric, not dead code.
280280
- **(1)+(3) VERDICT (2026-07-27, #2462 series #3491–#3573): the catch-all is NOT retired — it is rehabilitated, and step (1) is absorbed.** Step (3) decomposed the dispatcher the other way around: every domain body moved to `packages/runtime/src/domains/*` (+ the `action-execution` subsystem), `dispatch()` shrank to the cross-cutting gate stages (env resolution → identity → auth gate → membership → scope strip) followed by a first-match `DomainHandlerRegistry` lookup, and the legacy if-chain reached zero domain branches. The original disease was never the catch-all itself but what it delegated INTO — an unenumerable god if-chain where "declared ≠ mounted ≠ implemented" could hide (the v16 seam bug class). Delegating into a thin gates+registry pipeline is the sane terminal shape: the route table is enumerable (`DomainHandlerRegistry.list()`), every entry has an owner module and seam tests, and the cloud fallback fabric (`/share-links` as the per-env primary surface, `/notifications` as sole owner) rides the SAME pipeline verified end-to-end by `@objectstack/http-conformance` (41 cross-adapter assertions) + the dogfood suite (351) — cloud-side `objectos-runtime` (240 tests incl. the kernel-resolver strategy) passes against the decomposed dispatcher unchanged. "Kill the dead callable branches" (1) is thereby absorbed: there are no branches left to kill, only registry entries with real owners. The optional future polish — having adapters enumerate the registry into explicit per-prefix mounts so the HTTP layer prints its own route table — would require turning the gate stages into per-route middleware; deliberately NOT scheduled (cost exceeds benefit while conformance + route-parity gates hold the line).

0 commit comments

Comments
 (0)