Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/amsg-vapid-public-key.md
Original file line number Diff line number Diff line change
@@ -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`)。
22 changes: 22 additions & 0 deletions packages/rei-standard-amsg/client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} 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 ─────────────────────────────────────────────────

/**
Expand Down
60 changes: 60 additions & 0 deletions packages/rei-standard-amsg/client/test/vapid-public-key.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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` 类型消息都靠它推送,缺了就发不出去。

## 导入入口
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' } } };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 || {};
Comment thread
Tosd0 marked this conversation as resolved.
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 };
}
4 changes: 3 additions & 1 deletion packages/rei-standard-amsg/server/src/server/single-user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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)
}
};
}
160 changes: 159 additions & 1 deletion packages/rei-standard-amsg/server/test/single-user-worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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=<jwt>, k=<publicKey>`
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');
});
Loading