Skip to content

Commit 8b56f11

Browse files
committed
Merge origin/main into claude/issue-5271-api-metadata-type-registry
2 parents 845542a + 5aae790 commit 8b56f11

63 files changed

Lines changed: 6861 additions & 569 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: 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.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `publishPackageDrafts` 现在对 `api` draft 跑 ADR-0121 端点发布门 (#5206 step 2)
6+
7+
`protocol.publishPackageDrafts` 是 Studio「全部发布」的真实入径(ADR-0033 /
8+
ADR-0067 D2)。在此之前,它唯一的按类型前置检查是对象命名空间前缀
9+
(`validateObjectNamespacePrefix`,仅 `d.type === 'object'`),于是一条 `api`
10+
draft **不经任何一道门**就被提升为 `active` —— 与 #5189
11+
`MetadataManager.publishPackage` 上修掉的是同一形状、另一条路。
12+
13+
安全后果早已被 PR #5203 的装载期兜底挡住:端点匹配器在建索引时用同一个
14+
`firstFailure` 重判每一条存量条目,没过门的被排除出索引并 `error` 点名。所以
15+
这次修的是**拒绝得太晚**:ADR-0121 的原文是「publish 拒绝」,作者应当在
16+
publish 当场拿到点名 key 的处方,而不是到装载期日志里才发现自己的端点在答
17+
404。
18+
19+
**判据只有一份。** 本改动调用 `@objectstack/spec/api` 导出的
20+
`validateApiEndpointDeclarations`(#5203 公开)—— 就是 stack schema 跑的那个
21+
函数、`publishPackage` 跑的那个函数、装载期兜底跑的那个 `firstFailure`。拒绝
22+
文案直接用门函数自己的消息(已包含端点名、越界的 key 和改法),本包不复述任何
23+
一条「什么算可服务」的规则。
24+
25+
`publishPackage` 不同,这条路**有身份**:包的 `manifest.namespace` 本来就
26+
为对象前缀规则读过了,所以这里跑的是**全量门**,命名空间门(ADR-0121 D1/D2)
27+
包含在内。命名空间门****以「包声明了 namespace」为条件 —— 门函数自己的前置
28+
判据(声明了 `apis:` 的 stack 必须显式声明 `manifest.namespace`)本身就是一条
29+
判据,对「压根没有 namespace」的包跳过它,等于给最不可能过编译期的那批包留一
30+
个洞。对象前缀规则对无 namespace 的包网开一面,是因为一个裸对象名只是命名气味;
31+
一个无命名空间的端点是一个**无主 URL**
32+
33+
**行为变化(用户可见)**:
34+
35+
- 一条 `api` draft 若违反端点门(最典型:ADR-0121 D6 —— `authRequired: false`
36+
却没有 `rateLimit.enabled: true` 的预算),`publishPackageDrafts` 现在返回
37+
`success: false` / `publishedCount: 0`,该条目进入 `failed[]`,`code`
38+
`ENDPOINT_GATE`;body 连 `ApiEndpointSchema` 都不满足的,`code`
39+
`ENDPOINT_SCHEMA`(解析是判定的前置,不是第六道门 —— 判不了的形状也服务不
40+
了)。
41+
- **失败粒度沿用既有语义,未发明新的批次语义**:与命名空间前缀违规完全一致,
42+
这是一次**提升任何东西之前**的前置拒绝,整批不落地(`published: []`),同批
43+
的健康 draft 保持 draft 态。这既是 ADR-0067 D2 的「一次 commit 不能落一半」,
44+
也是 #5189 在另一条路上的同一姿势(`itemsPublished: 0`)。两类违规现在合并
45+
**同一份报告**里返回,作者一次往返就能看全。
46+
- 判定范围是**本批被提升的 draft**,与紧邻它的对象前缀规则一致。与同包已
47+
`active` 的端点撞车不在此拦截 —— 匹配器对全库重复声明有确定性裁决并 `error`
48+
点名(`buildEndpointIndex`);把范围扩到整包 active 集合意味着「因为你没在发
49+
布的东西而拒绝这次发布」,那是另一份契约,不是一个 bug 修复。
50+
51+
装载期兜底(#5203)原样保留,未移除也未削弱:publish**更早**的那道门,不是
52+
最后那道门的替代品。
53+
54+
`api``DEFAULT_METADATA_TYPE_REGISTRY` / `BUILTIN_METADATA_TYPE_SCHEMAS`
55+
(即 Studio 直写路径的 422)是 #5206 的第 1 步,拆在子单 #5271(spec 车道);
56+
本改动**不依赖**它落地。

0 commit comments

Comments
 (0)