Skip to content

Commit 8e8e36f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4903-readonly-strip-signal
2 parents 819d4a4 + ecc61ab commit 8e8e36f

25 files changed

Lines changed: 3105 additions & 93 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: 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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* framework#5087 — what `EmailServicePlugin` is constructed with on the
5+
* `os serve` path, and specifically what happens when SMTP is selected.
6+
*
7+
* `OS_EMAIL_PROVIDER=smtp` used to be unreachable: the plugin knew three
8+
* providers (`log`/`resend`/`postmark`), so `smtp` fell into the "no apiKey"
9+
* arm and was silently rewritten to `log`. The server then booted "fine",
10+
* every send was recorded in `sys_email` as sent, and nothing left the box.
11+
* These pin the opposite: a complete SMTP configuration reaches the plugin,
12+
* and an incomplete one fails the boot instead of degrading into a transport
13+
* that reports success.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { resolveEmailCapabilityArg } from './serve.js';
18+
19+
describe('resolveEmailCapabilityArg', () => {
20+
it('defaults to the log provider when nothing is configured', () => {
21+
const { options, warning } = resolveEmailCapabilityArg({}, {});
22+
expect(options).toMatchObject({ provider: 'log' });
23+
expect(options).not.toHaveProperty('providerOptions');
24+
expect(warning).toBeUndefined();
25+
});
26+
27+
it('assembles the SMTP connection from OS_EMAIL_SMTP_*', () => {
28+
const { options, warning } = resolveEmailCapabilityArg({}, {
29+
OS_EMAIL_PROVIDER: 'smtp',
30+
OS_EMAIL_SMTP_HOST: ' smtp.exmail.qq.com ',
31+
OS_EMAIL_SMTP_PORT: '465',
32+
OS_EMAIL_SMTP_SECURE: 'true',
33+
OS_EMAIL_SMTP_USER: 'ops@example.cn',
34+
OS_EMAIL_SMTP_PASSWORD: 'sekrit',
35+
OS_EMAIL_FROM: 'Acme <no-reply@example.cn>',
36+
});
37+
expect(warning).toBeUndefined();
38+
expect(options).toMatchObject({
39+
provider: 'smtp',
40+
providerOptions: {
41+
host: 'smtp.exmail.qq.com',
42+
port: 465,
43+
secure: true,
44+
user: 'ops@example.cn',
45+
password: 'sekrit',
46+
},
47+
defaultFrom: { name: 'Acme', address: 'no-reply@example.cn' },
48+
});
49+
});
50+
51+
it('reads OS_EMAIL_SMTP_SECURE=false as plain-connect', () => {
52+
const { options } = resolveEmailCapabilityArg({}, {
53+
OS_EMAIL_PROVIDER: 'smtp',
54+
OS_EMAIL_SMTP_HOST: 'smtp.x',
55+
OS_EMAIL_SMTP_SECURE: 'false',
56+
});
57+
expect((options.providerOptions as any).secure).toBe(false);
58+
expect(resolveEmailCapabilityArg({}, {
59+
OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_HOST: 'smtp.x', OS_EMAIL_SMTP_SECURE: '0',
60+
}).options.providerOptions).toMatchObject({ secure: false });
61+
});
62+
63+
it('layers env over config.email.options', () => {
64+
const { options } = resolveEmailCapabilityArg(
65+
{ provider: 'smtp', options: { host: 'smtp.config', port: 25 } },
66+
{ OS_EMAIL_SMTP_HOST: 'smtp.env' },
67+
);
68+
expect(options.providerOptions).toEqual({ host: 'smtp.env', port: 25 });
69+
});
70+
71+
it('accepts an SMTP host declared only in config.email.options', () => {
72+
const { options } = resolveEmailCapabilityArg({ provider: 'smtp', options: { host: 'smtp.config' } }, {});
73+
expect(options).toMatchObject({ provider: 'smtp', providerOptions: { host: 'smtp.config' } });
74+
});
75+
76+
it('THROWS on provider=smtp without a host — never a silent LogTransport', () => {
77+
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'smtp' }))
78+
.toThrow(/no SMTP host is configured/);
79+
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_PORT: '587' }))
80+
.toThrow(/OS_EMAIL_SMTP_HOST/);
81+
});
82+
83+
it('does not apply the apiKey fallback to smtp', () => {
84+
// The `resend`/`postmark` arm degrades to `log` when the key is missing;
85+
// smtp must never reach it (it needs no apiKey at all).
86+
const { options, warning } = resolveEmailCapabilityArg({}, {
87+
OS_EMAIL_PROVIDER: 'smtp',
88+
OS_EMAIL_SMTP_HOST: 'smtp.x',
89+
});
90+
expect(options.provider).toBe('smtp');
91+
expect(warning).toBeUndefined();
92+
});
93+
94+
it('keeps the pre-existing resend/postmark behaviour', () => {
95+
const withKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'resend', OS_EMAIL_API_KEY: 're_x' });
96+
expect(withKey.options).toMatchObject({ provider: 'resend', apiKey: 're_x' });
97+
expect(withKey.warning).toBeUndefined();
98+
99+
const noKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'postmark' });
100+
expect(noKey.options.provider).toBe('log');
101+
expect(noKey.warning).toMatch(/no apiKey found/);
102+
});
103+
104+
it('still derives the fallback from-address and template context', () => {
105+
const { options } = resolveEmailCapabilityArg({}, { OS_APP_NAME: 'Acme CRM' }, 'ignored');
106+
expect(options.defaultTemplateContext).toMatchObject({ appName: 'Acme CRM' });
107+
expect(options.defaultFrom).toEqual({ name: 'Acme CRM', address: 'no-reply@acme-crm.local' });
108+
});
109+
});

0 commit comments

Comments
 (0)