Skip to content

Commit d80c445

Browse files
committed
Merge origin/main into claude/issue-5015-notification-embed-retirement
同步 #5289(theme token 退役)、#5293(HttpServerConfig 退役)、#5296 等。 冲突 1 处:`packages/spec/src/migrations/registry.ts` —— step17 `rationale` 的纯追加碰撞(#5021 与 #5015 各追加一段)。按「双方事实都保留」解决: main 已落地的 theme 段落原样保留,本单段落改写开头衔接语跟在其后。 生成物一律按 os-regen 四步处理:先 `git checkout origin/main --` 取回 main 侧全部 os-regen 路径,再整体重新生成(gen:schema / gen:api-surface / gen:spec-changes / gen:upgrade-guide / gen:docs / gen:skill-refs / gen:skill-docs / gen:strictness-ledger),零文本合并、零手改数字。 merge-base gate 现在一次性为三个兄弟单的 5 处整 def 删除背书: system/HttpServerConfig(9 行)、ui/Animation(2)、ui/EmbedConfig(7)、 ui/NotificationAction(3)、ui/ZIndex(8),全部走 #4650 路径 3。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
2 parents b421ebc + 175d789 commit d80c445

74 files changed

Lines changed: 7157 additions & 768 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
fix(auth): an unrecognised membership policy is refused by both reconcilers, not auto-bound by one of them (#5205)
6+
7+
**The sign-up path used to bind anyway.** `reconcileMembership` and
8+
`backfillMemberships` — both public exports of `@objectstack/plugin-auth` — read
9+
the same `policy` field and judged it with opposite predicates. Sign-up tested
10+
`policy === 'invite-only'`, so any *other* value fell through to the `auto`
11+
branch and auto-bound the new user; the backfill tested `policy !== 'auto'` and
12+
refused. One input, two opposite postures, and the fail-open half was the one
13+
that runs per sign-up. A caller who wrote `'inviteOnly'` — or any host passing
14+
the policy from JavaScript, past the `MembershipPolicy` type — got auto-binding
15+
while believing they had switched it off, with nothing in the logs to say so.
16+
17+
Both entry points now check `isMembershipPolicy()` before any policy semantics
18+
and refuse: nothing is bound, and the refusal names the offending value at
19+
`error` level (and on the returned result, so it survives a caller that passed
20+
no logger). This is the posture #5152 took one layer up at the settings
21+
boundary — an unrecognised value is rejected loudly, never coerced to `auto`.
22+
23+
**Contract change — `ReconcileOutcome` gains `'invalid-policy'`, and
24+
`BackfillMembershipsResult.reason` gains the same member.** Both are exported
25+
types, so a consumer that switches exhaustively over them (a `never`-checked
26+
`default`, or a `Record< ReconcileOutcome, … >`) must handle the new member.
27+
The new verdict is deliberately *not* a reuse of the existing `policy-skip` /
28+
`'policy'`: those mean "a valid policy said no", and reporting them for "this
29+
is not a policy" sends whoever is debugging a missing bind to inspect a
30+
deployment setting that is fine. `BackfillMembershipsResult` also gains an
31+
optional `error?: string`, and the `logger` shape on `ReconcileMembershipDeps`
32+
gains an optional `error?` method (it falls back to `warn`).
33+
34+
**No behaviour change for the two real policies.** `auto` binds and
35+
`invite-only` skips exactly as before, on both paths — the framework's own
36+
callers resolve the policy through `AuthManager.getMembershipPolicy()`, whose
37+
return type is `MembershipPolicy`, so nothing on a supported path can reach the
38+
new branch. This closes the dormant divergence on the export surface.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics): reject a dataset's cross-datasource JOIN when it is compiled, not when it is queried (#5115)
6+
7+
#5033 routed a dataset's raw SQL to its base object's own datasource, which
8+
turned a JOIN whose target lives in another database into a **loud query-time
9+
failure** — correct, but late: the dataset can still be saved, published and
10+
put on a dashboard, and the failure lands in front of whoever opens that
11+
dashboard, usually in another environment on another day. It is a pure metadata
12+
error, decidable the moment the dataset is compiled: the whole dataset is
13+
lowered into ONE statement on the base object's datasource, so a join target
14+
bound elsewhere is simply not there.
15+
16+
`compileDataset` now decides it. `AnalyticsService.registerDataset` — the single
17+
door every dataset passes through, whether pre-registered at boot, saved, or
18+
previewed as a Studio draft — hands the compiler the datasource and federation
19+
probes that already existed on `AnalyticsServiceConfig`, and a proven conflict
20+
is rejected before any SQL is built. The message names both objects, both
21+
datasources, the offending `include` path, and the two ways out (bind both
22+
objects to the same datasource, or drop the relationship), in the same wording
23+
family as the #5033 query-time diagnostic so the two never read as two bugs.
24+
25+
**Who is affected.** This is a tightening: a dataset that used to compile and
26+
then fail (or, before #5033, silently read the wrong database) now fails at
27+
registration. It fires only where the metadata *proves* the conflict — the base
28+
object and a join target each declare an explicit `object.datasource` and the
29+
two names differ. A dataset registered at boot is skipped with a WARN naming the
30+
conflict, as before; the rest of the host's datasets still register.
31+
32+
**What is deliberately not rejected** ("cannot answer, do not block", the same
33+
tiering as `isRegisteredObject` / `getObjectFieldNames`):
34+
35+
- a host that wires no datasource probe at all (no data engine) — compiles
36+
exactly as it did before;
37+
- either side leaving `datasource` at its default. `'default'` is the schema's
38+
default *value*, not a routing decision: `ObjectQL.getDriver` short-circuits
39+
only on an explicit non-`'default'` name, then falls through to
40+
`datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split
41+
(audit/telemetry/event) and the owning package's `defaultDatasource` — none of
42+
which are visible to the compiler. Treating `'default'` as "the primary DB"
43+
would reject datasets whose objects a mapping rule in fact lands on the *same*
44+
database;
45+
- a federated (external) participant on either side. `NativeSQLStrategy` already
46+
declines such a cube (ADR-0062 D6), so the query is served by the ObjectQL
47+
FK-expand path, which crosses datasources by construction.
48+
49+
Everything not proven here keeps failing loudly at query time via #5033.
50+
Making cross-datasource dashboards actually *work* (declining in
51+
`NativeSQLStrategy` and serving the join with two reads) is separate and not
52+
part of this change.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire `HttpServerConfigSchema` — nine documented keys with zero readers AND no way to write them (#4938)
6+
7+
`system/http-server.zod.ts` declared `HttpServerConfigSchema` with nine keys —
8+
`port`, `host`, `cors`, `requestTimeout`, `bodyLimit`, `compression`,
9+
`security`, `static`, `trustProxy`. `authorable-surface.json` listed all nine
10+
and `content/docs/references/` rendered them as protocol documentation. Both
11+
halves of the contract were empty:
12+
13+
- **Zero runtime readers.** No package in any repo (objectstack / cloud /
14+
objectui) ever parsed a document with this schema or read a key off it. The
15+
only non-spec mentions were "Used by:" comments in `shared/http.zod.ts`
16+
pointing back at it.
17+
- **Zero authoring entry** — worse than the ordinary declared-but-unread
18+
defect. `stack.zod.ts` had no `server:` key, `config-schema.json` had no
19+
`HttpServerConfig`, and no settings manifest carried it, so the configuration
20+
the docs promised could not even be written down, let alone take effect.
21+
22+
What actually decides these things is three *other* shapes: the CLI `serve`
23+
arguments, the Hono adapter's `ObjectStackHonoOptions`, and
24+
`DispatcherPluginConfig.securityHeaders`. `HttpServerConfigSchema` was
25+
unacquainted with all three. Per ADR-0049 enforce-or-remove, and the 2026-08-04
26+
ruling on #4938, the unreachable face is removed.
27+
28+
FROM → TO, per retired key:
29+
30+
| removed | what to do instead |
31+
|---|---|
32+
| `HttpServerConfig.port` / `.host` | the deployment owns the socket — `objectstack serve -p <port>` / `PORT` |
33+
| `HttpServerConfig.static` | the transport plugin's `staticMounts` |
34+
| `HttpServerConfig.cors` | the transport adapter — `OS_CORS_ORIGIN` / `OS_CORS_CREDENTIALS` / `OS_CORS_MAX_AGE` |
35+
| `HttpServerConfig.security.helmet` | the dispatcher plugin's `securityHeaders` (on by default) |
36+
| `HttpServerConfig.security.rateLimit` | `defineStack({ server: { security: { rateLimit } } })` — LIVE since #5006 |
37+
| `HttpServerConfig.trustProxy` | `defineStack({ server: { trustProxy } })` — LIVE since #5006 |
38+
| `HttpServerConfig.requestTimeout` / `.bodyLimit` / `.compression` | nothing consumes them; they return with an executor or not at all |
39+
40+
Two of the nine were **activated** rather than lost: #5006 mounted
41+
`security.rateLimit` and `trustProxy` on the deliberately narrow
42+
`StackServerConfigSchema`, which grows one key at a time, each arriving with its
43+
consumer. `cors` is registered as the FIRST per-key admission candidate for that
44+
shape — embedding (`example-embed-objectql`) is a real scenario — and will
45+
arrive the #4910 way, key and executor together, rather than sitting on the
46+
export surface as a dead declaration in the meantime.
47+
48+
The retirement kit:
49+
50+
- **No `retiredKey()` tombstone, deliberately** — route 3 of the retirement
51+
playbook ("nothing parses it"), the shape #4834 / PR #4878 used for the kernel
52+
plugin-runtime family. A tombstone is a message to whoever writes the key, and
53+
the only surface on which anyone can write a server key is
54+
`StackServerConfigSchema`; it is `strictObject` and already rejects all seven
55+
by name. Those prescriptions were refreshed from "not authorable — no runtime
56+
reads it" to name the retirement and its replacement.
57+
- **No ADR-0087 D2 conversion**, for the same reason: there is no author source
58+
to rewrite, because the shape was never reachable from an authoring surface.
59+
The channel for code consumers is `api-surface.json` (which lost all three
60+
`HttpServerConfig*` entries) feeding the release-time `spec-changes.json`
61+
diff, plus this changeset.
62+
- Baselines updated deliberately: `json-schema.manifest.json` (−1, the #2978
63+
ratchet fired first and demanded it), `authorable-surface.json` (−9, allowed
64+
by the #4650 gate's path 3 "def no longer emitted by this build"),
65+
`api-surface.json` (−3). Reference docs and the strictness-ledger counts
66+
regenerated.
67+
- **The container, not the file.** `RouteHandlerMetadata` (consumed by
68+
`packages/rest`) and `MiddlewareType` / `MiddlewareConfig` (consumed by
69+
`packages/runtime`) stay, as do `CorsConfigSchema`, `RateLimitConfigSchema`
70+
and `StaticMountSchema` in `shared/http.zod.ts` — each has live consumers
71+
outside the retired shape, so none of them was orphaned by it.
72+
73+
No runtime behaviour changes — that impossibility is the reason for the removal.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
fix(core): one throwing `kernel:shutdown` handler no longer skips every plugin `destroy()` and kills the process under a false "Shutdown timed out" (#5274)
6+
7+
**On `ObjectKernel`, a single bad shutdown subscriber used to end the entire teardown
8+
and `process.exit(1)` the host — reporting a timeout that never happened.**
9+
10+
`performShutdown()` dispatched `kernel:shutdown` through `context.trigger` (a bare
11+
awaited loop that never catches), so the first handler that threw propagated out to
12+
`shutdown()`'s `Promise.race` catch. That catch was written for the timeout race alone
13+
and treated every exception as one, producing three consequences at once:
14+
15+
1. the remaining `kernel:shutdown` handlers never ran;
16+
2. **every** plugin's `destroy()` was skipped — the reverse-order destroy pass sits
17+
after the trigger in `performShutdown()`, so it was never reached;
18+
3. the process was killed by `process.exit(1)` under the log line
19+
`Shutdown timed out — forcing exit`, while nothing had timed out — sending whoever
20+
read it to the `shutdownTimeout` config for a handler bug.
21+
22+
Two changes, matching the reasoning #5257 recorded at `LiteKernel`'s shutdown dispatch
23+
site:
24+
25+
- **`kernel:shutdown` now dispatches ISOLATING on `ObjectKernel` too.** A handler that
26+
throws is logged as `Hook handler failed: kernel:shutdown` and the remaining handlers
27+
still run, followed by the reverse-order `destroy()` pass and the `onShutdown()`
28+
handlers — both of which already isolated per plugin and per handler. What is queued
29+
behind a failing shutdown handler is the cleanup that flushes buffers, closes
30+
connections and releases locks, so one bad handler must not amplify into leaks and
31+
unflushed writes. The BOOT-path hooks are untouched: `kernel:ready`,
32+
`kernel:bootstrapped` and `kernel:listening` still propagate and still fail the boot
33+
(#5170, #5257).
34+
- **The timeout catch now handles only a genuine timeout**, discriminated by identity on
35+
the timer's own rejection — not by message, not by type, so nothing a plugin throws
36+
can impersonate it. A genuine `shutdownTimeout` overrun is **unchanged**: it still
37+
logs `Shutdown timed out — forcing exit` and still calls `process.exit(1)`, because
38+
teardown really is hung and the process would otherwise hold what it failed to
39+
release. Any other exception is logged at `error` and follows the normal path —
40+
`state = 'stopped'`, return — with no `process.exit`, leaving an embedding host
41+
(cloud auth-proxy, CLI, a test runner) its own chance to finish cleanly.
42+
43+
`shutdown()` still never rejects, so no existing caller changes. Telling the two paths
44+
apart is the point of the fix, and both are pinned by named tests.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/plugin-auth": major
3+
"@objectstack/verify": minor
4+
---
5+
6+
BREAKING(auth): `organization/create` 改判**实际生效的** tenancy posture —— 没有组织墙的部署不再能创建组织 (#5261)
7+
8+
`POST /api/v1/auth/organization/create` 的闸门此前判的是操作者**请求的** posture
9+
(`postureEnforcesWall(resolveTenancyPosture())`,一次纯 env 读)。现在判 `tenancy` 服务给出的
10+
**生效** posture —— `tenancy?.posture ?? resolveTenancyPosture()`,与 `/auth/config` 的
11+
`features.multiOrgEnabled` 是**同一次求值**。
12+
13+
## 为什么
14+
15+
两个站点此前只在一种形状下分叉,而那种形状恰恰是最不该放行的一种 —— ADR-0093 D5 **降级态**:
16+
请求了 `isolated`/`group`,但企业包 `@objectstack/organizations` 缺席,于是 `tenancy.posture`
17+
解析为 `single` 且 `degraded=true`。此时:
18+
19+
- 闸门读「请求」→ **放行**;
20+
- `/auth/config` 读「生效」→ `multiOrgEnabled=false`,console 把「创建组织」入口**藏起来**。
21+
22+
结果是 UI 没有按钮而 API 打得通,并且建出来的每一个组织都是**没有任何引擎强制的租户边界** ——
23+
声明了但没强制,ADR-0049 最讨厌的那一类,只不过发生在部署层。改判生效 posture 之后两者同解、
24+
永不分叉:**没有墙,就没有组织**,无论这个部署是从未要过墙,还是要了没拿到。
25+
26+
## 破坏性影响(有意为之)
27+
28+
**没有安装企业包 `@objectstack/organizations` 的部署将完全无法创建组织**,任何 env 组合都不行 ——
29+
`OS_TENANCY_POSTURE=isolated`、`OS_MULTI_ORG_ENABLED=true`、两个一起设,都不再能把闸门说通。
30+
这是一次实打实的能力收缩,不是 knob 纠正,所以搭 v17 主版本车。
31+
32+
| 部署形状 | 改前 | 改后 |
33+
|---|---|---|
34+
| 有企业包,posture `isolated` / `group`(墙真的立着) | 200 | **200**(不变) |
35+
| **请求了墙但企业包缺席(D5 降级态)** | 200 | **403** ⚠️ |
36+
| `single` / 两个 knob 都不设 | 403 | 403(不变) |
37+
| 未注册 `tenancy` 服务的精简嵌入(回落 env 解析) | 按 env | 按 env(不变) |
38+
39+
`serve.ts` 本来就在降级态**默认拒绝启动**(要 `OS_ALLOW_DEGRADED_TENANCY=1` 才走),所以这条收缩
40+
命中的是一个已经需要显式选择才能到达的形状:从此那里的 org-create 路由也一并拒绝,而不是半通不通。
41+
cloud 控制面与任何装了企业包的部署不受影响。
42+
43+
**迁移**:需要多组织能力的部署安装并声明 `@objectstack/organizations`(ADR-0081 D2)。仅靠 env
44+
声明一个墙、而没有实现它的运行时,不再被当作多组织部署对待。
45+
46+
## `@objectstack/verify`(minor,新增)
47+
48+
`BootOptions.multiTenant` 增加 `'posture-only'` 取值:注册一个内置的 `org-scoping` 服务替身,
49+
让 `tenancy` 服务解析出真实、**非降级**的 `isolated` posture,从而打开受 posture 把守的路由 ——
50+
供那些「组织墙是**前置条件**而非被测对象」的 fixture 使用(#3624 的 `org-create-default-team`
51+
dogfood 就是为它而建:那条回归此前靠「boot 后翻 env、闸门 live 读」开路,本次收缩把这个绕法关死了)。
52+
53+
⛔ 它**不做任何租户隔离**:不 stamp `organization_id`,不 scope 任何查询 —— 它让部署的
54+
**posture** 为真,不是让**墙**为真。跨租户隔离的唯一诚实证明仍然是 `multiTenant: true` +
55+
真实的企业包,这也是那些 gate 在本仓继续 skip 而不是假装通过的原因。
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/plugin-dev': patch
5+
'@objectstack/driver-sql': patch
6+
'@objectstack/cli': patch
7+
'@objectstack/cloud-connection': patch
8+
---
9+
10+
fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262)
11+
12+
ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted
13+
`OS_MULTI_ORG_ENABLED` to a back-compat *input* of `resolveTenancyPosture()`.
14+
A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or
15+
`group`), legacy boolean unset — therefore reads `false` from
16+
`resolveMultiOrgEnabled()` while running a fully mounted organization wall.
17+
#5233 corrected two sites in `plugin-auth`; a census found eight more, all
18+
written before that function's doc comment was corrected. Third recurrence of
19+
the shape (cloud#1020, #5233).
20+
21+
Each site was judged separately for **which** posture answers its question —
22+
what the operator REQUESTED, or what the `tenancy` service reports is actually
23+
IN FORCE — rather than converted mechanically:
24+
25+
- `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the
26+
REQUESTED posture (it is constructed below the kernel, with no service
27+
registry to ask). The `organization_id` column was always provisioned; what
28+
diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's
29+
hottest predicate unindexed while SecurityPlugin compiled that same wall.
30+
- `plugin-dev` — whether to load the enterprise `@objectstack/organizations`.
31+
REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall,
32+
so asking whether the wall is up would be circular. A posture-only dev stack
33+
previously never loaded the package at all and served traffic unwalled. Its
34+
diagnostic now names the posture that was requested instead of asserting
35+
`OS_MULTI_ORG_ENABLED=true` at an operator who never set it.
36+
- `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture,
37+
via the `tenancy` service. These ask "will the per-org replay run instead of
38+
me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so
39+
keying on the request would defer to a replay that can never happen. Walled
40+
deployments previously inline-seeded exactly the NULL-organization rows the
41+
code's own comment exists to avoid.
42+
- `cloud-connection` marketplace local install (install-time seed + rehydrate
43+
heal) — EFFECTIVE posture, same reasoning. The install path is a write path:
44+
a walled deployment wrote every sample row with no `organization_id`, landing
45+
the app's data outside the wall its own reads apply.
46+
- `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no
47+
kernel to ask, and a suppressed warning is the costlier error for a
48+
diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a
49+
process-level fact into a per-instance verdict on whichever write landed
50+
first. The gate now resolves live, which is affordable because
51+
`auditMissingTenant` consults it only after the `tenantId` early-out.
52+
- `cli` `os verify` — REQUESTED posture. This one produced a green verification
53+
run over an unverified property: a posture-only deployment silently skipped
54+
every multi-tenant proof and exited 0.
55+
56+
**No configuration change is needed anywhere.** Deployments setting only
57+
`OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()`
58+
falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true`
59+
belt-and-braces configuration stays valid. Deployments that set only
60+
`OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is
61+
unchanged at every site; only the knob each one reads is corrected.

0 commit comments

Comments
 (0)