Skip to content

Commit 3c95707

Browse files
committed
Merge origin/main into claude/issue-5090-fallback-seam
2 parents b785436 + ecc61ab commit 3c95707

37 files changed

Lines changed: 4808 additions & 110 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the by-id BULK write faces refuse a row that names no record (#5088)
6+
7+
`updateMany`, and `batch`'s `update` and `delete` branches, now answer
8+
`RECORD_NOT_FOUND` (404) for a row whose id resolves to nothing — the same code
9+
and the same message (`Record <id> not found in <object>`) the single-record
10+
`PATCH` / `DELETE` have answered since #4435.
11+
12+
Before this, #4435's "a write that touched zero rows must not report success"
13+
was live on only 2 of the 5 write faces in `protocol.ts` (`updateData`'s
14+
existence probe and `deleteMany`'s `deleted === false`). The three bulk faces
15+
went straight to the engine, with two visible consequences:
16+
17+
- **`updateMany` / `batch.update`** — a stale id entered the write pipeline.
18+
With no stored row to overlay, #4770's record materialisation (stored ⊕
19+
payload) produced a payload-only record, a hook `condition` reading any
20+
untouched field found it absent, and #4775's unevaluable-condition abort
21+
fired. The row failed `INTERNAL_ERROR` with a diagnostic accusing a *correct*
22+
hook of naming an undeclared field, so an operator with one stale id in a
23+
batch was told their hook was broken and pointed at the object's field list.
24+
Under `atomic: true` that row also poisoned the batch, taking every later row
25+
to `NOT_ATTEMPTED`. Hooks, automation and audit rows no longer fire at all for
26+
a record that does not exist.
27+
- **`batch.delete`** — discarded the driver's return and reported
28+
`success: true` unconditionally, so a batch of typo'd ids reported every one
29+
of them deleted. It now reads the driver contract's positive not-found value
30+
(`=== false`), exactly as `deleteMany` does.
31+
32+
Existence is asked with the same `probeRecord` the single-record path uses: it
33+
answers EXISTENCE, not visibility, so the by-id write policy stays #1994's
34+
decision inside `engine.update` and the `rls-by-id-write` proof can still go
35+
red. `upsert` is deliberately unchanged (a missing id still inserts), as are
36+
the predicate bulk writes (`multi: true`, no per-row id) and the `atomic`
37+
response shape — the causal row keeps its position, later rows stay
38+
`NOT_ATTEMPTED`, and rows with real ids behave exactly as before.
39+
40+
Note for high-volume callers: each by-id row in these three faces now costs one
41+
extra existence read before its write.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/metadata": minor
3+
---
4+
5+
feat(metadata): 端点匹配器 —— `MetadataManager.matchEndpoint` 惰性索引实现 (#5089)
6+
7+
`IMetadataService.matchEndpoint?` 的契约在 #5080/#5097 落地(声明先行),本变更补上
8+
`metadata` 槽位占位者 `MetadataManager` 的实现:把已声明的 `api` 元数据条目编成
9+
**METHOD → 精确路径 → 端点** 的惰性索引,供 HTTP 分发器在「没有内建域认领这条路径」
10+
与「回答语义 404」之间做一次查表。这是 #5040 端点执行器程序的 E2 单。
11+
12+
**结构性不可达,零行为变更。** 17.x 里没有任何东西会调用 `matchEndpoint`:挂载 seam
13+
是 #5090 的面,而 publish/validate 对非空 `apis:` 仍然硬拒(#4936)。新代码在真实组合
14+
里不暴露任何 HTTP 行为;测试直接驱动服务,这正是 #5040 设计选定的验收姿态。
15+
16+
实现要点(逐字实现契约文本,`packages/spec/src/contracts/metadata-service.ts`):
17+
18+
- **匹配维度**:`method` 大写规整后比较(请求动词大小写不敏感);`path` 去掉**一个**
19+
尾斜杠后**整串精确**比较,两侧同规则。17.x 不做百分号解码、不做 Unicode 规整、
20+
不做大小写折叠 —— 原串即键。词表(ADR-0121)未定义任何路径模板语法,因此
21+
`params` **恒为 `{}`**;此处不发明只存在于实现里的方言。
22+
- **答案是 parse 后的形状**:每条经 `ApiEndpointSchema.safeParse`,默认值已物化 ——
23+
作者省略 `authRequired` 时消费方拿到的是 `true`,不可能把「缺省」误读为放行。
24+
- **坏条目响亮缺席**:解析失败的存量条目被跳过并以 `error` 级点名(说明该路由将回 404
25+
及如何修),绝不返回半合法形状,也绝不牵连同批的好条目。
26+
- **重复声明确定性收敛**:两条条目声明同一 METHOD+path 时,`name` 字典序在前者保留
27+
路由,被弃者连同规则一并 `error` 级点名 —— 不是静默 last-write-wins,每个节点、每次
28+
启动的解析结果一致。
29+
- **断存储抛错,不伪装 404**:`undefined` 只表示「无声明拥有这条路由」;读不到存储时
30+
抛出(与 `loadDiagnosed` 的 miss/outage 之分同源,ADR-0110 D3),因为 miss 会变成
31+
404,而故障不得伪装成 404。构建失败不缓存,下次调用重试。
32+
- **失效**:挂在仓内既有机制上,不新造事件系统 —— `invalidateListCache('api')` 覆盖
33+
全部本地写入(含 artifact 装载 / HMR 的 `{ notify: false }` 写入,这些按构造不经过
34+
watcher),`subscribe('api', …)` 覆盖集群对端回放(它只经 `notifyWatchersLocal`)。
35+
失效后下次调用整体重建。
36+
37+
`ApiEndpointSchema` 与 `packages/spec` 未做任何改动(词表冻结)。
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
docs(plugin-auth): the session of record is always `sys_session` — cache backs rate-limit counters only (#4785)
6+
7+
Settles an architectural question that had been answered two different ways by
8+
the code and the docs. **Nothing about the runtime changes**: this records the
9+
decision, proves the behaviour that depends on it, and corrects the docs that
10+
described the road not taken.
11+
12+
**The decision.** ObjectStack's session of record is always the `sys_session`
13+
table. The kernel `cache` service serves authentication as the ADR-0069 D2
14+
rate-limit counter store and nothing else. It is never bound as better-auth's
15+
`secondaryStorage`, because that option is not a counter store — handing
16+
better-auth one also relocates sessions into it (`createSession` skips the
17+
`sys_session` row; `findSession` answers from the cached snapshot without
18+
reading the database). ADR-0069 D4's three session controls — idle timeout,
19+
absolute lifetime, concurrent-session cap — all revoke by writing that row, so a
20+
cache-backed session store would silently disable every one of them. Dual-writing
21+
(`session.storeSessionInDatabase: true`) was considered and rejected as the worst
22+
of the options: the row exists, so the controls *appear* to work, while the read
23+
path still answers from the cache.
24+
25+
**Why this needed settling rather than just fixing.** The conflict had never
26+
fired — the cache lookup that would have wired `secondaryStorage` ran before the
27+
cache service registered, so the binding never took in a standard composition.
28+
The declaration and the runtime disagreed for a month and no test could tell,
29+
because no test asserted that a D4 control ends a *live session*; they asserted
30+
at most that a row got stamped. A stamped row nobody reads is exactly the failure
31+
mode in question.
32+
33+
**What is new.** `session-of-record.test.ts` drives the real better-auth pipeline
34+
end to end and proves each of the three D4 controls actually de-authenticates a
35+
live session cookie — not that a column was written. It also pins the
36+
counter-factual: with a `secondaryStorage` bound, `sys_session` stays empty and
37+
the idle timeout never fires. Two facts that make the guarantee hold for real
38+
deployments are pinned with it — `AuthManager` does not plumb
39+
`storeSessionInDatabase`, so the rejected dual-write shape is unreachable through
40+
configuration; and the default composition (OIDC provider on) makes better-auth
41+
*refuse to boot* with a `secondaryStorage` rather than degrade quietly.
42+
43+
**For hosts.** `cacheSecondaryStorage()` remains exported for anyone who wants
44+
better-auth's cached session store deliberately. It now says plainly what it
45+
costs: opting in disables the ADR-0069 D4 session controls, and a revoked session
46+
stays usable until its cached copy expires. Moving sessions into the cache
47+
platform-wide would be a new decision requiring its own revocation-consistency
48+
requirements, not a configuration change.
49+
50+
ADR-0069's D2 "shared store" is scoped to rate-limit counters, D4 records
51+
`sys_session` as a precondition rather than a deployment preference, and the
52+
`ICacheService` contract page no longer lists session storage among the cache's
53+
uses.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing): recompute sharing rules for predicate (`multi`) writes — stale `sys_record_share` grants no longer survive a bulk update (#4779)
6+
7+
`bindRuleHooks` located the rows to recompute from a single record id:
8+
9+
```ts
10+
const id = String(data?.id ?? ctx?.input?.id ?? '');
11+
if (!id) return;
12+
```
13+
14+
`ObjectQL.update()` only populates `input.id` when `where.id` is a scalar. A
15+
predicate write (`multi: true`) routes to `updateMany`, leaves `input.id`
16+
undefined, and carries no id in its payload — so **every bulk write skipped
17+
sharing-rule recompute entirely**.
18+
19+
The consequence is a fail-open on the authorization side. A criteria-based rule
20+
materialises `sys_record_share` rows; an admin then bulk-updates those records
21+
out of the criteria (`{ where: { region: 'east' }, multi: true, data: { region:
22+
'west' } }`); nothing recomputes, the grant rows stay in the table, and the
23+
recipients keep the read/edit access the rule no longer implies. Same family as
24+
#4757 (`sys_attachment`) and #4778 (approval locks), but better hidden — a stale
25+
grant is indistinguishable from a legitimate one. The reverse direction (bulk
26+
update **into** a rule's criteria never granting) was broken too.
27+
28+
**What changes**
29+
30+
The hooks now key off the write's ROW SET instead of one id. `beforeUpdate` /
31+
`beforeDelete` resolve the affected rows from the predicate and stash them on
32+
the shared hook context (the `before` hook is where it must happen — the write
33+
is what makes those rows unfindable); the `after` hook acts on them:
34+
35+
- **Bounded set (≤ 1000 rows, `RULE_RECOMPUTE_ROW_CAP`)** — `evaluateAllForRecord`
36+
per row, synchronously. Diff-based, so this covers both directions: rows moved
37+
out of a rule's criteria are revoked, rows moved in are granted.
38+
- **Unbounded set** (over the cap, `multi: true` with no `where` at all, or a
39+
resolve that failed) — every `source: 'rule'` grant on the object is revoked
40+
**synchronously** in one set-based statement, and the deserved grants are
41+
restored **asynchronously** by reconciling the object's rules.
42+
43+
**The write is never refused.** Refusing would turn an internal recompute bound
44+
into a business-visible limit on how many rows an admin may update, reported by
45+
a subsystem they never configured. The asymmetry it trades on instead:
46+
over-granting is a security incident, under-granting is an availability wobble.
47+
So the safety half is always synchronous and complete, and only the expensive
48+
restoration half is deferred.
49+
50+
**Operational note.** After a bulk write whose row set could not be bounded,
51+
recipients may briefly lose access to records they still qualify for, until the
52+
background re-grant finishes. It is logged with the object and the reason. The
53+
re-grant is in-process; if it is lost to a crash, the plugin's existing
54+
`kernel:bootstrapped` backfill re-runs the same idempotent reconcile on the next
55+
start, and any subsequent `sys_sharing_rule` write reconciles too.
56+
57+
**Also fixed:** the rule hooks now bind `afterDelete` and retire the deleted
58+
records' rule grants. Nothing else could: `evaluateRule` iterates records that
59+
still exist, so a grant whose record is gone was unreachable by every reconcile
60+
path and outlived restarts. Harmless only while record ids are never reused —
61+
an assumption nothing in the platform enforces.
62+
63+
New on `SharingRuleService`: `revokeRuleGrantsForObject`,
64+
`revokeRuleGrantsForRecords` and `evaluateAllRulesForObject`. Manual
65+
(`source: 'manual'`) shares are never touched by any of them.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/plugin-email": minor
3+
"@objectstack/service-settings": patch
4+
"@objectstack/cli": patch
5+
---
6+
7+
feat(plugin-email): real SMTP delivery — `SmtpTransport`, settings hot-swap, and a `mail/test` that actually sends (#5087)
8+
9+
The **Mail Delivery** settings page has always defaulted to SMTP and offered a
10+
full host / port / TLS / username / password form. Nothing behind it delivered:
11+
`applyMailSettings` treated `provider: 'smtp'` as a no-op ("transport
12+
unchanged"), `mail/test` answered `ok: true, "Configuration looks valid … Wire
13+
@objectstack/plugin-mail for actual delivery"` — a success toast for a message
14+
nobody sent, naming a package that has never existed — and the code pointed
15+
operators at `@objectstack/plugin-mail-smtp`, which is not in this repo or on
16+
npm. A workspace that selected SMTP got a green form, a green test button, and
17+
mail that only ever reached the log and the `sys_email` table. For deployments
18+
in China this left **no** working channel at all: Resend and Postmark are
19+
overseas HTTPS SaaS with unreliable reach and deliverability to QQ / 163 /
20+
enterprise mailboxes, where SMTP is the normal path (Aliyun DirectMail, Tencent
21+
SES, corporate mail servers).
22+
23+
**`SmtpTransport` now ships in `@objectstack/plugin-email`** (ADR-0012: SMTP in
24+
core, implemented with `nodemailer`). `nodemailer` is a real dependency but is
25+
imported **lazily on the first send**, so deployments that never select SMTP —
26+
and non-Node runtimes — never load `node:net` / `node:tls`.
27+
28+
Three doors reach it, all sharing one options reader so they cannot drift:
29+
30+
- **Settings → Mail** (`smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` /
31+
`smtp_password`) hot-swaps the live transport on save, no restart.
32+
- **`os serve`** via `OS_EMAIL_PROVIDER=smtp` plus the new `OS_EMAIL_SMTP_HOST` /
33+
`_PORT` / `_SECURE` / `_USER` / `_PASSWORD` (or `config.email.options`).
34+
- **Constructor**: `new EmailServicePlugin({ provider: 'smtp', providerOptions:
35+
{ host, port, secure, user, password } })`.
36+
37+
TLS is one toggle with the wire behaviour derived from the port, as providers
38+
document it: on `465` implicit TLS (SMTPS); on any other port a **required**
39+
STARTTLS upgrade, so a server that refuses to upgrade fails the send instead of
40+
leaking credentials over a cleartext socket; `secure: false` connects in the
41+
clear and upgrades only when STARTTLS is offered.
42+
43+
**Failure is loud everywhere, because a silent fallback is the bug this fixes.**
44+
On the construction path (CLI / plugin options) a `smtp` provider with no host
45+
**throws** and the boot fails — it no longer degrades into a LogTransport that
46+
reports every send as successful. On the settings hot-swap path a save can never
47+
kill a running server, so the previous transport is kept — but the failure is
48+
logged at `error` naming the consequence and the fix, and **`mail/test` now
49+
performs a real delivery** through the settings on screen and reports the SMTP
50+
server's own words (`535 … authentication failed`) instead of a green toast. The
51+
built-in fallback `mail/test` handler (used only when no email plugin is
52+
mounted) answers `ok: false` and says plainly that nothing was sent.
53+
54+
Nothing to migrate: `log`, `resend` and `postmark` behave exactly as before, and
55+
a deployment that never selects `smtp` is unaffected.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,29 @@ Auth settings precedence:
124124

125125
| Variable | Type | Default | Description |
126126
|:---|:---|:---|:---|
127-
| `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending. Real SMTP delivery requires the separate `@objectstack/plugin-mail-smtp` package. |
127+
| `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `smtp` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending. |
128128
| `OS_EMAIL_API_KEY` | string | — | API key for `resend` / `postmark`. |
129129
| `OS_EMAIL_FROM` | email | — | Default `From:` address. |
130130
| `OS_EMAIL_RETRIES` | number | `0` | Retry count for transient send failures (`0` = no retry). |
131-
132-
> No SMTP transport ships in the open-core runtime — `OS_EMAIL_PROVIDER`
133-
> only materialises `log`, `resend`, or `postmark`. Real SMTP delivery
134-
> requires the separate `@objectstack/plugin-mail-smtp` package, which reads
135-
> its own SMTP settings (configured in **Settings → Mail** or via the
136-
> plugin's constructor options).
131+
| `OS_EMAIL_SMTP_HOST` | string | — | SMTP server hostname. **Required** when `OS_EMAIL_PROVIDER=smtp` — a boot without it fails loudly rather than starting with a transport that logs mail instead of sending it. |
132+
| `OS_EMAIL_SMTP_PORT` | number | `587` | SMTP port. `465` selects implicit TLS (SMTPS). |
133+
| `OS_EMAIL_SMTP_SECURE` | boolean | `true` | Require TLS. On port `465` that means implicit TLS; on any other port a **required** STARTTLS upgrade (a server that will not upgrade fails the send instead of leaking credentials in the clear). `false` connects in the clear and upgrades only when STARTTLS is offered. |
134+
| `OS_EMAIL_SMTP_USER` | string | — | SMTP AUTH username. Omit for servers that accept unauthenticated relay. |
135+
| `OS_EMAIL_SMTP_PASSWORD` | string | — | SMTP AUTH password. |
136+
137+
> SMTP delivery ships in `@objectstack/plugin-email` (ADR-0012) and is
138+
> implemented with `nodemailer`, imported lazily so deployments that never
139+
> select SMTP — and non-Node runtimes — never load `node:net` / `node:tls`.
140+
> Any provider that speaks SMTP works through it: a corporate mail server,
141+
> Aliyun DirectMail, Tencent SES, QQ / 163 enterprise mailboxes.
142+
>
143+
> These variables configure the transport at **boot**. The same connection
144+
> can be configured at runtime in **Settings → Mail** (namespace `mail`,
145+
> keys `smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` /
146+
> `smtp_password`), which hot-swaps the live transport without a restart and
147+
> whose **Send test email** button performs a real delivery. Settings-page
148+
> values are overridden by the namespace's own `OS_MAIL_*` env door
149+
> (e.g. `OS_MAIL_SMTP_HOST`), which locks the field in the UI.
137150
138151
---
139152

‎content/docs/kernel/contracts/cache-service.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Reference for the Cache Service contract — key-value caching with
55

66
# ICacheService Contract
77

8-
The Cache Service provides a **key-value caching** layer used throughout ObjectStack for metadata caching, query result caching, session storage, and rate limiting. Implementations can range from in-memory Maps to Redis clusters.
8+
The Cache Service provides a **key-value caching** layer used throughout ObjectStack for metadata caching, query result caching, and rate limiting. Implementations can range from in-memory Maps to Redis clusters. It does **not** store sessions: the session of record is always the `sys_session` table, because ADR-0069 D4's session controls (idle timeout, absolute lifetime, concurrent-session cap) revoke a session by writing that row. A host may opt in explicitly with `cacheSecondaryStorage()` from `@objectstack/plugin-auth`, but should know what it buys: better-auth then answers session lookups from the cached snapshot without reading the database, so **opting in disables all three D4 session controls** — a revoked session stays usable until its cached copy expires.
99

1010
<Callout type="info">
1111
**Source:** `packages/spec/src/contracts/cache-service.ts`

0 commit comments

Comments
 (0)