diff --git a/.changeset/amsg-vapid-public-key.md b/.changeset/amsg-vapid-public-key.md new file mode 100644 index 0000000..b275e56 --- /dev/null +++ b/.changeset/amsg-vapid-public-key.md @@ -0,0 +1,9 @@ +--- +"@rei-standard/amsg-server": minor +"@rei-standard/amsg-client": minor +--- + +单用户 worker 暴露 VAPID 公钥端点,供前端跨源订阅。 + +- amsg-server:单用户 Worker 新增 `GET /vapid-public-key`,返回本 Worker 自己的 `VAPID_PUBLIC_KEY`(未配置时返回 503 `VAPID_NOT_CONFIGURED`)。和其它端点共用同一套 CORS 与 `serverToken` 校验。前端拿它作为 `applicationServerKey` 来创建 Web Push 订阅——各自部署的 worker 各有各的 VAPID,公钥在运行时从 worker 拉取。 +- amsg-client:新增 `ReiClient.getVapidPublicKey()`,GET 该端点并返回公钥字符串(配了 `serverToken` 时带上 `X-Client-Token`)。 diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 1ba520d..43683ee 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -440,6 +440,28 @@ export class ReiClient { this._userKey = this._hexToUint8Array(userKey); } + /** + * Fetch the amsg-server worker's own VAPID public key. + * + * A browser needs this as `applicationServerKey` when creating a Web Push + * subscription. Each self-hosted worker owns its VAPID keypair, so pull the + * key at runtime rather than baking it into the frontend. Sends + * `X-Client-Token` when a `serverToken` is configured. + * + * @returns {Promise} The base64url VAPID public key. + * @throws {Error} When the worker has no VAPID public key configured (503). + */ + async getVapidPublicKey() { + const res = await fetch(`${this._baseUrl}/vapid-public-key`, { + method: 'GET', + headers: this._withServerToken({}) + }); + + const json = await res.json(); + if (!json.success) throw new Error(json.error?.message || 'Failed to fetch VAPID public key'); + return json.publicKey; + } + // ─── Public API ───────────────────────────────────────────────── /** diff --git a/packages/rei-standard-amsg/client/test/vapid-public-key.test.mjs b/packages/rei-standard-amsg/client/test/vapid-public-key.test.mjs new file mode 100644 index 0000000..cc2dd2f --- /dev/null +++ b/packages/rei-standard-amsg/client/test/vapid-public-key.test.mjs @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReiClient } from '../src/index.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; + +test('getVapidPublicKey() GETs /vapid-public-key with X-Client-Token and returns the key string', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { + captured.push({ url: String(url), method: init && init.method, headers: (init && init.headers) || {} }); + return new Response(JSON.stringify({ success: true, publicKey: 'BPUB123' }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + }; + let key; + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 's3cret' }); + key = await client.getVapidPublicKey(); + } finally { + globalThis.fetch = original; + } + assert.equal(captured.length, 1); + assert.equal(captured[0].url, 'https://w.dev/vapid-public-key'); + assert.equal(captured[0].method, 'GET'); + assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); + assert.equal(key, 'BPUB123'); +}); + +test('getVapidPublicKey() without serverToken sends no X-Client-Token', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { + captured.push({ headers: (init && init.headers) || {} }); + return new Response(JSON.stringify({ success: true, publicKey: 'BPUB123' }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + }; + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER }); + await client.getVapidPublicKey(); + } finally { + globalThis.fetch = original; + } + assert.equal(captured[0].headers['X-Client-Token'], undefined); +}); + +test('getVapidPublicKey() throws on a non-success response', async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => new Response( + JSON.stringify({ success: false, error: { code: 'VAPID_NOT_CONFIGURED', message: 'VAPID 未配置' } }), + { status: 503, headers: { 'Content-Type': 'application/json' } } + ); + try { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER }); + await assert.rejects(() => client.getVapidPublicKey(), /VAPID 未配置/); + } finally { + globalThis.fetch = original; + } +}); diff --git a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md index 2d175b2..44902cc 100644 --- a/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md +++ b/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md @@ -27,9 +27,11 @@ ## 端点 -`/get-user-key`、`/schedule-message`、`/messages`、`/update-message`、`/cancel-message`、`/init-tenant`。 +`/get-user-key`、`/schedule-message`、`/messages`、`/update-message`、`/cancel-message`、`/init-tenant`、`/vapid-public-key`。 **没有 HTTP `/send-notifications`**——定时投递由 CF Cron Trigger 直接触发 `scheduled()`。 +`GET /vapid-public-key` 返回本 Worker 的 `VAPID_PUBLIC_KEY`,供前端创建 Web Push 订阅时作 `applicationServerKey`;未配置 VAPID 时返回 503。跟其它端点一样受 CORS 和 `serverToken` 约束。 + VAPID 和 webpush 都要配齐:定时投递(cron)和 `instant` 类型消息都靠它推送,缺了就发不出去。 ## 导入入口 diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js index 98ad210..d85b9c3 100644 --- a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js +++ b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js @@ -12,6 +12,8 @@ * GET /messages → list * PUT /update-message → patch * DELETE /cancel-message → delete + * GET /vapid-public-key → this worker's VAPID public key (for the frontend's + * Web Push subscription); 503 if VAPID_PUBLIC_KEY unset * * CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or * an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo @@ -111,6 +113,8 @@ export function createSingleUserCloudflareWorker(buildConfig) { result = await server.handlers.updateMessage.PUT(url, headers, await request.text()); } else if (method === 'DELETE' && pathname.endsWith('/cancel-message')) { result = await server.handlers.cancelMessage.DELETE(url, headers); + } else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) { + result = await server.handlers.vapidPublicKey.GET(url, headers); } else { result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } }; } diff --git a/packages/rei-standard-amsg/server/src/server/handlers/vapid-public-key.js b/packages/rei-standard-amsg/server/src/server/handlers/vapid-public-key.js new file mode 100644 index 0000000..b5ed306 --- /dev/null +++ b/packages/rei-standard-amsg/server/src/server/handlers/vapid-public-key.js @@ -0,0 +1,47 @@ +/** + * Handler: vapid-public-key + * + * Exposes this worker's own VAPID public key so a browser frontend can build a + * Web Push subscription (`applicationServerKey`) at runtime. Each self-hosted + * worker owns its keypair, so the key can't be baked into the frontend — it + * pulls it from here. + * + * Auth funnels through the same resolveTenant as every other endpoint, so with + * a serverToken configured this route requires `X-Client-Token` too (the + * all-or-nothing contract). The public key itself is not a secret; gating it + * just keeps every endpoint consistent. + * + * @param {Object} ctx - Server context ({ vapid, tenantManager, ... }). + * @returns {{ GET: function }} + */ + +export function createVapidPublicKeyHandler(ctx) { + async function GET(url, headers) { + const effectiveHeaders = headers || url || {}; + const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders); + if (!tenantResult.ok) { + return tenantResult.error; + } + + const publicKey = ctx.vapid && ctx.vapid.publicKey; + if (!publicKey) { + return { + status: 503, + body: { + success: false, + error: { + code: 'VAPID_NOT_CONFIGURED', + message: 'VAPID 公钥未配置:请为本 Worker 设置 VAPID_PUBLIC_KEY' + } + } + }; + } + + return { + status: 200, + body: { success: true, publicKey } + }; + } + + return { GET }; +} diff --git a/packages/rei-standard-amsg/server/src/server/single-user.js b/packages/rei-standard-amsg/server/src/server/single-user.js index 97d6b7b..7254742 100644 --- a/packages/rei-standard-amsg/server/src/server/single-user.js +++ b/packages/rei-standard-amsg/server/src/server/single-user.js @@ -23,6 +23,7 @@ import { createScheduleMessageHandler } from './handlers/schedule-message.js'; import { createUpdateMessageHandler } from './handlers/update-message.js'; import { createCancelMessageHandler } from './handlers/cancel-message.js'; import { createMessagesHandler } from './handlers/messages.js'; +import { createVapidPublicKeyHandler } from './handlers/vapid-public-key.js'; export function createSingleUserServer(config) { if (!config || !config.db) throw new Error('[amsg-server single-user] config.db is required'); @@ -53,7 +54,8 @@ export function createSingleUserServer(config) { scheduleMessage: createScheduleMessageHandler(ctx), updateMessage: createUpdateMessageHandler(ctx), cancelMessage: createCancelMessageHandler(ctx), - messages: createMessagesHandler(ctx) + messages: createMessagesHandler(ctx), + vapidPublicKey: createVapidPublicKeyHandler(ctx) } }; } diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs index 35691a8..3e610ea 100644 --- a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createSingleUserCloudflareWorker } from '../src/server/cloudflare/single-user-worker.js'; import { createTestD1 } from './helpers/sqlite-d1.mjs'; import { createD1Adapter } from '../src/server/adapters/d1.js'; +import { createWebCryptoWebPush } from '../src/server/lib/webpush-webcrypto.js'; import { deriveUserEncryptionKey, encryptPayload, encryptForStorage } from '../src/server/lib/encryption.js'; const USER = '550e8400-e29b-41d4-a716-446655440000'; @@ -184,7 +185,8 @@ test('serverToken set → every exposed route rejects wrong/missing token with 4 ['POST', 'https://w.dev/schedule-message'], ['GET', 'https://w.dev/messages?status=all'], ['PUT', 'https://w.dev/update-message?id=x'], - ['DELETE', 'https://w.dev/cancel-message?id=x'] + ['DELETE', 'https://w.dev/cancel-message?id=x'], + ['GET', 'https://w.dev/vapid-public-key'] ]; for (const [method, url] of routes) { @@ -201,3 +203,159 @@ test('serverToken set → every exposed route rejects wrong/missing token with 4 assert.equal(missing.status, 401, `${method} ${url} with no token must be 401`); } }); + +// ─── GET /vapid-public-key ───────────────────────────────────────────────── +// The frontend needs THIS worker's own VAPID public key at runtime to build a +// Web Push subscription (applicationServerKey). Each self-hosted worker owns its +// keypair, so the key can't be baked into the frontend — it pulls it from here. + +// A real P-256 keypair, so the push-signing path (buildVapidJwt) runs for real. +// Needed to prove the endpoint hands back the very key that signs pushes. +async function genVapid() { + const kp = await globalThis.crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const pub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); + const jwk = await globalThis.crypto.subtle.exportKey('jwk', kp.privateKey); + const b64url = (u8) => Buffer.from(u8).toString('base64url'); + return { publicKey: b64url(pub), privateKey: Buffer.from(jwk.d, 'base64url').toString('base64url') }; +} + +// A real subscriber keypair, so the aes128gcm encryption path runs (a fake +// p256dh would throw before push signing ever puts the key on the wire). +async function genSubscription() { + const kp = await globalThis.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); + const auth = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const b64url = (u8) => Buffer.from(u8).toString('base64url'); + return { endpoint: 'https://push.example.com/sub/abc', keys: { p256dh: b64url(raw), auth: b64url(auth) } }; +} + +test('GET /vapid-public-key returns the configured public key', async () => { + const d1 = createTestD1(); + const worker = makeWorker(d1); // vapid.publicKey === 'pub', no serverToken + const res = await worker.fetch(new Request('https://w.dev/vapid-public-key', { method: 'GET' }), { DB: d1 }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.success, true); + assert.equal(body.publicKey, 'pub'); +}); + +// Anti-divergence guard: the endpoint reads cfg.vapid.publicKey while push +// signing reads cfg.webpush — two separate config fields that COULD drift. If +// they ever point at different keys, the frontend subscribes with a key this +// worker can't sign for and every push 403s. Pin them to the same value. +test('the exposed VAPID public key is the same key push signing actually uses', async () => { + const { publicKey, privateKey } = await genVapid(); + const email = 'mailto:x@example.com'; + const sub = await genSubscription(); + + const d1 = createTestD1(); + const adapter = createD1Adapter(d1); + await adapter.initSchema(); + const userKey = deriveUserEncryptionKey(USER, MASTER_KEY); + const enc = encryptForStorage(JSON.stringify({ + contactName: 'Rei', messageType: 'fixed', userMessage: 'hi', recurrenceType: 'none', + pushSubscription: sub + }), userKey); + await adapter.createTask({ user_id: USER, uuid: 'due', encrypted_payload: enc, next_send_at: '2020-01-01T00:00:00.000Z', message_type: 'fixed' }); + + const worker = createSingleUserCloudflareWorker(() => ({ + db: adapter, + masterKey: MASTER_KEY, + vapid: { email, publicKey, privateKey }, + webpush: createWebCryptoWebPush({ email, publicKey, privateKey }) + })); + const env = { DB: d1 }; + + // 1) what the endpoint hands the frontend + const res = await worker.fetch(new Request('https://w.dev/vapid-public-key', { method: 'GET' }), env); + const endpointKey = (await res.json()).publicKey; + + // 2) what push signing puts on the wire: `Authorization: vapid t=, k=` + let wireKey = null; + const original = globalThis.fetch; + globalThis.fetch = async (_url, init) => { + const authz = (init.headers && (init.headers['Authorization'] || init.headers['authorization'])) || ''; + const m = /k=([^,\s]+)/.exec(authz); + if (m) wireKey = m[1]; + return new Response(null, { status: 201 }); + }; + try { + await worker.scheduled({}, env); + } finally { + globalThis.fetch = original; + } + + assert.equal(endpointKey, publicKey); + assert.ok(wireKey, 'push signing put a k= on the wire'); + assert.equal(endpointKey, wireKey); +}); + +test('GET /vapid-public-key → 503 VAPID_NOT_CONFIGURED when no public key is set', async () => { + const d1 = createTestD1(); + const worker = createSingleUserCloudflareWorker((env) => ({ + db: createD1Adapter(env.DB), + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', privateKey: 'priv' }, // publicKey absent + webpush: { async sendNotification() {} } + })); + const res = await worker.fetch(new Request('https://w.dev/vapid-public-key', { method: 'GET' }), { DB: d1 }); + assert.equal(res.status, 503); + const body = await res.json(); + assert.equal(body.success, false); + assert.equal(body.error.code, 'VAPID_NOT_CONFIGURED'); +}); + +test('GET /vapid-public-key honours serverToken: right token → 200, wrong/missing → 401', async () => { + const d1 = createTestD1(); + const worker = createSingleUserCloudflareWorker(() => ({ + db: createD1Adapter(d1), + masterKey: MASTER_KEY, + serverToken: 's3cret', + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} } + })); + const env = { DB: d1 }; + + const ok = await worker.fetch( + new Request('https://w.dev/vapid-public-key', { method: 'GET', headers: { 'X-Client-Token': 's3cret' } }), + env + ); + assert.equal(ok.status, 200); + assert.equal((await ok.json()).publicKey, 'pub'); + + const wrong = await worker.fetch( + new Request('https://w.dev/vapid-public-key', { method: 'GET', headers: { 'X-Client-Token': 'nope' } }), + env + ); + assert.equal(wrong.status, 401); + + const missing = await worker.fetch(new Request('https://w.dev/vapid-public-key', { method: 'GET' }), env); + assert.equal(missing.status, 401); +}); + +test('CORS: OPTIONS /vapid-public-key preflight answered, GET echoes the allowed origin', async () => { + const d1 = createTestD1(); + const worker = createSingleUserCloudflareWorker((env) => ({ + db: createD1Adapter(env.DB), + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} }, + cors: { origin: 'https://app.example.com' } + })); + const env = { DB: d1 }; + + const preflight = await worker.fetch( + new Request('https://w.dev/vapid-public-key', { method: 'OPTIONS', headers: { Origin: 'https://app.example.com' } }), + env + ); + assert.equal(preflight.status, 204); + assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); + assert.match(preflight.headers.get('Access-Control-Allow-Headers'), /X-Client-Token/); + + const got = await worker.fetch( + new Request('https://w.dev/vapid-public-key', { method: 'GET', headers: { Origin: 'https://app.example.com' } }), + env + ); + assert.equal(got.status, 200); + assert.equal(got.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); +});