Skip to content

Commit 39fcadf

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14328-trigger-kind-resolver
2 parents 897103f + 5bc2f27 commit 39fcadf

32 files changed

Lines changed: 1866 additions & 75 deletions
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
fix(sharing): gate the share-link route probe on `publicSharing.enabled`, at both probe sites (#14637)
7+
8+
**BREAKING** runtime behaviour change on a published HTTP path:
9+
`GET /api/v1/share-links/:token/resolve` answers `404 INVALID_OR_EXPIRED` where
10+
it used to answer `401 NEEDS_PASSWORD` / `401 WRONG_PASSWORD` /
11+
`401 SIGN_IN_REQUIRED` / `410 EXPIRED_OR_REVOKED`, for every link whose object
12+
has `publicSharing.enabled` switched off. Shipped as `minor` under the repo's
13+
launch-window convention (a breaking change does not burn a major while the
14+
stack is in lockstep). No published export is added, removed or re-shaped; the
15+
level carries the breaking banner, not a surface change.
16+
17+
#14033 made `publicSharing.enabled` a standing policy: `resolveToken()` re-reads
18+
the object's current block on every redemption and refuses a switched-off link
19+
with the same undifferentiated `null` a revoked, expired, unknown or ineligible
20+
token gets — because, in that gate's own words, for a caller who may hold
21+
nothing but a token a distinguishable "sharing is off for this object" is an
22+
**existence oracle**.
23+
24+
The HTTP layer above it then re-opened exactly that oracle. Both share-link
25+
surfaces run a row probe after `resolveToken()` returns null, to answer with a
26+
more useful status, and both answered from the `sys_share_link` row with no
27+
knowledge of the object's block. So an anonymous caller could still tell a
28+
real-but-switched-off token from an unknown one three ways: a row carrying
29+
`password_hash` drew `401 NEEDS_PASSWORD`, the same row with any password drew
30+
`401 WRONG_PASSWORD` — including a **correct** password, which is both an oracle
31+
and a lie, since that link can serve nothing — and a row with
32+
`audience: 'signed_in'` drew `401 SIGN_IN_REQUIRED`. A security property stated
33+
in one layer and defeated in the layer above it is worse than one never claimed,
34+
because the next reader believes the comment.
35+
36+
**What changed.** Both probes read the object's standing policy before they
37+
answer from the row, and when the block is off every arm falls through to the
38+
generic `404 INVALID_OR_EXPIRED` that unknown, revoked, expired and ineligible
39+
tokens already give — byte-for-byte the answer a token that never existed
40+
receives. The `410 EXPIRED_OR_REVOKED` arm is included: gating only the two 401
41+
arms would leave a third class of link answer and a rule about which arms are
42+
gated. An object whose schema the engine cannot answer for is `enabled: false`
43+
by `getPolicy`'s definition and is refused the same way — fail-closed, the same
44+
definition `createLink` and `resolveToken` already use.
45+
46+
The fix lands at **both** sites in one change, because the probe exists twice:
47+
`plugin-sharing`'s REST routes, and the `/share-links` dispatcher domain in
48+
`@objectstack/runtime` that is the designed primary surface for cloud's
49+
per-environment kernels (`registerShareLinkRoutes: false`). Fixing one would
50+
have moved the oracle to whichever embedding uses the other.
51+
52+
**Nothing else moves.** With the block ON, every refusal is exactly what it was:
53+
`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED` and `EXPIRED_OR_REVOKED`
54+
are unchanged in status, code and message, and a correct password or a signed-in
55+
viewer still resolves the record. Mint-time behaviour is untouched, no
56+
`sys_share_link` row is written or read differently, and no error code is added
57+
or retired.
58+
59+
**Consumer impact.** A viewer that branches on the refusal STATUS sees TWO
60+
changes, for links on a switched-off object only — and the measured consumer
61+
branches on status alone. On the objectui console at `67dadd6`,
62+
`apps/console/src/pages/SharedRecordPage.tsx` lines 70-85 dispatch on
63+
`res.status` and never on the body's error code, so:
64+
65+
- all three 401 arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`)
66+
rendered the password prompt and now render the 404 copy, "This link is
67+
invalid or no longer available.";
68+
- the 410 arm rendered "This link has expired or was revoked." and now renders
69+
that same 404 copy.
70+
71+
Both shifts are the intended outcome and were accepted with the ruling: a
72+
correct password on such a link yields nothing, so prompting for one teaches the
73+
holder to open a door that is bricked up, and "expired or revoked" is a claim
74+
about a token whose existence the caller must not be able to confirm. Links on
75+
objects whose block is on are unaffected — prompt, 410 copy and 200 render
76+
included.
77+
78+
Maintainer ruling 2026-09-03 (decision batch #17, item 1), verbatim 「同意」,
79+
adopting option A over option B (keep the 401 and document the accepted oracle)
80+
and option C (gate only the two 401 arms, rejected as proliferation).
81+
82+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, type, default and accept-set, and this change is only WHICH HTTP STATUS the route layer answers with while that switch is off. There is no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform on any consumer — a deployment that wants the 401 affordance back enables the object's block, which is an authoring decision, not a migration. -->
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-auth": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
feat(platform-objects,plugin-auth): a user may set their own `sys_user.locale` (#14787)
8+
9+
Maintainer ruling 2026-09-03, option B, quoted verbatim and untranslated as
10+
adopted:
11+
12+
> 「同意」
13+
14+
The identity table's user-writable set grows from two fields to three. This is a
15+
security-boundary act, taken by the maintainer and recorded as one — it is the
16+
first widening of the ADR-0092 D2 self-service whitelist since that ADR shipped
17+
`{name, image}` as its first and only entry. `sys_user.locale` landed
18+
`readonly` and off the whitelist three weeks earlier (#13881 / #14775), which
19+
recorded a decision nobody had made yet; the ruling made it.
20+
21+
Three edits move together, and each one is inert without the other two:
22+
23+
- `SYS_USER_PROFILE_EDIT_FIELDS` becomes `{name, image, locale}`, so the
24+
identity write guard admits the column instead of stripping it (and, on a
25+
locale-only PATCH, throwing). `SYS_USER_IMPORT_UPDATE_FIELDS` inherits the
26+
widening by construction — it is a spread of the profile set, not a second
27+
list.
28+
- `MANAGED_EXTENSION_EDITABLE_FIELDS` gains a `sys_user` entry holding
29+
`locale` and nothing else.
30+
- `sys_user.locale` drops `readonly`. Without this the engine's readonly strip
31+
removes a caller-supplied value before the guard or the validator ever sees
32+
it, so the whitelist entry alone would have been a silent no-op.
33+
34+
**A malformed value is refused, not stored.** The column now declares a
35+
`locale_bcp47_shape` `format` validation rule carrying the same BCP-47 pattern
36+
the delivery-time reader uses, so objectql's rule validator rejects a malformed
37+
tag on insert, by-id update and bulk update with the standard
38+
`VALIDATION_FAILED` / `invalid_format` envelope (HTTP 400). The check is of
39+
SHAPE, not of membership: an unknown-but-well-formed tag is accepted and falls
40+
to the delivery ladder's floor rather than dead-lettering a notification, which
41+
is the property #13881's per-recipient chain was built to hold. An absent, null
42+
or empty column stays legal — clearing it is how a user returns to the
43+
deployment default, which remains the fallback.
44+
45+
**What did NOT widen.** The ADR-0092 D6 session-snapshot mirror keeps
46+
`{name, image}`: better-auth has no `locale` on its user model and it is
47+
deliberately not an `additionalFields` entry, so there is no cached copy to keep
48+
coherent, and merging one in would manufacture a `user.locale` key present only
49+
on sessions that happen to be cached and only after a profile edit. The mirror
50+
set is now named separately from the update whitelist rather than derived from
51+
it.
52+
53+
**Who may perform the write is unchanged, and is a separate question.** ADR-0092
54+
D5 leaves that with the permission layer: `member_default` still denies
55+
`allowEdit` on `sys_user`, so a rank-and-file member reaches this column through
56+
no shipped surface yet — the widening opens the COLUMN, not a self-service
57+
route. Granting one (the `sys_api_key` shape: an explicit `member_default` entry
58+
plus a `_self` row-scope for writes) is a further security-boundary decision
59+
that this ruling did not take.
60+
61+
The `identity-write-guard` and `managed-extension-fields` pins that recorded the
62+
old posture are FLIPPED, not deleted, each naming the ruling that reversed it —
63+
a pin that recorded a real decision is evidence, and evidence of a superseded
64+
decision is what tells the next reader the reversal was deliberate.
65+
66+
`@objectstack/service-messaging` is a docs-and-export change only: its
67+
`LOCALE_TAG_SHAPE` is unchanged in behaviour and now exported so a parity pin
68+
can hold it byte-identical to the write-side pattern. Read-side normalization
69+
stays — it is strictly the stricter of the two (`"null"` is shape-legal and only
70+
the read side refuses it) and it guards values that arrive below the data API,
71+
where no write rule runs.

.claude/skills/pm-dispatch/references/lanes/cli.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@
1414

1515
## 常设承诺
1616

17-
- **Required checks 六个**:`TypeScript Type Check` · `Lint & Repo Gates` ·
18-
`Test Core` · `Dogfood Regression Gate` · `Build Core` ·
19-
`Temporal Conformance (live PG + MySQL)` —— 逐 job 读各自 `conclusion`,⛔ 不认
20-
聚合,`in_progress` 不是过;advisory 门禁红进 main 是共享损伤,照样止血立单。
2117
- **PR 侧绿 ≠ 队列侧绿**;队列分支读法与边界在 platform-readings(条目名
2218
`main/pr-{number}-{parent sha}`,parent sha 可重建队序;⚠️ ref 在出队后滞留)。
2319
- `dispatch-gates.mjs` 只报**路径推导**的地板 —— 条款②从卡**内容**判且优先于它;已知盲

.claude/skills/pm-dispatch/references/lanes/services.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818
—— 单独的绿不是证据。
1919
- **安全族卡的披露纪律**:复现配方不落任何公开面(卡、PR、评论);证据以抽象描述
2020
或私有通道承载。
21-
- 门禁读数不轻信聚合:`check:type-check-debt` 可以在包级 typecheck 绿时红;
22-
`check:i18n` 以「PREREQUISITE NOT MET — workspace CLI 未 build」退 3 不是漂移。
2321

2422
## 席内判断
2523

.claude/skills/pm-dispatch/references/platform-readings.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,16 @@
294294
降档(那正是降档保险丝要拒的替换),但「本车道 fable 强制」多是过宽的回忆 ——
295295
`dispatch-gates.mjs --tier PATH` 逐路径现推(实测:`SKILL.md``.claude/agents/**` 强制,`scripts/pm/**`
296296
`references/**` 无 ⇒ 全阻塞的三卡 fold 拆成 1 阻 3 可跑);路径线是**下限不是放行**
297+
- **required checks 的名单是每仓事实**(逐 job 读判定的规则住上面队列段):objectstack 六个 ——
298+
`TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · `Dogfood Regression Gate` ·
299+
`Build Core` · `Temporal Conformance (live PG + MySQL)`;`in_progress` 不是过;advisory 门禁
300+
红进 main 是共享损伤,照样止血立单。⛔ 聚合命令同样不作判定:`check:type-check-debt` 可在
301+
包级 typecheck 绿时红;`check:i18n` 以「PREREQUISITE NOT MET — workspace CLI 未 build」退 3 不是漂移。
302+
- ⚠️ **计数不是机理读数(被当天推翻的推断的墓碑)**:「`GET .../actions/runs?event=merge_group`
303+
计数 0 ⇒ required 集为空」提出当天即被自身推翻 —— 同一姊妹仓 2026-08-24 首现 merge_group
304+
run(0 → 8),同日再测 224(阳性对照 `event=pull_request` 全程非零)。计数答「至今发生过没有」,
305+
不答「机制在不在」:零计数只作**弱先验**,判 required 集为空要读 ruleset
306+
的 required 集本身、或看队列合并是否真在等检查;⛔ 别处写下的计数值一律先复测再用。
297307

298308
## 闭合关键词解析(PR 正文写侧)
299309

.claude/skills/pm-dispatch/references/rest-channel.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,6 @@ payload → REST → MCP/GraphQL** 的策略住那里,本表是逐操作的通
7676
`PUT .../pulls/{n}/merge`,强制队列 ruleset 下回 **405 `Changes must be made through the merge queue`**;② PR
7777
上的 `added_to_merge_queue` timeline 事件;③ 对已入队 PR 调 update-branch 回「已入队分支不能更新,
7878
要改先出队」。①② 拼写与边界是 `platform-readings.md` 队列段既有行,本条只归拢判据。
79-
- ⚠️ **计数不是机理读数(被当天推翻的推断的墓碑)**:「`GET .../actions/runs?event=merge_group`
80-
计数 0 ⇒ required 集为空」提出当天即被自身推翻 —— 同一姊妹仓 2026-08-24 首现 merge_group
81-
run(0 → 8),同日再测 224(阳性对照 `event=pull_request` 全程非零)。计数答「至今发生过没有」,
82-
不答「机制在不在」:零计数只作**弱先验**,判 required 集为空要读 ruleset
83-
的 required 集本身、或看队列合并是否真在等检查;⛔ 别处写下的计数值一律先复测再用。
8479
- **required job 名与分片矩阵的改名耦合(现行,自 2026-08-24)**:
8580
队列 required 集按 **job / check-run 名**匹配,**workflow 名从不作为 check context 出现**(所以拿
8681
workflow 名在选择器里搜什么也搜不到);改其中任一 job 名**或 test 分片矩阵的形状**,

content/docs/permissions/system-context.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
@@ -117,7 +117,7 @@ that silently does not happen.
117117
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5891` |
118118
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3735`, `:3745`, `:3772` |
119119
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
120-
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
120+
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` |
121121
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6589` |
122122
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12084` |
123123
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12013` |
@@ -135,7 +135,7 @@ The largest single consumer — **17 of the 106 sites**.
135135
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
138-
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
138+
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` |
139139
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
140140
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |
141141

content/docs/permissions/tenant-audit-census.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.**
8484

8585
Tenancy itself is enabled *by default*`isTenancyDisabled()` reads
8686
`tenancy.enabled === false` and nothing else — so the object registry only has to
87-
find the opt-outs. Across 297 declared objects — the dated, ⛔ unenforced
87+
find the opt-outs. Across 298 declared objects — the dated, ⛔ unenforced
8888
corpus-scale figure below — exactly two opt out (`sys_api_key`,
8989
`sys_sso_provider`), and no write call site on this surface targets either.
9090

@@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
224224
their values are not compared. The reasoning, and the measurement behind it,
225225
are in `scripts/check-tenant-audit-census.mjs`.
226226

227-
Measured on 2026-09-03 at `98b1cf0b7`.
227+
Measured on 2026-09-03 at `631038b03`.
228228

229229
| corpus scale (not enforced) | count |
230230
| :--- | ---: |
231-
| tracked non-test sources scanned | 540 |
231+
| tracked non-test sources scanned | 542 |
232232
| engine-shaped types recognised | 57 |
233-
| declared objects in the registry | 297 |
233+
| declared objects in the registry | 298 |
234234
| same-named calls subtracted as non-engine | 130 |
235235

236236
{/* END GENERATED: tenant-audit-census */}

docs/audits/2026-08-tenant-audit-write-call-sites.counts.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
5252
their values are not compared. The reasoning, and the measurement behind it,
5353
are in `scripts/check-tenant-audit-census.mjs`.
5454

55-
Measured on 2026-09-03 at `98b1cf0b7`.
55+
Measured on 2026-09-03 at `631038b03`.
5656

5757
| corpus scale (not enforced) | count |
5858
| :--- | ---: |
59-
| tracked non-test sources scanned | 540 |
59+
| tracked non-test sources scanned | 542 |
6060
| engine-shaped types recognised | 57 |
61-
| declared objects in the registry | 297 |
61+
| declared objects in the registry | 298 |
6262
| same-named calls subtracted as non-engine | 130 |
6363

6464
## Every site

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
304304
}
305305
}
306306
}
307+
},
308+
_validations: {
309+
locale_bcp47_shape: {
310+
message: "Locale must be a BCP-47 language tag, such as zh-CN or ja-JP."
311+
}
307312
}
308313
},
309314
sys_session: {

0 commit comments

Comments
 (0)