From 4007ddba6c72949ab612e3b9d32b5fbe935cde3f Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:52:56 +0800 Subject: [PATCH 01/22] =?UTF-8?q?docs(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20Cloudflare=20Worker=20=E6=A8=A1=E5=BC=8F=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单用户跑在 CF Worker 上:schedule 存 D1,cron 用 CF Cron Trigger。 只替换 tenant-context 层,复用现有 handler / 加密 / 消息处理。 Co-Authored-By: Claude Opus 4.8 --- ...7-01-amsg-single-user-cloudflare-design.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md diff --git a/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md b/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md new file mode 100644 index 0000000..d5b8947 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md @@ -0,0 +1,254 @@ +# amsg-server 单用户 · Cloudflare Worker 模式 设计稿 + +日期:2026-07-01 +分支:`feat/amsg-single-user-cloudflare` + +## 这是啥 + +给 `@rei-standard/amsg-server` 加一条「单用户」部署路径:整套服务跑在一个 Cloudflare Worker 上,定时任务(schedule)存进 D1,cron 用 CF 自带的 Cron Trigger 触发。目标是让「只有自己一个人用」的场景不用碰多租户那一套(租户注册表、Blob、租户 token),配几个环境变量就能起。 + +现状是 amsg-server 只有多租户模式:每个请求都要带租户 token,服务端验 token → 查 Netlify Blob 里的租户记录 → 拿到该租户的数据库连接和 masterKey。单用户模式把这一层换掉,其余照搬。 + +## 用在什么时候 + +- 自部署、单人使用,不需要多租户隔离 +- 想白嫖 Cloudflare 全家桶(Worker + D1 + Cron Trigger),不想额外挂一个 Postgres 和外部定时器 +- 愿意为「D1 落库数据」留一层加密(masterKey 放 env、密文进 D1,两边分家),换取 D1 单独泄漏时 LLM API key / prompt 不外泄 + +多租户 SaaS 场景继续用现有的 `createReiServer` + Neon/Pg,不受本次改动影响。 + +--- + +## 一、核心思路:只换 context 层 + +现有 7 个 handler(schedule-message / messages / update-message / cancel-message / get-user-key / send-notifications / init-tenant)全都通过 `ctx.tenantManager.resolveTenant(headers)` 拿到 `{ tenantId, db, masterKey }`。 + +只要造一个**接口同构**的单用户版 context,handler 一行都不用改。 + +### 单用户 context + +新文件 `server/src/server/tenant/single-user-context.js`,导出: + +```js +createSingleUserContextManager({ db, masterKey, serverToken }) +``` + +暴露和多租户版一模一样的两个方法: + +| 方法 | 多租户版(现状) | 单用户版(新) | +|---|---|---| +| `resolveTenant(headers, opts)` | 验 Bearer JWT → 查 Blob → 拿 db/masterKey | 配了 `serverToken` 就用 timing-safe 比对 `X-Client-Token` 头,没配就直接放行;返回固定 `{ ok:true, context:{ tenantId:'single', tokenType:'tenant'\|'cron', db, masterKey } }` | +| `initializeTenant()` | 建租户记录 + 建表 + 发 token | **只调 `db.initSchema()`**,返回 `{ tenantId:'single' }`,不发任何 token | + +要点: +- `db` 和 `masterKey` 由上层(Worker 入口)从 env + binding 拿好后直接传进来,context 内部不再查 Blob。 +- `serverToken` 没配 = 端点开放;配了 = 所有业务请求都要带 `X-Client-Token: `,用 `crypto.timingSafeEqual`(或等价的定长比较)防时序侧信道。 +- `send-notifications` handler 走 HTTP 时会带 `allowCronToken:true`,单用户版直接放行即可(真正的 cron 走 CF `scheduled()`,不经过这里)。 + +### 单用户入口 + +新文件 `server/src/server/single-user.js`,导出: + +```js +createSingleUserServer({ vapid, masterKey, serverToken, db, webpush }) +``` + +和 `createReiServer` 并列。内部组装出和多租户版同形的 `ctx`(把 `tenantManager` 换成单用户 context,`webpush` 换成注入进来的推送实现,见第四节),返回同样的 `{ handlers }`。 + +--- + +## 二、D1 adapter + +新文件 `server/src/server/adapters/d1.js`,导出 `createD1Adapter(db)`(`db` 是 CF 的 D1 binding,即 `env.DB`),实现 `DbAdapter` 接口全部 13 个方法。 + +**不塞进现有 `createAdapter` factory**:factory 硬要求 `connectionString`,而 D1 只有 binding 对象,没有连接串。单用户 context 直接调 `createD1Adapter(env.DB)`。`createD1Adapter` 从 `index.js` 单独导出即可。 + +### SQLite 方言 schema + +现有 schema 是 Postgres 方言,D1 是 SQLite,另存一份 `server/src/server/adapters/schema.sqlite.js`: + +```sql +CREATE TABLE IF NOT EXISTS scheduled_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + uuid TEXT, + encrypted_payload TEXT NOT NULL, + message_type TEXT NOT NULL CHECK (message_type IN ('fixed','prompted','auto','instant')), + next_send_at TEXT NOT NULL, -- ISO8601 UTC 字符串 + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')), + retry_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL, -- ISO8601 UTC,adapter 显式写入 + updated_at TEXT NOT NULL +); +``` + +索引沿用现有那 5 个(含 `uidx_uuid` 唯一约束、几个 partial index)——SQLite 支持 partial index 和 CHECK,SQL 基本能原样搬。 + +关键差异处理: +- **时间戳存 TEXT(ISO8601 UTC)**。ISO8601 带 `Z` 的定长格式(`YYYY-MM-DDTHH:mm:ss.sssZ`)字典序 = 时间序,所以 `next_send_at <= ?` 这种比较用字符串比就对。 +- **时间戳必须归一化**:adapter 在写入 / 比较前一律 `new Date(v).toISOString()`,把带时区偏移(如 `+08:00`)的输入统一成 `Z` 形式。否则 `+08:00` 和 `Z` 混着存会让字典序比较出错。这条用测试钉住(混时区输入仍能正确比较)。 +- `NOW()` / `INTERVAL '7 days'` SQLite 没有 → adapter 在 JS 里算好「当前 ISO」「截止 ISO」再 bind 进去。 +- `SERIAL` → `INTEGER PRIMARY KEY AUTOINCREMENT`。 +- `initSchema`:D1 一次 `prepare` 只能跑一条语句,建表 + 建索引拆成多条,用 `db.batch([...])` 或顺序 `.run()`。 +- 建 / 删返回值:D1 `.run()` 返回 `{ success, meta:{ changes, last_row_id } }`。用 `meta.changes` 判断「删了几行」,用 `meta.last_row_id` 或 `RETURNING *` 拿新建行。 + +### 方法映射 + +13 个方法的 WHERE / SET 子句语义**逐条对齐现有 `pg.js`**(实现时对着 pg.js 抄,只换方言),返回的 `TaskRow` 形状必须和 Pg/Neon 完全一致:`id` / `retry_count` 是数字,时间戳是 ISO 字符串,其余是字符串。SQLite 原生返回类型正好对上(INTEGER→number,TEXT→string),不用额外转换。 + +几个重点方法: +- `getPendingTasks(limit)`:`WHERE status='pending' AND next_send_at <= ? ORDER BY next_send_at ASC LIMIT ?`,第一个参数 bind 当前 ISO。cron 内核重度依赖它。 +- `createTask(params)`:`INSERT ... RETURNING *`(D1 支持 RETURNING)→ `.first()`。归一化 `next_send_at`,`created_at`/`updated_at` 写当前 ISO。 +- `updateTaskById(id, updates)`:按 `updates` 里的键动态拼 SET,每次都带 `updated_at = <当前ISO>`,涉及 `next_send_at` 时归一化。 +- `cleanupOldTasks(days)`:`DELETE WHERE status IN ('sent','failed') AND updated_at < ?`,bind「当前 - days」的 ISO,返回 `meta.changes`。 + +--- + +## 三、cron:抽出 tick 内核 + +现状:定时逻辑埋在 `handlers/send-notifications.js` 里——先 `resolveTenant` 验 cron token,再批处理待发任务(取 pending → 逐条处理并发 8 → 成功后按 recurrence 删除或改期 → 失败重试 → 清理旧任务)。 + +改造:把「验完 token 之后的批处理内核」抽成一个纯函数,放 `server/src/server/lib/run-tick.js`: + +```js +runScheduledTick({ db, masterKey, vapid, webpush }) → { totalTasks, successCount, failedCount, ... } +``` + +- HTTP handler(多租户):`resolveTenant` 拿到 `{db, masterKey}` 后调 `runScheduledTick`。 +- CF `scheduled(event, env, ctx)`(单用户):直接构造 `{ db: createD1Adapter(env.DB), masterKey: env.AMSG_MASTER_KEY, ... }` 调 `runScheduledTick`,**不需要 cron token**(CF 运行时直接触发,没有 HTTP 头)。 + +多租户 send-notifications 的对外行为不变。抽取前后行为一致,用回归测试锁住(见第六节)。 + +--- + +## 四、CF 上的 Web Push(已知风险 + 方案) + +**风险**:`web-push` npm 包在 CF Worker 上大概率跑不起来——它底层用 Node 的 `https.request`,而 CF Worker 出站只认 `fetch`,`nodejs_compat` 也不保证补齐 `https.request`。 + +**为什么现有代码能救**:server 的消息处理没有直接 `import 'web-push'`,而是把它当依赖注入——`ctx.webpush.sendNotification(subscription, payload)`。只要注入一个「行为兼容」的实现,消息处理逻辑一行不用改。 + +**方案**:在 server 里加一个自带的 Web Crypto 版推送模块 `server/src/server/lib/webpush-webcrypto.js`(照搬 instant `src/webpush.js` 那套 RFC 8291/8292 实现,纯 Web Crypto,能在 CF 跑)。它对外暴露和 `web-push` 兼容的接口: + +- `setVapidDetails(subject, publicKey, privateKey)` +- `sendNotification(subscription, payload)`:成功正常返回;失败抛出带 `.statusCode` 的错误(现有失败处理会看 statusCode 判断 410 之类,得对齐)。 + +单用户 Worker 入口把这个模块当 `webpush` 注入给 `createSingleUserServer`;多租户路径继续用 `web-push` npm,不受影响。 + +**取舍**:和 instant 有一份重复实现,换来 server 不新增对 instant 的包依赖。后续若嫌重复,可把这套推送下沉到 `shared`,两边共用——本次不做(YAGNI)。 + +**实现时先验证**:搭个最小 Worker 实测 `web-push` npm 是否真的挂;若某些路径能用则相应简化。方案以「自带 Web Crypto 实现」为准,验证只为确认,不阻塞。 + +--- + +## 五、CF Worker 模板(对标 instant 的 sfworker) + +新目录 `server/examples/cloudflare-single-user/`,和 instant 把 worker 当 example 放包内的结构对齐: + +### `worker.js` + +```js +export default { + async fetch(request, env, ctx) { /* 小路由,分发到 handlers */ }, + async scheduled(event, env, ctx) { /* 直接 runScheduledTick */ }, +} +``` + +- `fetch`:一个小路由,按 `METHOD + path` 把 CF `Request` 分发到 `createSingleUserServer(...).handlers` 对应的方法。每请求构造:`db = createD1Adapter(env.DB)`,`masterKey = env.AMSG_MASTER_KEY`,`serverToken = env.AMSG_SERVER_TOKEN`,`vapid` 从 env,`webpush = createWebCryptoWebPush()`。构造开销可忽略(对标 instant `createCloudflareWorker((env)=>config)` 每请求建 config 的做法)。 +- 路由覆盖:`POST /init-tenant`(退化成幂等建表)、`POST /schedule-message`、`GET /messages`、`PUT /update-message`、`DELETE /cancel-message`、`GET /user-key`、`POST /send-notifications`(HTTP 兜底,可选)。 +- `scheduled`:直接 `runScheduledTick({ db: createD1Adapter(env.DB), masterKey: env.AMSG_MASTER_KEY, vapid, webpush })`,无需 token。 + +路由把 CF `Request` 适配成现有 handler 期望的入参——实现时对齐现有 handler 的调用约定(参考 `examples/api` 里 handler 是怎么被调的)。 + +### `wrangler.toml` + +```toml +name = "amsg-single-user" +main = "worker.js" +compatibility_flags = ["nodejs_compat"] + +[[d1_databases]] +binding = "DB" +database_name = "amsg" +database_id = "<填你的>" + +[triggers] +crons = ["* * * * *"] # 按需,最小 1 分钟一跳 +``` + +VAPID / masterKey / serverToken 走 `wrangler secret put`,不写进 toml。 + +### `schema.sql` + +建表脚本,给愿意用命令行的人 `wrangler d1 execute amsg --file schema.sql` 用。内容 = 第二节的 SQLite 建表 + 5 个索引。 + +### 建表的两条路(都给) + +1. **命令行**:`wrangler d1 execute amsg --file schema.sql`(主路径,一步到位)。 +2. **HTTP 兜底**:部署后调一次 `POST /init-tenant`(内部只跑 `db.initSchema()`,`CREATE TABLE IF NOT EXISTS`,幂等可重复调)。给没有命令行环境的人用。若配了 `serverToken`,这个端点也要带 `X-Client-Token`。 + +### `README.md` + +一页跑通:建 D1 → 建表 → 配 secrets(VAPID / masterKey / 可选 serverToken)→ `wrangler deploy` → 验证。 + +--- + +## 六、client 单用户档 + +现状:client 自己不带租户 token(假定有代理 / 拦截器注入 `Authorization: Bearer`);加密路径要先 `init()` 拉 userKey。可选的弱密钥 `instantClientToken`(`X-Client-Token`)现在**只**加在 instant 明文路径上。 + +单用户服务端不验租户 token → client 加密路径本来就能原样跑。唯一要加的能力:**把共享密钥也带到 schedule / messages 这些路径**。 + +改法:`ReiClientConfig` 新增一个字段 `serverToken`(语义清楚、不动老的 `instantClientToken`)。配了 `serverToken` 就给**所有**请求(schedule / list / update / cancel / user-key / instant)加上 `X-Client-Token: ` 头。加密、`userId`、`init()` 这些都不动。 + +`instantClientToken` 保持原样(只管 instant 明文路径),两者互不干扰。 + +--- + +## 七、测试(当回归守卫) + +- **D1 adapter**:跑 13 方法的 CRUD;重点覆盖 `getPendingTasks` 的时间比较(含混时区输入归一化后仍正确)、`cleanupOldTasks` 的截止时间、`uidx_uuid` 唯一约束冲突。用 better-sqlite3 或 miniflare 的本地 D1 当测试后端。 +- **`runScheduledTick` 抽取**:一个测试锁住多租户 `send-notifications` 抽取前后行为一致(能在抽取破坏行为时挂、抽对时过)。 +- **单用户 context**:配了 `serverToken` → 缺头 / 错头拦截、对头放行;没配 → 直接放行;`initializeTenant` 只建表、不发 token。 +- **Web Crypto 推送 shim**:接口和失败错误形状(`.statusCode`)与 `web-push` 对齐。 +- **client**:`serverToken` 配了,所有路径的请求都带 `X-Client-Token`;没配则一个都不带(instant 明文路径的 `instantClientToken` 行为不变)。 + +--- + +## 八、文件清单(全在 amsg-server 包内,不新增包) + +**新增** +- `server/src/server/tenant/single-user-context.js` +- `server/src/server/single-user.js` +- `server/src/server/adapters/d1.js` +- `server/src/server/adapters/schema.sqlite.js` +- `server/src/server/lib/run-tick.js` +- `server/src/server/lib/webpush-webcrypto.js` +- `server/examples/cloudflare-single-user/{worker.js, wrangler.toml, schema.sql, README.md}` +- 对应测试文件 + +**改动** +- `server/src/server/handlers/send-notifications.js`:改成调 `runScheduledTick` +- `server/src/server/index.js`:导出 `createSingleUserServer`、`createD1Adapter` +- `client/src/index.js`:新增 `serverToken` 配置 + +--- + +## 九、环境变量 / 配置清单(单用户 Worker) + +| 名字 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `DB` | D1 binding | 是 | D1 数据库绑定 | +| `AMSG_MASTER_KEY` | secret | 是 | 加密用主密钥,随机 32 字节 hex,生成后粘贴一次 | +| `VAPID_EMAIL` / `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | secret | 是 | Web Push VAPID | +| `AMSG_SERVER_TOKEN` | secret | 否 | 配了就校验 `X-Client-Token`,不配端点开放 | +| `[triggers] crons` | wrangler 配置 | 是 | cron 触发频率 | + +--- + +## 十、非目标(YAGNI) + +- 不做多用户 / 租户隔离(那是现有多租户模式的活) +- 不把 Web Crypto 推送下沉到 shared(先在 server 内自带,重复可接受) +- 不动多租户模式的对外行为 +- 不做 D1 之外的单用户存储后端(KV / DO 等) +- 单用户模式下不引入租户 token / cron token 体系 From 0ed7e99a1321d6f74fe4007a07d242dbdc49c910 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:05:57 +0800 Subject: [PATCH 02/22] =?UTF-8?q?docs(amsg):=20=E6=8C=89=20Codex=20review?= =?UTF-8?q?=20=E4=BF=AE=E8=AE=A2=E5=8D=95=E7=94=A8=E6=88=B7=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 砍掉 HTTP send-notifications 兜底,定时只走 CF scheduled(),堵住免验触发(P1) - init 单独写建表路由,不复用会校验 driver/databaseUrl 的旧 handler(P2) - serverToken 只加到 amsg-server 端点,不碰 instant 路径,避免和 instantClientToken 撞头(P2) Co-Authored-By: Claude Opus 4.8 --- ...7-01-amsg-single-user-cloudflare-design.md | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md b/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md index d5b8947..08624c3 100644 --- a/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md +++ b/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md @@ -21,9 +21,12 @@ ## 一、核心思路:只换 context 层 -现有 7 个 handler(schedule-message / messages / update-message / cancel-message / get-user-key / send-notifications / init-tenant)全都通过 `ctx.tenantManager.resolveTenant(headers)` 拿到 `{ tenantId, db, masterKey }`。 +现有业务 handler 里,schedule-message / messages / update-message / cancel-message / get-user-key 这 **5 个**都通过 `ctx.tenantManager.resolveTenant(headers)` 拿到 `{ tenantId, db, masterKey }`。只要造一个**接口同构**的单用户版 context,这 5 个一行都不用改。 -只要造一个**接口同构**的单用户版 context,handler 一行都不用改。 +另外两个要单独处理(Codex review 后修正,原稿误把它们也算进「零改动」): + +- **init-tenant**:现有 `createInitTenantHandler` 不走 `resolveTenant`,它在调 `initializeTenant` 前就先校验 body 里的 `driver`/`databaseUrl`(多租户要连 Pg/Neon)。D1 单用户没这俩参数,没法零改动复用 → 单用户单独写一个「只建表」的 init 路由。 +- **send-notifications**:它的批处理内核抽成 `runScheduledTick`(第三节)。单用户的定时**只走 CF Cron Trigger(`scheduled()`)**,**不暴露 HTTP 入口**(避免鉴权绕过,见下)。 ### 单用户 context @@ -37,13 +40,13 @@ createSingleUserContextManager({ db, masterKey, serverToken }) | 方法 | 多租户版(现状) | 单用户版(新) | |---|---|---| -| `resolveTenant(headers, opts)` | 验 Bearer JWT → 查 Blob → 拿 db/masterKey | 配了 `serverToken` 就用 timing-safe 比对 `X-Client-Token` 头,没配就直接放行;返回固定 `{ ok:true, context:{ tenantId:'single', tokenType:'tenant'\|'cron', db, masterKey } }` | +| `resolveTenant(headers, opts)` | 验 Bearer JWT → 查 Blob → 拿 db/masterKey | 配了 `serverToken` 就用 timing-safe 比对 `X-Client-Token` 头,没配就直接放行;返回固定 `{ ok:true, context:{ tenantId:'single', tokenType:'tenant', db, masterKey } }` | | `initializeTenant()` | 建租户记录 + 建表 + 发 token | **只调 `db.initSchema()`**,返回 `{ tenantId:'single' }`,不发任何 token | 要点: - `db` 和 `masterKey` 由上层(Worker 入口)从 env + binding 拿好后直接传进来,context 内部不再查 Blob。 -- `serverToken` 没配 = 端点开放;配了 = 所有业务请求都要带 `X-Client-Token: `,用 `crypto.timingSafeEqual`(或等价的定长比较)防时序侧信道。 -- `send-notifications` handler 走 HTTP 时会带 `allowCronToken:true`,单用户版直接放行即可(真正的 cron 走 CF `scheduled()`,不经过这里)。 +- `serverToken` 没配 = 端点开放;配了 = **所有暴露出去的 HTTP 端点**都要带 `X-Client-Token: `,用 `crypto.timingSafeEqual`(或等价的定长比较)防时序侧信道。**没有免验的后门**。 +- 单用户不暴露 HTTP `send-notifications`,所以不存在「`allowCronToken:true` 要不要放行」这种口子。定时只由 CF `scheduled()` 触发(CF 平台内部直接调,不经过 HTTP,天生外人碰不到)。 ### 单用户入口 @@ -114,10 +117,12 @@ CREATE TABLE IF NOT EXISTS scheduled_messages ( runScheduledTick({ db, masterKey, vapid, webpush }) → { totalTasks, successCount, failedCount, ... } ``` -- HTTP handler(多租户):`resolveTenant` 拿到 `{db, masterKey}` 后调 `runScheduledTick`。 +- HTTP handler(多租户):`resolveTenant` 拿到 `{db, masterKey}` 后调 `runScheduledTick`。多租户对外行为不变。 - CF `scheduled(event, env, ctx)`(单用户):直接构造 `{ db: createD1Adapter(env.DB), masterKey: env.AMSG_MASTER_KEY, ... }` 调 `runScheduledTick`,**不需要 cron token**(CF 运行时直接触发,没有 HTTP 头)。 -多租户 send-notifications 的对外行为不变。抽取前后行为一致,用回归测试锁住(见第六节)。 +单用户**不提供** HTTP `/send-notifications`。原因:那条 HTTP 入口在多租户里靠 cron token 保护,单用户没这套 token,留着就等于一个免验的公开端点——谁都能打进来触发发消息、烧 LLM API key(Codex review 的 P1)。有 CF Cron Trigger 就够了,直接砍掉。 + +抽取前后多租户行为一致,用回归测试锁住(见第七节)。 --- @@ -154,7 +159,7 @@ export default { ``` - `fetch`:一个小路由,按 `METHOD + path` 把 CF `Request` 分发到 `createSingleUserServer(...).handlers` 对应的方法。每请求构造:`db = createD1Adapter(env.DB)`,`masterKey = env.AMSG_MASTER_KEY`,`serverToken = env.AMSG_SERVER_TOKEN`,`vapid` 从 env,`webpush = createWebCryptoWebPush()`。构造开销可忽略(对标 instant `createCloudflareWorker((env)=>config)` 每请求建 config 的做法)。 -- 路由覆盖:`POST /init-tenant`(退化成幂等建表)、`POST /schedule-message`、`GET /messages`、`PUT /update-message`、`DELETE /cancel-message`、`GET /user-key`、`POST /send-notifications`(HTTP 兜底,可选)。 +- 路由覆盖:`POST /init-tenant`(单用户专用建表路由,只跑 `initSchema`,幂等;配了 `serverToken` 也要带 `X-Client-Token`)、`POST /schedule-message`、`GET /messages`、`PUT /update-message`、`DELETE /cancel-message`、`GET /user-key`。**不含 HTTP `/send-notifications`**——定时只走 `scheduled()`。 - `scheduled`:直接 `runScheduledTick({ db: createD1Adapter(env.DB), masterKey: env.AMSG_MASTER_KEY, vapid, webpush })`,无需 token。 路由把 CF `Request` 适配成现有 handler 期望的入参——实现时对齐现有 handler 的调用约定(参考 `examples/api` 里 handler 是怎么被调的)。 @@ -198,9 +203,9 @@ VAPID / masterKey / serverToken 走 `wrangler secret put`,不写进 toml。 单用户服务端不验租户 token → client 加密路径本来就能原样跑。唯一要加的能力:**把共享密钥也带到 schedule / messages 这些路径**。 -改法:`ReiClientConfig` 新增一个字段 `serverToken`(语义清楚、不动老的 `instantClientToken`)。配了 `serverToken` 就给**所有**请求(schedule / list / update / cancel / user-key / instant)加上 `X-Client-Token: ` 头。加密、`userId`、`init()` 这些都不动。 +改法:`ReiClientConfig` 新增一个字段 `serverToken`(语义清楚、不动老的 `instantClientToken`)。配了 `serverToken` 就给 **amsg-server 自己的端点**(schedule / messages / update / cancel / user-key / init)加上 `X-Client-Token: ` 头。加密、`userId`、`init()` 这些都不动。 -`instantClientToken` 保持原样(只管 instant 明文路径),两者互不干扰。 +**serverToken 不加到 instant 路径**(Codex review 的 P2):instant 可能指向另一个 worker(`customBaseUrls.instant`)、用它自己的 `instantClientToken`,两者都落在同一个 `X-Client-Token` 头上,混用会打架(server 的 token 会顶掉 instant 的,或让 instant 收到错的密钥)。所以 serverToken 只管 amsg-server 端点,instant 路径继续用 `instantClientToken`,井水不犯河水。 --- @@ -209,8 +214,10 @@ VAPID / masterKey / serverToken 走 `wrangler secret put`,不写进 toml。 - **D1 adapter**:跑 13 方法的 CRUD;重点覆盖 `getPendingTasks` 的时间比较(含混时区输入归一化后仍正确)、`cleanupOldTasks` 的截止时间、`uidx_uuid` 唯一约束冲突。用 better-sqlite3 或 miniflare 的本地 D1 当测试后端。 - **`runScheduledTick` 抽取**:一个测试锁住多租户 `send-notifications` 抽取前后行为一致(能在抽取破坏行为时挂、抽对时过)。 - **单用户 context**:配了 `serverToken` → 缺头 / 错头拦截、对头放行;没配 → 直接放行;`initializeTenant` 只建表、不发 token。 +- **鉴权无后门(P1 回归守卫)**:配了 `serverToken` 时,暴露的每个 HTTP 端点都要求带对的 `X-Client-Token`——用一个测试钉住「没有任何端点能免验触发」,防止将来又冒出个 `allowCronToken` 式的口子。 +- **单用户 init 路由**:无参 `POST` 只建表、幂等可重复调;配了 `serverToken` 时缺 / 错 `X-Client-Token` 被拦。 - **Web Crypto 推送 shim**:接口和失败错误形状(`.statusCode`)与 `web-push` 对齐。 -- **client**:`serverToken` 配了,所有路径的请求都带 `X-Client-Token`;没配则一个都不带(instant 明文路径的 `instantClientToken` 行为不变)。 +- **client**:`serverToken` 配了,**amsg-server 端点**的请求带 `X-Client-Token`,**instant 路径不带**(instant 仍用自己的 `instantClientToken`);没配 serverToken 则 server 端点都不带。 --- @@ -219,6 +226,7 @@ VAPID / masterKey / serverToken 走 `wrangler secret put`,不写进 toml。 **新增** - `server/src/server/tenant/single-user-context.js` - `server/src/server/single-user.js` +- `server/src/server/handlers/single-user-init.js`(只跑 `initSchema` 的建表路由,不复用 `createInitTenantHandler`) - `server/src/server/adapters/d1.js` - `server/src/server/adapters/schema.sqlite.js` - `server/src/server/lib/run-tick.js` From 3c3841ded468f5cd0a9abff2bf9316641d5e70a9 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:43:32 +0800 Subject: [PATCH 03/22] =?UTF-8?q?docs(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20CF=20Worker=20=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=881?= =?UTF-8?q?2=20=E4=BB=BB=E5=8A=A1=EF=BC=8CTDD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../2026-07-01-amsg-single-user-cloudflare.md | 2119 +++++++++++++++++ 1 file changed, 2119 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-amsg-single-user-cloudflare.md diff --git a/docs/superpowers/plans/2026-07-01-amsg-single-user-cloudflare.md b/docs/superpowers/plans/2026-07-01-amsg-single-user-cloudflare.md new file mode 100644 index 0000000..6abb882 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-amsg-single-user-cloudflare.md @@ -0,0 +1,2119 @@ +# amsg-server 单用户 · Cloudflare Worker 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 `@rei-standard/amsg-server` 能以「单用户」模式跑在一个 Cloudflare Worker 上:定时消息存 D1,cron 用 CF Cron Trigger,绕过多租户注册表 / Blob / token。 + +**Architecture:** 只替换 tenant-context 层——单用户版 context 从 env+binding 直接给出 `{ db: D1adapter, masterKey, tenantId:'single' }`,接口和多租户版同构,5 个业务 handler(schedule/messages/update/cancel/user-key)一行不改直接复用。D1 adapter 实现现有 13 方法的 `DbAdapter` 接口(SQLite 方言)。定时批处理内核抽成 `runScheduledTick` 纯函数,CF `scheduled()` 直接调。Web Push 因为 `web-push` npm 在 Worker 上跑不了,复用 instant 的纯 Web Crypto 实现。 + +**Tech Stack:** Node.js ESM、Cloudflare Workers(D1 + Cron Triggers)、`node --test`、better-sqlite3(测试用 D1 兼容后端)、Web Crypto(RFC 8291/8292 Web Push、constant-time 比较)。 + +**设计稿:** `docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md` + +--- + +## 文件结构 + +包内路径都相对 `packages/rei-standard-amsg/`。 + +**新增(server 包)** +| 文件 | 职责 | +|---|---| +| `server/src/server/adapters/schema.sqlite.js` | SQLite 方言的建表 + 索引 SQL 常量 | +| `server/src/server/adapters/d1.js` | D1 adapter:实现 13 个 `DbAdapter` 方法 + `createD1Adapter(db)` | +| `server/src/server/lib/constant-time.js` | 可移植 constant-time 字符串比较(Node + Worker 都能跑) | +| `server/src/server/tenant/single-user-context.js` | 单用户 context manager(`resolveTenant` / `initializeTenant`) | +| `server/src/server/handlers/single-user-init.js` | 单用户建表路由(只跑 `initSchema`,不复用多租户 init) | +| `server/src/server/single-user.js` | `createSingleUserServer(config)` 组装 handlers | +| `server/src/server/lib/run-tick.js` | `runScheduledTick(ctx)` 定时批处理内核(从 send-notifications 抽出) | +| `server/src/server/lib/webpush-webcrypto.js` | 从 instant 移植的纯 Web Crypto Web Push + `createWebCryptoWebPush(vapid)` 包装 | +| `server/src/server/lib/webcrypto-utils.js` | 从 instant 移植的 Web Crypto 工具函数(webpush-webcrypto 依赖) | +| `server/src/server/cloudflare/single-user-worker.js` | `createSingleUserCloudflareWorker(buildConfig)` → `{ fetch, scheduled }` | +| `server/examples/cloudflare-single-user/worker.js` | 示例 Worker 入口(薄接线) | +| `server/examples/cloudflare-single-user/wrangler.toml` | D1 binding + cron trigger 配置 | +| `server/examples/cloudflare-single-user/schema.sql` | 命令行建表脚本 | +| `server/examples/cloudflare-single-user/README.md` | 部署跑通说明 | +| `server/test/helpers/sqlite-d1.mjs` | 测试用:better-sqlite3 上的 D1 兼容 shim | +| `server/test/*.test.mjs` | 各单元测试 | + +**改动** +| 文件 | 改动 | +|---|---| +| `server/src/server/handlers/send-notifications.js` | 批处理内核换成调 `runScheduledTick` | +| `server/src/server/index.js` | 导出 `createSingleUserServer` / `createD1Adapter` / `createWebCryptoWebPush` / `runScheduledTick` / `createSingleUserCloudflareWorker` | +| `server/package.json` | devDependencies 加 `better-sqlite3` | +| `client/src/index.js` | `ReiClientConfig` 加 `serverToken`;5 条 server 路径带 `X-Client-Token` | +| `client/package.json` | 加 `test` 脚本(若无) | + +**关键约定(读源码确认过,实现时照此)** +- Handler 是普通函数,返回 `{ status, body }` 明文对象,**每个方法的入参约定不同**: + - `getUserKey.GET(url, headers)`、`messages.GET(url, headers)`、`cancelMessage.DELETE(url, headers)` + - `scheduleMessage.POST(headers, body)`、`singleUserInit.POST(headers, body)` + - `updateMessage.PUT(url, headers, body)` +- `resolveTenant(headers, options?)` 返回 `{ ok:true, context:{ tenantId, tokenType, db, masterKey } }` 或 `{ ok:false, error:{ status, body } }`。 +- D1 binding API:`db.prepare(sql).bind(...p).run()` → `{ meta:{ changes, last_row_id } }`;`.first()` → 单行或 null;`.all()` → `{ results:[...] }`。 +- 时间戳在 SQLite 里存 TEXT(ISO8601 UTC);adapter 写入/比较前一律 `new Date(v).toISOString()` 归一化,保证字典序=时间序。 +- uuid 唯一冲突:D1 报 `UNIQUE constraint failed`,现有 `isUniqueViolation()` 已匹配 `"unique constraint"` 子串 → 冲突自动变 409,无需改动。 + +--- + +## Task 1: 测试用 D1 兼容 shim(better-sqlite3) + +**Files:** +- Modify: `packages/rei-standard-amsg/server/package.json`(devDependencies 加 better-sqlite3) +- Create: `packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs` +- Test: `packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs` + +- [ ] **Step 1: 装 better-sqlite3 到 server 包** + +Run(在仓库根目录): +```bash +npm install better-sqlite3 --workspace @rei-standard/amsg-server --save-dev +``` +Expected: `server/package.json` 的 `devDependencies` 出现 `better-sqlite3`,根 `package-lock.json` 更新。 + +- [ ] **Step 2: 写 shim** + +Create `packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs`: +```js +/** + * Test-only D1-compatible wrapper over an in-memory better-sqlite3 database. + * Exposes the subset of the Cloudflare D1 binding API the adapter uses: + * db.prepare(sql).bind(...params).run() / .first() / .all() + * so adapter tests exercise real SQLite (real SQL, real constraints). + */ +import Database from 'better-sqlite3'; + +export function createTestD1() { + const db = new Database(':memory:'); + + function prepare(sql) { + let bound = []; + const stmt = { + bind(...args) { + bound = args; + return stmt; + }, + async run() { + const info = db.prepare(sql).run(...bound); + return { success: true, meta: { changes: info.changes, last_row_id: Number(info.lastInsertRowid) } }; + }, + async first() { + const row = db.prepare(sql).get(...bound); + return row === undefined ? null : row; + }, + async all() { + const rows = db.prepare(sql).all(...bound); + return { success: true, results: rows, meta: {} }; + } + }; + return stmt; + } + + return { + prepare, + _raw: db, + close() { + db.close(); + } + }; +} +``` + +- [ ] **Step 3: 写 shim 自测** + +Create `packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; + +test('sqlite-d1 shim returns D1-shaped run/first/all results', async () => { + const db = createTestD1(); + await db.prepare('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)').run(); + + const ins = await db.prepare('INSERT INTO t (v) VALUES (?)').bind('hello').run(); + assert.equal(ins.meta.changes, 1); + assert.equal(typeof ins.meta.last_row_id, 'number'); + + const row = await db.prepare('SELECT v FROM t WHERE id = ?').bind(ins.meta.last_row_id).first(); + assert.equal(row.v, 'hello'); + + const missing = await db.prepare('SELECT v FROM t WHERE id = ?').bind(9999).first(); + assert.equal(missing, null); + + const list = await db.prepare('SELECT * FROM t').all(); + assert.equal(list.results.length, 1); + + db.close(); +}); +``` + +- [ ] **Step 4: 跑测试** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS,包含 `sqlite-d1 shim returns D1-shaped run/first/all results`。 + +- [ ] **Step 5: 提交** + +```bash +git add packages/rei-standard-amsg/server/package.json packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs package-lock.json +git commit -m "test(amsg): 加 D1 兼容 shim(better-sqlite3)供 adapter 测试" +``` + +--- + +## Task 2: SQLite 方言 schema 常量 + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js` +- Test: `packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs` + +- [ ] **Step 1: 写失败测试** + +Create `packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SQLITE_TABLE_SQL, SQLITE_INDEXES } from '../src/server/adapters/schema.sqlite.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; + +test('SQLITE_TABLE_SQL uses SQLite dialect', () => { + assert.match(SQLITE_TABLE_SQL, /INTEGER PRIMARY KEY AUTOINCREMENT/); + assert.match(SQLITE_TABLE_SQL, /next_send_at TEXT NOT NULL/); + assert.doesNotMatch(SQLITE_TABLE_SQL, /SERIAL/); + assert.doesNotMatch(SQLITE_TABLE_SQL, /TIMESTAMP WITH TIME ZONE/); +}); + +test('SQLITE_INDEXES defines the 5 indexes incl. the critical unique guard', () => { + assert.equal(SQLITE_INDEXES.length, 5); + const uidx = SQLITE_INDEXES.find((i) => i.name === 'uidx_uuid'); + assert.ok(uidx && uidx.critical === true); +}); + +test('schema applies cleanly on real SQLite', async () => { + const db = createTestD1(); + await db.prepare(SQLITE_TABLE_SQL).run(); + for (const index of SQLITE_INDEXES) { + await db.prepare(index.sql).run(); + } + // CHECK constraint rejects a bad status + await assert.rejects( + db.prepare( + `INSERT INTO scheduled_messages (user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at) + VALUES ('u', 'x', 'p', 'fixed', '2026-01-01T00:00:00.000Z', 'bogus', 0, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')` + ).run() + ); + db.close(); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '../src/server/adapters/schema.sqlite.js'`。 + +- [ ] **Step 3: 写 schema 常量** + +Create `packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js`: +```js +/** + * SQLite (Cloudflare D1) dialect schema for scheduled_messages. + * + * Differences from the Postgres schema (adapters/schema.js): + * - id: INTEGER PRIMARY KEY AUTOINCREMENT (vs SERIAL) + * - timestamps stored as TEXT ISO8601 UTC (vs TIMESTAMP WITH TIME ZONE) + * - no NOW()/DEFAULT; the adapter always writes timestamps explicitly + * Partial indexes and CHECK constraints are native to SQLite, so they carry over. + */ + +export const SQLITE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS scheduled_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + uuid TEXT, + encrypted_payload TEXT NOT NULL, + message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')), + next_send_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) +`; + +export const SQLITE_INDEXES = [ + { + name: 'idx_pending_tasks_optimized', + sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized + ON scheduled_messages (status, next_send_at, id, retry_count) + WHERE status = 'pending'`, + critical: false + }, + { + name: 'idx_cleanup_completed', + sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed + ON scheduled_messages (status, updated_at) + WHERE status IN ('sent', 'failed')`, + critical: false + }, + { + name: 'idx_failed_retry', + sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry + ON scheduled_messages (status, retry_count, next_send_at) + WHERE status = 'failed' AND retry_count < 3`, + critical: false + }, + { + name: 'idx_user_id', + sql: `CREATE INDEX IF NOT EXISTS idx_user_id + ON scheduled_messages (user_id)`, + critical: false + }, + { + name: 'uidx_uuid', + sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid + ON scheduled_messages (uuid) + WHERE uuid IS NOT NULL`, + critical: true + } +]; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS(3 个 schema 测试全过)。 + +- [ ] **Step 5: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs +git commit -m "feat(amsg): D1 用的 SQLite 方言 schema 常量" +``` + +--- + +## Task 3: D1 adapter(13 方法) + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/adapters/d1.js` +- Modify: `packages/rei-standard-amsg/server/src/server/index.js`(导出 `createD1Adapter`) +- Test: `packages/rei-standard-amsg/server/test/d1-adapter.test.mjs` + +- [ ] **Step 1: 写失败测试(覆盖全 13 方法 + 时间归一化 + uuid 唯一)** + +Create `packages/rei-standard-amsg/server/test/d1-adapter.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; + +async function freshAdapter() { + const db = createTestD1(); + const adapter = createD1Adapter(db); + await adapter.initSchema(); + return { adapter, db }; +} + +function baseTask(overrides = {}) { + return { + user_id: USER, + uuid: overrides.uuid || 'uuid-1', + encrypted_payload: 'enc', + next_send_at: overrides.next_send_at || '2026-01-01T00:00:00.000Z', + message_type: overrides.message_type || 'fixed' + }; +} + +test('initSchema creates table and indexes', async () => { + const { adapter } = await freshAdapter(); + const res = await adapter.initSchema(); // idempotent (IF NOT EXISTS) + assert.equal(res.indexesFailed, 0); + assert.equal(res.indexesCreated, 5); +}); + +test('createTask returns id/uuid/status/created_at and normalizes next_send_at', async () => { + const { adapter } = await freshAdapter(); + // input uses +08:00 offset — must be normalized to Z form on store + const row = await adapter.createTask(baseTask({ next_send_at: '2026-01-01T08:00:00+08:00' })); + assert.equal(typeof row.id, 'number'); + assert.equal(row.uuid, 'uuid-1'); + assert.equal(row.status, 'pending'); + assert.equal(row.next_send_at, '2026-01-01T00:00:00.000Z'); +}); + +test('getPendingTasks respects next_send_at <= now with mixed-offset inputs', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'due', next_send_at: '2020-01-01T00:00:00.000Z' })); // past → due + await adapter.createTask(baseTask({ uuid: 'future', next_send_at: '2999-01-01T00:00:00+00:00' })); // future → not due + const pending = await adapter.getPendingTasks(50); + const uuids = pending.map((t) => t.uuid); + assert.deepEqual(uuids, ['due']); +}); + +test('getTaskByUuid / getTaskByUuidOnly find pending tasks', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'a' })); + assert.ok(await adapter.getTaskByUuid('a', USER)); + assert.equal(await adapter.getTaskByUuid('a', 'other-user'), null); + assert.ok(await adapter.getTaskByUuidOnly('a')); +}); + +test('updateTaskById updates fields + bumps updated_at', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'u' })); + const updated = await adapter.updateTaskById(row.id, { status: 'failed', retry_count: 2 }); + assert.equal(updated.status, 'failed'); + assert.equal(updated.retry_count, 2); +}); + +test('updateTaskByUuid updates only pending rows and returns {uuid, updated_at}', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'u' })); + const res = await adapter.updateTaskByUuid('u', USER, 'enc2', { next_send_at: '2027-01-01T00:00:00.000Z' }); + assert.equal(res.uuid, 'u'); + assert.ok(res.updated_at); + assert.equal(await adapter.updateTaskByUuid('missing', USER, 'enc2'), null); +}); + +test('delete + getTaskStatus', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'd' })); + assert.equal(await adapter.getTaskStatus('d', USER), 'pending'); + assert.equal(await adapter.deleteTaskById(row.id), true); + assert.equal(await adapter.deleteTaskById(row.id), false); + assert.equal(await adapter.getTaskStatus('d', USER), null); +}); + +test('deleteTaskByUuid scoped to user', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'd2' })); + assert.equal(await adapter.deleteTaskByUuid('d2', 'other'), false); + assert.equal(await adapter.deleteTaskByUuid('d2', USER), true); +}); + +test('listTasks paginates and counts', async () => { + const { adapter } = await freshAdapter(); + for (let i = 0; i < 3; i++) await adapter.createTask(baseTask({ uuid: `l${i}` })); + const page = await adapter.listTasks(USER, { limit: 2, offset: 0 }); + assert.equal(page.total, 3); + assert.equal(page.tasks.length, 2); +}); + +test('cleanupOldTasks removes only old sent/failed rows', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'old' })); + // mark sent with an updated_at far in the past + await adapter.updateTaskById(row.id, { status: 'sent', updated_at: '2000-01-01T00:00:00.000Z' }); + const removed = await adapter.cleanupOldTasks(7); + assert.equal(removed, 1); +}); + +test('uuid uniqueness violation surfaces as an error matched by isUniqueViolation', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'dup' })); + await assert.rejects( + adapter.createTask(baseTask({ uuid: 'dup' })), + (err) => /unique constraint/i.test(err.message) + ); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '../src/server/adapters/d1.js'`。 + +- [ ] **Step 3: 写 D1 adapter** + +Create `packages/rei-standard-amsg/server/src/server/adapters/d1.js`: +```js +/** + * Cloudflare D1 (SQLite) Database Adapter. + * + * @implements {import('./interface.js').DbAdapter} + * + * Timestamps are stored as ISO8601 UTC TEXT. Every timestamp is normalized + * with new Date(v).toISOString() before store/compare so lexical ordering + * equals chronological ordering (mixed offsets like +08:00 vs Z are unified). + */ + +import { SQLITE_TABLE_SQL, SQLITE_INDEXES } from './schema.sqlite.js'; + +export class D1Adapter { + /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */ + constructor(db) { + /** @private */ + this._db = db; + } + + /** @private */ + _now() { + return new Date().toISOString(); + } + + /** @private */ + _iso(value) { + const d = new Date(value); + if (Number.isNaN(d.getTime())) { + throw new Error(`[amsg-server D1] invalid timestamp: ${value}`); + } + return d.toISOString(); + } + + async initSchema() { + await this._db.prepare(SQLITE_TABLE_SQL).run(); + + const indexResults = []; + for (const index of SQLITE_INDEXES) { + try { + await this._db.prepare(index.sql).run(); + indexResults.push({ name: index.name, status: 'success', critical: !!index.critical }); + } catch (error) { + indexResults.push({ name: index.name, status: 'failed', critical: !!index.critical, error: error.message }); + } + } + + const criticalFailures = indexResults.filter((i) => i.critical && i.status === 'failed'); + if (criticalFailures.length > 0) { + const names = criticalFailures.map((i) => i.name).join(', '); + throw new Error( + `Critical index creation failed (${names}). ` + + 'Please remove duplicate UUID rows and run initSchema again.' + ); + } + + return { + columnsCreated: 10, + indexesCreated: indexResults.filter((r) => r.status === 'success').length, + indexesFailed: indexResults.filter((r) => r.status === 'failed').length, + columns: [], + indexes: indexResults + }; + } + + async dropSchema() { + await this._db.prepare('DROP TABLE IF EXISTS scheduled_messages').run(); + } + + async createTask(params) { + const now = this._now(); + const nextSendAt = this._iso(params.next_send_at); + const res = await this._db.prepare( + `INSERT INTO scheduled_messages + (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)` + ).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now).run(); + + const id = res.meta.last_row_id; + return this._db.prepare( + `SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?` + ).bind(id).first(); + } + + async getTaskByUuid(uuid, userId) { + return this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = ? AND user_id = ? AND status = 'pending' + LIMIT 1` + ).bind(uuid, userId).first(); + } + + async getTaskByUuidOnly(uuid) { + return this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = ? AND status = 'pending' + LIMIT 1` + ).bind(uuid).first(); + } + + async updateTaskById(taskId, updates) { + const sets = []; + const values = []; + for (const [key, value] of Object.entries(updates)) { + sets.push(`${key} = ?`); + values.push(key === 'next_send_at' ? this._iso(value) : value); + } + // Callers may pass updated_at explicitly (tests); otherwise stamp now. + if (!Object.prototype.hasOwnProperty.call(updates, 'updated_at')) { + sets.push('updated_at = ?'); + values.push(this._now()); + } + values.push(taskId); + + await this._db.prepare( + `UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = ?` + ).bind(...values).run(); + + return this._db.prepare('SELECT * FROM scheduled_messages WHERE id = ?').bind(taskId).first(); + } + + async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) { + const now = this._now(); + const sets = ['encrypted_payload = ?', 'updated_at = ?']; + const values = [encryptedPayload, now]; + if (extraFields) { + for (const [key, value] of Object.entries(extraFields)) { + sets.push(`${key} = ?`); + values.push(key === 'next_send_at' ? this._iso(value) : value); + } + } + values.push(uuid, userId); + + const res = await this._db.prepare( + `UPDATE scheduled_messages SET ${sets.join(', ')} + WHERE uuid = ? AND user_id = ? AND status = 'pending'` + ).bind(...values).run(); + + if (!res.meta.changes) return null; + return { uuid, updated_at: now }; + } + + async deleteTaskById(taskId) { + const res = await this._db.prepare('DELETE FROM scheduled_messages WHERE id = ?').bind(taskId).run(); + return res.meta.changes > 0; + } + + async deleteTaskByUuid(uuid, userId) { + const res = await this._db.prepare( + 'DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?' + ).bind(uuid, userId).run(); + return res.meta.changes > 0; + } + + async getPendingTasks(limit = 50) { + const res = await this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE status = 'pending' AND next_send_at <= ? + ORDER BY next_send_at ASC + LIMIT ?` + ).bind(this._now(), limit).all(); + return res.results || []; + } + + async listTasks(userId, opts = {}) { + const { status = 'all', limit = 20, offset = 0 } = opts; + const conditions = ['user_id = ?']; + const params = [userId]; + if (status !== 'all') { + conditions.push('status = ?'); + params.push(status); + } + const where = conditions.join(' AND '); + + const countRow = await this._db.prepare( + `SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}` + ).bind(...params).first(); + const total = Number(countRow.count) || 0; + + const res = await this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at + FROM scheduled_messages + WHERE ${where} + ORDER BY next_send_at ASC + LIMIT ? OFFSET ?` + ).bind(...params, limit, offset).all(); + + return { tasks: res.results || [], total }; + } + + async cleanupOldTasks(days = 7) { + const safeDays = Math.max(1, Math.floor(Number(days))); + const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1000).toISOString(); + const res = await this._db.prepare( + `DELETE FROM scheduled_messages + WHERE status IN ('sent', 'failed') AND updated_at < ?` + ).bind(cutoff).run(); + return res.meta.changes || 0; + } + + async getTaskStatus(uuid, userId) { + const row = await this._db.prepare( + 'SELECT status FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1' + ).bind(uuid, userId).first(); + return row ? row.status : null; + } +} + +/** + * Create a D1 adapter from a Cloudflare D1 binding (env.DB). + * @param {{ prepare: (sql: string) => any }} db + * @returns {import('./interface.js').DbAdapter} + */ +export function createD1Adapter(db) { + if (!db || typeof db.prepare !== 'function') { + throw new Error('[amsg-server] createD1Adapter requires a D1 database binding (env.DB)'); + } + return new D1Adapter(db); +} +``` + +- [ ] **Step 4: 导出 createD1Adapter** + +Modify `packages/rei-standard-amsg/server/src/server/index.js` — 在 `export { createAdapter } from './adapters/factory.js';` 那一行后面加: +```js +export { createD1Adapter } from './adapters/d1.js'; +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS(d1-adapter 全部 11 个测试通过,含时间归一化与 uuid 唯一冲突)。 + +- [ ] **Step 6: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/adapters/d1.js packages/rei-standard-amsg/server/src/server/index.js packages/rei-standard-amsg/server/test/d1-adapter.test.mjs +git commit -m "feat(amsg): D1 (SQLite) adapter 实现 DbAdapter 全部方法" +``` + +--- + +## Task 4: 可移植 constant-time 比较 + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/lib/constant-time.js` +- Test: `packages/rei-standard-amsg/server/test/constant-time.test.mjs` + +- [ ] **Step 1: 写失败测试** + +Create `packages/rei-standard-amsg/server/test/constant-time.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { constantTimeEqual } from '../src/server/lib/constant-time.js'; + +test('constantTimeEqual matches equal strings', async () => { + assert.equal(await constantTimeEqual('secret-token', 'secret-token'), true); +}); + +test('constantTimeEqual rejects different strings', async () => { + assert.equal(await constantTimeEqual('secret-token', 'wrong-token'), false); +}); + +test('constantTimeEqual rejects different lengths', async () => { + assert.equal(await constantTimeEqual('abc', 'abcd'), false); +}); + +test('constantTimeEqual handles empty / non-string safely', async () => { + assert.equal(await constantTimeEqual('', ''), true); + assert.equal(await constantTimeEqual('x', ''), false); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '.../constant-time.js'`。 + +- [ ] **Step 3: 写实现** + +Create `packages/rei-standard-amsg/server/src/server/lib/constant-time.js`: +```js +/** + * Portable constant-time string comparison. + * + * Runs identically on Node (tests) and Cloudflare Workers (prod). We avoid + * both node:crypto's timingSafeEqual (undefined on Workers historically) and + * crypto.subtle.timingSafeEqual (absent on Node). Instead we compare the + * HMAC-SHA256 of each input under a fresh random key (the "double HMAC" + * pattern): equal-length fixed digests, no early-out, length-independent. + * + * globalThis.crypto (Web Crypto) is available on Node >= 20 and on Workers. + */ +export async function constantTimeEqual(a, b) { + const enc = new TextEncoder(); + const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const key = await globalThis.crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const da = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(a)))); + const db = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(b)))); + + let diff = 0; + for (let i = 0; i < da.length; i++) { + diff |= da[i] ^ db[i]; + } + return diff === 0; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/lib/constant-time.js packages/rei-standard-amsg/server/test/constant-time.test.mjs +git commit -m "feat(amsg): 可移植 constant-time 比较(Node + Worker 通用)" +``` + +--- + +## Task 5: 单用户 context manager + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js` +- Test: `packages/rei-standard-amsg/server/test/single-user-context.test.mjs` + +- [ ] **Step 1: 写失败测试** + +Create `packages/rei-standard-amsg/server/test/single-user-context.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserContextManager } from '../src/server/tenant/single-user-context.js'; + +const fakeDb = { async initSchema() { return { indexesCreated: 5, indexesFailed: 0 }; } }; + +test('no serverToken → open, resolves fixed single-user context', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk' }); + const res = await mgr.resolveTenant({}); + assert.equal(res.ok, true); + assert.equal(res.context.tenantId, 'single'); + assert.equal(res.context.tokenType, 'tenant'); + assert.equal(res.context.masterKey, 'mk'); + assert.equal(res.context.db, fakeDb); +}); + +test('serverToken set → missing header rejected 401', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk', serverToken: 's3cret' }); + const res = await mgr.resolveTenant({}); + assert.equal(res.ok, false); + assert.equal(res.error.status, 401); +}); + +test('serverToken set → wrong header rejected, correct header accepted', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk', serverToken: 's3cret' }); + assert.equal((await mgr.resolveTenant({ 'X-Client-Token': 'nope' })).ok, false); + assert.equal((await mgr.resolveTenant({ 'x-client-token': 's3cret' })).ok, true); +}); + +test('initializeTenant only builds schema, issues no token', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk' }); + const res = await mgr.initializeTenant(); + assert.equal(res.tenantId, 'single'); + assert.ok(res.schema); + assert.equal(res.tenantToken, undefined); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '.../single-user-context.js'`。 + +- [ ] **Step 3: 写实现** + +Create `packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js`: +```js +/** + * Single-user tenant context manager. + * + * Interface-compatible with createTenantContextManager (resolveTenant / + * initializeTenant), so the existing business handlers reuse it unchanged. + * No blob registry, no tenant token — db and masterKey come from the caller + * (the Worker resolves them from env + D1 binding per request). + */ + +import { constantTimeEqual } from '../lib/constant-time.js'; +import { getHeader } from '../lib/request.js'; + +export function createSingleUserContextManager({ db, masterKey, serverToken } = {}) { + if (!db) throw new Error('[amsg-server single-user] db (adapter) is required'); + if (!masterKey) throw new Error('[amsg-server single-user] masterKey is required'); + const token = String(serverToken || '').trim(); + + async function isAuthorized(headers) { + if (!token) return true; // open when no shared secret configured + const provided = getHeader(headers, 'x-client-token'); + if (!provided) return false; + return constantTimeEqual(provided, token); + } + + async function resolveTenant(headers) { + if (!(await isAuthorized(headers))) { + return { + ok: false, + error: { + status: 401, + body: { success: false, error: { code: 'INVALID_CLIENT_TOKEN', message: '共享密钥无效或缺失' } } + } + }; + } + return { + ok: true, + context: { tenantId: 'single', tokenType: 'tenant', db, masterKey } + }; + } + + async function initializeTenant() { + const schema = await db.initSchema(); + return { tenantId: 'single', schema }; + } + + return { resolveTenant, initializeTenant }; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js packages/rei-standard-amsg/server/test/single-user-context.test.mjs +git commit -m "feat(amsg): 单用户 context manager(接口同构,复用现有 handler)" +``` + +--- + +## Task 6: 单用户建表路由 + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js` +- Test: `packages/rei-standard-amsg/server/test/single-user-init.test.mjs` + +- [ ] **Step 1: 写失败测试** + +Create `packages/rei-standard-amsg/server/test/single-user-init.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserInitHandler } from '../src/server/handlers/single-user-init.js'; +import { createSingleUserContextManager } from '../src/server/tenant/single-user-context.js'; + +function makeCtx(serverToken) { + let initCalled = 0; + const db = { async initSchema() { initCalled++; return { indexesCreated: 5, indexesFailed: 0 }; } }; + const tenantManager = createSingleUserContextManager({ db, masterKey: 'mk', serverToken }); + return { ctx: { tenantManager }, calls: () => initCalled }; +} + +test('init builds schema and returns 200', async () => { + const { ctx, calls } = makeCtx(); + const handler = createSingleUserInitHandler(ctx); + const res = await handler.POST({}, undefined); + assert.equal(res.status, 200); + assert.equal(res.body.success, true); + assert.equal(res.body.data.tenantId, 'single'); + assert.equal(calls(), 1); +}); + +test('init rejects wrong shared secret with 401 and does not build schema', async () => { + const { ctx, calls } = makeCtx('s3cret'); + const handler = createSingleUserInitHandler(ctx); + const res = await handler.POST({ 'x-client-token': 'wrong' }, undefined); + assert.equal(res.status, 401); + assert.equal(calls(), 0); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '.../single-user-init.js'`。 + +- [ ] **Step 3: 写实现** + +Create `packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js`: +```js +/** + * Handler: single-user-init + * + * Idempotent "just create the tables" endpoint for single-user deployments + * (the degenerate form of init-tenant). Reuses resolveTenant purely to enforce + * the optional shared secret, then runs initSchema. Issues no token. + * + * @param {Object} ctx - Single-user server context (ctx.tenantManager). + * @returns {{ POST: function }} + */ +export function createSingleUserInitHandler(ctx) { + async function POST(headers /* , body */) { + const auth = await ctx.tenantManager.resolveTenant(headers || {}); + if (!auth.ok) { + return auth.error; + } + try { + const result = await ctx.tenantManager.initializeTenant(); + return { + status: 200, + body: { success: true, data: { tenantId: result.tenantId, schema: result.schema } } + }; + } catch (error) { + return { + status: 500, + body: { success: false, error: { code: 'INIT_FAILED', message: error.message } } + }; + } + } + + return { POST }; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js packages/rei-standard-amsg/server/test/single-user-init.test.mjs +git commit -m "feat(amsg): 单用户幂等建表路由" +``` + +--- + +## Task 7: createSingleUserServer 组装 + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/single-user.js` +- Modify: `packages/rei-standard-amsg/server/src/server/index.js`(导出 `createSingleUserServer`) +- Test: `packages/rei-standard-amsg/server/test/single-user-server.test.mjs` + +- [ ] **Step 1: 写失败测试(真 D1 + 已知 masterKey,跑 schedule→list→cancel 全链路)** + +Create `packages/rei-standard-amsg/server/test/single-user-server.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserServer } from '../src/server/single-user.js'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; +import { deriveUserEncryptionKey, encryptPayload, encryptForStorage, decryptFromStorage } from '../src/server/lib/encryption.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const MASTER_KEY = 'a'.repeat(64); + +async function makeServer() { + const db = createD1Adapter(createTestD1()); + await db.initSchema(); + const server = createSingleUserServer({ db, masterKey: MASTER_KEY }); + return server; +} + +function encBody(obj) { + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + return JSON.stringify(encryptPayload(obj, userKey)); +} + +test('createSingleUserServer exposes the reused handlers + init', async () => { + const server = await makeServer(); + for (const k of ['init', 'getUserKey', 'scheduleMessage', 'updateMessage', 'cancelMessage', 'messages']) { + assert.ok(server.handlers[k], `missing handler ${k}`); + } + assert.equal(server.handlers.sendNotifications, undefined); // NOT exposed in single-user +}); + +test('schedule → list → cancel round-trips through single-user server over D1', async () => { + const server = await makeServer(); + const headers = { + 'X-User-Id': USER, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }; + + const payload = { + contactName: 'Rei', + messageType: 'fixed', + userMessage: 'hi', + firstSendTime: '2999-01-01T00:00:00.000Z', + recurrenceType: 'none', + pushSubscription: { endpoint: 'https://example.com/x', keys: { p256dh: 'k', auth: 'a' } } + }; + const created = await server.handlers.scheduleMessage.POST(headers, encBody(payload)); + assert.equal(created.status, 201); + const uuid = created.body.data.uuid; + + const listed = await server.handlers.messages.GET(`/messages?status=all`, { 'X-User-Id': USER }); + assert.equal(listed.status, 200); + + const cancelled = await server.handlers.cancelMessage.DELETE(`/cancel-message?id=${uuid}`, { 'X-User-Id': USER }); + assert.equal(cancelled.status, 200); +}); + +test('masterKey wiring: storage encrypt/decrypt round-trips', () => { + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const round = JSON.parse(decryptFromStorage(encryptForStorage(JSON.stringify({ a: 1 }), userKey), userKey)); + assert.equal(round.a, 1); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '.../single-user.js'`。 + +- [ ] **Step 3: 写实现** + +Create `packages/rei-standard-amsg/server/src/server/single-user.js`: +```js +/** + * Single-user ReiStandard server assembly. + * + * Same shape as createReiServer ({ handlers }), but wired for a single user: + * - tenant context comes from createSingleUserContextManager (db + masterKey + * supplied by the caller; no blob registry, no tenant token) + * - only the 5 business handlers + an idempotent init route are exposed + * - send-notifications is NOT exposed over HTTP (cron runs via CF scheduled()) + * + * @param {Object} config + * @param {import('./adapters/interface.js').DbAdapter} config.db + * @param {string} config.masterKey + * @param {string} [config.serverToken] - optional shared secret (X-Client-Token) + * @param {{ email?: string, publicKey?: string, privateKey?: string }} [config.vapid] + * @param {{ sendNotification: function }} [config.webpush] - web-push-compatible sender + * @returns {{ handlers: Object, ctx: Object }} + */ + +import { createSingleUserContextManager } from './tenant/single-user-context.js'; +import { createSingleUserInitHandler } from './handlers/single-user-init.js'; +import { createGetUserKeyHandler } from './handlers/get-user-key.js'; +import { createScheduleMessageHandler } from './handlers/schedule-message.js'; +import { createUpdateMessageHandler } from './handlers/update-message.js'; +import { createCancelMessageHandler } from './handlers/cancel-message.js'; +import { createMessagesHandler } from './handlers/messages.js'; + +export function createSingleUserServer(config) { + if (!config || !config.db) throw new Error('[amsg-server single-user] config.db is required'); + if (!config.masterKey) throw new Error('[amsg-server single-user] config.masterKey is required'); + + const vapid = config.vapid || {}; + const tenantManager = createSingleUserContextManager({ + db: config.db, + masterKey: config.masterKey, + serverToken: config.serverToken + }); + + const ctx = { + vapid: { + email: vapid.email || '', + publicKey: vapid.publicKey || '', + privateKey: vapid.privateKey || '' + }, + webpush: config.webpush || null, + tenantManager + }; + + return { + ctx, + handlers: { + init: createSingleUserInitHandler(ctx), + getUserKey: createGetUserKeyHandler(ctx), + scheduleMessage: createScheduleMessageHandler(ctx), + updateMessage: createUpdateMessageHandler(ctx), + cancelMessage: createCancelMessageHandler(ctx), + messages: createMessagesHandler(ctx) + } + }; +} +``` + +- [ ] **Step 4: 导出** + +Modify `packages/rei-standard-amsg/server/src/server/index.js` — 在 `createD1Adapter` 导出行后面加: +```js +export { createSingleUserServer } from './single-user.js'; +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS(含 schedule→list→cancel 全链路,证明 5 handler 在单用户 context + D1 下零改动可跑)。 + +- [ ] **Step 6: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/single-user.js packages/rei-standard-amsg/server/src/server/index.js packages/rei-standard-amsg/server/test/single-user-server.test.mjs +git commit -m "feat(amsg): createSingleUserServer 组装单用户 handlers" +``` + +--- + +## Task 8: 抽出 runScheduledTick + 重构 send-notifications + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/lib/run-tick.js` +- Modify: `packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js` +- Modify: `packages/rei-standard-amsg/server/src/server/index.js`(导出 `runScheduledTick`) +- Test: `packages/rei-standard-amsg/server/test/run-tick.test.mjs` + +- [ ] **Step 1: 写回归测试(锁住抽取前后行为一致)** + +Create `packages/rei-standard-amsg/server/test/run-tick.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runScheduledTick } from '../src/server/lib/run-tick.js'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; +import { deriveUserEncryptionKey, encryptForStorage } from '../src/server/lib/encryption.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const MASTER_KEY = 'a'.repeat(64); +const VAPID = { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }; + +async function seed(adapter, { uuid, recurrenceType, nextSendAt }) { + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = encryptForStorage(JSON.stringify({ + contactName: 'Rei', + messageType: 'fixed', + userMessage: 'hi', + recurrenceType, + pushSubscription: { endpoint: 'https://example.com/x', keys: { p256dh: 'k', auth: 'a' } } + }), userKey); + await adapter.createTask({ user_id: USER, uuid, encrypted_payload: enc, next_send_at: nextSendAt, message_type: 'fixed' }); +} + +function fakeWebpush() { + const sent = []; + return { sent, async sendNotification(sub, payload) { sent.push(payload); } }; +} + +test('one-off task: delivered then deleted', async () => { + const adapter = createD1Adapter(createTestD1()); + await adapter.initSchema(); + await seed(adapter, { uuid: 'once', recurrenceType: 'none', nextSendAt: '2020-01-01T00:00:00.000Z' }); + + const webpush = fakeWebpush(); + const res = await runScheduledTick({ db: adapter, masterKey: MASTER_KEY, vapid: VAPID, webpush }); + + assert.equal(res.successCount, 1); + assert.equal(res.details.deletedOnceOffTasks, 1); + assert.ok(webpush.sent.length >= 1); + assert.equal((await adapter.getPendingTasks(50)).length, 0); +}); + +test('daily task: delivered then rescheduled +24h, retry reset', async () => { + const adapter = createD1Adapter(createTestD1()); + await adapter.initSchema(); + await seed(adapter, { uuid: 'daily', recurrenceType: 'daily', nextSendAt: '2020-01-01T00:00:00.000Z' }); + + const webpush = fakeWebpush(); + const res = await runScheduledTick({ db: adapter, masterKey: MASTER_KEY, vapid: VAPID, webpush }); + + assert.equal(res.successCount, 1); + assert.equal(res.details.updatedRecurringTasks, 1); + const row = await adapter.getTaskByUuidOnly('daily'); + assert.equal(row.next_send_at, '2020-01-02T00:00:00.000Z'); + assert.equal(row.retry_count, 0); +}); + +test('delivery failure increments retry_count', async () => { + const adapter = createD1Adapter(createTestD1()); + await adapter.initSchema(); + await seed(adapter, { uuid: 'fail', recurrenceType: 'none', nextSendAt: '2020-01-01T00:00:00.000Z' }); + + const webpush = { async sendNotification() { throw new Error('push failed'); } }; + const res = await runScheduledTick({ db: adapter, masterKey: MASTER_KEY, vapid: VAPID, webpush }); + + assert.equal(res.failedCount, 1); + const row = await adapter.getTaskByUuidOnly('fail'); + assert.equal(row.retry_count, 1); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '.../run-tick.js'`。 + +- [ ] **Step 3: 写 runScheduledTick(把 send-notifications 的批处理内核原样搬进来)** + +Create `packages/rei-standard-amsg/server/src/server/lib/run-tick.js`: +```js +/** + * Scheduled tick core: fetch due tasks, deliver, reschedule/retry, cleanup. + * Extracted verbatim from the send-notifications handler so both the HTTP + * handler (multi-tenant) and the CF scheduled() path (single-user) share it. + * + * @param {Object} ctx - { db, masterKey, vapid, webpush } + * @returns {Promise} summary { totalTasks, successCount, failedCount, processedAt, executionTime, details } + */ + +import { deriveUserEncryptionKey, decryptFromStorage } from './encryption.js'; +import { processSingleMessage } from './message-processor.js'; + +export async function runScheduledTick(ctx) { + const db = ctx.db; + const masterKey = ctx.masterKey; + + const startTime = Date.now(); + const tasks = await db.getPendingTasks(50); + + const MAX_CONCURRENT = 8; + const results = { + totalTasks: tasks.length, + successCount: 0, + failedCount: 0, + deletedOnceOffTasks: 0, + updatedRecurringTasks: 0, + failedTasks: [] + }; + + async function handleDeliveryFailure(task, reason) { + results.failedCount++; + try { + if (task.retry_count >= 3) { + await db.updateTaskById(task.id, { status: 'failed' }); + results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' }); + } else { + const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000); + await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 }); + results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() }); + } + } catch (updateError) { + results.failedTasks.push({ taskId: task.id, reason, status: 'retry_update_failed', updateError: updateError.message }); + } + } + + async function handlePostSendPersistenceFailure(task, reason) { + results.failedCount++; + let markedSent = false; + try { + await db.updateTaskById(task.id, { status: 'sent', retry_count: 0 }); + markedSent = true; + } catch (_markSentError) { + markedSent = false; + } + results.failedTasks.push({ + taskId: task.id, + reason, + status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed', + messageDelivered: true + }); + } + + async function processTask(task) { + let sendResult; + try { + sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey); + } catch (error) { + await handleDeliveryFailure(task, error.message || '消息发送失败'); + return; + } + + if (!sendResult.success) { + await handleDeliveryFailure(task, sendResult.error || '消息发送失败'); + return; + } + + try { + const userKey = deriveUserEncryptionKey(task.user_id, masterKey); + const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + + if (decryptedPayload.recurrenceType === 'none') { + await db.deleteTaskById(task.id); + results.deletedOnceOffTasks++; + } else { + let nextSendAt; + const currentSendAt = new Date(task.next_send_at); + if (decryptedPayload.recurrenceType === 'daily') { + nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000); + } else if (decryptedPayload.recurrenceType === 'weekly') { + nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000); + } + await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 }); + results.updatedRecurringTasks++; + } + + results.successCount++; + } catch (error) { + await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败'); + } + } + + const taskQueue = [...tasks]; + const processing = []; + + while (taskQueue.length > 0 || processing.length > 0) { + while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) { + const task = taskQueue.shift(); + const promise = processTask(task); + processing.push(promise); + promise.finally(() => { + const index = processing.indexOf(promise); + if (index > -1) processing.splice(index, 1); + }); + } + if (processing.length > 0) { + await Promise.race(processing); + } + } + + await db.cleanupOldTasks(7); + + const executionTime = Date.now() - startTime; + + return { + totalTasks: results.totalTasks, + successCount: results.successCount, + failedCount: results.failedCount, + processedAt: new Date().toISOString(), + executionTime, + details: { + deletedOnceOffTasks: results.deletedOnceOffTasks, + updatedRecurringTasks: results.updatedRecurringTasks, + failedTasks: results.failedTasks + } + }; +} +``` + +- [ ] **Step 4: 重构 send-notifications handler 调用它** + +Modify `packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js` — 用下面整段替换 `import` 之后到 `return { POST };` 之间的实现(保留顶部 doc 注释、`export function` 签名、`resolveTenant` 与 VAPID 校验,把批处理那一大坨换成 `runScheduledTick`): +```js +import { runScheduledTick } from '../lib/run-tick.js'; + +export function createSendNotificationsHandler(ctx) { + async function POST(urlOrHeaders, maybeHeaders) { + const url = typeof urlOrHeaders === 'string' ? urlOrHeaders : ''; + const headers = maybeHeaders || (typeof urlOrHeaders === 'object' ? urlOrHeaders : {}); + + const tenantResult = await ctx.tenantManager.resolveTenant(headers, { allowCronToken: true, url }); + if (!tenantResult.ok) { + return tenantResult.error; + } + + const { db, masterKey } = tenantResult.context; + + if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) { + return { + status: 500, + body: { + success: false, + error: { + code: 'VAPID_CONFIG_ERROR', + message: 'VAPID 配置缺失,无法发送推送通知', + details: { + missingKeys: [ + !ctx.vapid.email && 'VAPID_EMAIL', + !ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY', + !ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY' + ].filter(Boolean) + } + } + } + }; + } + + const data = await runScheduledTick({ ...ctx, db, masterKey }); + return { status: 200, body: { success: true, data } }; + } + + return { POST }; +} +``` +> 删掉旧的 `import { deriveUserEncryptionKey }...`、`import { decryptFromStorage }...`、`import { processSingleMessage }...`(现在都在 run-tick.js 里)。 + +- [ ] **Step 5: 导出 runScheduledTick** + +Modify `packages/rei-standard-amsg/server/src/server/index.js` — 在 `createSingleUserServer` 导出行后面加: +```js +export { runScheduledTick } from './lib/run-tick.js'; +``` + +- [ ] **Step 6: 跑测试确认通过(含现有 sdk / message-processor 测试不回归)** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS,run-tick 3 个测试 + 原有 `sdk.test.mjs` / `message-processor.test.mjs` 全绿。 + +- [ ] **Step 7: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/lib/run-tick.js packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js packages/rei-standard-amsg/server/src/server/index.js packages/rei-standard-amsg/server/test/run-tick.test.mjs +git commit -m "refactor(amsg): 抽出 runScheduledTick,HTTP handler 与 CF cron 共用" +``` + +--- + +## Task 9: Web Crypto Web Push shim(移植 instant) + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js`(复制自 instant) +- Create: `packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js`(复制自 instant + 加包装) +- Modify: `packages/rei-standard-amsg/server/src/server/index.js`(导出 `createWebCryptoWebPush`) +- Test: `packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs` + +- [ ] **Step 1: 复制 instant 的两个文件** + +Run(在仓库根目录): +```bash +cp packages/rei-standard-amsg/instant/src/utils.js packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js +cp packages/rei-standard-amsg/instant/src/webpush.js packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js +``` +Expected: 两个文件出现在 server/src/server/lib/。 + +- [ ] **Step 2: 改导入路径** + +Modify `packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js` — 把顶部 +```js +import { ... } from './utils.js'; +``` +改成 +```js +import { ... } from './webcrypto-utils.js'; +``` +(`...` 保留原有的 `utf8, toUint8, concatBytes, bytesToBase64Url, base64UrlToBytes, jsonToBase64Url, hmacSha256, randomBytes` 那一串,只改文件名。) +> 注:`webpush-webcrypto.js` 顶部还 `import { normalizeVapidSubject } from '@rei-standard/amsg-shared'` —— server 的 package.json 已依赖 amsg-shared,无需改。若 `webcrypto-utils.js` 内部再 import 别的相对文件,一并把路径核对好(instant/src/utils.js 目前零内部相对依赖)。 + +- [ ] **Step 3: 追加 createWebCryptoWebPush 包装(web-push 兼容接口)** + +Edit `packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js` — 在文件末尾追加: +```js + +/** + * web-push-compatible sender backed by the Web Crypto implementation above. + * message-processor calls `ctx.webpush.sendNotification(subscription, payloadString)`, + * so we only need that one method. VAPID keys are baked in at construction. + * + * @param {{ email: string, publicKey: string, privateKey: string }} vapid + * @returns {{ sendNotification: (subscription: Object, payload: string) => Promise }} + */ +export function createWebCryptoWebPush(vapid) { + const subject = vapid.email; + const publicKey = vapid.publicKey; + const privateKey = vapid.privateKey; + return { + async sendNotification(subscription, payload) { + return sendWebPush({ + subscription, + payload, + vapid: { subject, publicKey, privateKey }, + fetch: globalThis.fetch + }); + } + }; +} +``` +> 前置核对(读 `sendWebPush` 签名后确认):`sendWebPush({ subscription, payload, vapid, ttl, fetch })` 里 `vapid` 用的字段名是 `subject / publicKey / privateKey`。若实际字段名不同(例如 `email`),把上面对象里的键改成一致的。`payload` 传字符串即可(`sendWebPush` 内部会转字节)。 + +- [ ] **Step 4: 写测试(真实生成一个订阅,跑通加密 + VAPID JWT,mock fetch)** + +Create `packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createWebCryptoWebPush, verifyVapidJwt } from '../src/server/lib/webpush-webcrypto.js'; + +// A real, valid P-256 VAPID keypair + a real subscriber key are needed for the +// encryption path to run. Generate them at test time via Web Crypto. +async function genVapid() { + const kp = await globalThis.crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const pub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); // 65-byte uncompressed + const jwk = await globalThis.crypto.subtle.exportKey('jwk', kp.privateKey); + const b64url = (u8) => Buffer.from(u8).toString('base64url'); + return { publicKey: b64url(pub), privateKeyJwk: jwk }; +} + +async function genSubscription() { + const kp = await globalThis.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); + const auth = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const b64url = (u8) => Buffer.from(u8).toString('base64url'); + return { endpoint: 'https://push.example.com/sub/abc', keys: { p256dh: b64url(raw), auth: b64url(auth) } }; +} + +test('sendNotification encrypts + attaches VAPID and posts to the endpoint', async () => { + const { publicKey, privateKeyJwk } = await genVapid(); + // sendWebPush expects the VAPID private key in the same encoding instant uses. + // Verify against instant/src/webpush.js buildVapidJwt for the exact expected + // privateKey format (raw d value base64url vs JWK). Adjust `privateKey` below + // to match; this test asserts the wire request shape, not a live push. + const privateKey = Buffer.from(privateKeyJwk.d, 'base64url').toString('base64url'); + + const sub = await genSubscription(); + let captured = null; + const fetchImpl = async (url, init) => { + captured = { url, init }; + return new Response(null, { status: 201 }); + }; + + const sender = createWebCryptoWebPush({ email: 'mailto:x@example.com', publicKey, privateKey }); + // Inject our fetch by temporarily overriding globalThis.fetch + const original = globalThis.fetch; + globalThis.fetch = fetchImpl; + try { + await sender.sendNotification(sub, JSON.stringify({ messageKind: 'content', message: 'hello' })); + } finally { + globalThis.fetch = original; + } + + assert.ok(captured, 'fetch was called'); + assert.equal(captured.url, sub.endpoint); + assert.equal(captured.init.headers['Content-Encoding'], 'aes128gcm'); + const authz = captured.init.headers['Authorization'] || captured.init.headers['authorization']; + assert.match(authz, /^vapid t=/); +}); +``` +> 说明:这个测试对「VAPID 私钥编码」较敏感——`buildVapidJwt` 期望的 `privateKey` 具体格式(raw `d` 的 base64url,还是别的)要对着 instant `webpush.js` 里 `buildVapidJwt` 的 `importKey` 调用核对,Step 4 里 `privateKey` 那行按实际改。若在 CI 上编码对齐困难,退一步:把断言收敛为「`sendNotification` 调到了 fetch、URL 是 endpoint、`Content-Encoding: aes128gcm`」,VAPID 头单独用 `verifyVapidJwt` 在另一条能造出匹配密钥的测试里验。 + +- [ ] **Step 5: 导出 createWebCryptoWebPush** + +Modify `packages/rei-standard-amsg/server/src/server/index.js` — 在 `runScheduledTick` 导出行后面加: +```js +export { createWebCryptoWebPush } from './lib/webpush-webcrypto.js'; +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS。 + +- [ ] **Step 7: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js packages/rei-standard-amsg/server/src/server/index.js packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs +git commit -m "feat(amsg): 移植 instant 的 Web Crypto Web Push,供 CF Worker 用" +``` + +--- + +## Task 10: CF Worker 工厂(fetch 路由 + scheduled) + +**Files:** +- Create: `packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js` +- Modify: `packages/rei-standard-amsg/server/src/server/index.js`(导出 `createSingleUserCloudflareWorker`) +- Test: `packages/rei-standard-amsg/server/test/single-user-worker.test.mjs` + +- [ ] **Step 1: 写失败测试(用全局 Request/Response + 测试 D1 跑 fetch 路由 + scheduled)** + +Create `packages/rei-standard-amsg/server/test/single-user-worker.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserCloudflareWorker } from '../src/server/cloudflare/single-user-worker.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { deriveUserEncryptionKey, encryptPayload, encryptForStorage } from '../src/server/lib/encryption.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const MASTER_KEY = 'a'.repeat(64); + +function makeWorker(d1) { + return createSingleUserCloudflareWorker((env) => ({ + db: createD1Adapter(env.DB), + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} } + })); +} + +test('fetch routes init + schedule + messages, unknown → 404', async () => { + const d1 = createTestD1(); + const worker = makeWorker(d1); + const env = { DB: d1 }; + + // build tables via the init route + const initRes = await worker.fetch(new Request('https://w.dev/init-tenant', { method: 'POST' }), env); + assert.equal(initRes.status, 200); + + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const body = JSON.stringify(encryptPayload({ + contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', + firstSendTime: '2999-01-01T00:00:00.000Z', recurrenceType: 'none', + pushSubscription: { endpoint: 'https://e.com/x', keys: { p256dh: 'k', auth: 'a' } } + }, userKey)); + + const schedRes = await worker.fetch(new Request('https://w.dev/schedule-message', { + method: 'POST', + headers: { 'X-User-Id': USER, 'X-Payload-Encrypted': 'true', 'X-Encryption-Version': '1' }, + body + }), env); + assert.equal(schedRes.status, 201); + + const listRes = await worker.fetch(new Request('https://w.dev/messages?status=all', { + method: 'GET', headers: { 'X-User-Id': USER } + }), env); + assert.equal(listRes.status, 200); + + const notFound = await worker.fetch(new Request('https://w.dev/nope', { method: 'GET' }), env); + assert.equal(notFound.status, 404); +}); + +test('scheduled() runs the tick over env.DB', async () => { + const d1 = createTestD1(); + const adapter = createD1Adapter(d1); + await adapter.initSchema(); + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = encryptForStorage(JSON.stringify({ + contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', recurrenceType: 'none', + pushSubscription: { endpoint: 'https://e.com/x', keys: { p256dh: 'k', auth: 'a' } } + }), userKey); + await adapter.createTask({ user_id: USER, uuid: 'due', encrypted_payload: enc, next_send_at: '2020-01-01T00:00:00.000Z', message_type: 'fixed' }); + + let sent = 0; + const worker = createSingleUserCloudflareWorker(() => ({ + db: adapter, + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() { sent++; } } + })); + + await worker.scheduled({}, { DB: d1 }); + assert.ok(sent >= 1); + assert.equal((await adapter.getPendingTasks(50)).length, 0); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: FAIL,`Cannot find module '.../single-user-worker.js'`。 + +- [ ] **Step 3: 写实现** + +Create `packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js`: +```js +/** + * Cloudflare Worker factory for the single-user amsg-server. + * + * Mirrors instant's createCloudflareWorker: you pass a buildConfig(env) that + * returns the single-user config; we build the server per request (cheap) and + * dispatch. Returns { fetch, scheduled } for `export default`. + * + * Routes (server endpoints only — NO /send-notifications; cron is scheduled()): + * POST /init-tenant → build tables (idempotent) + * GET /get-user-key → derive user key + * POST /schedule-message → create task + * GET /messages → list + * PUT /update-message → patch + * DELETE /cancel-message → delete + */ + +import { createSingleUserServer } from '../single-user.js'; +import { createD1Adapter } from '../adapters/d1.js'; +import { runScheduledTick } from '../lib/run-tick.js'; + +function headersToObject(h) { + const out = {}; + for (const [k, v] of h) out[k] = v; + return out; +} + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json; charset=utf-8' } + }); +} + +export function createSingleUserCloudflareWorker(buildConfig) { + async function resolveConfig(env) { + const cfg = await buildConfig(env); + if (!cfg.db) cfg.db = createD1Adapter(env.DB); + return cfg; + } + + async function fetch(request, env /* , ctx */) { + const cfg = await resolveConfig(env); + const server = createSingleUserServer(cfg); + + const url = request.url; + const { pathname } = new URL(url); + const method = request.method.toUpperCase(); + const headers = headersToObject(request.headers); + + let result; + if (method === 'POST' && pathname.endsWith('/init-tenant')) { + result = await server.handlers.init.POST(headers, await request.text()); + } else if (method === 'GET' && pathname.endsWith('/get-user-key')) { + result = await server.handlers.getUserKey.GET(url, headers); + } else if (method === 'POST' && pathname.endsWith('/schedule-message')) { + result = await server.handlers.scheduleMessage.POST(headers, await request.text()); + } else if (method === 'GET' && pathname.endsWith('/messages')) { + result = await server.handlers.messages.GET(url, headers); + } else if (method === 'PUT' && pathname.endsWith('/update-message')) { + result = await server.handlers.updateMessage.PUT(url, headers, await request.text()); + } else if (method === 'DELETE' && pathname.endsWith('/cancel-message')) { + result = await server.handlers.cancelMessage.DELETE(url, headers); + } else { + result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } }; + } + + return jsonResponse(result.status, result.body); + } + + async function scheduled(event, env /* , ctx */) { + const cfg = await resolveConfig(env); + const vapid = cfg.vapid || {}; + if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) { + console.error('[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick'); + return; + } + await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush }); + } + + return { fetch, scheduled }; +} +``` + +- [ ] **Step 4: 导出** + +Modify `packages/rei-standard-amsg/server/src/server/index.js` — 在 `createWebCryptoWebPush` 导出行后面加: +```js +export { createSingleUserCloudflareWorker } from './cloudflare/single-user-worker.js'; +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-server` +Expected: PASS(fetch 路由 + scheduled 都过)。 + +- [ ] **Step 6: 提交** + +```bash +git add packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js packages/rei-standard-amsg/server/src/server/index.js packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +git commit -m "feat(amsg): CF Worker 工厂(fetch 路由 + scheduled cron)" +``` + +--- + +## Task 11: 示例 Worker + wrangler + schema.sql + README + +**Files:** +- Create: `packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js` +- Create: `packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml` +- Create: `packages/rei-standard-amsg/server/examples/cloudflare-single-user/schema.sql` +- Create: `packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md` + +(本任务是配置/文档产物,没有单测;验证靠 Step 5 的构建 + 可选 `wrangler dev` 冒烟。) + +- [ ] **Step 1: worker.js** + +Create `packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js`: +```js +/** + * Single-user amsg-server on Cloudflare Workers. + * Schedules live in D1; cron runs via CF Cron Trigger (see wrangler.toml). + */ +import { + createSingleUserCloudflareWorker, + createWebCryptoWebPush +} from '@rei-standard/amsg-server'; + +export default createSingleUserCloudflareWorker((env) => ({ + // db defaults to createD1Adapter(env.DB) + masterKey: env.AMSG_MASTER_KEY, + serverToken: env.AMSG_SERVER_TOKEN, // optional shared secret; omit to leave endpoints open + vapid: { + email: env.VAPID_EMAIL, + publicKey: env.VAPID_PUBLIC_KEY, + privateKey: env.VAPID_PRIVATE_KEY + }, + webpush: createWebCryptoWebPush({ + email: env.VAPID_EMAIL, + publicKey: env.VAPID_PUBLIC_KEY, + privateKey: env.VAPID_PRIVATE_KEY + }) +})); +``` + +- [ ] **Step 2: wrangler.toml** + +Create `packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml`: +```toml +name = "amsg-single-user" +main = "worker.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[[d1_databases]] +binding = "DB" +database_name = "amsg" +database_id = "<你的 D1 database id>" + +# CF Cron Trigger(5 段:分 时 日 月 周,UTC)。每分钟跑一次定时投递: +[triggers] +crons = ["* * * * *"] +``` + +- [ ] **Step 3: schema.sql** + +Create `packages/rei-standard-amsg/server/examples/cloudflare-single-user/schema.sql`: +```sql +-- 单用户 amsg-server 的 D1 建表脚本。 +-- 用法:wrangler d1 execute amsg --file schema.sql +-- 也可以部署后 POST /init-tenant 让服务端自动建(幂等)。 + +CREATE TABLE IF NOT EXISTS scheduled_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + uuid TEXT, + encrypted_payload TEXT NOT NULL, + message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')), + next_send_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized + ON scheduled_messages (status, next_send_at, id, retry_count) + WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_cleanup_completed + ON scheduled_messages (status, updated_at) + WHERE status IN ('sent', 'failed'); +CREATE INDEX IF NOT EXISTS idx_failed_retry + ON scheduled_messages (status, retry_count, next_send_at) + WHERE status = 'failed' AND retry_count < 3; +CREATE INDEX IF NOT EXISTS idx_user_id + ON scheduled_messages (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid + ON scheduled_messages (uuid) + WHERE uuid IS NOT NULL; +``` + +- [ ] **Step 4: README.md** + +Create `packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md`: +```markdown +# 单用户 amsg-server · Cloudflare Worker + +定时消息存 D1,定时投递用 CF Cron Trigger。适合只有自己一个人用、想全程跑在 Cloudflare 上的场景。 + +## 跑通步骤 + +1. 建 D1 数据库,把返回的 id 填进 `wrangler.toml` 的 `database_id`: + ```bash + wrangler d1 create amsg + ``` +2. 建表(二选一): + - 命令行:`wrangler d1 execute amsg --file schema.sql` + - 或部署后调一次 `POST /init-tenant`(幂等;配了 serverToken 要带 `X-Client-Token`) +3. 配 secrets: + ```bash + wrangler secret put AMSG_MASTER_KEY # 随机 32 字节 hex,见下 + wrangler secret put VAPID_EMAIL # 例如 mailto:you@example.com + wrangler secret put VAPID_PUBLIC_KEY + wrangler secret put VAPID_PRIVATE_KEY + wrangler secret put AMSG_SERVER_TOKEN # 可选:共享密钥,配了才校验 X-Client-Token + ``` + 生成 `AMSG_MASTER_KEY`: + ```bash + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + ``` +4. 部署:`wrangler deploy` + +## 端点 + +`/get-user-key`、`/schedule-message`、`/messages`、`/update-message`、`/cancel-message`、`/init-tenant`。 +**没有 HTTP `/send-notifications`**——定时投递由 CF Cron Trigger 直接触发 `scheduled()`。 + +## 客户端 + +`@rei-standard/amsg-client` 配 `baseUrl` 指向本 Worker;若设了 `AMSG_SERVER_TOKEN`,client 也要配同样的 `serverToken`。 +``` + +- [ ] **Step 5: 构建校验(示例引用的导出都存在、能打进包)** + +Run: `npm run build --workspace @rei-standard/amsg-server` +Expected: 构建成功,`dist/` 里 `createSingleUserCloudflareWorker` / `createWebCryptoWebPush` 都在导出中(构建不报缺失导出)。 +(可选冒烟:在示例目录 `wrangler dev`,`curl -X POST localhost:8787/init-tenant` 返回 200。) + +- [ ] **Step 6: 提交** + +```bash +git add packages/rei-standard-amsg/server/examples/cloudflare-single-user/ +git commit -m "docs(amsg): 单用户 CF Worker 示例(worker + wrangler + schema + README)" +``` + +--- + +## Task 12: client 单用户档(serverToken) + +**Files:** +- Modify: `packages/rei-standard-amsg/client/src/index.js` +- Modify: `packages/rei-standard-amsg/client/package.json`(加 test 脚本,若无) +- Test: `packages/rei-standard-amsg/client/test/server-token.test.mjs` + +- [ ] **Step 1: 写失败测试** + +Create `packages/rei-standard-amsg/client/test/server-token.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReiClient } from '../src/index.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; + +function stubFetch(captured) { + return async (url, init) => { + captured.push({ url: String(url), headers: (init && init.headers) || {} }); + return new Response(JSON.stringify({ success: true, data: {} }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + }; +} + +test('serverToken adds X-Client-Token to amsg-server requests', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = stubFetch(captured); + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 's3cret' }); + await client.cancelMessage('some-uuid'); + } finally { + globalThis.fetch = original; + } + assert.equal(captured.length, 1); + assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); +}); + +test('no serverToken → no X-Client-Token on server requests', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = stubFetch(captured); + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER }); + await client.cancelMessage('some-uuid'); + } finally { + globalThis.fetch = original; + } + assert.equal(captured[0].headers['X-Client-Token'], undefined); +}); +``` + +- [ ] **Step 2: 确认 client 有 test 脚本** + +Read `packages/rei-standard-amsg/client/package.json`。若 `scripts` 里没有 `test`,加上: +```json +"test": "node --test test/*.test.mjs" +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `npm test --workspace @rei-standard/amsg-client` +Expected: FAIL(`serverToken` 未实现,`X-Client-Token` 缺失)。 + +- [ ] **Step 4: 加 serverToken 字段 + 私有 helper** + +Modify `packages/rei-standard-amsg/client/src/index.js`: + +(a) 构造器里,`this._instantClientToken = ...` 那一段(约 364-366 行)后面加: +```js + /** @private */ + this._serverToken = typeof config.serverToken === 'string' && config.serverToken + ? config.serverToken + : ''; +``` + +(b) `_resolveBaseUrl(endpointName)` 方法后面,加一个私有 helper: +```js + /** + * Attach the single-user shared secret to amsg-server endpoint requests. + * Never applied to the instant path (that uses instantClientToken). + * @private + * @param {Record} headers + * @returns {Record} + */ + _withServerToken(headers) { + if (this._serverToken) headers['X-Client-Token'] = this._serverToken; + return headers; + } +``` + +(c) `init()`(约 406-409 行)的 headers 换成: +```js + headers: this._withServerToken({ 'X-User-Id': this._userId }) +``` + +(d) `scheduleMessage()`(约 449-458 行)的 headers 换成: +```js + headers: this._withServerToken({ + 'Content-Type': 'application/json', + 'X-User-Id': this._userId, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }), +``` + +(e) `updateMessage()`(约 882-891 行)的 headers 换成: +```js + headers: this._withServerToken({ + 'Content-Type': 'application/json', + 'X-User-Id': this._userId, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }), +``` + +(f) `cancelMessage()`(约 903-906 行)的 headers 换成: +```js + headers: this._withServerToken({ 'X-User-Id': this._userId }) +``` + +(g) `listMessages()`(约 929-936 行)的 headers 换成: +```js + headers: this._withServerToken({ + 'X-User-Id': this._userId, + 'X-Response-Encrypted': 'true', + 'X-Encryption-Version': '1' + }) +``` + +(h) `ReiClientConfig` typedef(文件顶部,`instantClientToken` 那条 `@property` 附近)加一条: +```js + * @property {string} [serverToken] - Optional shared secret for a single-user amsg-server. + * When set, sent as the `X-Client-Token` header on amsg-server endpoints + * (schedule / messages / update / cancel / user-key / init). Not applied to the + * instant path (that uses `instantClientToken`). +``` + +> `_buildInstantRequest`(约 1026 行)**不动**——instant 路径继续只用 `instantClientToken`,避免和 serverToken 在同一个 `X-Client-Token` 头上打架。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `npm test --workspace @rei-standard/amsg-client` +Expected: PASS(两个 serverToken 测试通过)。 + +- [ ] **Step 6: 构建 client(regen dist + d.ts)** + +Run: `npm run build --workspace @rei-standard/amsg-client` +Expected: 构建成功,`dist/index.d.ts` 的 `ReiClientConfig` 出现 `serverToken`。 + +- [ ] **Step 7: 提交** + +```bash +git add packages/rei-standard-amsg/client/src/index.js packages/rei-standard-amsg/client/package.json packages/rei-standard-amsg/client/test/server-token.test.mjs +git commit -m "feat(amsg-client): 单用户 serverToken,给 amsg-server 端点带共享密钥" +``` + +--- + +## 收尾校验 + +- [ ] **全量测试**:`npm test`(根目录,跑所有 workspace)→ 全绿。 +- [ ] **全量构建**:`npm run build`(根目录)→ 全部包构建通过。 +- [ ] **changeset**:本次改动涉及 `@rei-standard/amsg-server`(feat)和 `@rei-standard/amsg-client`(feat)。按仓库惯例加 changeset:`npx changeset`,两个包都选 minor,写一句面向用户的说明(「amsg-server 新增单用户 Cloudflare Worker 模式;amsg-client 新增 serverToken」)。 +- [ ] 发版流程见 `RELEASING.md`,本计划不触发发布。 + +--- + +## 备注:已知风险与验证过的假设 + +- **web-push npm 在 CF Worker 跑不了**(用 Node 的 `crypto.createECDH` / `https.request`)——已证实,故 Task 9 用 instant 的纯 Web Crypto 实现。(来源:web-push-libs/web-push#718 及多个 Web Crypto 替代库。) +- **CF Cron Trigger 是 5 段 cron**(`分 时 日 月 周`,UTC),`scheduled(event, env, ctx)` 由平台内部触发、无 HTTP、无需 token。 +- **constant-time 比较**:不用 Node 的 `crypto.timingSafeEqual`(Worker 上有历史 undefined bug),也不用 CF 的 `crypto.subtle.timingSafeEqual`(Node 没有)——用 `globalThis.crypto.subtle` 双 HMAC,两边通用。 +- **D1 = SQLite**:partial index / CHECK / `AUTOINCREMENT` 原生支持;`createTask` 用 `INSERT` + `last_row_id` + `SELECT`,不依赖 `RETURNING`(绕开一个不确定点)。 +- **uuid 唯一冲突 → 409**:`isUniqueViolation()` 匹配 `"unique constraint"` 子串,D1/SQLite 的报错天然命中,handler 无需改。 +- **时间戳字典序**:全部归一化成 `toISOString()`(`Z` 定长格式)后,`next_send_at <= ?` 用字符串比较即等价时间比较。 From 6d820b995661dafba69bf61eacf417b03272433f Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:00:52 +0800 Subject: [PATCH 04/22] =?UTF-8?q?docs(amsg):=20=E8=AE=BE=E8=AE=A1=E7=A8=BF?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=20constant-time=20=E6=AF=94=E8=BE=83?= =?UTF-8?q?=E7=9A=84=E5=8F=AF=E7=A7=BB=E6=A4=8D=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-07-01-amsg-single-user-cloudflare-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md b/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md index 08624c3..1ebe922 100644 --- a/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md +++ b/docs/superpowers/specs/2026-07-01-amsg-single-user-cloudflare-design.md @@ -45,7 +45,8 @@ createSingleUserContextManager({ db, masterKey, serverToken }) 要点: - `db` 和 `masterKey` 由上层(Worker 入口)从 env + binding 拿好后直接传进来,context 内部不再查 Blob。 -- `serverToken` 没配 = 端点开放;配了 = **所有暴露出去的 HTTP 端点**都要带 `X-Client-Token: `,用 `crypto.timingSafeEqual`(或等价的定长比较)防时序侧信道。**没有免验的后门**。 +- `serverToken` 没配 = 端点开放;配了 = **所有暴露出去的 HTTP 端点**都要带 `X-Client-Token: `,用**可移植的 constant-time 比较**防时序侧信道。**没有免验的后门**。 + - 注意(验证后修正):别用 Node 的 `crypto.timingSafeEqual`(Worker 上有返回 undefined 的历史 bug),也别用 CF 的 `crypto.subtle.timingSafeEqual`(Node 没有)。这个文件测试跑在 Node、生产跑在 Worker,两边都要过 → 写一个基于 Web Crypto(`crypto.subtle` 两边都有)的双 HMAC 比较,或手写定长常数时间比较。 - 单用户不暴露 HTTP `send-notifications`,所以不存在「`allowCronToken:true` 要不要放行」这种口子。定时只由 CF `scheduled()` 触发(CF 平台内部直接调,不经过 HTTP,天生外人碰不到)。 ### 单用户入口 From 8a7ab2fe443bed28196841a65a84bea22c0b4454 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:04:36 +0800 Subject: [PATCH 05/22] =?UTF-8?q?test(amsg):=20=E5=8A=A0=20D1=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20shim=EF=BC=88better-sqlite3=EF=BC=89=E4=BE=9B=20ada?= =?UTF-8?q?pter=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 440 ++++++++++++++++++ .../rei-standard-amsg/server/package.json | 5 +- .../server/test/helpers/sqlite-d1.mjs | 42 ++ .../server/test/sqlite-d1-shim.test.mjs | 23 + 4 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs create mode 100644 packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs diff --git a/package-lock.json b/package-lock.json index f613710..a01c2d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1390,6 +1390,27 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/better-path-resolve": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", @@ -1403,6 +1424,43 @@ "node": ">=4" } }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -1422,6 +1480,31 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1477,6 +1560,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1536,6 +1626,32 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -1546,6 +1662,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1568,6 +1694,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -1638,6 +1774,16 @@ "node": ">=4" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -1690,6 +1836,13 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1729,6 +1882,13 @@ "rollup": "^4.34.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -1759,6 +1919,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1849,6 +2016,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1865,6 +2053,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2089,6 +2284,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -2104,6 +2312,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -2145,6 +2360,26 @@ "thenify-all": "^1.0.0" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.93.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.93.0.tgz", + "integrity": "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2155,6 +2390,16 @@ "node": ">=0.10.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -2494,6 +2739,34 @@ "node": ">=0.10.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prettier": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", @@ -2510,6 +2783,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -2548,6 +2832,22 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -2588,6 +2888,21 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2771,6 +3086,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -2818,6 +3180,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2841,6 +3213,16 @@ "node": ">=4" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2864,6 +3246,36 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -3007,6 +3419,19 @@ } } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3044,6 +3469,13 @@ "node": ">= 4.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -3079,6 +3511,13 @@ "node": ">= 8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3129,6 +3568,7 @@ }, "devDependencies": { "@neondatabase/serverless": "^1.0.2", + "better-sqlite3": "^12.11.1", "pg": "^8.18.0", "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 2efeaa7..86a9814 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -33,9 +33,9 @@ "node": ">=20" }, "dependencies": { + "@netlify/blobs": "^8.1.0", "@rei-standard/amsg-shared": "^0.3.0", - "web-push": "^3.6.7", - "@netlify/blobs": "^8.1.0" + "web-push": "^3.6.7" }, "peerDependencies": { "@neondatabase/serverless": ">=0.9.0", @@ -51,6 +51,7 @@ }, "devDependencies": { "@neondatabase/serverless": "^1.0.2", + "better-sqlite3": "^12.11.1", "pg": "^8.18.0", "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs b/packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs new file mode 100644 index 0000000..ebb67aa --- /dev/null +++ b/packages/rei-standard-amsg/server/test/helpers/sqlite-d1.mjs @@ -0,0 +1,42 @@ +/** + * Test-only D1-compatible wrapper over an in-memory better-sqlite3 database. + * Exposes the subset of the Cloudflare D1 binding API the adapter uses: + * db.prepare(sql).bind(...params).run() / .first() / .all() + * so adapter tests exercise real SQLite (real SQL, real constraints). + */ +import Database from 'better-sqlite3'; + +export function createTestD1() { + const db = new Database(':memory:'); + + function prepare(sql) { + let bound = []; + const stmt = { + bind(...args) { + bound = args; + return stmt; + }, + async run() { + const info = db.prepare(sql).run(...bound); + return { success: true, meta: { changes: info.changes, last_row_id: Number(info.lastInsertRowid) } }; + }, + async first() { + const row = db.prepare(sql).get(...bound); + return row === undefined ? null : row; + }, + async all() { + const rows = db.prepare(sql).all(...bound); + return { success: true, results: rows, meta: {} }; + } + }; + return stmt; + } + + return { + prepare, + _raw: db, + close() { + db.close(); + } + }; +} diff --git a/packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs b/packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs new file mode 100644 index 0000000..4722980 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/sqlite-d1-shim.test.mjs @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; + +test('sqlite-d1 shim returns D1-shaped run/first/all results', async () => { + const db = createTestD1(); + await db.prepare('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)').run(); + + const ins = await db.prepare('INSERT INTO t (v) VALUES (?)').bind('hello').run(); + assert.equal(ins.meta.changes, 1); + assert.equal(typeof ins.meta.last_row_id, 'number'); + + const row = await db.prepare('SELECT v FROM t WHERE id = ?').bind(ins.meta.last_row_id).first(); + assert.equal(row.v, 'hello'); + + const missing = await db.prepare('SELECT v FROM t WHERE id = ?').bind(9999).first(); + assert.equal(missing, null); + + const list = await db.prepare('SELECT * FROM t').all(); + assert.equal(list.results.length, 1); + + db.close(); +}); From e0cc4e6996facbc2d1ef25bf3e2aaf41a624025d Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:40:41 +0800 Subject: [PATCH 06/22] =?UTF-8?q?feat(amsg):=20D1=20=E7=94=A8=E7=9A=84=20S?= =?UTF-8?q?QLite=20=E6=96=B9=E8=A8=80=20schema=20=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/adapters/schema.sqlite.js | 70 ++++++++++++++++ .../server/test/schema-sqlite.test.mjs | 81 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js create mode 100644 packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js b/packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js new file mode 100644 index 0000000..7e4036d --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/schema.sqlite.js @@ -0,0 +1,70 @@ +/** + * SQLite (Cloudflare D1) dialect schema for scheduled_messages. + * + * Differences from the Postgres schema (adapters/schema.js): + * - id: INTEGER PRIMARY KEY AUTOINCREMENT (vs SERIAL) + * - timestamps stored as TEXT ISO8601 UTC (vs TIMESTAMP WITH TIME ZONE) + * - no NOW()/DEFAULT; the adapter always writes timestamps explicitly + * - retry_count is NOT NULL here (Postgres omits NOT NULL); every write path + * sets it explicitly, so the tighter constraint just documents that intent + * Partial indexes and CHECK constraints are native to SQLite, so they carry over. + * Index entries mirror the Postgres INDEXES shape ({ name, sql, description, + * critical }) so both adapters' initSchema() return the same index metadata. + */ + +export const SQLITE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS scheduled_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + uuid TEXT, + encrypted_payload TEXT NOT NULL, + message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')), + next_send_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) +`; + +export const SQLITE_INDEXES = [ + { + name: 'idx_pending_tasks_optimized', + sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized + ON scheduled_messages (status, next_send_at, id, retry_count) + WHERE status = 'pending'`, + description: 'Main query index (Cron Job finds pending tasks)', + critical: false + }, + { + name: 'idx_cleanup_completed', + sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed + ON scheduled_messages (status, updated_at) + WHERE status IN ('sent', 'failed')`, + description: 'Cleanup query index', + critical: false + }, + { + name: 'idx_failed_retry', + sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry + ON scheduled_messages (status, retry_count, next_send_at) + WHERE status = 'failed' AND retry_count < 3`, + description: 'Failed retry index', + critical: false + }, + { + name: 'idx_user_id', + sql: `CREATE INDEX IF NOT EXISTS idx_user_id + ON scheduled_messages (user_id)`, + description: 'User task query index', + critical: false + }, + { + name: 'uidx_uuid', + sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid + ON scheduled_messages (uuid) + WHERE uuid IS NOT NULL`, + description: 'UUID uniqueness guard', + critical: true + } +]; diff --git a/packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs b/packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs new file mode 100644 index 0000000..3f14fba --- /dev/null +++ b/packages/rei-standard-amsg/server/test/schema-sqlite.test.mjs @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SQLITE_TABLE_SQL, SQLITE_INDEXES } from '../src/server/adapters/schema.sqlite.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; + +const INSERT_SQL = `INSERT INTO scheduled_messages + (user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at) + VALUES (?, ?, 'p', ?, '2026-01-01T00:00:00.000Z', 'pending', 0, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`; + +async function applySchema(db) { + await db.prepare(SQLITE_TABLE_SQL).run(); + for (const index of SQLITE_INDEXES) { + await db.prepare(index.sql).run(); + } +} + +test('SQLITE_TABLE_SQL uses SQLite dialect', () => { + assert.match(SQLITE_TABLE_SQL, /INTEGER PRIMARY KEY AUTOINCREMENT/); + assert.match(SQLITE_TABLE_SQL, /next_send_at TEXT NOT NULL/); + assert.doesNotMatch(SQLITE_TABLE_SQL, /SERIAL/); + assert.doesNotMatch(SQLITE_TABLE_SQL, /TIMESTAMP WITH TIME ZONE/); +}); + +test('SQLITE_INDEXES defines the 5 indexes incl. the critical unique guard', () => { + assert.equal(SQLITE_INDEXES.length, 5); + const uidx = SQLITE_INDEXES.find((i) => i.name === 'uidx_uuid'); + assert.ok(uidx && uidx.critical === true); + // Every entry mirrors the Postgres INDEXES shape so both adapters' + // initSchema() return the same index metadata (name/sql/description/critical). + for (const index of SQLITE_INDEXES) { + assert.equal(typeof index.description, 'string'); + assert.ok(index.description.length > 0, `${index.name} missing description`); + } +}); + +test('schema applies cleanly on real SQLite', async () => { + const db = createTestD1(); + try { + await applySchema(db); + // CHECK constraint rejects a bad status + await assert.rejects( + db.prepare( + `INSERT INTO scheduled_messages (user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at) + VALUES ('u', 'x', 'p', 'fixed', '2026-01-01T00:00:00.000Z', 'bogus', 0, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')` + ).run() + ); + } finally { + db.close(); + } +}); + +test('CHECK rejects an invalid message_type', async () => { + const db = createTestD1(); + try { + await applySchema(db); + await assert.rejects(db.prepare(INSERT_SQL).bind('u', 'mt', 'nope').run()); + } finally { + db.close(); + } +}); + +test('uidx_uuid enforces uniqueness on non-null uuid but allows multiple NULLs', async () => { + const db = createTestD1(); + try { + await applySchema(db); + // First row with uuid 'dup' inserts fine. + await db.prepare(INSERT_SQL).bind('u', 'dup', 'fixed').run(); + // Second row reusing the same uuid is blocked by the unique guard. + await assert.rejects( + db.prepare(INSERT_SQL).bind('u', 'dup', 'fixed').run(), + /unique/i + ); + // Partial index (WHERE uuid IS NOT NULL) lets multiple NULL-uuid rows coexist. + await db.prepare(INSERT_SQL).bind('u', null, 'fixed').run(); + await db.prepare(INSERT_SQL).bind('u', null, 'fixed').run(); + const rows = await db.prepare('SELECT COUNT(*) AS c FROM scheduled_messages WHERE uuid IS NULL').all(); + assert.equal(Number(rows.results[0].c), 2); + } finally { + db.close(); + } +}); From 9cd0587c51de251810058b0a30c2a2c9ac0c8896 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:52:22 +0800 Subject: [PATCH 07/22] =?UTF-8?q?feat(amsg):=20D1=20(SQLite)=20adapter=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20DbAdapter=20=E5=85=A8=E9=83=A8=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/server/adapters/d1.js | 221 ++++++++++++++++++ .../server/src/server/index.js | 1 + .../server/test/d1-adapter.test.mjs | 130 +++++++++++ 3 files changed, 352 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/d1.js create mode 100644 packages/rei-standard-amsg/server/test/d1-adapter.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/adapters/d1.js b/packages/rei-standard-amsg/server/src/server/adapters/d1.js new file mode 100644 index 0000000..172fa38 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/d1.js @@ -0,0 +1,221 @@ +/** + * Cloudflare D1 (SQLite) Database Adapter. + * + * @implements {import('./interface.js').DbAdapter} + * + * Timestamps are stored as ISO8601 UTC TEXT. Every timestamp is normalized + * with new Date(v).toISOString() before store/compare so lexical ordering + * equals chronological ordering (mixed offsets like +08:00 vs Z are unified). + */ + +import { SQLITE_TABLE_SQL, SQLITE_INDEXES } from './schema.sqlite.js'; + +export class D1Adapter { + /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */ + constructor(db) { + /** @private */ + this._db = db; + } + + /** @private */ + _now() { + return new Date().toISOString(); + } + + /** @private */ + _iso(value) { + const d = new Date(value); + if (Number.isNaN(d.getTime())) { + throw new Error(`[amsg-server D1] invalid timestamp: ${value}`); + } + return d.toISOString(); + } + + async initSchema() { + await this._db.prepare(SQLITE_TABLE_SQL).run(); + + const indexResults = []; + for (const index of SQLITE_INDEXES) { + try { + await this._db.prepare(index.sql).run(); + indexResults.push({ name: index.name, status: 'success', description: index.description, critical: !!index.critical }); + } catch (error) { + indexResults.push({ name: index.name, status: 'failed', description: index.description, critical: !!index.critical, error: error.message }); + } + } + + const criticalFailures = indexResults.filter((i) => i.critical && i.status === 'failed'); + if (criticalFailures.length > 0) { + const names = criticalFailures.map((i) => i.name).join(', '); + throw new Error( + `Critical index creation failed (${names}). ` + + 'Please remove duplicate UUID rows and run initSchema again.' + ); + } + + return { + columnsCreated: 10, + indexesCreated: indexResults.filter((r) => r.status === 'success').length, + indexesFailed: indexResults.filter((r) => r.status === 'failed').length, + columns: [], + indexes: indexResults + }; + } + + async dropSchema() { + await this._db.prepare('DROP TABLE IF EXISTS scheduled_messages').run(); + } + + async createTask(params) { + const now = this._now(); + const nextSendAt = this._iso(params.next_send_at); + const res = await this._db.prepare( + `INSERT INTO scheduled_messages + (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)` + ).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now).run(); + + const id = res.meta.last_row_id; + return this._db.prepare( + `SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?` + ).bind(id).first(); + } + + async getTaskByUuid(uuid, userId) { + return this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = ? AND user_id = ? AND status = 'pending' + LIMIT 1` + ).bind(uuid, userId).first(); + } + + async getTaskByUuidOnly(uuid) { + return this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = ? AND status = 'pending' + LIMIT 1` + ).bind(uuid).first(); + } + + async updateTaskById(taskId, updates) { + const sets = []; + const values = []; + for (const [key, value] of Object.entries(updates)) { + sets.push(`${key} = ?`); + values.push(key === 'next_send_at' ? this._iso(value) : value); + } + // Callers may pass updated_at explicitly (tests); otherwise stamp now. + if (!Object.prototype.hasOwnProperty.call(updates, 'updated_at')) { + sets.push('updated_at = ?'); + values.push(this._now()); + } + values.push(taskId); + + await this._db.prepare( + `UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = ?` + ).bind(...values).run(); + + return this._db.prepare('SELECT * FROM scheduled_messages WHERE id = ?').bind(taskId).first(); + } + + async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) { + const now = this._now(); + const sets = ['encrypted_payload = ?', 'updated_at = ?']; + const values = [encryptedPayload, now]; + if (extraFields) { + for (const [key, value] of Object.entries(extraFields)) { + sets.push(`${key} = ?`); + values.push(key === 'next_send_at' ? this._iso(value) : value); + } + } + values.push(uuid, userId); + + const res = await this._db.prepare( + `UPDATE scheduled_messages SET ${sets.join(', ')} + WHERE uuid = ? AND user_id = ? AND status = 'pending'` + ).bind(...values).run(); + + if (!res.meta.changes) return null; + return { uuid, updated_at: now }; + } + + async deleteTaskById(taskId) { + const res = await this._db.prepare('DELETE FROM scheduled_messages WHERE id = ?').bind(taskId).run(); + return res.meta.changes > 0; + } + + async deleteTaskByUuid(uuid, userId) { + const res = await this._db.prepare( + 'DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?' + ).bind(uuid, userId).run(); + return res.meta.changes > 0; + } + + async getPendingTasks(limit = 50) { + const res = await this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE status = 'pending' AND next_send_at <= ? + ORDER BY next_send_at ASC + LIMIT ?` + ).bind(this._now(), limit).all(); + return res.results || []; + } + + async listTasks(userId, opts = {}) { + const { status = 'all', limit = 20, offset = 0 } = opts; + const conditions = ['user_id = ?']; + const params = [userId]; + if (status !== 'all') { + conditions.push('status = ?'); + params.push(status); + } + const where = conditions.join(' AND '); + + const countRow = await this._db.prepare( + `SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}` + ).bind(...params).first(); + const total = Number(countRow.count) || 0; + + const res = await this._db.prepare( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at + FROM scheduled_messages + WHERE ${where} + ORDER BY next_send_at ASC + LIMIT ? OFFSET ?` + ).bind(...params, limit, offset).all(); + + return { tasks: res.results || [], total }; + } + + async cleanupOldTasks(days = 7) { + const safeDays = Math.max(1, Math.floor(Number(days))); + const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1000).toISOString(); + const res = await this._db.prepare( + `DELETE FROM scheduled_messages + WHERE status IN ('sent', 'failed') AND updated_at < ?` + ).bind(cutoff).run(); + return res.meta.changes || 0; + } + + async getTaskStatus(uuid, userId) { + const row = await this._db.prepare( + 'SELECT status FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1' + ).bind(uuid, userId).first(); + return row ? row.status : null; + } +} + +/** + * Create a D1 adapter from a Cloudflare D1 binding (env.DB). + * @param {{ prepare: (sql: string) => any }} db + * @returns {import('./interface.js').DbAdapter} + */ +export function createD1Adapter(db) { + if (!db || typeof db.prepare !== 'function') { + throw new Error('[amsg-server] createD1Adapter requires a D1 database binding (env.DB)'); + } + return new D1Adapter(db); +} diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js index 6594928..f203506 100644 --- a/packages/rei-standard-amsg/server/src/server/index.js +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -146,6 +146,7 @@ export async function createReiServer(config) { // Re-export utilities that consumers may need export { createAdapter } from './adapters/factory.js'; +export { createD1Adapter } from './adapters/d1.js'; export { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from './lib/encryption.js'; export { validateScheduleMessagePayload, validateLlmMessagesArray, validateSplitPattern, validateAvatarUrl, isValidISO8601, isValidUrl, isValidUUID, isValidUUIDv4 } from './lib/validation.js'; export { createTenantToken, verifyTenantToken } from './tenant/token.js'; diff --git a/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs b/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs new file mode 100644 index 0000000..f47f9e9 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs @@ -0,0 +1,130 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; + +async function freshAdapter() { + const db = createTestD1(); + const adapter = createD1Adapter(db); + await adapter.initSchema(); + return { adapter, db }; +} + +function baseTask(overrides = {}) { + return { + user_id: USER, + uuid: overrides.uuid || 'uuid-1', + encrypted_payload: 'enc', + next_send_at: overrides.next_send_at || '2026-01-01T00:00:00.000Z', + message_type: overrides.message_type || 'fixed' + }; +} + +test('initSchema creates table and indexes', async () => { + const { adapter } = await freshAdapter(); + const res = await adapter.initSchema(); // idempotent (IF NOT EXISTS) + assert.equal(res.indexesFailed, 0); + assert.equal(res.indexesCreated, 5); +}); + +test('createTask returns id/uuid/status/created_at and normalizes next_send_at', async () => { + const { adapter } = await freshAdapter(); + // input uses +08:00 offset — must be normalized to Z form on store + const row = await adapter.createTask(baseTask({ next_send_at: '2026-01-01T08:00:00+08:00' })); + assert.equal(typeof row.id, 'number'); + assert.equal(row.uuid, 'uuid-1'); + assert.equal(row.status, 'pending'); + assert.equal(row.next_send_at, '2026-01-01T00:00:00.000Z'); +}); + +test('getPendingTasks respects next_send_at <= now with mixed-offset inputs', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'due', next_send_at: '2020-01-01T00:00:00.000Z' })); // past → due + await adapter.createTask(baseTask({ uuid: 'future', next_send_at: '2999-01-01T00:00:00+00:00' })); // future → not due + const pending = await adapter.getPendingTasks(50); + const uuids = pending.map((t) => t.uuid); + assert.deepEqual(uuids, ['due']); +}); + +test('getTaskByUuid / getTaskByUuidOnly find pending tasks', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'a' })); + assert.ok(await adapter.getTaskByUuid('a', USER)); + assert.equal(await adapter.getTaskByUuid('a', 'other-user'), null); + assert.ok(await adapter.getTaskByUuidOnly('a')); +}); + +test('updateTaskById updates fields + bumps updated_at', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'u' })); + const updated = await adapter.updateTaskById(row.id, { status: 'failed', retry_count: 2 }); + assert.equal(updated.status, 'failed'); + assert.equal(updated.retry_count, 2); +}); + +test('updateTaskByUuid updates only pending rows and returns {uuid, updated_at}', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'u' })); + const res = await adapter.updateTaskByUuid('u', USER, 'enc2', { next_send_at: '2027-01-01T00:00:00.000Z' }); + assert.equal(res.uuid, 'u'); + assert.ok(res.updated_at); + assert.equal(await adapter.updateTaskByUuid('missing', USER, 'enc2'), null); +}); + +test('delete + getTaskStatus', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'd' })); + assert.equal(await adapter.getTaskStatus('d', USER), 'pending'); + assert.equal(await adapter.deleteTaskById(row.id), true); + assert.equal(await adapter.deleteTaskById(row.id), false); + assert.equal(await adapter.getTaskStatus('d', USER), null); +}); + +test('deleteTaskByUuid scoped to user', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'd2' })); + assert.equal(await adapter.deleteTaskByUuid('d2', 'other'), false); + assert.equal(await adapter.deleteTaskByUuid('d2', USER), true); +}); + +test('listTasks paginates and counts', async () => { + const { adapter } = await freshAdapter(); + for (let i = 0; i < 3; i++) await adapter.createTask(baseTask({ uuid: `l${i}` })); + const page = await adapter.listTasks(USER, { limit: 2, offset: 0 }); + assert.equal(page.total, 3); + assert.equal(page.tasks.length, 2); +}); + +test('listTasks status filter counts only matching rows', async () => { + const { adapter } = await freshAdapter(); + const a = await adapter.createTask(baseTask({ uuid: 'lf1' })); + await adapter.createTask(baseTask({ uuid: 'lf2' })); + await adapter.createTask(baseTask({ uuid: 'lf3' })); + await adapter.updateTaskById(a.id, { status: 'sent' }); // 1 sent, 2 pending + const sent = await adapter.listTasks(USER, { status: 'sent' }); + assert.equal(sent.total, 1); + assert.equal(sent.tasks.length, 1); + assert.equal(sent.tasks[0].status, 'sent'); + const pending = await adapter.listTasks(USER, { status: 'pending' }); + assert.equal(pending.total, 2); +}); + +test('cleanupOldTasks removes only old sent/failed rows', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'old' })); + // mark sent with an updated_at far in the past + await adapter.updateTaskById(row.id, { status: 'sent', updated_at: '2000-01-01T00:00:00.000Z' }); + const removed = await adapter.cleanupOldTasks(7); + assert.equal(removed, 1); +}); + +test('uuid uniqueness violation surfaces as an error matched by isUniqueViolation', async () => { + const { adapter } = await freshAdapter(); + await adapter.createTask(baseTask({ uuid: 'dup' })); + await assert.rejects( + adapter.createTask(baseTask({ uuid: 'dup' })), + (err) => /unique constraint/i.test(err.message) + ); +}); From a07d9af670823d9221d1719b099020178772d0c9 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:02:28 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(amsg):=20=E5=8F=AF=E7=A7=BB=E6=A4=8D?= =?UTF-8?q?=20constant-time=20=E6=AF=94=E8=BE=83=EF=BC=88Node=20+=20Worker?= =?UTF-8?q?=20=E9=80=9A=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/server/lib/constant-time.js | 32 +++++++++++++++++++ .../server/test/constant-time.test.mjs | 23 +++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/lib/constant-time.js create mode 100644 packages/rei-standard-amsg/server/test/constant-time.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/lib/constant-time.js b/packages/rei-standard-amsg/server/src/server/lib/constant-time.js new file mode 100644 index 0000000..1534ba8 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/constant-time.js @@ -0,0 +1,32 @@ +/** + * Portable constant-time string comparison. + * + * Runs identically on Node (tests) and Cloudflare Workers (prod). We avoid + * both node:crypto's timingSafeEqual (undefined on Workers historically) and + * crypto.subtle.timingSafeEqual (absent on Node). Instead we HMAC-SHA256 both + * inputs under one fresh random key, then compare the two digests. Because the + * key is random per call, an attacker can't precompute or replay a stable + * timing oracle; digests are a fixed 32 bytes, so the XOR-accumulate compare + * runs the same number of steps regardless of input length, with no early-out. + * + * globalThis.crypto (Web Crypto) is available on Node >= 20 and on Workers. + */ +export async function constantTimeEqual(a, b) { + const enc = new TextEncoder(); + const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const key = await globalThis.crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const da = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(a)))); + const db = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(b)))); + + let diff = 0; + for (let i = 0; i < da.length; i++) { + diff |= da[i] ^ db[i]; + } + return diff === 0; +} diff --git a/packages/rei-standard-amsg/server/test/constant-time.test.mjs b/packages/rei-standard-amsg/server/test/constant-time.test.mjs new file mode 100644 index 0000000..4bf3567 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/constant-time.test.mjs @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { constantTimeEqual } from '../src/server/lib/constant-time.js'; + +test('constantTimeEqual matches equal strings', async () => { + assert.equal(await constantTimeEqual('secret-token', 'secret-token'), true); +}); + +test('constantTimeEqual rejects different strings', async () => { + assert.equal(await constantTimeEqual('secret-token', 'wrong-token'), false); +}); + +test('constantTimeEqual rejects different lengths', async () => { + assert.equal(await constantTimeEqual('abc', 'abcd'), false); +}); + +test('constantTimeEqual handles empty / non-string safely', async () => { + assert.equal(await constantTimeEqual('', ''), true); + assert.equal(await constantTimeEqual('x', ''), false); + // Non-string inputs are coerced via String() rather than throwing. + assert.equal(await constantTimeEqual(null, null), true); + assert.equal(await constantTimeEqual(null, 'x'), false); +}); From 3d5b12810610d7492253453794dffd26b9949ac9 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:08:13 +0800 Subject: [PATCH 09/22] =?UTF-8?q?feat(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20context=20manager=EF=BC=88=E6=8E=A5=E5=8F=A3=E5=90=8C?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E5=A4=8D=E7=94=A8=E7=8E=B0=E6=9C=89=20handle?= =?UTF-8?q?r=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/tenant/single-user-context.js | 47 +++++++++++++++++++ .../server/test/single-user-context.test.mjs | 38 +++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js create mode 100644 packages/rei-standard-amsg/server/test/single-user-context.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js b/packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js new file mode 100644 index 0000000..85758cb --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/tenant/single-user-context.js @@ -0,0 +1,47 @@ +/** + * Single-user tenant context manager. + * + * Interface-compatible with createTenantContextManager (resolveTenant / + * initializeTenant), so the existing business handlers reuse it unchanged. + * No blob registry, no tenant token — db and masterKey come from the caller + * (the Worker resolves them from env + D1 binding per request). + */ + +import { constantTimeEqual } from '../lib/constant-time.js'; +import { getHeader } from '../lib/request.js'; + +export function createSingleUserContextManager({ db, masterKey, serverToken } = {}) { + if (!db) throw new Error('[amsg-server single-user] db (adapter) is required'); + if (!masterKey) throw new Error('[amsg-server single-user] masterKey is required'); + const token = String(serverToken || '').trim(); + + async function isAuthorized(headers) { + if (!token) return true; // open when no shared secret configured + const provided = getHeader(headers, 'x-client-token'); + if (!provided) return false; + return constantTimeEqual(provided, token); + } + + async function resolveTenant(headers) { + if (!(await isAuthorized(headers))) { + return { + ok: false, + error: { + status: 401, + body: { success: false, error: { code: 'INVALID_CLIENT_TOKEN', message: '共享密钥无效或缺失' } } + } + }; + } + return { + ok: true, + context: { tenantId: 'single', tokenType: 'tenant', db, masterKey } + }; + } + + async function initializeTenant() { + const schema = await db.initSchema(); + return { tenantId: 'single', schema }; + } + + return { resolveTenant, initializeTenant }; +} diff --git a/packages/rei-standard-amsg/server/test/single-user-context.test.mjs b/packages/rei-standard-amsg/server/test/single-user-context.test.mjs new file mode 100644 index 0000000..c036109 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/single-user-context.test.mjs @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserContextManager } from '../src/server/tenant/single-user-context.js'; + +const fakeDb = { async initSchema() { return { indexesCreated: 5, indexesFailed: 0 }; } }; + +test('no serverToken → open, resolves fixed single-user context', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk' }); + const res = await mgr.resolveTenant({}); + assert.equal(res.ok, true); + assert.equal(res.context.tenantId, 'single'); + assert.equal(res.context.tokenType, 'tenant'); + assert.equal(res.context.masterKey, 'mk'); + assert.equal(res.context.db, fakeDb); +}); + +test('serverToken set → missing header rejected 401', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk', serverToken: 's3cret' }); + const res = await mgr.resolveTenant({}); + assert.equal(res.ok, false); + assert.equal(res.error.status, 401); +}); + +test('serverToken set → wrong header rejected, correct header accepted', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk', serverToken: 's3cret' }); + assert.equal((await mgr.resolveTenant({ 'X-Client-Token': 'nope' })).ok, false); + assert.equal((await mgr.resolveTenant({ 'x-client-token': 's3cret' })).ok, true); + // mixed-case key WITH correct value must also pass (guards case-insensitive lookup) + assert.equal((await mgr.resolveTenant({ 'X-Client-Token': 's3cret' })).ok, true); +}); + +test('initializeTenant only builds schema, issues no token', async () => { + const mgr = createSingleUserContextManager({ db: fakeDb, masterKey: 'mk' }); + const res = await mgr.initializeTenant(); + assert.equal(res.tenantId, 'single'); + assert.ok(res.schema); + assert.equal(res.tenantToken, undefined); +}); From aaf53ce915edb1d7c401ed1a1b4eab2b928676c2 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:14:27 +0800 Subject: [PATCH 10/22] =?UTF-8?q?feat(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E5=BB=BA=E8=A1=A8=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/handlers/single-user-init.js | 32 +++++++++++++++++++ .../server/test/single-user-init.test.mjs | 29 +++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js create mode 100644 packages/rei-standard-amsg/server/test/single-user-init.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js b/packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js new file mode 100644 index 0000000..c26e956 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/single-user-init.js @@ -0,0 +1,32 @@ +/** + * Handler: single-user-init + * + * Idempotent "just create the tables" endpoint for single-user deployments + * (the degenerate form of init-tenant). Reuses resolveTenant purely to enforce + * the optional shared secret, then runs initSchema. Issues no token. + * + * @param {Object} ctx - Single-user server context (ctx.tenantManager). + * @returns {{ POST: function }} + */ +export function createSingleUserInitHandler(ctx) { + async function POST(headers /* , body */) { + const auth = await ctx.tenantManager.resolveTenant(headers || {}); + if (!auth.ok) { + return auth.error; + } + try { + const result = await ctx.tenantManager.initializeTenant(); + return { + status: 200, + body: { success: true, data: { tenantId: result.tenantId, schema: result.schema } } + }; + } catch (error) { + return { + status: 500, + body: { success: false, error: { code: 'INIT_FAILED', message: error.message } } + }; + } + } + + return { POST }; +} diff --git a/packages/rei-standard-amsg/server/test/single-user-init.test.mjs b/packages/rei-standard-amsg/server/test/single-user-init.test.mjs new file mode 100644 index 0000000..035a6f2 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/single-user-init.test.mjs @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserInitHandler } from '../src/server/handlers/single-user-init.js'; +import { createSingleUserContextManager } from '../src/server/tenant/single-user-context.js'; + +function makeCtx(serverToken) { + let initCalled = 0; + const db = { async initSchema() { initCalled++; return { indexesCreated: 5, indexesFailed: 0 }; } }; + const tenantManager = createSingleUserContextManager({ db, masterKey: 'mk', serverToken }); + return { ctx: { tenantManager }, calls: () => initCalled }; +} + +test('init builds schema and returns 200', async () => { + const { ctx, calls } = makeCtx(); + const handler = createSingleUserInitHandler(ctx); + const res = await handler.POST({}, undefined); + assert.equal(res.status, 200); + assert.equal(res.body.success, true); + assert.equal(res.body.data.tenantId, 'single'); + assert.equal(calls(), 1); +}); + +test('init rejects wrong shared secret with 401 and does not build schema', async () => { + const { ctx, calls } = makeCtx('s3cret'); + const handler = createSingleUserInitHandler(ctx); + const res = await handler.POST({ 'x-client-token': 'wrong' }, undefined); + assert.equal(res.status, 401); + assert.equal(calls(), 0); +}); From ebfd361affeb16a8b86da60cbd2763fa062667ef Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:19:59 +0800 Subject: [PATCH 11/22] =?UTF-8?q?feat(amsg):=20createSingleUserServer=20?= =?UTF-8?q?=E7=BB=84=E8=A3=85=E5=8D=95=E7=94=A8=E6=88=B7=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/server/index.js | 1 + .../server/src/server/single-user.js | 59 ++++++++++++++++++ .../server/test/single-user-server.test.mjs | 62 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/single-user.js create mode 100644 packages/rei-standard-amsg/server/test/single-user-server.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js index f203506..69a8e51 100644 --- a/packages/rei-standard-amsg/server/src/server/index.js +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -147,6 +147,7 @@ export async function createReiServer(config) { // Re-export utilities that consumers may need export { createAdapter } from './adapters/factory.js'; export { createD1Adapter } from './adapters/d1.js'; +export { createSingleUserServer } from './single-user.js'; export { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from './lib/encryption.js'; export { validateScheduleMessagePayload, validateLlmMessagesArray, validateSplitPattern, validateAvatarUrl, isValidISO8601, isValidUrl, isValidUUID, isValidUUIDv4 } from './lib/validation.js'; export { createTenantToken, verifyTenantToken } from './tenant/token.js'; diff --git a/packages/rei-standard-amsg/server/src/server/single-user.js b/packages/rei-standard-amsg/server/src/server/single-user.js new file mode 100644 index 0000000..97d6b7b --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/single-user.js @@ -0,0 +1,59 @@ +/** + * Single-user ReiStandard server assembly. + * + * Same shape as createReiServer ({ handlers }), but wired for a single user: + * - tenant context comes from createSingleUserContextManager (db + masterKey + * supplied by the caller; no blob registry, no tenant token) + * - only the 5 business handlers + an idempotent init route are exposed + * - send-notifications is NOT exposed over HTTP (cron runs via CF scheduled()) + * + * @param {Object} config + * @param {import('./adapters/interface.js').DbAdapter} config.db + * @param {string} config.masterKey + * @param {string} [config.serverToken] - optional shared secret (X-Client-Token) + * @param {{ email?: string, publicKey?: string, privateKey?: string }} [config.vapid] + * @param {{ sendNotification: function }} [config.webpush] - web-push-compatible sender + * @returns {{ handlers: Object, ctx: Object }} + */ + +import { createSingleUserContextManager } from './tenant/single-user-context.js'; +import { createSingleUserInitHandler } from './handlers/single-user-init.js'; +import { createGetUserKeyHandler } from './handlers/get-user-key.js'; +import { createScheduleMessageHandler } from './handlers/schedule-message.js'; +import { createUpdateMessageHandler } from './handlers/update-message.js'; +import { createCancelMessageHandler } from './handlers/cancel-message.js'; +import { createMessagesHandler } from './handlers/messages.js'; + +export function createSingleUserServer(config) { + if (!config || !config.db) throw new Error('[amsg-server single-user] config.db is required'); + if (!config.masterKey) throw new Error('[amsg-server single-user] config.masterKey is required'); + + const vapid = config.vapid || {}; + const tenantManager = createSingleUserContextManager({ + db: config.db, + masterKey: config.masterKey, + serverToken: config.serverToken + }); + + const ctx = { + vapid: { + email: vapid.email || '', + publicKey: vapid.publicKey || '', + privateKey: vapid.privateKey || '' + }, + webpush: config.webpush || null, + tenantManager + }; + + return { + ctx, + handlers: { + init: createSingleUserInitHandler(ctx), + getUserKey: createGetUserKeyHandler(ctx), + scheduleMessage: createScheduleMessageHandler(ctx), + updateMessage: createUpdateMessageHandler(ctx), + cancelMessage: createCancelMessageHandler(ctx), + messages: createMessagesHandler(ctx) + } + }; +} diff --git a/packages/rei-standard-amsg/server/test/single-user-server.test.mjs b/packages/rei-standard-amsg/server/test/single-user-server.test.mjs new file mode 100644 index 0000000..9b608cc --- /dev/null +++ b/packages/rei-standard-amsg/server/test/single-user-server.test.mjs @@ -0,0 +1,62 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserServer } from '../src/server/single-user.js'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; +import { deriveUserEncryptionKey, encryptPayload, encryptForStorage, decryptFromStorage } from '../src/server/lib/encryption.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const MASTER_KEY = 'a'.repeat(64); + +async function makeServer() { + const db = createD1Adapter(createTestD1()); + await db.initSchema(); + const server = createSingleUserServer({ db, masterKey: MASTER_KEY }); + return server; +} + +function encBody(obj) { + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + return JSON.stringify(encryptPayload(obj, userKey)); +} + +test('createSingleUserServer exposes the reused handlers + init', async () => { + const server = await makeServer(); + for (const k of ['init', 'getUserKey', 'scheduleMessage', 'updateMessage', 'cancelMessage', 'messages']) { + assert.ok(server.handlers[k], `missing handler ${k}`); + } + assert.equal(server.handlers.sendNotifications, undefined); // NOT exposed in single-user +}); + +test('schedule → list → cancel round-trips through single-user server over D1', async () => { + const server = await makeServer(); + const headers = { + 'X-User-Id': USER, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }; + + const payload = { + contactName: 'Rei', + messageType: 'fixed', + userMessage: 'hi', + firstSendTime: '2999-01-01T00:00:00.000Z', + recurrenceType: 'none', + pushSubscription: { endpoint: 'https://example.com/x', keys: { p256dh: 'k', auth: 'a' } } + }; + const created = await server.handlers.scheduleMessage.POST(headers, encBody(payload)); + assert.equal(created.status, 201); + const uuid = created.body.data.uuid; + + const listed = await server.handlers.messages.GET(`/messages?status=all`, { 'X-User-Id': USER }); + assert.equal(listed.status, 200); + + const cancelled = await server.handlers.cancelMessage.DELETE(`/cancel-message?id=${uuid}`, { 'X-User-Id': USER }); + assert.equal(cancelled.status, 200); +}); + +test('masterKey wiring: storage encrypt/decrypt round-trips', () => { + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const round = JSON.parse(decryptFromStorage(encryptForStorage(JSON.stringify({ a: 1 }), userKey), userKey)); + assert.equal(round.a, 1); +}); From 5cfd2acbc56257798e6159d3db3c3edee2e68800 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:31:00 +0800 Subject: [PATCH 12/22] =?UTF-8?q?refactor(amsg):=20=E6=8A=BD=E5=87=BA=20ru?= =?UTF-8?q?nScheduledTick=EF=BC=8CHTTP=20handler=20=E4=B8=8E=20CF=20cron?= =?UTF-8?q?=20=E5=85=B1=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/handlers/send-notifications.js | 145 +----------------- .../server/src/server/index.js | 1 + .../server/src/server/lib/run-tick.js | 136 ++++++++++++++++ .../server/test/run-tick.test.mjs | 69 +++++++++ 4 files changed, 209 insertions(+), 142 deletions(-) create mode 100644 packages/rei-standard-amsg/server/src/server/lib/run-tick.js create mode 100644 packages/rei-standard-amsg/server/test/run-tick.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js b/packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js index 52d31e8..486f8f6 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js @@ -6,9 +6,7 @@ * @returns {{ POST: function }} */ -import { deriveUserEncryptionKey } from '../lib/encryption.js'; -import { decryptFromStorage } from '../lib/encryption.js'; -import { processSingleMessage } from '../lib/message-processor.js'; +import { runScheduledTick } from '../lib/run-tick.js'; export function createSendNotificationsHandler(ctx) { async function POST(urlOrHeaders, maybeHeaders) { @@ -47,145 +45,8 @@ export function createSendNotificationsHandler(ctx) { }; } - const startTime = Date.now(); - const tasks = await db.getPendingTasks(50); - - const MAX_CONCURRENT = 8; - const results = { - totalTasks: tasks.length, - successCount: 0, - failedCount: 0, - deletedOnceOffTasks: 0, - updatedRecurringTasks: 0, - failedTasks: [] - }; - - async function handleDeliveryFailure(task, reason) { - results.failedCount++; - - try { - if (task.retry_count >= 3) { - await db.updateTaskById(task.id, { status: 'failed' }); - results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' }); - } else { - const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000); - await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 }); - results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() }); - } - } catch (updateError) { - results.failedTasks.push({ - taskId: task.id, - reason, - status: 'retry_update_failed', - updateError: updateError.message - }); - } - } - - async function handlePostSendPersistenceFailure(task, reason) { - results.failedCount++; - - let markedSent = false; - try { - await db.updateTaskById(task.id, { status: 'sent', retry_count: 0 }); - markedSent = true; - } catch (_markSentError) { - markedSent = false; - } - - results.failedTasks.push({ - taskId: task.id, - reason, - status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed', - messageDelivered: true - }); - } - - async function processTask(task) { - let sendResult; - try { - sendResult = await processSingleMessage(task, { - ...ctx, - db, - masterKey - }, masterKey); - } catch (error) { - await handleDeliveryFailure(task, error.message || '消息发送失败'); - return; - } - - if (!sendResult.success) { - await handleDeliveryFailure(task, sendResult.error || '消息发送失败'); - return; - } - - try { - const userKey = deriveUserEncryptionKey(task.user_id, masterKey); - const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); - - if (decryptedPayload.recurrenceType === 'none') { - await db.deleteTaskById(task.id); - results.deletedOnceOffTasks++; - } else { - let nextSendAt; - const currentSendAt = new Date(task.next_send_at); - if (decryptedPayload.recurrenceType === 'daily') { - nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000); - } else if (decryptedPayload.recurrenceType === 'weekly') { - nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000); - } - await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 }); - results.updatedRecurringTasks++; - } - - results.successCount++; - } catch (error) { - await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败'); - } - } - - // Dynamic task pool - const taskQueue = [...tasks]; - const processing = []; - - while (taskQueue.length > 0 || processing.length > 0) { - while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) { - const task = taskQueue.shift(); - const promise = processTask(task); - processing.push(promise); - promise.finally(() => { - const index = processing.indexOf(promise); - if (index > -1) processing.splice(index, 1); - }); - } - if (processing.length > 0) { - await Promise.race(processing); - } - } - - // Cleanup old tasks - await db.cleanupOldTasks(7); - - const executionTime = Date.now() - startTime; - - return { - status: 200, - body: { - success: true, - data: { - totalTasks: results.totalTasks, - successCount: results.successCount, - failedCount: results.failedCount, - processedAt: new Date().toISOString(), - executionTime, - details: { - deletedOnceOffTasks: results.deletedOnceOffTasks, - updatedRecurringTasks: results.updatedRecurringTasks, - failedTasks: results.failedTasks - } - } - } - }; + const data = await runScheduledTick({ ...ctx, db, masterKey }); + return { status: 200, body: { success: true, data } }; } return { POST }; diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js index 69a8e51..6e2544d 100644 --- a/packages/rei-standard-amsg/server/src/server/index.js +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -148,6 +148,7 @@ export async function createReiServer(config) { export { createAdapter } from './adapters/factory.js'; export { createD1Adapter } from './adapters/d1.js'; export { createSingleUserServer } from './single-user.js'; +export { runScheduledTick } from './lib/run-tick.js'; export { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from './lib/encryption.js'; export { validateScheduleMessagePayload, validateLlmMessagesArray, validateSplitPattern, validateAvatarUrl, isValidISO8601, isValidUrl, isValidUUID, isValidUUIDv4 } from './lib/validation.js'; export { createTenantToken, verifyTenantToken } from './tenant/token.js'; diff --git a/packages/rei-standard-amsg/server/src/server/lib/run-tick.js b/packages/rei-standard-amsg/server/src/server/lib/run-tick.js new file mode 100644 index 0000000..7ac75f4 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/run-tick.js @@ -0,0 +1,136 @@ +/** + * Scheduled tick core: fetch due tasks, deliver, reschedule/retry, cleanup. + * Extracted verbatim from the send-notifications handler so both the HTTP + * handler (multi-tenant) and the CF scheduled() path (single-user) share it. + * + * @param {Object} ctx - { db, masterKey, vapid, webpush } + * @returns {Promise} summary { totalTasks, successCount, failedCount, processedAt, executionTime, details } + */ + +import { deriveUserEncryptionKey, decryptFromStorage } from './encryption.js'; +import { processSingleMessage } from './message-processor.js'; + +export async function runScheduledTick(ctx) { + const db = ctx.db; + const masterKey = ctx.masterKey; + + const startTime = Date.now(); + const tasks = await db.getPendingTasks(50); + + const MAX_CONCURRENT = 8; + const results = { + totalTasks: tasks.length, + successCount: 0, + failedCount: 0, + deletedOnceOffTasks: 0, + updatedRecurringTasks: 0, + failedTasks: [] + }; + + async function handleDeliveryFailure(task, reason) { + results.failedCount++; + try { + if (task.retry_count >= 3) { + await db.updateTaskById(task.id, { status: 'failed' }); + results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' }); + } else { + const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000); + await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 }); + results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() }); + } + } catch (updateError) { + results.failedTasks.push({ taskId: task.id, reason, status: 'retry_update_failed', updateError: updateError.message }); + } + } + + async function handlePostSendPersistenceFailure(task, reason) { + results.failedCount++; + let markedSent = false; + try { + await db.updateTaskById(task.id, { status: 'sent', retry_count: 0 }); + markedSent = true; + } catch (_markSentError) { + markedSent = false; + } + results.failedTasks.push({ + taskId: task.id, + reason, + status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed', + messageDelivered: true + }); + } + + async function processTask(task) { + let sendResult; + try { + sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey); + } catch (error) { + await handleDeliveryFailure(task, error.message || '消息发送失败'); + return; + } + + if (!sendResult.success) { + await handleDeliveryFailure(task, sendResult.error || '消息发送失败'); + return; + } + + try { + const userKey = deriveUserEncryptionKey(task.user_id, masterKey); + const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + + if (decryptedPayload.recurrenceType === 'none') { + await db.deleteTaskById(task.id); + results.deletedOnceOffTasks++; + } else { + let nextSendAt; + const currentSendAt = new Date(task.next_send_at); + if (decryptedPayload.recurrenceType === 'daily') { + nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000); + } else if (decryptedPayload.recurrenceType === 'weekly') { + nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000); + } + await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 }); + results.updatedRecurringTasks++; + } + + results.successCount++; + } catch (error) { + await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败'); + } + } + + const taskQueue = [...tasks]; + const processing = []; + + while (taskQueue.length > 0 || processing.length > 0) { + while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) { + const task = taskQueue.shift(); + const promise = processTask(task); + processing.push(promise); + promise.finally(() => { + const index = processing.indexOf(promise); + if (index > -1) processing.splice(index, 1); + }); + } + if (processing.length > 0) { + await Promise.race(processing); + } + } + + await db.cleanupOldTasks(7); + + const executionTime = Date.now() - startTime; + + return { + totalTasks: results.totalTasks, + successCount: results.successCount, + failedCount: results.failedCount, + processedAt: new Date().toISOString(), + executionTime, + details: { + deletedOnceOffTasks: results.deletedOnceOffTasks, + updatedRecurringTasks: results.updatedRecurringTasks, + failedTasks: results.failedTasks + } + }; +} diff --git a/packages/rei-standard-amsg/server/test/run-tick.test.mjs b/packages/rei-standard-amsg/server/test/run-tick.test.mjs new file mode 100644 index 0000000..31d7cb7 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/run-tick.test.mjs @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runScheduledTick } from '../src/server/lib/run-tick.js'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; +import { deriveUserEncryptionKey, encryptForStorage } from '../src/server/lib/encryption.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const MASTER_KEY = 'a'.repeat(64); +const VAPID = { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }; + +async function seed(adapter, { uuid, recurrenceType, nextSendAt }) { + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = encryptForStorage(JSON.stringify({ + contactName: 'Rei', + messageType: 'fixed', + userMessage: 'hi', + recurrenceType, + pushSubscription: { endpoint: 'https://example.com/x', keys: { p256dh: 'k', auth: 'a' } } + }), userKey); + await adapter.createTask({ user_id: USER, uuid, encrypted_payload: enc, next_send_at: nextSendAt, message_type: 'fixed' }); +} + +function fakeWebpush() { + const sent = []; + return { sent, async sendNotification(sub, payload) { sent.push(payload); } }; +} + +test('one-off task: delivered then deleted', async () => { + const adapter = createD1Adapter(createTestD1()); + await adapter.initSchema(); + await seed(adapter, { uuid: 'once', recurrenceType: 'none', nextSendAt: '2020-01-01T00:00:00.000Z' }); + + const webpush = fakeWebpush(); + const res = await runScheduledTick({ db: adapter, masterKey: MASTER_KEY, vapid: VAPID, webpush }); + + assert.equal(res.successCount, 1); + assert.equal(res.details.deletedOnceOffTasks, 1); + assert.ok(webpush.sent.length >= 1); + assert.equal((await adapter.getPendingTasks(50)).length, 0); +}); + +test('daily task: delivered then rescheduled +24h, retry reset', async () => { + const adapter = createD1Adapter(createTestD1()); + await adapter.initSchema(); + await seed(adapter, { uuid: 'daily', recurrenceType: 'daily', nextSendAt: '2020-01-01T00:00:00.000Z' }); + + const webpush = fakeWebpush(); + const res = await runScheduledTick({ db: adapter, masterKey: MASTER_KEY, vapid: VAPID, webpush }); + + assert.equal(res.successCount, 1); + assert.equal(res.details.updatedRecurringTasks, 1); + const row = await adapter.getTaskByUuidOnly('daily'); + assert.equal(row.next_send_at, '2020-01-02T00:00:00.000Z'); + assert.equal(row.retry_count, 0); +}); + +test('delivery failure increments retry_count', async () => { + const adapter = createD1Adapter(createTestD1()); + await adapter.initSchema(); + await seed(adapter, { uuid: 'fail', recurrenceType: 'none', nextSendAt: '2020-01-01T00:00:00.000Z' }); + + const webpush = { async sendNotification() { throw new Error('push failed'); } }; + const res = await runScheduledTick({ db: adapter, masterKey: MASTER_KEY, vapid: VAPID, webpush }); + + assert.equal(res.failedCount, 1); + const row = await adapter.getTaskByUuidOnly('fail'); + assert.equal(row.retry_count, 1); +}); From 732e91f0602544e33162c0536b85e4ab61ac3333 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:59:22 +0800 Subject: [PATCH 13/22] =?UTF-8?q?feat(amsg):=20=E7=A7=BB=E6=A4=8D=20instan?= =?UTF-8?q?t=20=E7=9A=84=20Web=20Crypto=20Web=20Push=EF=BC=8C=E4=BE=9B=20C?= =?UTF-8?q?F=20Worker=20=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/server/index.js | 1 + .../server/src/server/lib/webcrypto-utils.js | 88 ++++ .../src/server/lib/webpush-webcrypto.js | 380 ++++++++++++++++++ .../server/test/webpush-webcrypto.test.mjs | 53 +++ 4 files changed, 522 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js create mode 100644 packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js create mode 100644 packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js index 6e2544d..63442b1 100644 --- a/packages/rei-standard-amsg/server/src/server/index.js +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -149,6 +149,7 @@ export { createAdapter } from './adapters/factory.js'; export { createD1Adapter } from './adapters/d1.js'; export { createSingleUserServer } from './single-user.js'; export { runScheduledTick } from './lib/run-tick.js'; +export { createWebCryptoWebPush } from './lib/webpush-webcrypto.js'; export { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from './lib/encryption.js'; export { validateScheduleMessagePayload, validateLlmMessagesArray, validateSplitPattern, validateAvatarUrl, isValidISO8601, isValidUrl, isValidUUID, isValidUUIDv4 } from './lib/validation.js'; export { createTenantToken, verifyTenantToken } from './tenant/token.js'; diff --git a/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js b/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js new file mode 100644 index 0000000..e3f44e9 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/webcrypto-utils.js @@ -0,0 +1,88 @@ +/** + * Runtime-neutral crypto + encoding helpers. + * + * Everything in here is implemented on top of WHATWG-standard primitives + * (`globalThis.crypto.subtle`, `TextEncoder`, `Uint8Array`) so the package + * runs on Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and + * Node ≥ 19 with zero polyfills. The Node adapter polyfills + * `globalThis.crypto` for Node 18 deployments before any of these are + * touched. + */ + +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false }); + +import { toUint8, concatBytes, base64UrlToBytes } from '@rei-standard/amsg-shared'; +export { toUint8, concatBytes, base64UrlToBytes }; + +/** UTF-8 encode a string into a Uint8Array. */ +export function utf8(str) { + return TEXT_ENCODER.encode(String(str)); +} + +/** UTF-8 decode a Uint8Array / ArrayBuffer into a string. */ +export function utf8Decode(buf) { + return TEXT_DECODER.decode(toUint8(buf)); +} + + +/** Encode bytes as base64url (no padding). */ +export function bytesToBase64Url(buf) { + const bytes = toUint8(buf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + // btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+). + const b64 = (typeof btoa === 'function') + ? btoa(bin) + : Buffer.from(bin, 'binary').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + + +/** Encode a JSON-serializable value as base64url (UTF-8 JSON). */ +export function jsonToBase64Url(value) { + return bytesToBase64Url(utf8(JSON.stringify(value))); +} + +/** + * Constant-time byte comparison. Returns true iff `a` and `b` are equal-length + * sequences with the same bytes. Length is intentionally NOT secret — early + * length-check is fine and matches Node `timingSafeEqual`'s contract. + */ +export function timingSafeEqualBytes(a, b) { + const x = toUint8(a); + const y = toUint8(b); + if (x.length !== y.length) return false; + let diff = 0; + for (let i = 0; i < x.length; i++) { + diff |= x[i] ^ y[i]; + } + return diff === 0; +} + +/** HMAC-SHA-256 over `data` with `keyBytes`. Returns 32-byte Uint8Array. */ +export async function hmacSha256(keyBytes, data) { + const key = await globalThis.crypto.subtle.importKey( + 'raw', + toUint8(keyBytes), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const sig = await globalThis.crypto.subtle.sign('HMAC', key, toUint8(data)); + return new Uint8Array(sig); +} + +/** `crypto.randomUUID()`. The Node adapter polyfills `globalThis.crypto`. */ +export function randomUUID() { + return globalThis.crypto.randomUUID(); +} + +/** Cryptographically random bytes. */ +export function randomBytes(n) { + const out = new Uint8Array(n); + globalThis.crypto.getRandomValues(out); + return out; +} diff --git a/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js b/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js new file mode 100644 index 0000000..c2b010b --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js @@ -0,0 +1,380 @@ +/** + * Web Push — RFC 8030 (transport) + RFC 8291 (aes128gcm payload encryption) + * + RFC 8292 (VAPID). + * + * Pure-WebCrypto implementation. Zero runtime dependencies. Runs natively on + * Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and Node ≥ 19. + * Node 18 deployments must go through the `adapters/node` entry which + * polyfills `globalThis.crypto` from `node:crypto.webcrypto`. + * + * The wire format produced here is byte-identical to the `web-push` npm + * package and to the Push API in any modern browser, so amsg-sw and any + * existing Web Push subscriptions keep working untouched. + */ + +import { + utf8, + toUint8, + concatBytes, + bytesToBase64Url, + base64UrlToBytes, + jsonToBase64Url, + hmacSha256, + randomBytes, +} from './webcrypto-utils.js'; +import { normalizeVapidSubject } from '@rei-standard/amsg-shared'; + +// RFC 8291 fixed labels (each followed by a NUL byte per HKDF "info" framing). +const KEY_INFO_PREFIX = utf8('WebPush: info\0'); +const CEK_INFO = utf8('Content-Encoding: aes128gcm\0'); +const NONCE_INFO = utf8('Content-Encoding: nonce\0'); + +const VAPID_DEFAULT_TTL = 60; // seconds — short, matches single-shot instant. +const VAPID_TOKEN_LIFETIME = 12 * 3600; // 12h — comfortably under the 24h RFC 8292 cap. +const RECORD_SIZE = 4096; // arbitrary — must be ≥ ciphertext length. + +/** + * Send a single Web Push notification. + * + * @param {Object} args + * @param {Object} args.subscription - Standard PushSubscription JSON. + * @param {string} args.subscription.endpoint + * @param {Object} args.subscription.keys + * @param {string} args.subscription.keys.p256dh - base64url, 65 B uncompressed P-256 point. + * @param {string} args.subscription.keys.auth - base64url, 16 B auth secret. + * @param {string} args.payload - Already-stringified JSON to deliver. + * @param {Object} args.vapid + * @param {string} args.vapid.email - VAPID `sub` (mailto: auto-prepended if missing). + * @param {string} args.vapid.publicKey - base64url, 65 B uncompressed P-256 point. + * @param {string} args.vapid.privateKey - base64url, 32 B scalar. + * @param {number} [args.ttl=60] - Push service TTL header, seconds. + * @param {typeof fetch} [args.fetch] - Override fetch impl (testing / proxy). + * @returns {Promise<{ statusCode: number, body: string, headers: Headers }>} + * @throws {Error} err.code = 'PUSH_SEND_FAILED' on push-service error. + */ +export async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) { + if (!subscription || typeof subscription.endpoint !== 'string') { + throw new Error('sendWebPush: invalid subscription'); + } + if (typeof payload !== 'string') { + throw new Error('sendWebPush: payload must be a string'); + } + if (!vapid || !vapid.email || !vapid.publicKey || !vapid.privateKey) { + throw new Error('VAPID_CONFIG_MISSING'); + } + + const subscriptionKeys = subscription.keys || {}; + if (typeof subscriptionKeys.p256dh !== 'string' || typeof subscriptionKeys.auth !== 'string') { + throw new Error('sendWebPush: subscription.keys.p256dh and .auth are required'); + } + + const encryptedBody = await encryptPushPayload({ + plaintext: utf8(payload), + uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh), + authSecret: base64UrlToBytes(subscriptionKeys.auth), + }); + + const jwt = await buildVapidJwt({ + audience: originOf(subscription.endpoint), + subject: normalizeVapidSubject(vapid.email), + publicKey: vapid.publicKey, + privateKey: vapid.privateKey, + }); + + const fetchFn = fetchImpl || globalThis.fetch; + if (typeof fetchFn !== 'function') { + throw new Error('sendWebPush: no fetch implementation available'); + } + + const res = await fetchFn(subscription.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Encoding': 'aes128gcm', + 'TTL': String(Number.isFinite(ttl) ? ttl : VAPID_DEFAULT_TTL), + 'Authorization': `vapid t=${jwt}, k=${vapid.publicKey}`, + }, + body: encryptedBody, + }); + + // Push services return 201 (RFC 8030) or 200/202 depending on implementation. + if (!res.ok) { + const text = await safeReadText(res); + const err = new Error( + `Web Push delivery failed: ${res.status} ${res.statusText || ''}${text ? ` — ${text}` : ''}` + ); + err.code = 'PUSH_SEND_FAILED'; + err.statusCode = res.status; + throw err; + } + + return { + statusCode: res.status, + body: await safeReadText(res), + headers: res.headers, + }; +} + +// ─── RFC 8291: aes128gcm payload encryption ──────────────────────────── + +/** + * @param {Object} args + * @param {Uint8Array} args.plaintext + * @param {Uint8Array} args.uaPublicKey - recipient p256dh, 65 B uncompressed. + * @param {Uint8Array} args.authSecret - recipient auth, 16 B. + * @returns {Promise} encryption header || ciphertext + */ +async function encryptPushPayload({ plaintext, uaPublicKey, authSecret }) { + // 1. Ephemeral ECDH key pair (as = "application server" per RFC 8291). + const asKeyPair = await globalThis.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + const asPublicRaw = new Uint8Array( + await globalThis.crypto.subtle.exportKey('raw', asKeyPair.publicKey) + ); + + // 2. ECDH shared secret with recipient's p256dh. + const uaPublicCryptoKey = await globalThis.crypto.subtle.importKey( + 'raw', + uaPublicKey, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + const ecdhSecret = new Uint8Array( + await globalThis.crypto.subtle.deriveBits( + { name: 'ECDH', public: uaPublicCryptoKey }, + asKeyPair.privateKey, + 256 + ) + ); + + // 3. IKM = HKDF-SHA256(salt=auth_secret, ikm=ecdh_secret, + // info="WebPush: info\0" || ua_public || as_public, L=32) + const keyInfo = concatBytes(KEY_INFO_PREFIX, uaPublicKey, asPublicRaw); + const ikm = await hkdfSha256(authSecret, ecdhSecret, keyInfo, 32); + + // 4. encryption_salt (random, 16 B). Goes into the header so the recipient + // can re-derive CEK / NONCE. + const salt = randomBytes(16); + + // 5. CEK = HKDF-SHA256(salt, ikm, "Content-Encoding: aes128gcm\0", 16) + const cekBytes = await hkdfSha256(salt, ikm, CEK_INFO, 16); + const cek = await globalThis.crypto.subtle.importKey( + 'raw', + cekBytes, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + + // 6. NONCE = HKDF-SHA256(salt, ikm, "Content-Encoding: nonce\0", 12) + const nonce = await hkdfSha256(salt, ikm, NONCE_INFO, 12); + + // 7. Single-record AES-128-GCM. Padding delimiter 0x02 marks the final + // (and only) record per RFC 8188 §2. + const padded = concatBytes(plaintext, new Uint8Array([0x02])); + const ciphertext = new Uint8Array( + await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cek, padded) + ); + + // 8. aes128gcm content-encoding framing (RFC 8188 §2.1): + // salt(16) || rs(4 BE) || idlen(1) || keyid(idlen) || ciphertext + // For Web Push, keyid is the application-server public key (65 B). + const header = new Uint8Array(16 + 4 + 1 + asPublicRaw.byteLength); + header.set(salt, 0); + writeUint32BE(header, 16, RECORD_SIZE); + header[20] = asPublicRaw.byteLength; + header.set(asPublicRaw, 21); + + return concatBytes(header, ciphertext); +} + +/** + * HKDF-SHA-256 (extract-then-expand) via WebCrypto. + * + * @param {Uint8Array} salt + * @param {Uint8Array} ikm + * @param {Uint8Array} info + * @param {number} length - desired output length in bytes (≤ 32 in our usage). + * @returns {Promise} + */ +async function hkdfSha256(salt, ikm, info, length) { + const baseKey = await globalThis.crypto.subtle.importKey( + 'raw', + toUint8(ikm), + { name: 'HKDF' }, + false, + ['deriveBits'] + ); + const bits = await globalThis.crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: toUint8(salt), + info: toUint8(info), + }, + baseKey, + length * 8 + ); + return new Uint8Array(bits); +} + +// ─── RFC 8292: VAPID JWT ─────────────────────────────────────────────── + +/** + * Build a VAPID `Authorization` JWT for a single push. + * + * @param {Object} args + * @param {string} args.audience - Origin of the push endpoint (e.g. https://fcm.googleapis.com). + * @param {string} args.subject - VAPID `sub` claim, typically `mailto:you@example.com`. + * @param {string} args.publicKey - base64url, 65 B uncompressed P-256 point. + * @param {string} args.privateKey - base64url, 32 B scalar. + * @returns {Promise} compact JWS (three base64url segments). + */ +export async function buildVapidJwt({ audience, subject, publicKey, privateKey }) { + const header = jsonToBase64Url({ typ: 'JWT', alg: 'ES256' }); + const payload = jsonToBase64Url({ + aud: audience, + exp: Math.floor(Date.now() / 1000) + VAPID_TOKEN_LIFETIME, + sub: subject, + }); + + const signingInput = utf8(`${header}.${payload}`); + + const pubBytes = base64UrlToBytes(publicKey); + const privBytes = base64UrlToBytes(privateKey); + if (pubBytes.length !== 65 || pubBytes[0] !== 0x04) { + throw new Error('VAPID publicKey must be a 65-byte uncompressed P-256 point (base64url).'); + } + if (privBytes.length !== 32) { + throw new Error('VAPID privateKey must be a 32-byte scalar (base64url).'); + } + + // Import as JWK so we can supply both private scalar (d) and public point + // (x, y) in one step — required by WebCrypto for ECDSA signing. + const jwk = { + kty: 'EC', + crv: 'P-256', + d: bytesToBase64Url(privBytes), + x: bytesToBase64Url(pubBytes.subarray(1, 33)), + y: bytesToBase64Url(pubBytes.subarray(33, 65)), + ext: true, + }; + const key = await globalThis.crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ); + + // WebCrypto ECDSA produces a raw 64-byte (r || s) signature — exactly the + // wire format JOSE/JWS expects, so no DER unwrapping is needed. + const sig = await globalThis.crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signingInput + ); + + return `${header}.${payload}.${bytesToBase64Url(sig)}`; +} + +/** + * Verify a VAPID JWT signature. Exported for tests / advanced consumers. + * Returns the decoded payload if the signature and `exp` are valid. + * + * @param {string} jwt + * @param {string} publicKey - VAPID public key (base64url, 65 B). + * @returns {Promise<{ aud: string, exp: number, sub: string }>} + */ +export async function verifyVapidJwt(jwt, publicKey) { + const parts = String(jwt).split('.'); + if (parts.length !== 3) throw new Error('VAPID JWT: malformed (expected three segments)'); + const [h, p, s] = parts; + + const pubBytes = base64UrlToBytes(publicKey); + if (pubBytes.length !== 65 || pubBytes[0] !== 0x04) { + throw new Error('VAPID publicKey must be 65 B uncompressed P-256 (base64url).'); + } + const jwk = { + kty: 'EC', + crv: 'P-256', + x: bytesToBase64Url(pubBytes.subarray(1, 33)), + y: bytesToBase64Url(pubBytes.subarray(33, 65)), + ext: true, + }; + const key = await globalThis.crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + + const ok = await globalThis.crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + base64UrlToBytes(s), + utf8(`${h}.${p}`) + ); + if (!ok) throw new Error('VAPID JWT: signature mismatch'); + + const payload = JSON.parse(new TextDecoder().decode(base64UrlToBytes(p))); + if (!payload.exp || payload.exp <= Math.floor(Date.now() / 1000)) { + throw new Error('VAPID JWT: expired'); + } + return payload; +} + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function originOf(endpoint) { + return new URL(endpoint).origin; +} + +function writeUint32BE(buf, offset, value) { + buf[offset] = (value >>> 24) & 0xff; + buf[offset + 1] = (value >>> 16) & 0xff; + buf[offset + 2] = (value >>> 8) & 0xff; + buf[offset + 3] = value & 0xff; +} + +async function safeReadText(res) { + try { + return await res.text(); + } catch { + return ''; + } +} + +// Re-export the HMAC helper so `index.js` can verify Bearer JWTs without a +// separate import path. Keeps the public surface tight. +export { hmacSha256 }; + +/** + * web-push-compatible sender backed by the Web Crypto implementation above. + * message-processor calls `ctx.webpush.sendNotification(subscription, payloadString)`, + * so we only need that one method. VAPID keys are baked in at construction. + * + * @param {{ email: string, publicKey: string, privateKey: string }} vapid + * @returns {{ sendNotification: (subscription: Object, payload: string) => Promise }} + */ +export function createWebCryptoWebPush(vapid) { + return { + async sendNotification(subscription, payload) { + return sendWebPush({ + subscription, + payload, + vapid: { + email: vapid.email, + publicKey: vapid.publicKey, + privateKey: vapid.privateKey + }, + fetch: globalThis.fetch + }); + } + }; +} diff --git a/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs b/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs new file mode 100644 index 0000000..f5e9de5 --- /dev/null +++ b/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createWebCryptoWebPush, verifyVapidJwt } from '../src/server/lib/webpush-webcrypto.js'; + +// Real P-256 VAPID keypair + a real subscriber key are needed for the +// encryption path to run. Generate them at test time via Web Crypto. +async function genVapid() { + const kp = await globalThis.crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const pub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); // 65-byte uncompressed + const jwk = await globalThis.crypto.subtle.exportKey('jwk', kp.privateKey); + const b64url = (u8) => Buffer.from(u8).toString('base64url'); + return { publicKey: b64url(pub), privateKey: Buffer.from(jwk.d, 'base64url').toString('base64url') }; +} + +async function genSubscription() { + const kp = await globalThis.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); + const auth = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const b64url = (u8) => Buffer.from(u8).toString('base64url'); + return { endpoint: 'https://push.example.com/sub/abc', keys: { p256dh: b64url(raw), auth: b64url(auth) } }; +} + +test('sendNotification encrypts + attaches VAPID and posts to the endpoint', async () => { + const { publicKey, privateKey } = await genVapid(); + const sub = await genSubscription(); + let captured = null; + const fetchImpl = async (url, init) => { + captured = { url, init }; + return new Response(null, { status: 201 }); + }; + + const sender = createWebCryptoWebPush({ email: 'mailto:x@example.com', publicKey, privateKey }); + const original = globalThis.fetch; + globalThis.fetch = fetchImpl; + try { + await sender.sendNotification(sub, JSON.stringify({ messageKind: 'content', message: 'hello' })); + } finally { + globalThis.fetch = original; + } + + assert.ok(captured, 'fetch was called'); + assert.equal(captured.url, sub.endpoint); + assert.equal(captured.init.headers['Content-Encoding'], 'aes128gcm'); + const authz = captured.init.headers['Authorization'] || captured.init.headers['authorization']; + assert.match(authz, /^vapid t=/); + // Extract the JWT and verify it against the VAPID public key (proves the key encoding is correct). + // verifyVapidJwt throws on a signature/expiry problem and otherwise returns the decoded payload, + // so a returned payload with the expected claims proves the signature checked out. + const jwt = authz.slice('vapid t='.length).split(',')[0].trim(); + const decoded = await verifyVapidJwt(jwt, publicKey); + assert.equal(decoded.aud, 'https://push.example.com'); + assert.equal(decoded.sub, 'mailto:x@example.com'); +}); From bee36350d6d425fc0dd072583687ffd42e8c2c69 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:04:47 +0800 Subject: [PATCH 14/22] =?UTF-8?q?feat(amsg):=20CF=20Worker=20=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=EF=BC=88fetch=20=E8=B7=AF=E7=94=B1=20+=20scheduled=20?= =?UTF-8?q?cron=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/cloudflare/single-user-worker.js | 95 +++++++++++++++ .../server/src/server/index.js | 1 + .../server/test/single-user-worker.test.mjs | 111 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js create mode 100644 packages/rei-standard-amsg/server/test/single-user-worker.test.mjs diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js new file mode 100644 index 0000000..2b3e071 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js @@ -0,0 +1,95 @@ +/** + * Cloudflare Worker factory for the single-user amsg-server. + * + * Mirrors instant's createCloudflareWorker: you pass a buildConfig(env) that + * returns the single-user config; we build the server per request (cheap) and + * dispatch. Returns { fetch, scheduled } for `export default`. + * + * Routes (server endpoints only — NO /send-notifications; cron is scheduled()): + * POST /init-tenant → build tables (idempotent) + * GET /get-user-key → derive user key + * POST /schedule-message → create task + * GET /messages → list + * PUT /update-message → patch + * DELETE /cancel-message → delete + */ + +import { createSingleUserServer } from '../single-user.js'; +import { createD1Adapter } from '../adapters/d1.js'; +import { runScheduledTick } from '../lib/run-tick.js'; + +function headersToObject(h) { + const out = {}; + for (const [k, v] of h) out[k] = v; + return out; +} + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json; charset=utf-8' } + }); +} + +export function createSingleUserCloudflareWorker(buildConfig) { + async function resolveConfig(env) { + const cfg = await buildConfig(env); + if (!cfg.db) cfg.db = createD1Adapter(env.DB); + return cfg; + } + + async function fetch(request, env /* , ctx */) { + // Error boundary: a handler (or config build) may throw — e.g. + // schedule-message re-throws a non-unique DB error. Keep the client-facing + // contract consistent (a JSON envelope, not the runtime's HTML error page). + try { + const cfg = await resolveConfig(env); + const server = createSingleUserServer(cfg); + + const url = request.url; + const { pathname } = new URL(url); + const method = request.method.toUpperCase(); + const headers = headersToObject(request.headers); + + let result; + if (method === 'POST' && pathname.endsWith('/init-tenant')) { + result = await server.handlers.init.POST(headers, await request.text()); + } else if (method === 'GET' && pathname.endsWith('/get-user-key')) { + result = await server.handlers.getUserKey.GET(url, headers); + } else if (method === 'POST' && pathname.endsWith('/schedule-message')) { + result = await server.handlers.scheduleMessage.POST(headers, await request.text()); + } else if (method === 'GET' && pathname.endsWith('/messages')) { + result = await server.handlers.messages.GET(url, headers); + } else if (method === 'PUT' && pathname.endsWith('/update-message')) { + result = await server.handlers.updateMessage.PUT(url, headers, await request.text()); + } else if (method === 'DELETE' && pathname.endsWith('/cancel-message')) { + result = await server.handlers.cancelMessage.DELETE(url, headers); + } else { + result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } }; + } + + return jsonResponse(result.status, result.body); + } catch (error) { + console.error('[amsg single-user] fetch() unhandled error:', error && error.message); + return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }); + } + } + + async function scheduled(event, env /* , ctx */) { + const cfg = await resolveConfig(env); + const vapid = cfg.vapid || {}; + if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) { + console.error('[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick'); + return; + } + // Swallow tick failures: pending tasks stay pending, so the next cron tick + // retries them. Logging keeps the failure visible in the tail log. + try { + await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush }); + } catch (error) { + console.error('[amsg single-user] scheduled(): tick failed:', error && error.message); + } + } + + return { fetch, scheduled }; +} diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js index 63442b1..cb08744 100644 --- a/packages/rei-standard-amsg/server/src/server/index.js +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -150,6 +150,7 @@ export { createD1Adapter } from './adapters/d1.js'; export { createSingleUserServer } from './single-user.js'; export { runScheduledTick } from './lib/run-tick.js'; export { createWebCryptoWebPush } from './lib/webpush-webcrypto.js'; +export { createSingleUserCloudflareWorker } from './cloudflare/single-user-worker.js'; export { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from './lib/encryption.js'; export { validateScheduleMessagePayload, validateLlmMessagesArray, validateSplitPattern, validateAvatarUrl, isValidISO8601, isValidUrl, isValidUUID, isValidUUIDv4 } from './lib/validation.js'; export { createTenantToken, verifyTenantToken } from './tenant/token.js'; diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs new file mode 100644 index 0000000..fac115f --- /dev/null +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -0,0 +1,111 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSingleUserCloudflareWorker } from '../src/server/cloudflare/single-user-worker.js'; +import { createTestD1 } from './helpers/sqlite-d1.mjs'; +import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { deriveUserEncryptionKey, encryptPayload, encryptForStorage } from '../src/server/lib/encryption.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const MASTER_KEY = 'a'.repeat(64); + +function makeWorker(d1) { + return createSingleUserCloudflareWorker((env) => ({ + db: createD1Adapter(env.DB), + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} } + })); +} + +test('fetch routes init + schedule + messages, unknown → 404', async () => { + const d1 = createTestD1(); + const worker = makeWorker(d1); + const env = { DB: d1 }; + + // build tables via the init route + const initRes = await worker.fetch(new Request('https://w.dev/init-tenant', { method: 'POST' }), env); + assert.equal(initRes.status, 200); + + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const body = JSON.stringify(encryptPayload({ + contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', + firstSendTime: '2999-01-01T00:00:00.000Z', recurrenceType: 'none', + pushSubscription: { endpoint: 'https://e.com/x', keys: { p256dh: 'k', auth: 'a' } } + }, userKey)); + + const schedRes = await worker.fetch(new Request('https://w.dev/schedule-message', { + method: 'POST', + headers: { 'X-User-Id': USER, 'X-Payload-Encrypted': 'true', 'X-Encryption-Version': '1' }, + body + }), env); + assert.equal(schedRes.status, 201); + + const listRes = await worker.fetch(new Request('https://w.dev/messages?status=all', { + method: 'GET', headers: { 'X-User-Id': USER } + }), env); + assert.equal(listRes.status, 200); + + const notFound = await worker.fetch(new Request('https://w.dev/nope', { method: 'GET' }), env); + assert.equal(notFound.status, 404); +}); + +test('scheduled() runs the tick over env.DB', async () => { + const d1 = createTestD1(); + const adapter = createD1Adapter(d1); + await adapter.initSchema(); + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = encryptForStorage(JSON.stringify({ + contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', recurrenceType: 'none', + pushSubscription: { endpoint: 'https://e.com/x', keys: { p256dh: 'k', auth: 'a' } } + }), userKey); + await adapter.createTask({ user_id: USER, uuid: 'due', encrypted_payload: enc, next_send_at: '2020-01-01T00:00:00.000Z', message_type: 'fixed' }); + + let sent = 0; + const worker = createSingleUserCloudflareWorker(() => ({ + db: adapter, + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() { sent++; } } + })); + + await worker.scheduled({}, { DB: d1 }); + assert.ok(sent >= 1); + assert.equal((await adapter.getPendingTasks(50)).length, 0); +}); + +test('fetch() turns an unexpected error into a JSON 500 (not the runtime error page)', async () => { + const worker = createSingleUserCloudflareWorker(() => { throw new Error('config boom'); }); + const origErr = console.error; + let logged = 0; + console.error = () => { logged++; }; + let res; + try { + res = await worker.fetch(new Request('https://w.dev/messages', { method: 'GET' }), {}); + } finally { + console.error = origErr; + } + assert.equal(res.status, 500); + assert.equal(res.headers.get('Content-Type'), 'application/json; charset=utf-8'); + const body = await res.json(); + assert.equal(body.success, false); + assert.equal(body.error.code, 'INTERNAL_ERROR'); + assert.ok(logged >= 1); // logged, not silently swallowed +}); + +test('scheduled() logs and swallows a tick failure so the next cron retries', async () => { + const worker = createSingleUserCloudflareWorker(() => ({ + db: { async getPendingTasks() { throw new Error('db down'); } }, + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} } + })); + const origErr = console.error; + let logged = 0; + console.error = () => { logged++; }; + try { + await worker.scheduled({}, { DB: null }); // must NOT throw + } finally { + console.error = origErr; + } + assert.ok(logged >= 1); +}); From 1cc1b56182e16120c26e63cd821e25d443964459 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:13:47 +0800 Subject: [PATCH 15/22] =?UTF-8?q?docs(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20CF=20Worker=20=E7=A4=BA=E4=BE=8B=EF=BC=88worker=20+=20wrangl?= =?UTF-8?q?er=20+=20schema=20+=20README=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../examples/cloudflare-single-user/README.md | 37 +++++++++++++++++++ .../cloudflare-single-user/schema.sql | 31 ++++++++++++++++ .../examples/cloudflare-single-user/worker.js | 24 ++++++++++++ .../cloudflare-single-user/wrangler.toml | 13 +++++++ 4 files changed, 105 insertions(+) create mode 100644 packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md create mode 100644 packages/rei-standard-amsg/server/examples/cloudflare-single-user/schema.sql create mode 100644 packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js create mode 100644 packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md new file mode 100644 index 0000000..922fc8b --- /dev/null +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md @@ -0,0 +1,37 @@ +# 单用户 amsg-server · Cloudflare Worker + +定时消息存 D1,定时投递用 CF Cron Trigger。适合只有自己一个人用、想全程跑在 Cloudflare 上的场景。 + +## 跑通步骤 + +1. 建 D1 数据库,把返回的 id 填进 `wrangler.toml` 的 `database_id`: + ```bash + wrangler d1 create amsg + ``` +2. 建表(二选一): + - 命令行:`wrangler d1 execute amsg --file schema.sql` + - 或部署后调一次 `POST /init-tenant`(幂等;配了 serverToken 要带 `X-Client-Token`) +3. 配 secrets: + ```bash + wrangler secret put AMSG_MASTER_KEY # 随机 32 字节 hex,见下 + wrangler secret put VAPID_EMAIL # 例如 mailto:you@example.com + wrangler secret put VAPID_PUBLIC_KEY + wrangler secret put VAPID_PRIVATE_KEY + wrangler secret put AMSG_SERVER_TOKEN # 可选:共享密钥,配了才校验 X-Client-Token + ``` + 生成 `AMSG_MASTER_KEY`: + ```bash + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + ``` +4. 部署:`wrangler deploy` + +## 端点 + +`/get-user-key`、`/schedule-message`、`/messages`、`/update-message`、`/cancel-message`、`/init-tenant`。 +**没有 HTTP `/send-notifications`**——定时投递由 CF Cron Trigger 直接触发 `scheduled()`。 + +VAPID 和 webpush 都要配齐:定时投递(cron)和 `instant` 类型消息都靠它推送,缺了就发不出去。 + +## 客户端 + +`@rei-standard/amsg-client` 配 `baseUrl` 指向本 Worker;若设了 `AMSG_SERVER_TOKEN`,client 也要配同样的 `serverToken`。 diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/schema.sql b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/schema.sql new file mode 100644 index 0000000..ffccce3 --- /dev/null +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/schema.sql @@ -0,0 +1,31 @@ +-- 单用户 amsg-server 的 D1 建表脚本。 +-- 用法:wrangler d1 execute amsg --file schema.sql +-- 也可以部署后 POST /init-tenant 让服务端自动建(幂等)。 + +CREATE TABLE IF NOT EXISTS scheduled_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + uuid TEXT, + encrypted_payload TEXT NOT NULL, + message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')), + next_send_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized + ON scheduled_messages (status, next_send_at, id, retry_count) + WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_cleanup_completed + ON scheduled_messages (status, updated_at) + WHERE status IN ('sent', 'failed'); +CREATE INDEX IF NOT EXISTS idx_failed_retry + ON scheduled_messages (status, retry_count, next_send_at) + WHERE status = 'failed' AND retry_count < 3; +CREATE INDEX IF NOT EXISTS idx_user_id + ON scheduled_messages (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid + ON scheduled_messages (uuid) + WHERE uuid IS NOT NULL; diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js new file mode 100644 index 0000000..42a036e --- /dev/null +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js @@ -0,0 +1,24 @@ +/** + * Single-user amsg-server on Cloudflare Workers. + * Schedules live in D1; cron runs via CF Cron Trigger (see wrangler.toml). + */ +import { + createSingleUserCloudflareWorker, + createWebCryptoWebPush +} from '@rei-standard/amsg-server'; + +export default createSingleUserCloudflareWorker((env) => ({ + // db defaults to createD1Adapter(env.DB) + masterKey: env.AMSG_MASTER_KEY, + serverToken: env.AMSG_SERVER_TOKEN, // optional shared secret; omit to leave endpoints open + vapid: { + email: env.VAPID_EMAIL, + publicKey: env.VAPID_PUBLIC_KEY, + privateKey: env.VAPID_PRIVATE_KEY + }, + webpush: createWebCryptoWebPush({ + email: env.VAPID_EMAIL, + publicKey: env.VAPID_PUBLIC_KEY, + privateKey: env.VAPID_PRIVATE_KEY + }) +})); diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml new file mode 100644 index 0000000..ee8d5ff --- /dev/null +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/wrangler.toml @@ -0,0 +1,13 @@ +name = "amsg-single-user" +main = "worker.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[[d1_databases]] +binding = "DB" +database_name = "amsg" +database_id = "<你的 D1 database id>" + +# CF Cron Trigger(5 段:分 时 日 月 周,UTC)。每分钟跑一次定时投递: +[triggers] +crons = ["* * * * *"] From ae22406fd56b35f03db62d1342eafb4c57f0f4aa Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:35:47 +0800 Subject: [PATCH 16/22] =?UTF-8?q?feat(amsg-client):=20=E5=8D=95=E7=94=A8?= =?UTF-8?q?=E6=88=B7=20serverToken=EF=BC=8C=E7=BB=99=20amsg-server=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E5=B8=A6=E5=85=B1=E4=BA=AB=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rei-standard-amsg/client/src/index.js | 37 +++++++++++++---- .../client/test/server-token.test.mjs | 41 +++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 packages/rei-standard-amsg/client/test/server-token.test.mjs diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 0407174..1ba520d 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -73,6 +73,11 @@ const TEXT_ENCODER = new TextEncoder(); * inside any frontend bundle that uses it, so * devtools can read it. Use for casual URL-direct * abuse only. + * @property {string} [serverToken] - Optional shared secret for a single-user amsg-server. + * When set, sent as the `X-Client-Token` header on + * amsg-server endpoints (schedule / messages / update / + * cancel / user-key / init). Not applied to the instant + * path (that uses `instantClientToken`). * @property {number|null} [maxPayloadBytes=null] - Optional local UTF-8 byte cap for outgoing request * payloads before encryption. `null` / omitted means * no SDK-level request-size limit. @@ -365,6 +370,10 @@ export class ReiClient { ? config.instantClientToken : ''; /** @private */ + this._serverToken = typeof config.serverToken === 'string' && config.serverToken + ? config.serverToken + : ''; + /** @private */ this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes); /** * Per-instance latch (set of method names already warned). The @@ -385,6 +394,18 @@ export class ReiClient { return this._customBaseUrls[endpointName] || this._baseUrl; } + /** + * Attach the single-user shared secret to amsg-server endpoint requests. + * Never applied to the instant path (that uses instantClientToken). + * @private + * @param {Record} headers + * @returns {Record} + */ + _withServerToken(headers) { + if (this._serverToken) headers['X-Client-Token'] = this._serverToken; + return headers; + } + // ─── Initialisation ───────────────────────────────────────────── /** @@ -405,7 +426,7 @@ export class ReiClient { const res = await fetch(`${this._baseUrl}/get-user-key`, { method: 'GET', - headers: { 'X-User-Id': this._userId } + headers: this._withServerToken({ 'X-User-Id': this._userId }) }); const json = await res.json(); @@ -448,12 +469,12 @@ export class ReiClient { const res = await fetch(`${this._baseUrl}/schedule-message`, { method: 'POST', - headers: { + headers: this._withServerToken({ 'Content-Type': 'application/json', 'X-User-Id': this._userId, 'X-Payload-Encrypted': 'true', 'X-Encryption-Version': '1' - }, + }), body: JSON.stringify(encrypted) }); @@ -881,12 +902,12 @@ export class ReiClient { const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, { method: 'PUT', - headers: { + headers: this._withServerToken({ 'Content-Type': 'application/json', 'X-User-Id': this._userId, 'X-Payload-Encrypted': 'true', 'X-Encryption-Version': '1' - }, + }), body: JSON.stringify(encrypted) }); @@ -902,7 +923,7 @@ export class ReiClient { async cancelMessage(uuid) { const res = await fetch(`${this._baseUrl}/cancel-message?id=${encodeURIComponent(uuid)}`, { method: 'DELETE', - headers: { 'X-User-Id': this._userId } + headers: this._withServerToken({ 'X-User-Id': this._userId }) }); return res.json(); @@ -928,11 +949,11 @@ export class ReiClient { const res = await fetch(url, { method: 'GET', - headers: { + headers: this._withServerToken({ 'X-User-Id': this._userId, 'X-Response-Encrypted': 'true', 'X-Encryption-Version': '1' - } + }) }); const json = await res.json(); diff --git a/packages/rei-standard-amsg/client/test/server-token.test.mjs b/packages/rei-standard-amsg/client/test/server-token.test.mjs new file mode 100644 index 0000000..245c3bf --- /dev/null +++ b/packages/rei-standard-amsg/client/test/server-token.test.mjs @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReiClient } from '../src/index.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; + +function stubFetch(captured) { + return async (url, init) => { + captured.push({ url: String(url), headers: (init && init.headers) || {} }); + return new Response(JSON.stringify({ success: true, data: {} }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + }; +} + +test('serverToken adds X-Client-Token to amsg-server requests', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = stubFetch(captured); + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 's3cret' }); + await client.cancelMessage('some-uuid'); + } finally { + globalThis.fetch = original; + } + assert.equal(captured.length, 1); + assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); +}); + +test('no serverToken → no X-Client-Token on server requests', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = stubFetch(captured); + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER }); + await client.cancelMessage('some-uuid'); + } finally { + globalThis.fetch = original; + } + assert.equal(captured[0].headers['X-Client-Token'], undefined); +}); From 19c264c4fe034a9089b42ef81dafb58101052d20 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:39:02 +0800 Subject: [PATCH 17/22] =?UTF-8?q?chore(amsg):=20=E5=8D=95=E7=94=A8?= =?UTF-8?q?=E6=88=B7=20Cloudflare=20+=20client=20serverToken=20=E7=9A=84?= =?UTF-8?q?=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/amsg-client-server-token.md | 5 +++++ .changeset/amsg-server-single-user-cloudflare.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/amsg-client-server-token.md create mode 100644 .changeset/amsg-server-single-user-cloudflare.md diff --git a/.changeset/amsg-client-server-token.md b/.changeset/amsg-client-server-token.md new file mode 100644 index 0000000..1a4150e --- /dev/null +++ b/.changeset/amsg-client-server-token.md @@ -0,0 +1,5 @@ +--- +"@rei-standard/amsg-client": minor +--- + +新增可选 `serverToken`:配置后,client 会在 amsg-server 端点(schedule / messages / update / cancel / user-key / init)的请求上带 `X-Client-Token` 共享密钥,用于单用户部署的访问校验。instant 路径不受影响,仍使用 `instantClientToken`。 diff --git a/.changeset/amsg-server-single-user-cloudflare.md b/.changeset/amsg-server-single-user-cloudflare.md new file mode 100644 index 0000000..f6d3eb9 --- /dev/null +++ b/.changeset/amsg-server-single-user-cloudflare.md @@ -0,0 +1,5 @@ +--- +"@rei-standard/amsg-server": minor +--- + +新增单用户模式:可在单个 Cloudflare Worker 上运行,定时消息存 D1、定时投递由 CF Cron Trigger 触发,无需多租户注册表 / Blob / tenant token。新增导出 `createSingleUserServer`、`createSingleUserCloudflareWorker`、`createD1Adapter`、`runScheduledTick`、`createWebCryptoWebPush`(Worker 上可用的纯 Web Crypto Web Push)。可选 `serverToken` 共享密钥,配置后所有 amsg-server 端点校验 `X-Client-Token`。可跑通的示例见 `examples/cloudflare-single-user/`。 From a5d68630a6f243ceefd8a5f7fb36d30b5ea51cb1 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:46:07 +0800 Subject: [PATCH 18/22] =?UTF-8?q?test(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=85=A8=E7=AB=AF=E7=82=B9=E9=89=B4=E6=9D=83=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=EF=BC=88=E9=85=8D=20serverToken=20=E6=97=B6?= =?UTF-8?q?=E5=9D=87=E9=9C=80=20X-Client-Token=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/test/single-user-worker.test.mjs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs index fac115f..26e5b11 100644 --- a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -109,3 +109,42 @@ test('scheduled() logs and swallows a tick failure so the next cron retries', as } assert.ok(logged >= 1); }); + +// Regression guard (design spec §7): with serverToken set, EVERY exposed HTTP +// endpoint must require X-Client-Token. Today all handlers funnel through the +// same resolveTenant, but this pins it down so a future handler that forgets +// that call can't silently ship an auth-bypassing route. +test('serverToken set → every exposed route rejects wrong/missing token with 401', async () => { + const d1 = createTestD1(); + const worker = createSingleUserCloudflareWorker(() => ({ + db: createD1Adapter(d1), + masterKey: MASTER_KEY, + serverToken: 's3cret', + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} } + })); + const env = { DB: d1 }; + + const routes = [ + ['POST', 'https://w.dev/init-tenant'], + ['GET', 'https://w.dev/get-user-key'], + ['POST', 'https://w.dev/schedule-message'], + ['GET', 'https://w.dev/messages?status=all'], + ['PUT', 'https://w.dev/update-message?id=x'], + ['DELETE', 'https://w.dev/cancel-message?id=x'] + ]; + + for (const [method, url] of routes) { + const wrong = await worker.fetch( + new Request(url, { method, headers: { 'X-Client-Token': 'nope', 'X-User-Id': USER } }), + env + ); + assert.equal(wrong.status, 401, `${method} ${url} with a wrong token must be 401`); + + const missing = await worker.fetch( + new Request(url, { method, headers: { 'X-User-Id': USER } }), + env + ); + assert.equal(missing.status, 401, `${method} ${url} with no token must be 401`); + } +}); From e4232a68c38b99553084db5ebe48931d57a232e8 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:06:53 +0800 Subject: [PATCH 19/22] =?UTF-8?q?chore(amsg):=20=E8=BF=9B=E5=85=A5=20chang?= =?UTF-8?q?esets=20=E9=A2=84=E5=8F=91=E5=B8=83=E6=A8=A1=E5=BC=8F=EF=BC=88n?= =?UTF-8?q?ext=EF=BC=89=EF=BC=8C=E6=9C=AC=E6=AC=A1=E8=B5=B0=20@next=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/pre.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..dd492b4 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,13 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "rei-standard-examples": "2.0.1", + "@rei-standard/amsg-client": "2.8.0", + "@rei-standard/amsg-instant": "0.10.0", + "@rei-standard/amsg-server": "2.5.3", + "@rei-standard/amsg-shared": "0.3.0", + "@rei-standard/amsg-sw": "2.3.2" + }, + "changesets": [] +} From 08e001844204d625e20fae01f983b5ebe1ae71b0 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:29:05 +0800 Subject: [PATCH 20/22] =?UTF-8?q?fix(amsg):=20=E5=9B=9E=E5=BA=94=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20=E2=80=94=20=E5=AE=9A=E6=97=B6=E6=8E=A8=E9=80=81=20?= =?UTF-8?q?TTL=20=E9=BB=98=E8=AE=A4=204=20=E5=91=A8=E3=80=81=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=8E=BB=E5=B0=BE=E6=96=9C=E6=9D=A0=E3=80=81D1=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=97=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/server/adapters/d1.js | 14 ++++++++++++++ .../src/server/cloudflare/single-user-worker.js | 4 +++- .../server/src/server/lib/webpush-webcrypto.js | 13 +++++++++++-- .../server/test/d1-adapter.test.mjs | 9 +++++++++ .../server/test/single-user-worker.test.mjs | 4 ++++ .../server/test/webpush-webcrypto.test.mjs | 17 +++++++++++++++++ 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/rei-standard-amsg/server/src/server/adapters/d1.js b/packages/rei-standard-amsg/server/src/server/adapters/d1.js index 172fa38..1c17645 100644 --- a/packages/rei-standard-amsg/server/src/server/adapters/d1.js +++ b/packages/rei-standard-amsg/server/src/server/adapters/d1.js @@ -10,6 +10,14 @@ import { SQLITE_TABLE_SQL, SQLITE_INDEXES } from './schema.sqlite.js'; +// Update methods build a dynamic SET clause from object keys. Callers pass only +// hardcoded column names today, but enforcing a whitelist keeps a future caller +// from ever turning a caller-supplied key into interpolated SQL. +const UPDATABLE_COLUMNS = new Set([ + 'user_id', 'uuid', 'encrypted_payload', 'message_type', + 'next_send_at', 'status', 'retry_count', 'created_at', 'updated_at' +]); + export class D1Adapter { /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */ constructor(db) { @@ -103,6 +111,9 @@ export class D1Adapter { const sets = []; const values = []; for (const [key, value] of Object.entries(updates)) { + if (!UPDATABLE_COLUMNS.has(key)) { + throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`); + } sets.push(`${key} = ?`); values.push(key === 'next_send_at' ? this._iso(value) : value); } @@ -126,6 +137,9 @@ export class D1Adapter { const values = [encryptedPayload, now]; if (extraFields) { for (const [key, value] of Object.entries(extraFields)) { + if (!UPDATABLE_COLUMNS.has(key)) { + throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`); + } sets.push(`${key} = ?`); values.push(key === 'next_send_at' ? this._iso(value) : value); } diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js index 2b3e071..9688241 100644 --- a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js +++ b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js @@ -47,7 +47,9 @@ export function createSingleUserCloudflareWorker(buildConfig) { const server = createSingleUserServer(cfg); const url = request.url; - const { pathname } = new URL(url); + // Strip trailing slash(es) so `/init-tenant/` routes like `/init-tenant` + // (endsWith matching is kept so a prefixed mount still resolves). + const pathname = new URL(url).pathname.replace(/\/+$/, '') || '/'; const method = request.method.toUpperCase(); const headers = headersToObject(request.headers); diff --git a/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js b/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js index c2b010b..3afb858 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js +++ b/packages/rei-standard-amsg/server/src/server/lib/webpush-webcrypto.js @@ -354,20 +354,29 @@ async function safeReadText(res) { // separate import path. Keeps the public surface tight. export { hmacSha256 }; +// Scheduled reminders must survive an offline device, so default to the same +// 4-week TTL the web-push npm backend applies. sendWebPush's module-level +// VAPID_DEFAULT_TTL (60s) is tuned for single-shot instant pushes; using it for +// durable schedules would drop any reminder whose device was offline > 1 min. +const SCHEDULED_DEFAULT_TTL = 2419200; // 4 weeks, in seconds + /** * web-push-compatible sender backed by the Web Crypto implementation above. * message-processor calls `ctx.webpush.sendNotification(subscription, payloadString)`, * so we only need that one method. VAPID keys are baked in at construction. * - * @param {{ email: string, publicKey: string, privateKey: string }} vapid + * @param {{ email: string, publicKey: string, privateKey: string }} [vapid] + * @param {{ ttl?: number }} [options] - Push TTL in seconds; defaults to 4 weeks + * (matches the web-push backend) so scheduled pushes outlive an offline device. * @returns {{ sendNotification: (subscription: Object, payload: string) => Promise }} */ -export function createWebCryptoWebPush(vapid) { +export function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) { return { async sendNotification(subscription, payload) { return sendWebPush({ subscription, payload, + ttl, vapid: { email: vapid.email, publicKey: vapid.publicKey, diff --git a/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs b/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs index f47f9e9..1fb6f29 100644 --- a/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs +++ b/packages/rei-standard-amsg/server/test/d1-adapter.test.mjs @@ -120,6 +120,15 @@ test('cleanupOldTasks removes only old sent/failed rows', async () => { assert.equal(removed, 1); }); +test('updateTaskById rejects an unknown column instead of interpolating it into SQL', async () => { + const { adapter } = await freshAdapter(); + const row = await adapter.createTask(baseTask({ uuid: 'wl' })); + await assert.rejects( + adapter.updateTaskById(row.id, { 'status = 1; DROP TABLE scheduled_messages; --': 'x' }), + /unknown update column/i + ); +}); + test('uuid uniqueness violation surfaces as an error matched by isUniqueViolation', async () => { const { adapter } = await freshAdapter(); await adapter.createTask(baseTask({ uuid: 'dup' })); diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs index 26e5b11..f4372ac 100644 --- a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -47,6 +47,10 @@ test('fetch routes init + schedule + messages, unknown → 404', async () => { const notFound = await worker.fetch(new Request('https://w.dev/nope', { method: 'GET' }), env); assert.equal(notFound.status, 404); + + // A trailing slash routes the same as without it (not a 404). + const trailingSlash = await worker.fetch(new Request('https://w.dev/init-tenant/', { method: 'POST' }), env); + assert.equal(trailingSlash.status, 200); }); test('scheduled() runs the tick over env.DB', async () => { diff --git a/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs b/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs index f5e9de5..d12a2a1 100644 --- a/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs +++ b/packages/rei-standard-amsg/server/test/webpush-webcrypto.test.mjs @@ -41,6 +41,8 @@ test('sendNotification encrypts + attaches VAPID and posts to the endpoint', asy assert.ok(captured, 'fetch was called'); assert.equal(captured.url, sub.endpoint); assert.equal(captured.init.headers['Content-Encoding'], 'aes128gcm'); + // Scheduled reminders default to a 4-week TTL so an offline device still gets them. + assert.equal(captured.init.headers['TTL'], '2419200'); const authz = captured.init.headers['Authorization'] || captured.init.headers['authorization']; assert.match(authz, /^vapid t=/); // Extract the JWT and verify it against the VAPID public key (proves the key encoding is correct). @@ -51,3 +53,18 @@ test('sendNotification encrypts + attaches VAPID and posts to the endpoint', asy assert.equal(decoded.aud, 'https://push.example.com'); assert.equal(decoded.sub, 'mailto:x@example.com'); }); + +test('sendNotification honours a custom ttl override', async () => { + const { publicKey, privateKey } = await genVapid(); + const sub = await genSubscription(); + let captured = null; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { captured = { url, init }; return new Response(null, { status: 201 }); }; + try { + const sender = createWebCryptoWebPush({ email: 'mailto:x@example.com', publicKey, privateKey }, { ttl: 120 }); + await sender.sendNotification(sub, JSON.stringify({ messageKind: 'content', message: 'hi' })); + } finally { + globalThis.fetch = original; + } + assert.equal(captured.init.headers['TTL'], '120'); +}); From 7d38ca005ad599d07be20772bd7f5e7bcceee4b4 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:50:13 +0800 Subject: [PATCH 21/22] =?UTF-8?q?fix(amsg):=20=E5=8A=A0=20/cloudflare=20?= =?UTF-8?q?=E5=AD=90=E8=B7=AF=E5=BE=84=E5=85=A5=E5=8F=A3=EF=BC=8CD1-only?= =?UTF-8?q?=20Worker=20=E6=89=93=E5=8C=85=E4=B8=8D=E5=86=8D=E6=8B=96=20pg/?= =?UTF-8?q?neon/web-push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amsg-server-single-user-cloudflare.md | 4 ++- .../examples/cloudflare-single-user/README.md | 4 +++ .../examples/cloudflare-single-user/worker.js | 2 +- .../rei-standard-amsg/server/package.json | 5 ++++ .../server/src/server/cloudflare.js | 28 +++++++++++++++++++ .../rei-standard-amsg/server/tsup.config.js | 8 +++++- 6 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 packages/rei-standard-amsg/server/src/server/cloudflare.js diff --git a/.changeset/amsg-server-single-user-cloudflare.md b/.changeset/amsg-server-single-user-cloudflare.md index f6d3eb9..84f96ee 100644 --- a/.changeset/amsg-server-single-user-cloudflare.md +++ b/.changeset/amsg-server-single-user-cloudflare.md @@ -2,4 +2,6 @@ "@rei-standard/amsg-server": minor --- -新增单用户模式:可在单个 Cloudflare Worker 上运行,定时消息存 D1、定时投递由 CF Cron Trigger 触发,无需多租户注册表 / Blob / tenant token。新增导出 `createSingleUserServer`、`createSingleUserCloudflareWorker`、`createD1Adapter`、`runScheduledTick`、`createWebCryptoWebPush`(Worker 上可用的纯 Web Crypto Web Push)。可选 `serverToken` 共享密钥,配置后所有 amsg-server 端点校验 `X-Client-Token`。可跑通的示例见 `examples/cloudflare-single-user/`。 +新增单用户模式:可在单个 Cloudflare Worker 上运行,定时消息存 D1、定时投递由 CF Cron Trigger 触发,无需多租户注册表 / Blob / tenant token。新增导出 `createSingleUserServer`、`createSingleUserCloudflareWorker`、`createD1Adapter`、`runScheduledTick`、`createWebCryptoWebPush`(Worker 上可用的纯 Web Crypto Web Push)。可选 `serverToken` 共享密钥,配置后所有 amsg-server 端点校验 `X-Client-Token`。 + +Worker 从子路径入口 `@rei-standard/amsg-server/cloudflare` 导入:该入口只含单用户 + D1 + Web Crypto 推送那条路径,不牵扯 pg / neon / web-push,只装了 D1 的环境也能打包通过。可跑通的示例见 `examples/cloudflare-single-user/`。 diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md index 922fc8b..7d9ba30 100644 --- a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md @@ -32,6 +32,10 @@ VAPID 和 webpush 都要配齐:定时投递(cron)和 `instant` 类型消息都靠它推送,缺了就发不出去。 +## 导入入口 + +Worker 从 `@rei-standard/amsg-server/cloudflare` 导入(不是包根)。这个子路径只含单用户 + D1 + Web Crypto 推送那条路径,不牵扯 pg / neon / web-push,所以只装了 D1 的环境也能打包通过。 + ## 客户端 `@rei-standard/amsg-client` 配 `baseUrl` 指向本 Worker;若设了 `AMSG_SERVER_TOKEN`,client 也要配同样的 `serverToken`。 diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js index 42a036e..b165833 100644 --- a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/worker.js @@ -5,7 +5,7 @@ import { createSingleUserCloudflareWorker, createWebCryptoWebPush -} from '@rei-standard/amsg-server'; +} from '@rei-standard/amsg-server/cloudflare'; export default createSingleUserCloudflareWorker((env) => ({ // db defaults to createD1Adapter(env.DB) diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 86a9814..cb75a88 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -20,6 +20,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" + }, + "./cloudflare": { + "types": "./dist/cloudflare.d.ts", + "import": "./dist/cloudflare.mjs", + "require": "./dist/cloudflare.cjs" } }, "files": [ diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare.js b/packages/rei-standard-amsg/server/src/server/cloudflare.js new file mode 100644 index 0000000..5d694e2 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/cloudflare.js @@ -0,0 +1,28 @@ +/** + * Cloudflare / D1 single-user entry point. + * + * Import this instead of the package root from a Worker bundle: + * + * import { createSingleUserCloudflareWorker } from '@rei-standard/amsg-server/cloudflare'; + * + * It reaches only the single-user + D1 + Web Crypto Web Push path — with no + * reference to the multi-tenant server, the pluggable pg/neon adapter factory, + * or the Node-oriented `web-push` module. That keeps a D1-only install (without + * the optional `pg` / `@neondatabase/serverless` peers) bundling cleanly: the + * root entry pulls those in through `createReiServer`, this one does not. + * + * node:crypto is the only Node builtin in this subgraph; enable it on Workers + * with `compatibility_flags = ["nodejs_compat"]` (see the example wrangler.toml). + */ + +export { createSingleUserCloudflareWorker } from './cloudflare/single-user-worker.js'; +export { createSingleUserServer } from './single-user.js'; +export { createD1Adapter } from './adapters/d1.js'; +export { createWebCryptoWebPush } from './lib/webpush-webcrypto.js'; +export { runScheduledTick } from './lib/run-tick.js'; +export { + deriveUserEncryptionKey, + decryptPayload, + encryptForStorage, + decryptFromStorage +} from './lib/encryption.js'; diff --git a/packages/rei-standard-amsg/server/tsup.config.js b/packages/rei-standard-amsg/server/tsup.config.js index 6c9fd46..3de6df3 100644 --- a/packages/rei-standard-amsg/server/tsup.config.js +++ b/packages/rei-standard-amsg/server/tsup.config.js @@ -1,7 +1,13 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: { index: 'src/server/index.js' }, + // Two entries: the root (multi-tenant, Node) and a Cloudflare/D1-only entry + // that omits the pg/neon/web-push graph so Worker bundles resolve on a + // D1-only install. See src/server/cloudflare.js. + entry: { + index: 'src/server/index.js', + cloudflare: 'src/server/cloudflare.js' + }, format: ['cjs', 'esm'], dts: true, outDir: 'dist', From b7a0bec255d1b29df0e99f01aa707509b07d0d54 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:54:22 +0800 Subject: [PATCH 22/22] =?UTF-8?q?feat(amsg):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20Worker=20=E5=8A=A0=20opt-in=20CORS=EF=BC=88=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=90=8C=E6=BA=90=EF=BC=8C=E9=85=8D=20cors.origin=20?= =?UTF-8?q?=E6=89=8D=E6=94=BE=E8=A1=8C=E8=B7=A8=E6=BA=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../examples/cloudflare-single-user/README.md | 9 ++++ .../server/cloudflare/single-user-worker.js | 53 +++++++++++++++++-- .../server/test/single-user-worker.test.mjs | 49 +++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md index 7d9ba30..2d175b2 100644 --- a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md @@ -39,3 +39,12 @@ Worker 从 `@rei-standard/amsg-server/cloudflare` 导入(不是包根)。这 ## 客户端 `@rei-standard/amsg-client` 配 `baseUrl` 指向本 Worker;若设了 `AMSG_SERVER_TOKEN`,client 也要配同样的 `serverToken`。 + +前端和 Worker 不同源时,浏览器会对带自定义头的请求发 CORS 预检。默认是同源、不开 CORS;跨源就在 config 里加 `cors`,填你的前端域名: + +```js +export default createSingleUserCloudflareWorker((env) => ({ + // ...其余 config + cors: { origin: 'https://你的前端域名' } // 或 '*',或 (origin) => 允许的域名 +})); +``` diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js index 9688241..98ad210 100644 --- a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js +++ b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js @@ -12,6 +12,10 @@ * GET /messages → list * PUT /update-message → patch * DELETE /cancel-message → delete + * + * CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or + * an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo + * Access-Control-* on responses. With no `cors` the Worker stays same-origin. */ import { createSingleUserServer } from '../single-user.js'; @@ -24,13 +28,45 @@ function headersToObject(h) { return out; } -function jsonResponse(status, body) { +function jsonResponse(status, body, extraHeaders) { return new Response(JSON.stringify(body), { status, - headers: { 'Content-Type': 'application/json; charset=utf-8' } + headers: { 'Content-Type': 'application/json; charset=utf-8', ...(extraHeaders || {}) } }); } +// The custom headers the amsg-client sends; browsers preflight any request +// carrying them, so cross-origin callers need them echoed in the CORS response. +const CORS_ALLOW_HEADERS = + 'Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryption-Version, X-Response-Encrypted, X-Client-Token'; +const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS'; + +/** + * Resolve the CORS response headers for a request, or null when CORS is off. + * Opt-in: with no `cors` config the Worker stays same-origin (no headers, and + * OPTIONS falls through to 404) — so nothing is exposed unless asked for. + * + * @param {undefined | { origin: string | ((requestOrigin: string) => string|null|undefined), allowHeaders?: string, maxAge?: number }} cors + * @param {string} requestOrigin - the request's Origin header (may be '') + */ +function corsHeadersFor(cors, requestOrigin) { + if (!cors || cors.origin == null) return null; + const allowOrigin = typeof cors.origin === 'function' + ? cors.origin(requestOrigin) || null + : cors.origin; // e.g. '*' or a fixed origin like 'https://app.example.com' + if (!allowOrigin) return null; + + const headers = { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Methods': CORS_ALLOW_METHODS, + 'Access-Control-Allow-Headers': cors.allowHeaders || CORS_ALLOW_HEADERS, + 'Access-Control-Max-Age': String(cors.maxAge ?? 86400) + }; + // A per-origin echo must vary the cache by Origin; '*' does not. + if (allowOrigin !== '*') headers['Vary'] = 'Origin'; + return headers; +} + export function createSingleUserCloudflareWorker(buildConfig) { async function resolveConfig(env) { const cfg = await buildConfig(env); @@ -44,13 +80,22 @@ export function createSingleUserCloudflareWorker(buildConfig) { // contract consistent (a JSON envelope, not the runtime's HTML error page). try { const cfg = await resolveConfig(env); + const cors = corsHeadersFor(cfg.cors, request.headers.get('origin') || ''); + const method = request.method.toUpperCase(); + + // CORS preflight: answer OPTIONS directly when CORS is configured. + if (method === 'OPTIONS') { + return cors + ? new Response(null, { status: 204, headers: cors }) + : jsonResponse(404, { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } }); + } + const server = createSingleUserServer(cfg); const url = request.url; // Strip trailing slash(es) so `/init-tenant/` routes like `/init-tenant` // (endsWith matching is kept so a prefixed mount still resolves). const pathname = new URL(url).pathname.replace(/\/+$/, '') || '/'; - const method = request.method.toUpperCase(); const headers = headersToObject(request.headers); let result; @@ -70,7 +115,7 @@ export function createSingleUserCloudflareWorker(buildConfig) { result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } }; } - return jsonResponse(result.status, result.body); + return jsonResponse(result.status, result.body, cors); } catch (error) { console.error('[amsg single-user] fetch() unhandled error:', error && error.message); return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }); diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs index f4372ac..35691a8 100644 --- a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -114,6 +114,55 @@ test('scheduled() logs and swallows a tick failure so the next cron retries', as assert.ok(logged >= 1); }); +test('CORS off by default: OPTIONS → 404 and no Access-Control header on responses', async () => { + const d1 = createTestD1(); + const worker = makeWorker(d1); + const env = { DB: d1 }; + await worker.fetch(new Request('https://w.dev/init-tenant', { method: 'POST' }), env); + + const preflight = await worker.fetch( + new Request('https://w.dev/messages', { method: 'OPTIONS', headers: { Origin: 'https://app.example.com' } }), + env + ); + assert.equal(preflight.status, 404); + + const listed = await worker.fetch( + new Request('https://w.dev/messages?status=all', { method: 'GET', headers: { 'X-User-Id': USER, Origin: 'https://app.example.com' } }), + env + ); + assert.equal(listed.headers.get('Access-Control-Allow-Origin'), null); +}); + +test('CORS opt-in: OPTIONS preflight answered, real response echoes the allowed origin', async () => { + const d1 = createTestD1(); + const worker = createSingleUserCloudflareWorker((env) => ({ + db: createD1Adapter(env.DB), + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} }, + cors: { origin: 'https://app.example.com' } + })); + const env = { DB: d1 }; + await worker.fetch(new Request('https://w.dev/init-tenant', { method: 'POST' }), env); + + const preflight = await worker.fetch( + new Request('https://w.dev/schedule-message', { method: 'OPTIONS', headers: { Origin: 'https://app.example.com' } }), + env + ); + assert.equal(preflight.status, 204); + assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); + assert.match(preflight.headers.get('Access-Control-Allow-Headers'), /X-Client-Token/); + assert.match(preflight.headers.get('Access-Control-Allow-Methods'), /DELETE/); + + const listed = await worker.fetch( + new Request('https://w.dev/messages?status=all', { method: 'GET', headers: { 'X-User-Id': USER, Origin: 'https://app.example.com' } }), + env + ); + assert.equal(listed.status, 200); + assert.equal(listed.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); + assert.equal(listed.headers.get('Vary'), 'Origin'); +}); + // Regression guard (design spec §7): with serverToken set, EVERY exposed HTTP // endpoint must require X-Client-Token. Today all handlers funnel through the // same resolveTenant, but this pins it down so a future handler that forgets