From ef29c412f1e56e7de790308ae6cd2693d360963f Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Sevenyine@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:37:46 +0800 Subject: [PATCH] feat: SDK restructuring --- .github/workflows/ci.yml | 27 + .github/workflows/release.yml | 48 + .gitignore | 4 + README.md | 35 +- docs/TEST_README.md | 4 +- examples/package.json | 13 + package-lock.json | 1892 +++++++++++++++++ package.json | 17 + packages/rei-standard-amsg/README.md | 35 + packages/rei-standard-amsg/client/README.md | 39 + .../rei-standard-amsg/client/package.json | 33 + .../rei-standard-amsg/client/src/index.js | 277 +++ .../rei-standard-amsg/client/tsup.config.js | 15 + packages/rei-standard-amsg/server/README.md | 56 + .../rei-standard-amsg/server/package.json | 51 + .../server/src/server/adapters/factory.js | 51 + .../server/src/server/adapters/interface.js | 71 + .../server/src/server/adapters/neon.js | 249 +++ .../server/src/server/adapters/pg.js | 244 +++ .../server/src/server/adapters/schema.js | 77 + .../src/server/handlers/cancel-message.js | 42 + .../src/server/handlers/get-master-key.js | 33 + .../src/server/handlers/init-database.js | 99 + .../server/src/server/handlers/messages.js | 74 + .../src/server/handlers/schedule-message.js | 187 ++ .../src/server/handlers/send-notifications.js | 181 ++ .../src/server/handlers/update-message.js | 110 + .../server/src/server/index.js | 146 ++ .../server/src/server/lib/db-errors.js | 19 + .../server/src/server/lib/encryption.js | 102 + .../src/server/lib/message-processor.js | 209 ++ .../server/src/server/lib/request.js | 120 ++ .../server/src/server/lib/validation.js | 110 + .../server/test/sdk.test.mjs | 664 ++++++ .../rei-standard-amsg/server/tsup.config.js | 15 + packages/rei-standard-amsg/sw/README.md | 36 + packages/rei-standard-amsg/sw/package.json | 33 + packages/rei-standard-amsg/sw/src/index.js | 347 +++ packages/rei-standard-amsg/sw/tsup.config.js | 15 + scripts/check-esm-syntax.mjs | 112 + scripts/publish-workspaces.mjs | 181 ++ tests/run-test.sh | 4 +- tests/test-active-messaging-api.js | 6 +- 43 files changed, 6074 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 examples/package.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/rei-standard-amsg/README.md create mode 100644 packages/rei-standard-amsg/client/README.md create mode 100644 packages/rei-standard-amsg/client/package.json create mode 100644 packages/rei-standard-amsg/client/src/index.js create mode 100644 packages/rei-standard-amsg/client/tsup.config.js create mode 100644 packages/rei-standard-amsg/server/README.md create mode 100644 packages/rei-standard-amsg/server/package.json create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/factory.js create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/interface.js create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/neon.js create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/pg.js create mode 100644 packages/rei-standard-amsg/server/src/server/adapters/schema.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/cancel-message.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/get-master-key.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/init-database.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/messages.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js create mode 100644 packages/rei-standard-amsg/server/src/server/handlers/update-message.js create mode 100644 packages/rei-standard-amsg/server/src/server/index.js create mode 100644 packages/rei-standard-amsg/server/src/server/lib/db-errors.js create mode 100644 packages/rei-standard-amsg/server/src/server/lib/encryption.js create mode 100644 packages/rei-standard-amsg/server/src/server/lib/message-processor.js create mode 100644 packages/rei-standard-amsg/server/src/server/lib/request.js create mode 100644 packages/rei-standard-amsg/server/src/server/lib/validation.js create mode 100644 packages/rei-standard-amsg/server/test/sdk.test.mjs create mode 100644 packages/rei-standard-amsg/server/tsup.config.js create mode 100644 packages/rei-standard-amsg/sw/README.md create mode 100644 packages/rei-standard-amsg/sw/package.json create mode 100644 packages/rei-standard-amsg/sw/src/index.js create mode 100644 packages/rei-standard-amsg/sw/tsup.config.js create mode 100644 scripts/check-esm-syntax.mjs create mode 100644 scripts/publish-workspaces.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d2a3d66 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run CI checks + run: npm run ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4ffb134 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - 'v*' + - 'rei-standard-amsg-*@*' + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Build and test + run: npm run ci + + - name: Verify npm token + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "Missing NPM_TOKEN secret" + exit 1 + fi + + - name: Publish public workspaces + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_PUBLISH_PROVENANCE: 'true' + run: npm run publish:workspaces diff --git a/.gitignore b/.gitignore index 6223cb8..a0922a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ ref # Node.js node_modules/ + +# Build output +packages/*/dist/ +packages/*/*/dist/ diff --git a/README.md b/README.md index 81f6914..75e8580 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,31 @@ **主动消息 API 标准**:统一的定时消息推送 API 规范,支持端到端加密、多消息类型和 Serverless 部署。 +## 📦 当前 Packages + +| Package | 版本 | 说明 | 文档 | +|---------|------|------|------| +| `@rei-standard/amsg-server` | `1.1.0` | 主动消息 API 服务端 SDK(标准 handler + DB adapter) | [packages/rei-standard-amsg/server/README.md](./packages/rei-standard-amsg/server/README.md) | +| `@rei-standard/amsg-client` | `1.1.0` | 浏览器端 SDK(加密、请求封装、Push 订阅) | [packages/rei-standard-amsg/client/README.md](./packages/rei-standard-amsg/client/README.md) | +| `@rei-standard/amsg-sw` | `1.1.0` | Service Worker 插件(推送展示、离线队列) | [packages/rei-standard-amsg/sw/README.md](./packages/rei-standard-amsg/sw/README.md) | + +按功能拆分后,主应用直接按包引用: + +- `@rei-standard/amsg-server`:`createReiServer`,用于创建 7 个标准 API 处理器 +- `@rei-standard/amsg-client`:`ReiClient`,用于前端加密和 API 调用 +- `@rei-standard/amsg-sw`:`installReiSW`,用于 SW 推送展示和离线请求队列 + +快速引用示例: + +```js +import { createReiServer } from '@rei-standard/amsg-server'; +import { ReiClient } from '@rei-standard/amsg-client'; +import { installReiSW } from '@rei-standard/amsg-sw'; +``` + +如果你要看字段说明、请求头要求、主应用接入方式,请直接看: +[SDK 总览文档](./packages/rei-standard-amsg/README.md)。 + --- > **⚠️ AI 编程助手使用须知** @@ -26,7 +51,7 @@ ### 🎯 快速开始 -[部署教程](./examples/README.md) → [本地测试](./docs/TEST_README.md) +[SDK 总览](./packages/rei-standard-amsg/README.md) → [部署教程](./examples/README.md) → [本地测试](./docs/TEST_README.md) ### 📖 核心文档 @@ -141,6 +166,12 @@ ReiStandard/ │ │ ├── cancel-message.js # 取消任务 │ │ └── messages.js # 查询任务列表 │ └── README.md # 部署教程 +├── packages/ +│ └── rei-standard-amsg/ +│ ├── README.md # SDK 包总览(聚合文档) +│ ├── server/ # 服务端 SDK 包 +│ ├── client/ # 浏览器 Client SDK 包 +│ └── sw/ # Service Worker SDK 包 ├── docs/ │ ├── TEST_README.md # 本地测试指南 │ └── VERCEL_TEST_DEPLOY.md # 生产监控部署 @@ -179,4 +210,4 @@ ReiStandard/ ## 👥 致谢 -本标准基于 Whale小手机 团队的主动消息实现经验总结而成。特别感谢:TO(发起人)、汤圆、脆脆机、koko、糯米机、33小手机、Raven、toufu、菲洛图等老师的小手机项目的积极参与和支持。 \ No newline at end of file +本标准基于 Whale小手机 团队的主动消息实现经验总结而成。特别感谢:TO(发起人)、汤圆、脆脆机、koko、糯米机、33小手机、Raven、toufu、菲洛图等老师的小手机项目的积极参与和支持。 diff --git a/docs/TEST_README.md b/docs/TEST_README.md index 8094457..64f520b 100644 --- a/docs/TEST_README.md +++ b/docs/TEST_README.md @@ -35,10 +35,10 @@ ### 1. 准备环境 -需要 Node.js 18+ (支持原生 fetch API) +需要 Node.js 20+(推荐使用当前 LTS) ```bash -node --version # 确保 >= 18.0.0 +node --version # 确保 >= 20.0.0 ``` ### 2. 配置测试参数 diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..23c6f6d --- /dev/null +++ b/examples/package.json @@ -0,0 +1,13 @@ +{ + "name": "rei-standard-examples", + "version": "1.1.0", + "private": true, + "description": "ReiStandard reference implementation examples", + "engines": { + "node": ">=20" + }, + "dependencies": { + "@neondatabase/serverless": ">=0.9.0", + "web-push": ">=3.6.0" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8d35433 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1892 @@ +{ + "name": "ReiStandard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "workspaces": [ + "packages/rei-standard-amsg/*", + "examples" + ], + "engines": { + "node": ">=20" + } + }, + "examples": { + "name": "rei-standard-examples", + "version": "1.1.0", + "dependencies": { + "@neondatabase/serverless": ">=0.9.0", + "web-push": ">=3.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@neondatabase/serverless": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", + "integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@rei-standard/amsg-client": { + "resolved": "packages/rei-standard-amsg/client", + "link": true + }, + "node_modules/@rei-standard/amsg-server": { + "resolved": "packages/rei-standard-amsg/server", + "link": true + }, + "node_modules/@rei-standard/amsg-sw": { + "resolved": "packages/rei-standard-amsg/sw", + "link": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "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", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rei-standard-examples": { + "resolved": "examples", + "link": true + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "packages/rei-standard-amsg/client": { + "name": "@rei-standard/amsg-client", + "version": "1.1.0", + "license": "MIT", + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/rei-standard-amsg/server": { + "name": "@rei-standard/amsg-server", + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "web-push": "^3.6.7" + }, + "devDependencies": { + "@neondatabase/serverless": "^1.0.2", + "pg": "^8.18.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@neondatabase/serverless": ">=0.9.0", + "pg": ">=8.0.0" + }, + "peerDependenciesMeta": { + "@neondatabase/serverless": { + "optional": true + }, + "pg": { + "optional": true + } + } + }, + "packages/rei-standard-amsg/sw": { + "name": "@rei-standard/amsg-sw", + "version": "1.1.0", + "license": "MIT", + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..18abd99 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "engines": { + "node": ">=20" + }, + "scripts": { + "check:esm": "node scripts/check-esm-syntax.mjs", + "build": "npm run build --workspaces --if-present", + "test": "npm run test --workspaces --if-present", + "ci": "npm run check:esm && npm run build && npm run test", + "publish:workspaces": "node scripts/publish-workspaces.mjs" + }, + "workspaces": [ + "packages/rei-standard-amsg/*", + "examples" + ] +} diff --git a/packages/rei-standard-amsg/README.md b/packages/rei-standard-amsg/README.md new file mode 100644 index 0000000..65ca586 --- /dev/null +++ b/packages/rei-standard-amsg/README.md @@ -0,0 +1,35 @@ +# ReiStandard AMSG SDK Workspace + +`packages/rei-standard-amsg` 目录是 ReiStandard 主动消息能力的 SDK 工作区,包含 3 个可发布包。 + +## 包总览 + +| Package | 当前版本 | 说明 | 文档 | +|---------|----------|------|------| +| `@rei-standard/amsg-server` | `1.1.0` | 主动消息 API 服务端 SDK(标准 handler + DB adapter) | [server/README.md](./server/README.md) | +| `@rei-standard/amsg-client` | `1.1.0` | 浏览器端 SDK(加密、请求封装、Push 订阅) | [client/README.md](./client/README.md) | +| `@rei-standard/amsg-sw` | `1.1.0` | Service Worker 插件(推送展示、离线队列) | [sw/README.md](./sw/README.md) | + +## 使用示例 + +```js +import { createReiServer } from '@rei-standard/amsg-server'; +import { ReiClient } from '@rei-standard/amsg-client'; +import { installReiSW } from '@rei-standard/amsg-sw'; +``` + +## Workspace 命令 + +在仓库根目录执行: + +```bash +npm run build +npm run test +``` + +## 相关文档 + +- [主 README](../../README.md) +- [API 技术规范](../../standards/active-messaging-api.md) +- [Service Worker 规范](../../standards/service-worker-specification.md) +- [部署教程](../../examples/README.md) diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md new file mode 100644 index 0000000..6639889 --- /dev/null +++ b/packages/rei-standard-amsg/client/README.md @@ -0,0 +1,39 @@ +# @rei-standard/amsg-client + +`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包。 + +## 文档导航 + +- [SDK 总览](../README.md) +- [主 README](../../../README.md) +- [Service Worker 规范](../../../standards/service-worker-specification.md) + +## 安装 + +```bash +npm install @rei-standard/amsg-client +``` + +## 使用 + +```js +import { ReiClient } from '@rei-standard/amsg-client'; + +const client = new ReiClient({ + baseUrl: '/api/v1', + userId: 'user-123' +}); + +await client.init(); +``` + +主要能力: + +- 自动处理 `schedule-message` / `update-message` 的加密请求 +- 自动处理 `messages` 的解密响应 +- Push 订阅辅助方法 + +## 相关包 + +- 服务端 SDK:[`@rei-standard/amsg-server`](../server/README.md) +- Service Worker SDK:[`@rei-standard/amsg-sw`](../sw/README.md) diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json new file mode 100644 index 0000000..27e0a24 --- /dev/null +++ b/packages/rei-standard-amsg/client/package.json @@ -0,0 +1,33 @@ +{ + "name": "@rei-standard/amsg-client", + "version": "1.1.0", + "description": "ReiStandard Active Messaging browser client SDK", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js new file mode 100644 index 0000000..6d8b07f --- /dev/null +++ b/packages/rei-standard-amsg/client/src/index.js @@ -0,0 +1,277 @@ +/** + * ReiStandard Client SDK + * v1.1.0 + * + * Lightweight browser client that handles: + * - AES-256-GCM encryption using the Web Crypto API + * - Push subscription management via the Push API + * - Convenient request helpers for all 7 endpoints + * + * Usage: + * import { ReiClient } from '@rei-standard/amsg-client'; + * + * const client = new ReiClient({ + * baseUrl: 'https://example.com/api/v1', + * userId: 'user-123', + * }); + * + * // Fetch master key and initialise encryption + * await client.init(); + * + * // Schedule a message (payload is auto-encrypted) + * await client.scheduleMessage({ ... }); + */ + +/** + * @typedef {Object} ReiClientConfig + * @property {string} baseUrl - Base URL of the API (e.g. https://host/api/v1). + * @property {string} userId - Current user identifier. + */ + +export class ReiClient { + /** + * @param {ReiClientConfig} config + */ + constructor(config) { + if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required'); + if (!config.userId) throw new Error('[rei-standard-amsg-client] userId is required'); + + /** @private */ + this._baseUrl = config.baseUrl.replace(/\/+$/, ''); + /** @private */ + this._userId = config.userId; + /** @private */ + this._masterKey = null; + /** @private */ + this._userKey = null; + } + + // ─── Initialisation ───────────────────────────────────────────── + + /** + * Fetch the master key and derive the user-specific encryption key. + * Must be called before any encrypted request. + */ + async init() { + const res = await fetch(`${this._baseUrl}/get-master-key`, { + method: 'GET', + headers: { 'X-User-Id': this._userId } + }); + + const json = await res.json(); + if (!json.success) throw new Error(json.error?.message || 'Failed to fetch master key'); + + this._masterKey = json.data.masterKey; + this._userKey = await this._deriveKey(this._masterKey, this._userId); + } + + // ─── Public API ───────────────────────────────────────────────── + + /** + * Schedule (or instantly send) a message. + * The payload is automatically encrypted before transmission. + * + * @param {Object} payload - Schedule message payload. + * @returns {Promise} API response body. + */ + async scheduleMessage(payload) { + const encrypted = await this._encrypt(JSON.stringify(payload)); + + const res = await fetch(`${this._baseUrl}/schedule-message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': this._userId, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }, + body: JSON.stringify(encrypted) + }); + + return res.json(); + } + + /** + * Update an existing scheduled message. + * + * @param {string} uuid - Task UUID. + * @param {Object} updates - Fields to update. + * @returns {Promise} + */ + async updateMessage(uuid, updates) { + const encrypted = await this._encrypt(JSON.stringify(updates)); + + const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': this._userId, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }, + body: JSON.stringify(encrypted) + }); + + return res.json(); + } + + /** + * Cancel / delete a scheduled message. + * + * @param {string} uuid - Task UUID. + * @returns {Promise} + */ + async cancelMessage(uuid) { + const res = await fetch(`${this._baseUrl}/cancel-message?id=${encodeURIComponent(uuid)}`, { + method: 'DELETE', + headers: { 'X-User-Id': this._userId } + }); + + return res.json(); + } + + /** + * List the current user's messages with optional filters. + * + * @param {Object} [opts] + * @param {string} [opts.status] + * @param {number} [opts.limit] + * @param {number} [opts.offset] + * @returns {Promise} + */ + async listMessages(opts = {}) { + const params = new URLSearchParams(); + if (opts.status) params.set('status', opts.status); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + + const qs = params.toString(); + const url = `${this._baseUrl}/messages${qs ? '?' + qs : ''}`; + + const res = await fetch(url, { + method: 'GET', + headers: { + 'X-User-Id': this._userId, + 'X-Response-Encrypted': 'true', + 'X-Encryption-Version': '1' + } + }); + + const json = await res.json(); + if (!json?.success || json?.encrypted !== true) return json; + + const decrypted = await this._decrypt(json.data); + return { + success: true, + encrypted: true, + version: json.version || 1, + data: decrypted + }; + } + + // ─── Push Subscription ────────────────────────────────────────── + + /** + * Subscribe to Web Push notifications. + * + * @param {string} vapidPublicKey - The server's VAPID public key. + * @param {ServiceWorkerRegistration} registration - An active SW registration. + * @returns {Promise} + */ + async subscribePush(vapidPublicKey, registration) { + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey) + }); + return subscription; + } + + // ─── Crypto helpers (Web Crypto API) ──────────────────────────── + + /** + * Derive a user-specific AES-256-GCM key from the master key and userId. + * @private + */ + async _deriveKey(masterKey, userId) { + const encoder = new TextEncoder(); + const data = encoder.encode(masterKey + userId); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return new Uint8Array(hashBuffer); + } + + /** + * Encrypt plaintext with AES-256-GCM. + * @private + * @param {string} plaintext + * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>} + */ + async _encrypt(plaintext) { + if (!this._userKey) throw new Error('[rei-standard-amsg-client] Not initialised. Call init() first.'); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['encrypt']); + const encoded = new TextEncoder().encode(plaintext); + const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded); + + // Web Crypto appends the 16-byte auth tag at the end of the ciphertext + const cipherArr = new Uint8Array(cipherBuf); + const encryptedData = cipherArr.slice(0, cipherArr.length - 16); + const authTag = cipherArr.slice(cipherArr.length - 16); + + return { + iv: this._toBase64(iv), + authTag: this._toBase64(authTag), + encryptedData: this._toBase64(encryptedData) + }; + } + + /** + * Decrypt an encrypted API payload. + * @private + * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload + * @returns {Promise} + */ + async _decrypt(encryptedPayload) { + if (!this._userKey) throw new Error('[rei-standard-amsg-client] Not initialised. Call init() first.'); + + const { iv, authTag, encryptedData } = encryptedPayload || {}; + if (typeof iv !== 'string' || typeof authTag !== 'string' || typeof encryptedData !== 'string') { + throw new Error('[rei-standard-amsg-client] Invalid encrypted payload'); + } + + const ivBytes = this._fromBase64(iv); + const authTagBytes = this._fromBase64(authTag); + const encryptedBytes = this._fromBase64(encryptedData); + const cipherBytes = new Uint8Array(encryptedBytes.length + authTagBytes.length); + cipherBytes.set(encryptedBytes); + cipherBytes.set(authTagBytes, encryptedBytes.length); + + const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['decrypt']); + const plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes); + return JSON.parse(new TextDecoder().decode(plainBuffer)); + } + + /** @private */ + _toBase64(uint8) { + const binary = Array.from(uint8, byte => String.fromCharCode(byte)).join(''); + return btoa(binary); + } + + /** @private */ + _fromBase64(base64) { + const raw = atob(base64); + const arr = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); + return arr; + } + + /** @private */ + _urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + const arr = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); + return arr; + } +} diff --git a/packages/rei-standard-amsg/client/tsup.config.js b/packages/rei-standard-amsg/client/tsup.config.js new file mode 100644 index 0000000..3766b19 --- /dev/null +++ b/packages/rei-standard-amsg/client/tsup.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.js' }, + format: ['cjs', 'esm'], + dts: true, + outDir: 'dist', + outExtension({ format }) { + return { js: format === 'esm' ? '.mjs' : '.cjs' }; + }, + platform: 'browser', + target: 'es2020', + splitting: false, + clean: true +}); diff --git a/packages/rei-standard-amsg/server/README.md b/packages/rei-standard-amsg/server/README.md new file mode 100644 index 0000000..36dd08e --- /dev/null +++ b/packages/rei-standard-amsg/server/README.md @@ -0,0 +1,56 @@ +# @rei-standard/amsg-server + +`@rei-standard/amsg-server` 是 ReiStandard 主动消息标准的服务端 SDK 包。 + +## 文档导航 + +- [SDK 总览](../README.md) +- [主 README](../../../README.md) +- [API 技术规范](../../../standards/active-messaging-api.md) + +## 安装 + +```bash +npm install @rei-standard/amsg-server web-push + +# 数据库驱动二选一 +npm install @neondatabase/serverless +# 或 +npm install pg +``` + +## 使用 + +```js +import { createReiServer } from '@rei-standard/amsg-server'; + +const rei = await createReiServer({ + db: { + driver: 'neon', + connectionString: process.env.DATABASE_URL + }, + encryptionKey: process.env.ENCRYPTION_KEY, + cronSecret: process.env.CRON_SECRET, + initSecret: process.env.INIT_SECRET, + vapid: { + email: process.env.VAPID_EMAIL, + publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + privateKey: process.env.VAPID_PRIVATE_KEY + } +}); +``` + +导出的标准 handler: + +- `rei.handlers.initDatabase` +- `rei.handlers.getMasterKey` +- `rei.handlers.scheduleMessage` +- `rei.handlers.sendNotifications` +- `rei.handlers.updateMessage` +- `rei.handlers.cancelMessage` +- `rei.handlers.messages` + +## 相关包 + +- 浏览器 SDK:[`@rei-standard/amsg-client`](../client/README.md) +- Service Worker SDK:[`@rei-standard/amsg-sw`](../sw/README.md) diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json new file mode 100644 index 0000000..a8e8fcd --- /dev/null +++ b/packages/rei-standard-amsg/server/package.json @@ -0,0 +1,51 @@ +{ + "name": "@rei-standard/amsg-server", + "version": "1.1.0", + "description": "ReiStandard Active Messaging server SDK with pluggable database adapters", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "test": "node --test test/*.test.mjs" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "web-push": "^3.6.7" + }, + "peerDependencies": { + "@neondatabase/serverless": ">=0.9.0", + "pg": ">=8.0.0" + }, + "peerDependenciesMeta": { + "@neondatabase/serverless": { + "optional": true + }, + "pg": { + "optional": true + } + }, + "devDependencies": { + "@neondatabase/serverless": "^1.0.2", + "pg": "^8.18.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/rei-standard-amsg/server/src/server/adapters/factory.js b/packages/rei-standard-amsg/server/src/server/adapters/factory.js new file mode 100644 index 0000000..7142305 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/factory.js @@ -0,0 +1,51 @@ +/** + * Adapter Factory + * ReiStandard SDK v1.1.0 + * + * Creates a database adapter instance based on the supplied configuration. + * + * @typedef {'neon'|'pg'} DriverName + * + * @typedef {Object} AdapterConfig + * @property {DriverName} driver - Which database driver to use. + * @property {string} connectionString - Database connection URL. + */ + +/** + * Create a database adapter. + * + * @param {AdapterConfig} config + * @returns {Promise} + */ +export async function createAdapter(config) { + if (!config || !config.driver) { + throw new Error( + '[rei-standard-amsg-server] "driver" is required in the db config. ' + + 'Supported drivers: neon, pg' + ); + } + + if (!config.connectionString) { + throw new Error( + '[rei-standard-amsg-server] "connectionString" is required in the db config.' + ); + } + + switch (config.driver) { + case 'neon': { + const { NeonAdapter } = await import('./neon.js'); + return new NeonAdapter(config.connectionString); + } + + case 'pg': { + const { PgAdapter } = await import('./pg.js'); + return new PgAdapter(config.connectionString); + } + + default: + throw new Error( + `[rei-standard-amsg-server] Unsupported driver "${config.driver}". ` + + 'Supported drivers: neon, pg' + ); + } +} diff --git a/packages/rei-standard-amsg/server/src/server/adapters/interface.js b/packages/rei-standard-amsg/server/src/server/adapters/interface.js new file mode 100644 index 0000000..f6c131d --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/interface.js @@ -0,0 +1,71 @@ +/** + * Database Adapter Interface + * ReiStandard SDK v1.1.0 + * + * Defines the contract that every database adapter must fulfil. + * Implementations live in ./neon.js, ./pg.js, etc. + */ + +/** + * @typedef {Object} TaskRow + * @property {number} id + * @property {string} user_id + * @property {string} uuid + * @property {string} encrypted_payload + * @property {string} message_type + * @property {string} next_send_at + * @property {string} status + * @property {number} retry_count + * @property {string} created_at + * @property {string} updated_at + */ + +/** + * @typedef {Object} InsertTaskParams + * @property {string} user_id + * @property {string} uuid + * @property {string} encrypted_payload + * @property {string} next_send_at + * @property {string} message_type + */ + +/** + * @typedef {Object} InitSchemaResult + * @property {number} columnsCreated + * @property {number} indexesCreated + * @property {number} indexesFailed + * @property {Array} columns + * @property {Array} indexes + */ + +/** + * @typedef {Object} DbAdapter + * @property {() => Promise} initSchema + * Create the scheduled_messages table and all indexes. + * @property {() => Promise} dropSchema + * Drop the scheduled_messages table (CASCADE). + * @property {(params: InsertTaskParams) => Promise} createTask + * Insert a new task row and return the created record. + * @property {(uuid: string, userId: string) => Promise} getTaskByUuid + * Fetch a single pending task by uuid + user_id. + * @property {(uuid: string) => Promise} getTaskByUuidOnly + * Fetch a single pending task by uuid only (used by instant processing). + * @property {(taskId: number, updates: Object) => Promise} updateTaskById + * Partially update a task row by its numeric id. + * @property {(uuid: string, userId: string, encryptedPayload: string, extraFields?: Object) => Promise} updateTaskByUuid + * Update a pending task's encrypted_payload (and optional index fields) by uuid + user_id. + * @property {(taskId: number) => Promise} deleteTaskById + * Delete a task by numeric id. Returns true if a row was affected. + * @property {(uuid: string, userId: string) => Promise} deleteTaskByUuid + * Delete a task by uuid + user_id. Returns true if a row was affected. + * @property {(limit?: number) => Promise} getPendingTasks + * Fetch pending tasks whose next_send_at <= NOW(), ordered ASC. + * @property {(userId: string, opts: {status?: string, limit?: number, offset?: number}) => Promise<{tasks: TaskRow[], total: number}>} listTasks + * List tasks for a user with optional filters and pagination. + * @property {(days?: number) => Promise} cleanupOldTasks + * Delete completed / failed tasks older than `days` (default 7). + * @property {(uuid: string, userId: string) => Promise} getTaskStatus + * Return the status string of a task (used to distinguish 404 from 409). + */ + +export {}; diff --git a/packages/rei-standard-amsg/server/src/server/adapters/neon.js b/packages/rei-standard-amsg/server/src/server/adapters/neon.js new file mode 100644 index 0000000..2312bd1 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/neon.js @@ -0,0 +1,249 @@ +/** + * Neon Serverless Database Adapter + * ReiStandard SDK v1.1.0 + * + * @implements {import('./interface.js').DbAdapter} + */ + +import { neon } from '@neondatabase/serverless'; +import { TABLE_SQL, INDEXES, VERIFY_TABLE_SQL, COLUMNS_SQL } from './schema.js'; + +export class NeonAdapter { + /** @param {string} connectionString */ + constructor(connectionString) { + /** @private */ + this._connectionString = connectionString; + /** @private */ + this._sql = null; + } + + /** @private */ + _getSql() { + if (!this._sql) { + this._sql = neon(this._connectionString); + } + return this._sql; + } + + async initSchema() { + const sql = this._getSql(); + + await sql.query(TABLE_SQL); + + const indexResults = []; + for (const index of INDEXES) { + try { + await sql.query(index.sql); + 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((index) => index.critical && index.status === 'failed'); + if (criticalFailures.length > 0) { + const failedNames = criticalFailures.map((index) => index.name).join(', '); + throw new Error( + `Critical index creation failed (${failedNames}). ` + + 'Please remove duplicate UUID rows and run initSchema again.' + ); + } + + const tableCheck = await sql.query(VERIFY_TABLE_SQL); + if (tableCheck.length === 0) { + throw new Error('Table creation verification failed'); + } + + const columns = await sql.query(COLUMNS_SQL); + + return { + columnsCreated: columns.length, + indexesCreated: indexResults.filter(r => r.status === 'success').length, + indexesFailed: indexResults.filter(r => r.status === 'failed').length, + columns: columns.map(c => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })), + indexes: indexResults + }; + } + + async dropSchema() { + const sql = this._getSql(); + await sql.query('DROP TABLE IF EXISTS scheduled_messages CASCADE'); + } + + async createTask(params) { + const sql = this._getSql(); + const rows = await sql.query( + `INSERT INTO scheduled_messages + (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', 0, NOW(), NOW()) + RETURNING id, uuid, next_send_at, status, created_at`, + [params.user_id, params.uuid, params.encrypted_payload, params.next_send_at, params.message_type] + ); + return rows[0] || null; + } + + async getTaskByUuid(uuid, userId) { + const sql = this._getSql(); + const rows = await sql.query( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = $1 AND user_id = $2 AND status = 'pending' + LIMIT 1`, + [uuid, userId] + ); + return rows[0] || null; + } + + async getTaskByUuidOnly(uuid) { + const sql = this._getSql(); + const rows = await sql.query( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = $1 AND status = 'pending' + LIMIT 1`, + [uuid] + ); + return rows[0] || null; + } + + async updateTaskById(taskId, updates) { + const sql = this._getSql(); + const sets = []; + const values = []; + let idx = 1; + + for (const [key, value] of Object.entries(updates)) { + sets.push(`${key} = $${idx}`); + values.push(value); + idx++; + } + + sets.push('updated_at = NOW()'); + values.push(taskId); + + const rows = await sql.query( + `UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return rows[0] || null; + } + + async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) { + const sql = this._getSql(); + const sets = ['encrypted_payload = $1', 'updated_at = NOW()']; + const values = [encryptedPayload]; + let idx = 2; + + if (extraFields) { + for (const [key, value] of Object.entries(extraFields)) { + sets.push(`${key} = $${idx}`); + values.push(value); + idx++; + } + } + + values.push(uuid, userId); + const rows = await sql.query( + `UPDATE scheduled_messages SET ${sets.join(', ')} + WHERE uuid = $${idx} AND user_id = $${idx + 1} AND status = 'pending' + RETURNING uuid, updated_at`, + values + ); + return rows[0] || null; + } + + async deleteTaskById(taskId) { + const sql = this._getSql(); + const rows = await sql.query('DELETE FROM scheduled_messages WHERE id = $1 RETURNING id', [taskId]); + return rows.length > 0; + } + + async deleteTaskByUuid(uuid, userId) { + const sql = this._getSql(); + const rows = await sql.query( + 'DELETE FROM scheduled_messages WHERE uuid = $1 AND user_id = $2 RETURNING id', + [uuid, userId] + ); + return rows.length > 0; + } + + async getPendingTasks(limit = 50) { + const sql = this._getSql(); + return sql.query( + `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 <= NOW() + ORDER BY next_send_at ASC + LIMIT $1`, + [limit] + ); + } + + async listTasks(userId, opts = {}) { + const sql = this._getSql(); + const { status = 'all', limit = 20, offset = 0 } = opts; + + const conditions = ['user_id = $1']; + const params = [userId]; + let idx = 2; + + if (status !== 'all') { + conditions.push(`status = $${idx}`); + params.push(status); + idx++; + } + + const where = conditions.join(' AND '); + + const countRows = await sql.query( + `SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}`, + params + ); + const total = parseInt(countRows[0].count, 10); + + const taskParams = [...params, limit, offset]; + const tasks = await sql.query( + `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 $${idx} OFFSET $${idx + 1}`, + taskParams + ); + + return { tasks, total }; + } + + async cleanupOldTasks(days = 7) { + const sql = this._getSql(); + const safeDays = Math.max(1, Math.floor(Number(days))); + const rows = await sql.query( + `DELETE FROM scheduled_messages + WHERE status IN ('sent', 'failed') + AND updated_at < NOW() - make_interval(days => $1) + RETURNING id`, + [safeDays] + ); + return rows.length; + } + + async getTaskStatus(uuid, userId) { + const sql = this._getSql(); + const rows = await sql.query( + 'SELECT status FROM scheduled_messages WHERE uuid = $1 AND user_id = $2 LIMIT 1', + [uuid, userId] + ); + return rows.length > 0 ? rows[0].status : null; + } +} diff --git a/packages/rei-standard-amsg/server/src/server/adapters/pg.js b/packages/rei-standard-amsg/server/src/server/adapters/pg.js new file mode 100644 index 0000000..5a0374c --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/pg.js @@ -0,0 +1,244 @@ +/** + * PostgreSQL (pg) Database Adapter + * ReiStandard SDK v1.1.0 + * + * Uses the standard 'pg' npm package. + * + * @implements {import('./interface.js').DbAdapter} + */ + +import { Pool } from 'pg'; +import { TABLE_SQL, INDEXES, VERIFY_TABLE_SQL, COLUMNS_SQL } from './schema.js'; + +export class PgAdapter { + /** @param {string} connectionString */ + constructor(connectionString) { + /** @private */ + this._connectionString = connectionString; + /** @private */ + this._pool = null; + } + + /** @private */ + _getPool() { + if (!this._pool) { + this._pool = new Pool({ connectionString: this._connectionString }); + } + return this._pool; + } + + /** @private */ + async _query(text, params) { + const pool = this._getPool(); + const result = await pool.query(text, params); + return result.rows; + } + + async initSchema() { + await this._query(TABLE_SQL); + + const indexResults = []; + for (const index of INDEXES) { + try { + await this._query(index.sql); + 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((index) => index.critical && index.status === 'failed'); + if (criticalFailures.length > 0) { + const failedNames = criticalFailures.map((index) => index.name).join(', '); + throw new Error( + `Critical index creation failed (${failedNames}). ` + + 'Please remove duplicate UUID rows and run initSchema again.' + ); + } + + const tableCheck = await this._query(VERIFY_TABLE_SQL); + if (tableCheck.length === 0) { + throw new Error('Table creation verification failed'); + } + + const columns = await this._query(COLUMNS_SQL); + + return { + columnsCreated: columns.length, + indexesCreated: indexResults.filter(r => r.status === 'success').length, + indexesFailed: indexResults.filter(r => r.status === 'failed').length, + columns: columns.map(c => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })), + indexes: indexResults + }; + } + + async dropSchema() { + await this._query('DROP TABLE IF EXISTS scheduled_messages CASCADE'); + } + + async createTask(params) { + const rows = await this._query( + `INSERT INTO scheduled_messages + (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', 0, NOW(), NOW()) + RETURNING id, uuid, next_send_at, status, created_at`, + [params.user_id, params.uuid, params.encrypted_payload, params.next_send_at, params.message_type] + ); + return rows[0] || null; + } + + async getTaskByUuid(uuid, userId) { + const rows = await this._query( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = $1 AND user_id = $2 AND status = 'pending' + LIMIT 1`, + [uuid, userId] + ); + return rows[0] || null; + } + + async getTaskByUuidOnly(uuid) { + const rows = await this._query( + `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count + FROM scheduled_messages + WHERE uuid = $1 AND status = 'pending' + LIMIT 1`, + [uuid] + ); + return rows[0] || null; + } + + async updateTaskById(taskId, updates) { + const sets = []; + const values = []; + let idx = 1; + + for (const [key, value] of Object.entries(updates)) { + sets.push(`${key} = $${idx}`); + values.push(value); + idx++; + } + + sets.push('updated_at = NOW()'); + values.push(taskId); + + const rows = await this._query( + `UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return rows[0] || null; + } + + async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) { + const sets = ['encrypted_payload = $1', 'updated_at = NOW()']; + const values = [encryptedPayload]; + let idx = 2; + + if (extraFields) { + for (const [key, value] of Object.entries(extraFields)) { + sets.push(`${key} = $${idx}`); + values.push(value); + idx++; + } + } + + values.push(uuid, userId); + const rows = await this._query( + `UPDATE scheduled_messages SET ${sets.join(', ')} + WHERE uuid = $${idx} AND user_id = $${idx + 1} AND status = 'pending' + RETURNING uuid, updated_at`, + values + ); + return rows[0] || null; + } + + async deleteTaskById(taskId) { + const rows = await this._query('DELETE FROM scheduled_messages WHERE id = $1 RETURNING id', [taskId]); + return rows.length > 0; + } + + async deleteTaskByUuid(uuid, userId) { + const rows = await this._query( + 'DELETE FROM scheduled_messages WHERE uuid = $1 AND user_id = $2 RETURNING id', + [uuid, userId] + ); + return rows.length > 0; + } + + async getPendingTasks(limit = 50) { + return this._query( + `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 <= NOW() + ORDER BY next_send_at ASC + LIMIT $1`, + [limit] + ); + } + + async listTasks(userId, opts = {}) { + const { status = 'all', limit = 20, offset = 0 } = opts; + + const conditions = ['user_id = $1']; + const params = [userId]; + let idx = 2; + + if (status !== 'all') { + conditions.push(`status = $${idx}`); + params.push(status); + idx++; + } + + const where = conditions.join(' AND '); + + const countRows = await this._query( + `SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}`, + params + ); + const total = parseInt(countRows[0].count, 10); + + const taskParams = [...params, limit, offset]; + const tasks = await this._query( + `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 $${idx} OFFSET $${idx + 1}`, + taskParams + ); + + return { tasks, total }; + } + + async cleanupOldTasks(days = 7) { + const safeDays = Math.max(1, Math.floor(Number(days))); + const rows = await this._query( + `DELETE FROM scheduled_messages + WHERE status IN ('sent', 'failed') + AND updated_at < NOW() - make_interval(days => $1) + RETURNING id`, + [safeDays] + ); + return rows.length; + } + + async getTaskStatus(uuid, userId) { + const rows = await this._query( + 'SELECT status FROM scheduled_messages WHERE uuid = $1 AND user_id = $2 LIMIT 1', + [uuid, userId] + ); + return rows.length > 0 ? rows[0].status : null; + } +} diff --git a/packages/rei-standard-amsg/server/src/server/adapters/schema.js b/packages/rei-standard-amsg/server/src/server/adapters/schema.js new file mode 100644 index 0000000..0f80b8a --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/adapters/schema.js @@ -0,0 +1,77 @@ +/** + * Shared SQL schema constants + * ReiStandard SDK v1.1.0 + */ + +export const TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS scheduled_messages ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + uuid VARCHAR(36), + encrypted_payload TEXT NOT NULL, + message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')), + next_send_at TIMESTAMP WITH TIME ZONE NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + retry_count INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) +`; + +export const 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)' + }, + { + 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' + }, + { + 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' + }, + { + name: 'idx_user_id', + sql: `CREATE INDEX IF NOT EXISTS idx_user_id + ON scheduled_messages (user_id)`, + description: 'User task query index' + }, + { + 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 + } +]; + +export const VERIFY_TABLE_SQL = ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'scheduled_messages' +`; + +export const COLUMNS_SQL = ` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'scheduled_messages' + ORDER BY ordinal_position +`; + +export const REQUIRED_COLUMNS = [ + 'id', 'user_id', 'uuid', 'encrypted_payload', + 'message_type', 'next_send_at', 'status', 'retry_count' +]; diff --git a/packages/rei-standard-amsg/server/src/server/handlers/cancel-message.js b/packages/rei-standard-amsg/server/src/server/handlers/cancel-message.js new file mode 100644 index 0000000..0237b7d --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/cancel-message.js @@ -0,0 +1,42 @@ +/** + * Handler: cancel-message + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context. + * @returns {{ DELETE: function }} + */ + +export function createCancelMessageHandler(ctx) { + async function DELETE(url, headers) { + const u = new URL(url, 'https://dummy'); + const taskUuid = u.searchParams.get('id'); + + if (!taskUuid) { + return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } }; + } + + const userId = headers['x-user-id']; + if (!userId) { + return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } }; + } + + const deleted = await ctx.db.deleteTaskByUuid(taskUuid, userId); + + if (!deleted) { + return { + status: 404, + body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } + }; + } + + return { + status: 200, + body: { + success: true, + data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() } + } + }; + } + + return { DELETE }; +} diff --git a/packages/rei-standard-amsg/server/src/server/handlers/get-master-key.js b/packages/rei-standard-amsg/server/src/server/handlers/get-master-key.js new file mode 100644 index 0000000..eecef5a --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/get-master-key.js @@ -0,0 +1,33 @@ +/** + * Handler: get-master-key + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context. + * @returns {{ GET: function }} + */ + +export function createGetMasterKeyHandler(ctx) { + async function GET(headers) { + const userId = headers['x-user-id']; + + if (!userId) { + return { + status: 400, + body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } + }; + } + + return { + status: 200, + body: { + success: true, + data: { + masterKey: ctx.encryptionKey, + version: 1 + } + } + }; + } + + return { GET }; +} diff --git a/packages/rei-standard-amsg/server/src/server/handlers/init-database.js b/packages/rei-standard-amsg/server/src/server/handlers/init-database.js new file mode 100644 index 0000000..8fee747 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/init-database.js @@ -0,0 +1,99 @@ +/** + * Handler: init-database + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context injected by createReiServer. + * @returns {{ GET: function, POST: function }} + */ + +import { REQUIRED_COLUMNS } from '../adapters/schema.js'; +import { parseJsonBody } from '../lib/request.js'; + +export function createInitDatabaseHandler(ctx) { + async function GET(headers) { + if (!ctx.initSecret) { + return { + status: 500, + body: { success: false, error: { code: 'INIT_SECRET_MISSING', message: 'initSecret 未配置,请在 createReiServer 配置中提供 initSecret' } } + }; + } + + const authHeader = (headers['authorization'] || '').trim(); + const expectedAuth = `Bearer ${ctx.initSecret}`; + + if (authHeader !== expectedAuth) { + return { + status: 401, + body: { success: false, error: { code: 'UNAUTHORIZED', message: '需要认证。请在请求头中添加: Authorization: Bearer {INIT_SECRET}' } } + }; + } + + const result = await ctx.db.initSchema(); + + const columnNames = result.columns.map(c => c.name); + const missingColumns = REQUIRED_COLUMNS.filter(col => !columnNames.includes(col)); + if (missingColumns.length > 0) { + console.warn('[init-database] ⚠️ Missing columns:', missingColumns); + } + + return { + status: 200, + body: { + success: true, + message: '数据库初始化成功!建议立即删除此 API 文件。', + data: { + table: 'scheduled_messages', + columnsCreated: result.columnsCreated, + indexesCreated: result.indexesCreated, + indexesFailed: result.indexesFailed, + details: { columns: result.columns, indexes: result.indexes }, + nextSteps: [ + '1. 验证表和索引已正确创建', + '2. 立即删除 /app/api/v1/init-database/route.js 文件', + '3. 从 .env 中删除 INIT_SECRET(可选)', + '4. 开始使用 ReiStandard API' + ] + } + } + }; + } + + async function POST(headers, body) { + if (!ctx.initSecret) { + return { + status: 500, + body: { success: false, error: { code: 'INIT_SECRET_MISSING', message: 'initSecret 未配置,请在 createReiServer 配置中提供 initSecret' } } + }; + } + + const authHeader = (headers['authorization'] || '').trim(); + const expectedAuth = `Bearer ${ctx.initSecret}`; + + if (authHeader !== expectedAuth) { + return { + status: 401, + body: { success: false, error: { code: 'UNAUTHORIZED', message: '需要认证' } } + }; + } + + const parsedBody = parseJsonBody(body); + if (!parsedBody.ok) { + return { + status: 400, + body: { success: false, error: parsedBody.error } + }; + } + + if (parsedBody.data.confirm !== 'DELETE_ALL_DATA') { + return { + status: 400, + body: { success: false, error: { code: 'CONFIRMATION_REQUIRED', message: '需要在请求体中提供确认参数: { "confirm": "DELETE_ALL_DATA" }' } } + }; + } + + await ctx.db.dropSchema(); + return GET(headers); + } + + return { GET, POST }; +} diff --git a/packages/rei-standard-amsg/server/src/server/handlers/messages.js b/packages/rei-standard-amsg/server/src/server/handlers/messages.js new file mode 100644 index 0000000..d442df5 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/messages.js @@ -0,0 +1,74 @@ +/** + * Handler: messages + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context. + * @returns {{ GET: function }} + */ + +import { deriveUserEncryptionKey, decryptFromStorage, encryptPayload } from '../lib/encryption.js'; + +export function createMessagesHandler(ctx) { + async function GET(url, headers) { + const userId = headers['x-user-id']; + + if (!userId) { + return { + status: 400, + body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } } + }; + } + + const u = new URL(url, 'https://dummy'); + const status = u.searchParams.get('status') || 'all'; + const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100); + const offset = parseInt(u.searchParams.get('offset') || '0', 10); + + if (isNaN(limit) || limit < 1) { + return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } }; + } + + if (isNaN(offset) || offset < 0) { + return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } }; + } + + const { tasks, total } = await ctx.db.listTasks(userId, { status, limit, offset }); + + const userKey = deriveUserEncryptionKey(userId, ctx.encryptionKey); + + const decryptedTasks = tasks.map(task => { + const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + return { + id: task.id, + uuid: task.uuid, + contactName: decrypted.contactName, + messageType: task.message_type, + messageSubtype: decrypted.messageSubtype, + nextSendAt: task.next_send_at, + recurrenceType: decrypted.recurrenceType, + status: task.status, + retryCount: task.retry_count, + createdAt: task.created_at, + updatedAt: task.updated_at + }; + }); + + const responsePayload = { + tasks: decryptedTasks, + pagination: { total, limit, offset, hasMore: offset + limit < total } + }; + const encryptedResponse = encryptPayload(responsePayload, userKey); + + return { + status: 200, + body: { + success: true, + encrypted: true, + version: 1, + data: encryptedResponse + } + }; + } + + return { GET }; +} diff --git a/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js b/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js new file mode 100644 index 0000000..c660632 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js @@ -0,0 +1,187 @@ +/** + * Handler: schedule-message + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context. + * @returns {{ POST: function }} + */ + +import { randomUUID } from 'crypto'; +import { deriveUserEncryptionKey, decryptPayload, encryptForStorage } from '../lib/encryption.js'; +import { isUniqueViolation } from '../lib/db-errors.js'; +import { parseEncryptedBody, isPlainObject } from '../lib/request.js'; +import { validateScheduleMessagePayload } from '../lib/validation.js'; +import { processMessagesByUuid } from '../lib/message-processor.js'; + +export function createScheduleMessageHandler(ctx) { + async function POST(headers, body) { + const isEncrypted = headers['x-payload-encrypted'] === 'true'; + const encryptionVersion = headers['x-encryption-version']; + const userId = headers['x-user-id']; + + if (!isEncrypted) { + return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } }; + } + if (!userId) { + return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } }; + } + if (encryptionVersion !== '1') { + return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } }; + } + + // Decrypt request body + const parsedBody = parseEncryptedBody(body); + if (!parsedBody.ok) { + return { status: 400, body: { success: false, error: parsedBody.error } }; + } + + const encryptedBody = parsedBody.data; + + let payload; + try { + const userKey = deriveUserEncryptionKey(userId, ctx.encryptionKey); + payload = decryptPayload(encryptedBody, userKey); + } catch (error) { + if (error instanceof SyntaxError) { + return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } }; + } + + const message = typeof error.message === 'string' ? error.message : ''; + if (message.includes('auth') || message.includes('Unsupported state')) { + return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } }; + } + + return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } }; + } + + if (!isPlainObject(payload)) { + return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } }; + } + + // Validate + const validationResult = validateScheduleMessagePayload(payload); + if (!validationResult.valid) { + return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } }; + } + + const taskUuid = payload.uuid || randomUUID(); + const userKey = deriveUserEncryptionKey(userId, ctx.encryptionKey); + + const fullTaskData = { + contactName: payload.contactName, + avatarUrl: payload.avatarUrl || null, + messageType: payload.messageType, + messageSubtype: payload.messageSubtype || 'chat', + userMessage: payload.userMessage || null, + firstSendTime: payload.firstSendTime, + recurrenceType: payload.recurrenceType || 'none', + apiUrl: payload.apiUrl || null, + apiKey: payload.apiKey || null, + primaryModel: payload.primaryModel || null, + completePrompt: payload.completePrompt || null, + pushSubscription: payload.pushSubscription, + metadata: payload.metadata || {} + }; + + const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey); + + // Instant type: check VAPID before creating the task to avoid orphaned rows + if (payload.messageType === 'instant') { + 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) + } + } + } + }; + } + } + + // Insert into database + let dbResult; + try { + dbResult = await ctx.db.createTask({ + user_id: userId, + uuid: taskUuid, + encrypted_payload: encryptedPayload, + next_send_at: payload.firstSendTime, + message_type: payload.messageType + }); + } catch (error) { + if (isUniqueViolation(error)) { + return { + status: 409, + body: { + success: false, + error: { + code: 'TASK_UUID_CONFLICT', + message: '任务 UUID 已存在,请使用新的 uuid 重新提交' + } + } + }; + } + throw error; + } + + if (!dbResult) { + return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } }; + } + + // Instant type: send immediately + if (payload.messageType === 'instant') { + try { + const sendResult = await processMessagesByUuid(taskUuid, ctx, 2, userId); + + if (!sendResult.success) { + return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } }; + } + + return { + status: 200, + body: { + success: true, + data: { + uuid: taskUuid, + contactName: payload.contactName, + messagesSent: sendResult.messagesSent, + sentAt: new Date().toISOString(), + status: 'sent', + retriesUsed: sendResult.retriesUsed || 0 + } + } + }; + } catch (error) { + return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } }; + } + } + + // Non-instant: return scheduled response + return { + status: 201, + body: { + success: true, + data: { + id: dbResult.id, + uuid: dbResult.uuid, + contactName: payload.contactName, + nextSendAt: dbResult.next_send_at, + status: dbResult.status, + createdAt: dbResult.created_at + } + } + }; + } + + return { POST }; +} 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 new file mode 100644 index 0000000..a15aeea --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/send-notifications.js @@ -0,0 +1,181 @@ +/** + * Handler: send-notifications + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context. + * @returns {{ POST: function }} + */ + +import { deriveUserEncryptionKey } from '../lib/encryption.js'; +import { decryptFromStorage } from '../lib/encryption.js'; +import { processSingleMessage } from '../lib/message-processor.js'; + +export function createSendNotificationsHandler(ctx) { + async function POST(headers) { + 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) + } + } + } + }; + } + + // Verify Cron Secret + const authHeader = (headers['authorization'] || '').trim(); + const expectedAuth = `Bearer ${ctx.cronSecret}`; + + if (authHeader !== expectedAuth) { + return { status: 401, body: { success: false, error: { code: 'UNAUTHORIZED', message: 'Cron Secret 验证失败' } } }; + } + + const startTime = Date.now(); + const tasks = await ctx.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 ctx.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 ctx.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 ctx.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); + } catch (error) { + await handleDeliveryFailure(task, error.message || '消息发送失败'); + return; + } + + if (!sendResult.success) { + await handleDeliveryFailure(task, sendResult.error || '消息发送失败'); + return; + } + + try { + const userKey = deriveUserEncryptionKey(task.user_id, ctx.encryptionKey); + const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + + if (decryptedPayload.recurrenceType === 'none') { + await ctx.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 ctx.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 ctx.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 + } + } + } + }; + } + + return { POST }; +} diff --git a/packages/rei-standard-amsg/server/src/server/handlers/update-message.js b/packages/rei-standard-amsg/server/src/server/handlers/update-message.js new file mode 100644 index 0000000..b776a83 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/update-message.js @@ -0,0 +1,110 @@ +/** + * Handler: update-message + * ReiStandard SDK v1.1.0 + * + * @param {Object} ctx - Server context. + * @returns {{ PUT: function }} + */ + +import { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from '../lib/encryption.js'; +import { parseEncryptedBody, isPlainObject } from '../lib/request.js'; +import { isValidISO8601 } from '../lib/validation.js'; + +export function createUpdateMessageHandler(ctx) { + async function PUT(url, headers, body) { + const u = new URL(url, 'https://dummy'); + const taskUuid = u.searchParams.get('id'); + + if (!taskUuid) { + return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } }; + } + + const userId = headers['x-user-id']; + if (!userId) { + return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } }; + } + + const isEncrypted = headers['x-payload-encrypted'] === 'true'; + const encryptionVersion = headers['x-encryption-version']; + + if (!isEncrypted) { + return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } }; + } + + if (encryptionVersion !== '1') { + return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } }; + } + + const parsedBody = parseEncryptedBody(body); + if (!parsedBody.ok) { + return { status: 400, body: { success: false, error: parsedBody.error } }; + } + + const encryptedBody = parsedBody.data; + const userKey = deriveUserEncryptionKey(userId, ctx.encryptionKey); + let updates; + + try { + updates = decryptPayload(encryptedBody, userKey); + } catch (_error) { + return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } }; + } + + if (!isPlainObject(updates)) { + return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } }; + } + + if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) { + return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } }; + } + + if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) { + return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } }; + } + + // Fetch existing task + const existingTask = await ctx.db.getTaskByUuid(taskUuid, userId); + + if (!existingTask) { + const taskStatus = await ctx.db.getTaskStatus(taskUuid, userId); + if (!taskStatus) { + return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } }; + } + return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } }; + } + + const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey)); + + const updatedData = { + ...existingData, + ...(updates.completePrompt && { completePrompt: updates.completePrompt }), + ...(updates.userMessage && { userMessage: updates.userMessage }), + ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }), + ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }), + ...(updates.metadata && { metadata: updates.metadata }) + }; + + const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey); + const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined; + + const result = await ctx.db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields); + + if (!result) { + return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } }; + } + + return { + status: 200, + body: { + success: true, + data: { + uuid: taskUuid, + updatedFields: Object.keys(updates), + updatedAt: result.updated_at + } + } + }; + } + + return { PUT }; +} diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js new file mode 100644 index 0000000..ce2769a --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -0,0 +1,146 @@ +/** + * ReiStandard Server SDK Entry Point + * v1.1.0 + * + * Usage: + * import { createReiServer } from '@rei-standard/amsg-server'; + * + * const rei = createReiServer({ + * db: { driver: 'neon', connectionString: process.env.DATABASE_URL }, + * encryptionKey: process.env.ENCRYPTION_KEY, + * cronSecret: process.env.CRON_SECRET, + * vapid: { + * email: process.env.VAPID_EMAIL, + * publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + * privateKey: process.env.VAPID_PRIVATE_KEY, + * }, + * initSecret: process.env.INIT_SECRET, + * }); + * + * // rei.handlers – object with 7 route handler factories + * // rei.adapter – the underlying database adapter + */ + +import { createAdapter } from './adapters/factory.js'; +import { createInitDatabaseHandler } from './handlers/init-database.js'; +import { createGetMasterKeyHandler } from './handlers/get-master-key.js'; +import { createScheduleMessageHandler } from './handlers/schedule-message.js'; +import { createSendNotificationsHandler } from './handlers/send-notifications.js'; +import { createUpdateMessageHandler } from './handlers/update-message.js'; +import { createCancelMessageHandler } from './handlers/cancel-message.js'; +import { createMessagesHandler } from './handlers/messages.js'; + +function normalizeVapidSubject(email) { + const trimmedEmail = String(email || '').trim(); + if (!trimmedEmail) return ''; + return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`; +} + +/** + * @typedef {Object} VapidConfig + * @property {string} [email] - VAPID contact email (e.g. mailto:…). + * @property {string} [publicKey] - VAPID public key. + * @property {string} [privateKey] - VAPID private key. + */ + +/** + * @typedef {'neon'|'pg'} DriverName + */ + +/** + * @typedef {Object} DbConfig + * @property {DriverName} driver - Database driver name. + * @property {string} connectionString - Database connection URL. + */ + +/** + * @typedef {Object} ReiServerConfig + * @property {DbConfig} db - Database configuration. + * @property {string} encryptionKey - 64-char hex master encryption key. + * @property {string} [cronSecret] - Bearer token for cron-triggered endpoints. + * @property {VapidConfig} [vapid] - VAPID keys for Web Push. + * @property {string} [initSecret] - Bearer token for the init-database endpoint. + */ + +/** + * @typedef {Object} ReiHandlers + * @property {{ GET: function, POST: function }} initDatabase + * @property {{ GET: function }} getMasterKey + * @property {{ POST: function }} scheduleMessage + * @property {{ POST: function }} sendNotifications + * @property {{ PUT: function }} updateMessage + * @property {{ DELETE: function }} cancelMessage + * @property {{ GET: function }} messages + */ + +/** + * @typedef {Object} ReiServer + * @property {ReiHandlers} handlers - The 7 standard API route handler objects. + * @property {import('./adapters/interface.js').DbAdapter} adapter - The database adapter instance. + */ + +/** + * Initialise the ReiStandard server. + * + * @param {ReiServerConfig} config + * @returns {Promise} + */ +export async function createReiServer(config) { + if (!config) throw new Error('[rei-standard-amsg-server] config is required'); + if (!config.encryptionKey) throw new Error('[rei-standard-amsg-server] encryptionKey is required'); + + const adapter = await createAdapter(config.db); + + // web-push is a hard dependency for ReiStandard server features + let webpushModule; + try { + const webpushImport = await import('web-push'); + webpushModule = webpushImport.default || webpushImport; + } catch (_err) { + throw new Error( + '[rei-standard-amsg-server] web-push is required. Install it with: npm install web-push' + ); + } + + const vapid = config.vapid || {}; + + if (vapid.email && vapid.publicKey && vapid.privateKey) { + webpushModule.setVapidDetails( + normalizeVapidSubject(vapid.email), + vapid.publicKey, + vapid.privateKey + ); + } + + /** @type {Object} Shared context injected into every handler */ + const ctx = { + db: adapter, + encryptionKey: config.encryptionKey, + cronSecret: config.cronSecret || '', + initSecret: config.initSecret || '', + vapid: { + email: vapid.email || '', + publicKey: vapid.publicKey || '', + privateKey: vapid.privateKey || '' + }, + webpush: webpushModule + }; + + return { + handlers: { + initDatabase: createInitDatabaseHandler(ctx), + getMasterKey: createGetMasterKeyHandler(ctx), + scheduleMessage: createScheduleMessageHandler(ctx), + sendNotifications: createSendNotificationsHandler(ctx), + updateMessage: createUpdateMessageHandler(ctx), + cancelMessage: createCancelMessageHandler(ctx), + messages: createMessagesHandler(ctx) + }, + adapter + }; +} + +// Re-export utilities that consumers may need +export { createAdapter } from './adapters/factory.js'; +export { deriveUserEncryptionKey, decryptPayload, encryptForStorage, decryptFromStorage } from './lib/encryption.js'; +export { validateScheduleMessagePayload, isValidISO8601, isValidUrl, isValidUUID } from './lib/validation.js'; diff --git a/packages/rei-standard-amsg/server/src/server/lib/db-errors.js b/packages/rei-standard-amsg/server/src/server/lib/db-errors.js new file mode 100644 index 0000000..c461ddc --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/db-errors.js @@ -0,0 +1,19 @@ +/** + * Database error helpers. + */ + +/** + * Check whether an error is caused by a unique-constraint violation. + * + * @param {unknown} error + * @returns {boolean} + */ +export function isUniqueViolation(error) { + if (!error || typeof error !== 'object') return false; + + const code = error.code; + if (code === '23505') return true; + + const message = typeof error.message === 'string' ? error.message.toLowerCase() : ''; + return message.includes('duplicate key') || message.includes('unique constraint'); +} diff --git a/packages/rei-standard-amsg/server/src/server/lib/encryption.js b/packages/rei-standard-amsg/server/src/server/lib/encryption.js new file mode 100644 index 0000000..e90a0be --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/encryption.js @@ -0,0 +1,102 @@ +/** + * Encryption utility library (SDK version) + * ReiStandard SDK v1.1.0 + * + * Wraps AES-256-GCM operations for request/response and storage encryption. + */ + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; + +/** + * Derive a user-specific encryption key from the master key. + * + * @param {string} userId - Unique user identifier. + * @param {string} masterKey - 64-char hex master key (ENCRYPTION_KEY env var). + * @returns {string} 64-char hex key. + */ +export function deriveUserEncryptionKey(userId, masterKey) { + return createHash('sha256') + .update(masterKey + userId) + .digest('hex') + .slice(0, 64); +} + +/** + * Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded). + * + * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload + * @param {string} encryptionKey - 64-char hex key. + * @returns {Object} Decrypted JSON object. + */ +export function decryptPayload(encryptedPayload, encryptionKey) { + const { iv, authTag, encryptedData } = encryptedPayload; + + const decipher = createDecipheriv( + 'aes-256-gcm', + Buffer.from(encryptionKey, 'hex'), + Buffer.from(iv, 'base64') + ); + + decipher.setAuthTag(Buffer.from(authTag, 'base64')); + + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encryptedData, 'base64')), + decipher.final() + ]); + + return JSON.parse(decrypted.toString('utf8')); +} + +/** + * Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded). + * + * @param {string|Object} payload + * @param {string} encryptionKey - 64-char hex key. + * @returns {{ iv: string, authTag: string, encryptedData: string }} + */ +export function encryptPayload(payload, encryptionKey) { + const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return { + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + encryptedData: encrypted.toString('base64') + }; +} + +/** + * Encrypt data for database storage (hex encoded, colon-separated). + * + * @param {string} text - Plaintext string. + * @param {string} encryptionKey - 64-char hex key. + * @returns {string} Format: iv:authTag:encryptedData + */ +export function encryptForStorage(text, encryptionKey) { + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv); + const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex'); + const authTag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; +} + +/** + * Decrypt data from database storage format. + * + * @param {string} encryptedText - Format: iv:authTag:encryptedData + * @param {string} encryptionKey - 64-char hex key. + * @returns {string} Plaintext string. + */ +export function decryptFromStorage(encryptedText, encryptionKey) { + const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':'); + const decipher = createDecipheriv( + 'aes-256-gcm', + Buffer.from(encryptionKey, 'hex'), + Buffer.from(ivHex, 'hex') + ); + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); + return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8'); +} diff --git a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js new file mode 100644 index 0000000..f5bb0a8 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js @@ -0,0 +1,209 @@ +/** + * Message Processor (SDK version) + * ReiStandard SDK v1.1.0 + * + * Handles single message content generation and Web Push delivery. + * Receives its dependencies (encryption helpers, webpush, VAPID config) + * via a context object so that it stays free of process.env references. + */ + +import { randomUUID } from 'crypto'; +import { decryptFromStorage, deriveUserEncryptionKey } from './encryption.js'; + +/** + * @typedef {Object} ProcessorContext + * @property {string} encryptionKey - 64-char hex master key. + * @property {Object} webpush - The web-push module instance (already VAPID-configured). + * @property {Object} vapid - { email, publicKey, privateKey } + * @property {import('../adapters/interface.js').DbAdapter} db + */ + +/** + * Process a single database task row: decrypt → generate content → push. + * + * @param {import('../adapters/interface.js').TaskRow} task + * @param {ProcessorContext} ctx + * @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>} + */ +export async function processSingleMessage(task, ctx) { + try { + const userKey = deriveUserEncryptionKey(task.user_id, ctx.encryptionKey); + const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); + + let messageContent; + + if (decryptedPayload.messageType === 'fixed') { + messageContent = decryptedPayload.userMessage; + + } else if (decryptedPayload.messageType === 'instant') { + if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) { + messageContent = await _callAI(decryptedPayload); + } else if (decryptedPayload.userMessage) { + messageContent = decryptedPayload.userMessage; + } else { + throw new Error('Invalid instant message: no content source available'); + } + + } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') { + messageContent = await _callAI(decryptedPayload); + } else { + throw new Error('Invalid message configuration: no content source available'); + } + + // Sentence splitting + const sentences = messageContent + .split(/([。!?!?]+)/) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) { + const punctuation = arr[i + 1] || ''; + acc.push(part.trim() + punctuation); + } + return acc; + }, []) + .filter(s => s.length > 0); + + const messages = sentences.length > 0 ? sentences : [messageContent]; + + if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) { + throw new Error('VAPID configuration missing - push notifications cannot be sent'); + } + + const pushSubscription = decryptedPayload.pushSubscription; + + for (let i = 0; i < messages.length; i++) { + const notificationPayload = { + title: `来自 ${decryptedPayload.contactName}`, + message: messages[i], + contactName: decryptedPayload.contactName, + messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`, + messageIndex: i + 1, + totalMessages: messages.length, + messageType: decryptedPayload.messageType, + messageSubtype: decryptedPayload.messageSubtype || 'chat', + taskId: task.id || null, + timestamp: new Date().toISOString(), + source: decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled', + avatarUrl: decryptedPayload.avatarUrl || null, + metadata: decryptedPayload.metadata || {} + }; + + await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload)); + + if (i < messages.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1500)); + } + } + + return { success: true, messagesSent: messages.length }; + + } catch (error) { + return { success: false, messagesSent: 0, error: error.message }; + } +} + +/** + * Process a single message identified by UUID (used for instant type). + * + * @param {string} uuid + * @param {ProcessorContext} ctx + * @param {number} [maxRetries=2] + * @param {string} [userId] + * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>} + */ +export async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId) { + let retryCount = 0; + + while (retryCount <= maxRetries) { + let task; + try { + task = userId + ? await ctx.db.getTaskByUuid(uuid, userId) + : await ctx.db.getTaskByUuidOnly(uuid); + } catch (error) { + if (retryCount < maxRetries) { + retryCount++; + await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); + continue; + } + + return { + success: false, + error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount } + }; + } + + if (!task) { + return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } }; + } + + const result = await processSingleMessage(task, ctx); + + if (!result.success) { + if (retryCount < maxRetries) { + retryCount++; + await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); + continue; + } + + try { + await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount }); + } catch (_updateError) { + // best-effort status update; keep original processing error as primary signal + } + + return { + success: false, + error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount } + }; + } + + try { + await ctx.db.deleteTaskById(task.id); + } catch (error) { + try { + await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 }); + } catch (_markSentError) { + // best effort: avoid re-sending if storage mutation partially fails + } + + return { + success: false, + error: { + code: 'POST_SEND_CLEANUP_FAILED', + message: '消息已发送,但任务清理失败', + details: { error: error.message } + } + }; + } + + return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount }; + } +} + +/** + * Call an OpenAI-compatible API. + * @private + */ +async function _callAI(payload) { + const aiResponse = await fetch(payload.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${payload.apiKey}` + }, + body: JSON.stringify({ + model: payload.primaryModel, + messages: [{ role: 'user', content: payload.completePrompt }], + max_tokens: 500, + temperature: 0.8 + }), + signal: AbortSignal.timeout(300000) + }); + + if (!aiResponse.ok) { + throw new Error(`AI API error: ${aiResponse.status} ${aiResponse.statusText}`); + } + + const aiData = await aiResponse.json(); + return aiData.choices[0].message.content.trim(); +} diff --git a/packages/rei-standard-amsg/server/src/server/lib/request.js b/packages/rei-standard-amsg/server/src/server/lib/request.js new file mode 100644 index 0000000..e4ca450 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/request.js @@ -0,0 +1,120 @@ +/** + * Request payload utilities. + * Keeps body parsing and shape validation consistent across handlers. + */ + +export const REQUEST_ERRORS = { + INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' }, + INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' }, + INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' } +}; + +/** + * @typedef {{ code: string, message: string }} ValidationError + */ + +/** + * @typedef {{ + * invalidJson?: ValidationError, + * invalidType?: ValidationError + * }} ParseBodyOptions + */ + +/** + * @typedef {{ + * ok: true, + * data: Record + * } | { + * ok: false, + * error: ValidationError + * }} ParseBodyResult + */ + +/** + * Parse body into a JSON object. + * + * @param {unknown} body + * @param {ParseBodyOptions} [options] + * @returns {ParseBodyResult} + */ +export function parseBodyAsObject(body, options = {}) { + const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON; + const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY; + + let parsed = body; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + return { ok: false, error: invalidJson }; + } + } + + if (!isPlainObject(parsed)) { + return { ok: false, error: invalidType }; + } + + return { ok: true, data: parsed }; +} + +/** + * Parse a standard JSON object body. + * + * @param {unknown} body + * @returns {ParseBodyResult} + */ +export function parseJsonBody(body) { + return parseBodyAsObject(body, { + invalidJson: REQUEST_ERRORS.INVALID_JSON, + invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY + }); +} + +/** + * Check if a value is a plain object (and not null/array). + * + * @param {unknown} value + * @returns {value is Record} + */ +export function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Check if an object follows the encrypted payload envelope shape. + * + * @param {unknown} payload + * @returns {payload is { iv: string, authTag: string, encryptedData: string }} + */ +export function isEncryptedEnvelope(payload) { + if (!isPlainObject(payload)) return false; + + return ( + typeof payload.iv === 'string' && + typeof payload.authTag === 'string' && + typeof payload.encryptedData === 'string' + ); +} + +/** + * Parse and validate an encrypted payload envelope. + * + * @param {unknown} body + * @returns {ParseBodyResult} + */ +export function parseEncryptedBody(body) { + const parsedBody = parseBodyAsObject(body, { + invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD, + invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD + }); + + if (!parsedBody.ok) { + return parsedBody; + } + + if (!isEncryptedEnvelope(parsedBody.data)) { + return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD }; + } + + return parsedBody; +} diff --git a/packages/rei-standard-amsg/server/src/server/lib/validation.js b/packages/rei-standard-amsg/server/src/server/lib/validation.js new file mode 100644 index 0000000..2a781de --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/lib/validation.js @@ -0,0 +1,110 @@ +/** + * Validation utility library (SDK version) + * ReiStandard SDK v1.1.0 + */ + +/** + * Validate ISO 8601 date string. + * @param {string} dateString + * @returns {boolean} + */ +export function isValidISO8601(dateString) { + const date = new Date(dateString); + return date instanceof Date && !isNaN(date.getTime()); +} + +/** + * Validate URL format. + * @param {string} urlString + * @returns {boolean} + */ +export function isValidUrl(urlString) { + try { + new URL(urlString); + return true; + } catch { + return false; + } +} + +/** + * Validate UUID format. + * @param {string} uuid + * @returns {boolean} + */ +export function isValidUUID(uuid) { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +} + +/** + * Validate the schedule-message request payload. + * + * @param {Object} payload + * @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }} + */ +export function validateScheduleMessagePayload(payload) { + if (!payload.contactName || typeof payload.contactName !== 'string') { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } }; + } + + if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) { + return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } }; + } + + if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) { + return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } }; + } + + if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) { + return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } }; + } + + if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } }; + } + + if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } }; + } + + if (payload.messageType === 'fixed') { + if (!payload.userMessage) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } }; + } + } + + if (payload.messageType === 'prompted' || payload.messageType === 'auto') { + const missingAiFields = []; + if (!payload.completePrompt) missingAiFields.push('completePrompt'); + if (!payload.apiUrl) missingAiFields.push('apiUrl'); + if (!payload.apiKey) missingAiFields.push('apiKey'); + if (!payload.primaryModel) missingAiFields.push('primaryModel'); + if (missingAiFields.length > 0) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } }; + } + } + + if (payload.messageType === 'instant') { + if (payload.recurrenceType && payload.recurrenceType !== 'none') { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } }; + } + const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel; + const hasUserMessage = payload.userMessage; + if (!hasAiConfig && !hasUserMessage) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or (completePrompt + apiUrl + apiKey + primaryModel)'] } }; + } + } + + if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } }; + } + if (payload.uuid && !isValidUUID(payload.uuid)) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } }; + } + if (payload.messageSubtype && !['chat', 'forum', 'moment'].includes(payload.messageSubtype)) { + return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } }; + } + + return { valid: true }; +} diff --git a/packages/rei-standard-amsg/server/test/sdk.test.mjs b/packages/rei-standard-amsg/server/test/sdk.test.mjs new file mode 100644 index 0000000..77ccd5a --- /dev/null +++ b/packages/rei-standard-amsg/server/test/sdk.test.mjs @@ -0,0 +1,664 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCipheriv, randomBytes } from 'node:crypto'; +import webpush from 'web-push'; +import { + createReiServer, + createAdapter, + deriveUserEncryptionKey, + encryptForStorage, + decryptFromStorage, + decryptPayload, + validateScheduleMessagePayload, + isValidISO8601, + isValidUrl, + isValidUUID +} from '../src/server/index.js'; +import { processMessagesByUuid } from '../src/server/lib/message-processor.js'; +import { createSendNotificationsHandler } from '../src/server/handlers/send-notifications.js'; + +function encryptClientPayload(payload, userId, masterKey) { + const userKey = deriveUserEncryptionKey(userId, masterKey); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', Buffer.from(userKey, 'hex'), iv); + const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return { + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + encryptedData: encrypted.toString('base64') + }; +} + +// ─── Encryption tests ────────────────────────────────────────── + +describe('encryption utilities', () => { + const masterKey = 'a'.repeat(64); + const userId = 'test-user'; + + it('deriveUserEncryptionKey returns a 64-char hex string', () => { + const key = deriveUserEncryptionKey(userId, masterKey); + assert.equal(key.length, 64); + assert.match(key, /^[0-9a-f]{64}$/); + }); + + it('encryptForStorage / decryptFromStorage round-trips', () => { + const key = deriveUserEncryptionKey(userId, masterKey); + const original = JSON.stringify({ hello: 'world', num: 42 }); + const encrypted = encryptForStorage(original, key); + + // format: iv:authTag:data + const parts = encrypted.split(':'); + assert.equal(parts.length, 3); + + const decrypted = decryptFromStorage(encrypted, key); + assert.equal(decrypted, original); + }); + + it('decryptFromStorage fails with wrong key', () => { + const key = deriveUserEncryptionKey(userId, masterKey); + const wrongKey = deriveUserEncryptionKey('other-user', masterKey); + const encrypted = encryptForStorage('secret', key); + + assert.throws(() => decryptFromStorage(encrypted, wrongKey)); + }); +}); + +// ─── Validation tests ────────────────────────────────────────── + +describe('validation utilities', () => { + it('isValidISO8601 accepts valid dates', () => { + assert.equal(isValidISO8601('2030-01-01T00:00:00Z'), true); + assert.equal(isValidISO8601('not a date'), false); + }); + + it('isValidUrl', () => { + assert.equal(isValidUrl('https://example.com'), true); + assert.equal(isValidUrl('ftp://x.com'), true); + assert.equal(isValidUrl('not-a-url'), false); + }); + + it('isValidUUID', () => { + assert.equal(isValidUUID('550e8400-e29b-41d4-a716-446655440000'), true); + assert.equal(isValidUUID('not-a-uuid'), false); + }); + + it('validateScheduleMessagePayload rejects missing contactName', () => { + const result = validateScheduleMessagePayload({}); + assert.equal(result.valid, false); + assert.equal(result.errorCode, 'INVALID_PARAMETERS'); + }); + + it('validateScheduleMessagePayload rejects invalid messageType', () => { + const result = validateScheduleMessagePayload({ contactName: 'A', messageType: 'bad' }); + assert.equal(result.valid, false); + assert.equal(result.errorCode, 'INVALID_MESSAGE_TYPE'); + }); + + it('validateScheduleMessagePayload accepts a valid fixed payload', () => { + const result = validateScheduleMessagePayload({ + contactName: 'Alice', + messageType: 'fixed', + firstSendTime: new Date(Date.now() + 60000).toISOString(), + pushSubscription: { endpoint: 'https://push.example.com' }, + userMessage: 'Hello!' + }); + assert.equal(result.valid, true); + }); +}); + +// ─── Adapter factory tests ───────────────────────────────────── + +describe('createAdapter', () => { + it('throws when no config', async () => { + await assert.rejects(() => createAdapter(), /driver.*required/i); + }); + + it('throws when driver is missing', async () => { + await assert.rejects(() => createAdapter({}), /driver.*required/i); + }); + + it('throws when connectionString is missing', async () => { + await assert.rejects(() => createAdapter({ driver: 'neon' }), /connectionString.*required/i); + }); + + it('throws for unsupported driver', async () => { + await assert.rejects(() => createAdapter({ driver: 'mysql', connectionString: 'x' }), /Unsupported driver/i); + }); +}); + +// ─── createReiServer tests ───────────────────────────────────── + +describe('createReiServer', () => { + it('throws without config', async () => { + await assert.rejects(() => createReiServer(), /config is required/i); + }); + + it('throws without encryptionKey', async () => { + await assert.rejects( + () => createReiServer({ db: { driver: 'neon', connectionString: 'postgres://x' } }), + /encryptionKey.*required/i + ); + }); + + it('returns handlers and adapter when configured with neon', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64) + }); + + assert.ok(server.handlers); + assert.ok(server.adapter); + assert.equal(typeof server.handlers.initDatabase.GET, 'function'); + assert.equal(typeof server.handlers.initDatabase.POST, 'function'); + assert.equal(typeof server.handlers.getMasterKey.GET, 'function'); + assert.equal(typeof server.handlers.scheduleMessage.POST, 'function'); + assert.equal(typeof server.handlers.sendNotifications.POST, 'function'); + assert.equal(typeof server.handlers.updateMessage.PUT, 'function'); + assert.equal(typeof server.handlers.cancelMessage.DELETE, 'function'); + assert.equal(typeof server.handlers.messages.GET, 'function'); + }); + + it('keeps existing mailto: prefix when configuring VAPID subject', async () => { + const vapidKeys = webpush.generateVAPIDKeys(); + const originalSetVapidDetails = webpush.setVapidDetails; + let capturedSubject = ''; + + webpush.setVapidDetails = (subject) => { + capturedSubject = subject; + }; + + try { + await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64), + vapid: { + email: 'mailto:test@example.com', + publicKey: vapidKeys.publicKey, + privateKey: vapidKeys.privateKey + } + }); + + assert.equal(capturedSubject, 'mailto:test@example.com'); + } finally { + webpush.setVapidDetails = originalSetVapidDetails; + } + }); + + it('adds mailto: prefix when VAPID email has no scheme', async () => { + const vapidKeys = webpush.generateVAPIDKeys(); + const originalSetVapidDetails = webpush.setVapidDetails; + let capturedSubject = ''; + + webpush.setVapidDetails = (subject) => { + capturedSubject = subject; + }; + + try { + await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64), + vapid: { + email: 'test@example.com', + publicKey: vapidKeys.publicKey, + privateKey: vapidKeys.privateKey + } + }); + + assert.equal(capturedSubject, 'mailto:test@example.com'); + } finally { + webpush.setVapidDetails = originalSetVapidDetails; + } + }); +}); + +// ─── Handler unit tests (no real DB) ─────────────────────────── + +describe('getMasterKey handler', () => { + let server; + + beforeEach(async () => { + server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'b'.repeat(64) + }); + }); + + it('returns 400 when X-User-Id is missing', async () => { + const result = await server.handlers.getMasterKey.GET({}); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'USER_ID_REQUIRED'); + }); + + it('returns masterKey when userId is present', async () => { + const result = await server.handlers.getMasterKey.GET({ 'x-user-id': 'u1' }); + assert.equal(result.status, 200); + assert.equal(result.body.data.masterKey, 'b'.repeat(64)); + assert.equal(result.body.data.version, 1); + }); +}); + +describe('initDatabase handler', () => { + it('returns 500 when initSecret is not configured', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64) + }); + + const result = await server.handlers.initDatabase.GET({}); + assert.equal(result.status, 500); + assert.equal(result.body.error.code, 'INIT_SECRET_MISSING'); + }); + + it('returns 500 on POST when initSecret is not configured', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64) + }); + + const result = await server.handlers.initDatabase.POST({}, '{}'); + assert.equal(result.status, 500); + assert.equal(result.body.error.code, 'INIT_SECRET_MISSING'); + }); + + it('returns 401 when authorization header is wrong', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64), + initSecret: 'my-secret' + }); + + const result = await server.handlers.initDatabase.GET({ authorization: 'Bearer wrong' }); + assert.equal(result.status, 401); + assert.equal(result.body.error.code, 'UNAUTHORIZED'); + }); + + it('returns 400 on POST when body is malformed JSON', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64), + initSecret: 'my-secret' + }); + + const result = await server.handlers.initDatabase.POST( + { authorization: 'Bearer my-secret' }, + '{not valid json}' + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_JSON'); + }); + + it('returns 400 on POST when body is not an object', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64), + initSecret: 'my-secret' + }); + + const result = await server.handlers.initDatabase.POST( + { authorization: 'Bearer my-secret' }, + null + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_REQUEST_BODY'); + }); +}); + +describe('messages handler validation', () => { + let server; + + beforeEach(async () => { + server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64) + }); + }); + + it('returns 400 for invalid limit', async () => { + const result = await server.handlers.messages.GET( + '/messages?limit=abc', + { 'x-user-id': 'u1' } + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_PARAMETERS'); + }); + + it('returns 400 for negative offset', async () => { + const result = await server.handlers.messages.GET( + '/messages?offset=-5', + { 'x-user-id': 'u1' } + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_PARAMETERS'); + }); + + it('returns 400 for zero limit', async () => { + const result = await server.handlers.messages.GET( + '/messages?limit=0', + { 'x-user-id': 'u1' } + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_PARAMETERS'); + }); + + it('returns encrypted payload for successful list query', async () => { + const userKey = deriveUserEncryptionKey('u1', 'a'.repeat(64)); + const encryptedPayload = encryptForStorage( + JSON.stringify({ + contactName: 'Alice', + messageSubtype: 'chat', + recurrenceType: 'none' + }), + userKey + ); + + server.adapter.listTasks = async () => ({ + tasks: [ + { + id: 1, + uuid: '550e8400-e29b-41d4-a716-446655440000', + encrypted_payload: encryptedPayload, + message_type: 'fixed', + next_send_at: '2030-01-01T00:00:00.000Z', + status: 'pending', + retry_count: 0, + created_at: '2030-01-01T00:00:00.000Z', + updated_at: '2030-01-01T00:00:00.000Z' + } + ], + total: 1 + }); + + const result = await server.handlers.messages.GET('/messages', { 'x-user-id': 'u1' }); + assert.equal(result.status, 200); + assert.equal(result.body.success, true); + assert.equal(result.body.encrypted, true); + assert.equal(result.body.version, 1); + + const decrypted = decryptPayload(result.body.data, userKey); + assert.equal(Array.isArray(decrypted.tasks), true); + assert.equal(decrypted.tasks.length, 1); + assert.equal(decrypted.tasks[0].contactName, 'Alice'); + assert.equal(decrypted.pagination.total, 1); + }); +}); + +describe('scheduleMessage handler', () => { + const masterKey = 'c'.repeat(64); + + it('returns 400 when encrypted body is missing', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: masterKey + }); + + const result = await server.handlers.scheduleMessage.POST( + { + 'x-user-id': 'u1', + 'x-payload-encrypted': 'true', + 'x-encryption-version': '1' + }, + undefined + ); + + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_ENCRYPTED_PAYLOAD'); + }); + + it('returns 409 when uuid already exists', async () => { + const server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: masterKey + }); + + server.adapter.createTask = async () => { + const duplicateError = new Error('duplicate key value violates unique constraint "uidx_uuid"'); + duplicateError.code = '23505'; + throw duplicateError; + }; + + const encryptedBody = encryptClientPayload( + { + uuid: '550e8400-e29b-41d4-a716-446655440000', + contactName: 'Alice', + messageType: 'fixed', + firstSendTime: new Date(Date.now() + 60_000).toISOString(), + pushSubscription: { endpoint: 'https://push.example.com' }, + userMessage: 'hello' + }, + 'u1', + masterKey + ); + + const result = await server.handlers.scheduleMessage.POST( + { + 'x-user-id': 'u1', + 'x-payload-encrypted': 'true', + 'x-encryption-version': '1' + }, + encryptedBody + ); + + assert.equal(result.status, 409); + assert.equal(result.body.error.code, 'TASK_UUID_CONFLICT'); + }); +}); + +describe('updateMessage handler', () => { + let server; + + beforeEach(async () => { + server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64) + }); + }); + + it('returns 400 when encryption header is missing', async () => { + const result = await server.handlers.updateMessage.PUT( + '/update-message?id=550e8400-e29b-41d4-a716-446655440000', + { 'x-user-id': 'u1' }, + '{}' + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'ENCRYPTION_REQUIRED'); + }); + + it('returns 400 for malformed encrypted payload JSON', async () => { + const result = await server.handlers.updateMessage.PUT( + '/update-message?id=550e8400-e29b-41d4-a716-446655440000', + { + 'x-user-id': 'u1', + 'x-payload-encrypted': 'true', + 'x-encryption-version': '1' + }, + '{not valid json}' + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_ENCRYPTED_PAYLOAD'); + }); + + it('returns 400 for missing request body', async () => { + const result = await server.handlers.updateMessage.PUT( + '/update-message?id=550e8400-e29b-41d4-a716-446655440000', + { + 'x-user-id': 'u1', + 'x-payload-encrypted': 'true', + 'x-encryption-version': '1' + }, + undefined + ); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, 'INVALID_ENCRYPTED_PAYLOAD'); + }); +}); + +describe('cancelMessage handler', () => { + let server; + + beforeEach(async () => { + server = await createReiServer({ + db: { driver: 'neon', connectionString: 'postgres://x' }, + encryptionKey: 'a'.repeat(64) + }); + }); + + it('returns 404 when target task does not exist', async () => { + server.adapter.deleteTaskByUuid = async () => false; + + const result = await server.handlers.cancelMessage.DELETE( + '/cancel-message?id=550e8400-e29b-41d4-a716-446655440000', + { 'x-user-id': 'u1' } + ); + + assert.equal(result.status, 404); + assert.equal(result.body.error.code, 'TASK_NOT_FOUND'); + }); + + it('returns 200 when task is deleted', async () => { + server.adapter.deleteTaskByUuid = async () => true; + + const result = await server.handlers.cancelMessage.DELETE( + '/cancel-message?id=550e8400-e29b-41d4-a716-446655440000', + { 'x-user-id': 'u1' } + ); + + assert.equal(result.status, 200); + assert.equal(result.body.success, true); + }); +}); + +describe('processMessagesByUuid delivery safety', () => { + it('does not retry delivery when cleanup fails after successful send', async () => { + const masterKey = 'd'.repeat(64); + const userId = 'u1'; + const task = { + id: 1, + user_id: userId, + uuid: '550e8400-e29b-41d4-a716-446655440000', + encrypted_payload: '', + message_type: 'fixed', + next_send_at: new Date(Date.now() + 60_000).toISOString(), + status: 'pending', + retry_count: 0 + }; + + const userKey = deriveUserEncryptionKey(userId, masterKey); + task.encrypted_payload = encryptForStorage( + JSON.stringify({ + contactName: 'Alice', + messageType: 'fixed', + userMessage: 'hello', + pushSubscription: { endpoint: 'https://push.example.com' }, + messageSubtype: 'chat', + metadata: {} + }), + userKey + ); + + let sendCount = 0; + const updateCalls = []; + + const ctx = { + encryptionKey: masterKey, + vapid: { + email: 'vapid@example.com', + publicKey: 'public-key', + privateKey: 'private-key' + }, + webpush: { + sendNotification: async () => { + sendCount++; + } + }, + db: { + getTaskByUuid: async () => task, + getTaskByUuidOnly: async () => task, + deleteTaskById: async () => { + throw new Error('delete failed'); + }, + updateTaskById: async (_taskId, updates) => { + updateCalls.push(updates); + return { id: task.id, ...updates }; + } + } + }; + + const result = await processMessagesByUuid(task.uuid, ctx, 2, userId); + + assert.equal(result.success, false); + assert.equal(result.error.code, 'POST_SEND_CLEANUP_FAILED'); + assert.equal(sendCount, 1); + assert.equal(updateCalls.length, 1); + assert.deepEqual(updateCalls[0], { status: 'sent', retry_count: 0 }); + }); +}); + +describe('sendNotifications post-send persistence safety', () => { + it('marks sent and avoids retry scheduling when cleanup fails', async () => { + const masterKey = 'e'.repeat(64); + const userId = 'u1'; + const userKey = deriveUserEncryptionKey(userId, masterKey); + const task = { + id: 42, + user_id: userId, + uuid: '550e8400-e29b-41d4-a716-446655440000', + encrypted_payload: encryptForStorage( + JSON.stringify({ + contactName: 'Alice', + messageType: 'fixed', + userMessage: 'hello', + pushSubscription: { endpoint: 'https://push.example.com' }, + recurrenceType: 'none', + messageSubtype: 'chat', + metadata: {} + }), + userKey + ), + message_type: 'fixed', + next_send_at: new Date(Date.now() - 60_000).toISOString(), + status: 'pending', + retry_count: 0 + }; + + let sendCount = 0; + const updateCalls = []; + const handler = createSendNotificationsHandler({ + encryptionKey: masterKey, + cronSecret: 'cron-secret', + vapid: { + email: 'vapid@example.com', + publicKey: 'public-key', + privateKey: 'private-key' + }, + webpush: { + sendNotification: async () => { + sendCount++; + } + }, + db: { + getPendingTasks: async () => [task], + deleteTaskById: async () => { + throw new Error('delete failed'); + }, + updateTaskById: async (_taskId, updates) => { + updateCalls.push(updates); + return { id: task.id, ...updates }; + }, + cleanupOldTasks: async () => 0 + } + }); + + const result = await handler.POST({ authorization: 'Bearer cron-secret' }); + + assert.equal(result.status, 200); + assert.equal(sendCount, 1); + assert.equal(result.body.success, true); + assert.equal(result.body.data.failedCount, 1); + assert.equal(result.body.data.details.failedTasks.length, 1); + assert.equal(result.body.data.details.failedTasks[0].status, 'post_send_cleanup_failed_marked_sent'); + assert.equal(updateCalls.length, 1); + assert.deepEqual(updateCalls[0], { status: 'sent', retry_count: 0 }); + }); +}); diff --git a/packages/rei-standard-amsg/server/tsup.config.js b/packages/rei-standard-amsg/server/tsup.config.js new file mode 100644 index 0000000..6c9fd46 --- /dev/null +++ b/packages/rei-standard-amsg/server/tsup.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/server/index.js' }, + format: ['cjs', 'esm'], + dts: true, + outDir: 'dist', + outExtension({ format }) { + return { js: format === 'esm' ? '.mjs' : '.cjs' }; + }, + platform: 'node', + target: 'node20', + splitting: true, + clean: true +}); diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md new file mode 100644 index 0000000..39df77a --- /dev/null +++ b/packages/rei-standard-amsg/sw/README.md @@ -0,0 +1,36 @@ +# @rei-standard/amsg-sw + +`@rei-standard/amsg-sw` 是 ReiStandard 主动消息标准的 Service Worker 插件包。 + +## 文档导航 + +- [SDK 总览](../README.md) +- [主 README](../../../README.md) +- [Service Worker 规范](../../../standards/service-worker-specification.md) + +## 安装 + +```bash +npm install @rei-standard/amsg-sw +``` + +## 使用 + +```js +import { installReiSW } from '@rei-standard/amsg-sw'; + +installReiSW(self, { + defaultIcon: '/icon-192x192.png', + defaultBadge: '/badge-72x72.png' +}); +``` + +导出: + +- `installReiSW` +- `REI_SW_MESSAGE_TYPE` + +## 相关包 + +- 服务端 SDK:[`@rei-standard/amsg-server`](../server/README.md) +- 浏览器 SDK:[`@rei-standard/amsg-client`](../client/README.md) diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json new file mode 100644 index 0000000..0116948 --- /dev/null +++ b/packages/rei-standard-amsg/sw/package.json @@ -0,0 +1,33 @@ +{ + "name": "@rei-standard/amsg-sw", + "version": "1.1.0", + "description": "ReiStandard Active Messaging service worker SDK", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js new file mode 100644 index 0000000..2364c17 --- /dev/null +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -0,0 +1,347 @@ +/** + * ReiStandard Service Worker helpers. + * + * Drop-in plugin for Service Workers that handles: + * - Basic push payload -> notification rendering + * - Offline request queueing and retry with Background Sync + * + * Notes: + * - This plugin intentionally does not install `notificationclick`. + * Main applications can implement their own click navigation logic. + * + * Usage (inside your sw.js): + * import { installReiSW, REI_SW_MESSAGE_TYPE } from '@rei-standard/amsg-sw'; + * installReiSW(self); + * + * Usage (inside your web app): + * navigator.serviceWorker.controller?.postMessage({ + * type: REI_SW_MESSAGE_TYPE.ENQUEUE_REQUEST, + * request: { + * url: '/api/messages/send', + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: { text: 'hello' } + * } + * }); + */ + +const REI_SW_DB_NAME = 'rei-sw'; +const REI_SW_DB_STORE = 'request-outbox'; +const REI_SW_DB_VERSION = 1; +const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox'; + +export const REI_SW_MESSAGE_TYPE = Object.freeze({ + ENQUEUE_REQUEST: 'REI_ENQUEUE_REQUEST', + FLUSH_QUEUE: 'REI_FLUSH_QUEUE', + QUEUE_RESULT: 'REI_QUEUE_RESULT' +}); + +/** + * @typedef {Object} ReiSWOptions + * @property {string} [defaultIcon] - Fallback notification icon URL. + * @property {string} [defaultBadge] - Fallback notification badge URL. + */ + +/** + * Install the ReiStandard Service Worker baseline handlers. + * + * @param {ServiceWorkerGlobalScope} sw - Typically `self` inside a SW script. + * @param {ReiSWOptions} [opts] + */ +export function installReiSW(sw, opts = {}) { + const defaultIcon = opts.defaultIcon || '/icon-192x192.png'; + const defaultBadge = opts.defaultBadge || '/badge-72x72.png'; + + sw.addEventListener('push', (event) => { + const payload = readPushPayload(event); + if (!payload) return; + + const notification = createNotificationFromPayload(payload, { + defaultIcon, + defaultBadge + }); + if (!notification) return; + + event.waitUntil( + sw.registration.showNotification(notification.title, notification.options) + ); + }); + + sw.addEventListener('message', (event) => { + const message = event.data; + if (!message || typeof message !== 'object') return; + + if (message.type === REI_SW_MESSAGE_TYPE.ENQUEUE_REQUEST) { + event.waitUntil( + enqueueAndFlush(sw, event, message.request) + ); + return; + } + + if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) { + event.waitUntil(flushQueuedRequests(sw)); + } + }); + + sw.addEventListener('sync', (event) => { + if (event.tag !== REI_SW_SYNC_TAG) return; + event.waitUntil(flushQueuedRequests(sw)); + }); +} + +function readPushPayload(event) { + if (!event.data) return null; + + try { + return event.data.json(); + } catch (_jsonError) { + try { + return { message: event.data.text() }; + } catch (_textError) { + return null; + } + } +} + +function createNotificationFromPayload(payload, defaults) { + if (!payload || typeof payload !== 'object') { + return { + title: 'New notification', + options: { + body: String(payload || ''), + icon: defaults.defaultIcon, + badge: defaults.defaultBadge + } + }; + } + + const pushNotification = payload.notification && typeof payload.notification === 'object' + ? payload.notification + : {}; + + const title = pushNotification.title || payload.title || 'New notification'; + const body = pushNotification.body || payload.body || payload.message || ''; + const data = payload.data && typeof payload.data === 'object' + ? { ...payload.data } + : {}; + + // Keep original payload so the app can decide how to route clicks. + if (data.payload == null) data.payload = payload; + + return { + title, + options: { + body, + icon: pushNotification.icon || payload.icon || payload.avatarUrl || defaults.defaultIcon, + badge: pushNotification.badge || payload.badge || defaults.defaultBadge, + tag: pushNotification.tag || payload.tag || payload.messageId || `rei-${Date.now()}`, + data, + renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false), + requireInteraction: Boolean( + pushNotification.requireInteraction ?? payload.requireInteraction ?? false + ) + } + }; +} + +async function enqueueAndFlush(sw, event, requestPayload) { + try { + const request = normalizeQueuedRequest(requestPayload); + const queueId = await addQueuedRequest(request); + + await registerFlushSync(sw); + await flushQueuedRequests(sw); + + respondToSender(event, { + type: REI_SW_MESSAGE_TYPE.QUEUE_RESULT, + ok: true, + queueId + }); + } catch (error) { + respondToSender(event, { + type: REI_SW_MESSAGE_TYPE.QUEUE_RESULT, + ok: false, + error: error instanceof Error ? error.message : 'Failed to queue request' + }); + } +} + +function normalizeQueuedRequest(requestPayload) { + if (!requestPayload || typeof requestPayload !== 'object') { + throw new Error('[rei-standard-amsg-sw] `request` payload is required'); + } + + const url = typeof requestPayload.url === 'string' ? requestPayload.url.trim() : ''; + if (!url) throw new Error('[rei-standard-amsg-sw] `request.url` is required'); + + const method = typeof requestPayload.method === 'string' + ? requestPayload.method.toUpperCase() + : 'POST'; + const headers = normalizeHeaders(requestPayload.headers); + const hasBody = method !== 'GET' && method !== 'HEAD'; + const body = hasBody ? normalizeRequestBody(requestPayload.body) : undefined; + + if ( + hasBody && + body && + !hasHeader(headers, 'content-type') && + typeof requestPayload.body === 'object' + ) { + headers['content-type'] = 'application/json'; + } + + return { + url, + method, + headers, + body, + createdAt: Date.now() + }; +} + +function normalizeHeaders(headersInput) { + const headers = {}; + if (!headersInput || typeof headersInput !== 'object') return headers; + + for (const [key, value] of Object.entries(headersInput)) { + if (value == null) continue; + headers[String(key).toLowerCase()] = String(value); + } + + return headers; +} + +function hasHeader(headers, name) { + const target = String(name || '').toLowerCase(); + return Object.prototype.hasOwnProperty.call(headers, target); +} + +function normalizeRequestBody(bodyInput) { + if (bodyInput == null) return ''; + if (typeof bodyInput === 'string') return bodyInput; + + try { + return JSON.stringify(bodyInput); + } catch (_error) { + throw new Error('[rei-standard-amsg-sw] request body is not serializable'); + } +} + +async function flushQueuedRequests(sw) { + const queuedRequests = await listQueuedRequests(); + + for (const queuedRequest of queuedRequests) { + const canDelete = await trySendQueuedRequest(queuedRequest); + + if (!canDelete) { + await registerFlushSync(sw); + return; + } + + await removeQueuedRequest(queuedRequest.id); + } +} + +async function trySendQueuedRequest(queuedRequest) { + try { + const response = await fetch(queuedRequest.url, { + method: queuedRequest.method, + headers: queuedRequest.headers, + body: queuedRequest.body + }); + + // 4xx is usually a permanent issue for this payload, so do not retry forever. + if (response.ok || (response.status >= 400 && response.status < 500)) { + return true; + } + + return false; + } catch (_error) { + return false; + } +} + +async function registerFlushSync(sw) { + const syncManager = sw.registration && sw.registration.sync; + if (!syncManager || typeof syncManager.register !== 'function') return; + + try { + await syncManager.register(REI_SW_SYNC_TAG); + } catch (_error) { + // Ignore unsupported/denied sync registration and rely on manual flush. + } +} + +function respondToSender(event, message) { + const messagePort = event.ports && event.ports[0]; + if (messagePort && typeof messagePort.postMessage === 'function') { + messagePort.postMessage(message); + return; + } + + const source = event.source; + if (source && typeof source.postMessage === 'function') { + source.postMessage(message); + } +} + +function openQueueDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(REI_SW_DB_NAME, REI_SW_DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (db.objectStoreNames.contains(REI_SW_DB_STORE)) return; + db.createObjectStore(REI_SW_DB_STORE, { keyPath: 'id', autoIncrement: true }); + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Failed to open queue database')); + }); +} + +async function withQueueStore(mode, handler) { + const db = await openQueueDatabase(); + + try { + return await new Promise((resolve, reject) => { + const transaction = db.transaction(REI_SW_DB_STORE, mode); + const store = transaction.objectStore(REI_SW_DB_STORE); + + transaction.oncomplete = () => resolve(undefined); + transaction.onerror = () => reject(transaction.error || new Error('Queue transaction failed')); + + Promise.resolve(handler(store, resolve, reject)).catch(reject); + }); + } finally { + db.close(); + } +} + +async function addQueuedRequest(request) { + return withQueueStore('readwrite', (store, resolve, reject) => { + const addRequest = store.add(request); + addRequest.onsuccess = () => resolve(addRequest.result); + addRequest.onerror = () => reject(addRequest.error || new Error('Failed to queue request')); + }); +} + +async function listQueuedRequests() { + return withQueueStore('readonly', (store, resolve, reject) => { + const allRequest = store.getAll(); + allRequest.onsuccess = () => { + const list = Array.isArray(allRequest.result) ? allRequest.result : []; + list.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); + resolve(list); + }; + allRequest.onerror = () => reject(allRequest.error || new Error('Failed to read queue')); + }); +} + +async function removeQueuedRequest(id) { + return withQueueStore('readwrite', (store, resolve, reject) => { + const deleteRequest = store.delete(id); + deleteRequest.onsuccess = () => resolve(undefined); + deleteRequest.onerror = () => reject(deleteRequest.error || new Error('Failed to remove queued request')); + }); +} diff --git a/packages/rei-standard-amsg/sw/tsup.config.js b/packages/rei-standard-amsg/sw/tsup.config.js new file mode 100644 index 0000000..3766b19 --- /dev/null +++ b/packages/rei-standard-amsg/sw/tsup.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.js' }, + format: ['cjs', 'esm'], + dts: true, + outDir: 'dist', + outExtension({ format }) { + return { js: format === 'esm' ? '.mjs' : '.cjs' }; + }, + platform: 'browser', + target: 'es2020', + splitting: false, + clean: true +}); diff --git a/scripts/check-esm-syntax.mjs b/scripts/check-esm-syntax.mjs new file mode 100644 index 0000000..b1370ce --- /dev/null +++ b/scripts/check-esm-syntax.mjs @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const rootDir = process.cwd(); +const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist']); +const ESM_EXTENSIONS = new Set(['.js', '.mjs']); +const CJS_PATTERNS = [ + /\brequire\s*\(/, + /\bmodule\.exports\b/, + /\bexports\.[A-Za-z_$]/ +]; + +function walkFiles(dir, onFile) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + walkFiles(fullPath, onFile); + continue; + } + onFile(fullPath); + } +} + +function findPackageJsonFiles() { + const files = []; + walkFiles(rootDir, (fullPath) => { + if (path.basename(fullPath) === 'package.json') { + files.push(fullPath); + } + }); + return files; +} + +function getEsmPackageDirs() { + const dirs = []; + for (const pkgFile of findPackageJsonFiles()) { + const raw = fs.readFileSync(pkgFile, 'utf8'); + const pkg = JSON.parse(raw); + if (pkg.type === 'module') { + dirs.push(path.dirname(pkgFile)); + } + } + return dirs; +} + +function listEsmSourceFiles(packageDir) { + const files = []; + walkFiles(packageDir, (fullPath) => { + const ext = path.extname(fullPath); + if (!ESM_EXTENSIONS.has(ext)) return; + files.push(fullPath); + }); + return files; +} + +function checkSyntax(filePath) { + const result = spawnSync(process.execPath, ['--check', filePath], { + encoding: 'utf8' + }); + + if (result.status === 0) return null; + + return { + type: 'syntax', + filePath, + message: (result.stderr || result.stdout || 'Unknown syntax error').trim() + }; +} + +function checkCjsTokens(filePath) { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + const errors = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const pattern of CJS_PATTERNS) { + if (!pattern.test(line)) continue; + errors.push({ + type: 'cjs-token', + filePath, + message: `CommonJS token found at line ${i + 1}: ${line.trim()}` + }); + break; + } + } + + return errors; +} + +const packageDirs = getEsmPackageDirs(); +const filesToCheck = packageDirs.flatMap(listEsmSourceFiles); +const errors = []; + +for (const filePath of filesToCheck) { + const syntaxError = checkSyntax(filePath); + if (syntaxError) errors.push(syntaxError); + errors.push(...checkCjsTokens(filePath)); +} + +if (errors.length > 0) { + console.error(`[check:esm] Found ${errors.length} issue(s):`); + for (const error of errors) { + const rel = path.relative(rootDir, error.filePath); + console.error(`- ${rel}: ${error.message}`); + } + process.exit(1); +} + +console.log(`[check:esm] OK - checked ${filesToCheck.length} file(s) across ${packageDirs.length} ESM package(s).`); diff --git a/scripts/publish-workspaces.mjs b/scripts/publish-workspaces.mjs new file mode 100644 index 0000000..0193231 --- /dev/null +++ b/scripts/publish-workspaces.mjs @@ -0,0 +1,181 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const rootDir = process.cwd(); +const dryRun = process.argv.includes('--dry-run'); +const useProvenance = process.env.NPM_PUBLISH_PROVENANCE !== 'false'; + +function readJson(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || rootDir, + encoding: 'utf8', + stdio: options.stdio || 'pipe' + }); + + if (result.status !== 0) { + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + const details = [stderr, stdout].filter(Boolean).join('\n'); + throw new Error(`Command failed: ${command} ${args.join(' ')}\n${details}`); + } + + return result; +} + +function pathExists(filePath) { + return fs.existsSync(filePath); +} + +function resolveWorkspaceDirs(workspaces) { + const dirs = new Set(); + + for (const pattern of workspaces) { + if (!pattern || typeof pattern !== 'string') continue; + + if (pattern.endsWith('/*')) { + const baseDir = path.join(rootDir, pattern.slice(0, -2)); + if (!pathExists(baseDir)) continue; + + const entries = fs.readdirSync(baseDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + dirs.add(path.join(baseDir, entry.name)); + } + + continue; + } + + const fullPath = path.join(rootDir, pattern); + if (!pathExists(fullPath)) continue; + dirs.add(fullPath); + } + + return Array.from(dirs); +} + +function collectExportTargets(value, targets) { + if (typeof value === 'string') { + if (value.startsWith('./')) targets.push(value); + return; + } + + if (!value || typeof value !== 'object') return; + + for (const nestedValue of Object.values(value)) { + collectExportTargets(nestedValue, targets); + } +} + +function ensureBuildArtifacts(pkgDir, pkg) { + const distDir = path.join(pkgDir, 'dist'); + + if (Array.isArray(pkg.files) && pkg.files.includes('dist')) { + if (!pathExists(distDir)) { + throw new Error(`[publish] Missing dist directory for ${pkg.name}. Run build first.`); + } + + const distEntries = fs.readdirSync(distDir); + if (distEntries.length === 0) { + throw new Error(`[publish] Empty dist directory for ${pkg.name}. Run build first.`); + } + } + + const exportTargets = []; + collectExportTargets(pkg.exports, exportTargets); + + for (const target of exportTargets) { + const absoluteTarget = path.join(pkgDir, target); + if (!pathExists(absoluteTarget)) { + throw new Error(`[publish] Missing export target for ${pkg.name}: ${target}`); + } + } +} + +function isVersionPublished(name, version) { + const spec = `${name}@${version}`; + const result = spawnSync( + 'npm', + ['view', spec, 'version', '--registry', 'https://registry.npmjs.org'], + { encoding: 'utf8' } + ); + + if (result.status === 0) { + const publishedVersion = (result.stdout || '').trim(); + return publishedVersion === version; + } + + const combinedOutput = `${result.stdout || ''}\n${result.stderr || ''}`; + if (/E404|404 Not Found|No match found for version/i.test(combinedOutput)) { + return false; + } + + throw new Error( + `[publish] Failed to query npm for ${spec}:\n${combinedOutput.trim()}` + ); +} + +function publishWorkspace(pkgDir, pkg) { + const npmArgs = ['publish', '--access', 'public']; + + if (useProvenance) { + npmArgs.push('--provenance'); + } + + if (dryRun) { + npmArgs.push('--dry-run'); + } + + console.log(`[publish] Publishing ${pkg.name}@${pkg.version} from ${path.relative(rootDir, pkgDir)}`); + run('npm', npmArgs, { cwd: pkgDir, stdio: 'inherit' }); +} + +function main() { + const rootPkg = readJson(path.join(rootDir, 'package.json')); + const workspacePatterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []; + const workspaceDirs = resolveWorkspaceDirs(workspacePatterns); + + const publishable = []; + + for (const workspaceDir of workspaceDirs) { + const packageJsonPath = path.join(workspaceDir, 'package.json'); + if (!pathExists(packageJsonPath)) continue; + + const pkg = readJson(packageJsonPath); + if (pkg.private) continue; + + if (!pkg.name || !pkg.version) { + throw new Error(`[publish] Invalid package manifest at ${packageJsonPath}`); + } + + publishable.push({ dir: workspaceDir, pkg }); + } + + if (publishable.length === 0) { + console.log('[publish] No public workspaces found. Nothing to publish.'); + return; + } + + for (const { dir, pkg } of publishable) { + ensureBuildArtifacts(dir, pkg); + + if (isVersionPublished(pkg.name, pkg.version)) { + console.log(`[publish] Skip ${pkg.name}@${pkg.version} (already published).`); + continue; + } + + publishWorkspace(dir, pkg); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/tests/run-test.sh b/tests/run-test.sh index 4a09c5f..2df71c2 100755 --- a/tests/run-test.sh +++ b/tests/run-test.sh @@ -12,8 +12,8 @@ echo "" # 检查 Node.js 版本 echo "检查 Node.js 版本..." NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) -if [ "$NODE_VERSION" -lt 18 ]; then - echo "❌ 错误: 需要 Node.js 18 或更高版本" +if [ "$NODE_VERSION" -lt 20 ]; then + echo "❌ 错误: 需要 Node.js 20 或更高版本" echo " 当前版本: $(node -v)" exit 1 fi diff --git a/tests/test-active-messaging-api.js b/tests/test-active-messaging-api.js index 0816afe..0ef05a5 100755 --- a/tests/test-active-messaging-api.js +++ b/tests/test-active-messaging-api.js @@ -770,10 +770,10 @@ async function runAllTests() { // ============ 入口 ============ -// 检查 Node.js 版本(需要 18+ 支持原生 fetch) +// 检查 Node.js 版本(需要 20+) const nodeVersion = parseInt(process.version.slice(1).split('.')[0]); -if (nodeVersion < 18) { - logError('此脚本需要 Node.js 18 或更高版本(支持原生 fetch API)'); +if (nodeVersion < 20) { + logError('此脚本需要 Node.js 20 或更高版本'); logInfo(`当前版本: ${process.version}`); process.exit(1); }