agentus 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/packages/server/mock/agent.mjs +29 -8
- package/packages/server/package.json +2 -1
- package/packages/server/src/acp/session-manager.ts +33 -1
- package/packages/server/src/dashscope.ts +82 -0
- package/packages/server/src/index.ts +188 -7
- package/packages/server/src/notify/center.ts +127 -7
- package/packages/server/src/notify/webpush.ts +295 -0
- package/packages/server/src/observer/hub.ts +241 -19
- package/packages/server/src/settings.ts +159 -2
- package/packages/server/src/speech/route.ts +181 -0
- package/packages/server/src/store/store.ts +57 -0
- package/packages/shared/src/index.ts +5 -1
- package/packages/web/dist/assets/index-1rw3zpcN.css +32 -0
- package/packages/web/dist/assets/index-BxGj4INU.js +125 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +44 -0
- package/packages/web/dist/assets/index-CdQ43EDJ.css +0 -32
- package/packages/web/dist/assets/index-DzCpHPZV.js +0 -125
|
@@ -17,6 +17,7 @@ import type { Duplex } from "node:stream";
|
|
|
17
17
|
import { WebSocketServer, WebSocket } from "ws";
|
|
18
18
|
import type { PermissionRequestView, PlanSnapshot, ServerEvent } from "../../../shared/src/index.js";
|
|
19
19
|
import { DEFAULT_NOTIFY_RULES } from "./types.js";
|
|
20
|
+
import { WebPushService } from "./webpush.js";
|
|
20
21
|
import type {
|
|
21
22
|
ActionCallback,
|
|
22
23
|
ActivityObject,
|
|
@@ -98,6 +99,33 @@ function tapTarget(url: string, prefer: OpenTarget["prefer"] = "app"): Pick<Acti
|
|
|
98
99
|
return { deeplink: url, open: { url, prefer: prefer ?? "auto" } };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** The session a notice points at, from its `open` URL (`/?session=<id>`). */
|
|
103
|
+
function sessionOfOpen(open: string | null | undefined): string | null {
|
|
104
|
+
if (!open) return null;
|
|
105
|
+
try {
|
|
106
|
+
return new URL(open, "http://local").searchParams.get("session");
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A subscription as the settings card may see it: the device, never the endpoint.
|
|
113
|
+
* The endpoint is a long-lived capability URL (whoever holds it can post to that browser's push
|
|
114
|
+
* service) — the card only needs to tell one device from another, so host + tail is enough. */
|
|
115
|
+
function publicSub(rec: { endpoint: string; ua?: string; createdAt: number; lastOkAt?: number; fails: number; lastError?: string }) {
|
|
116
|
+
let host = "";
|
|
117
|
+
try { host = new URL(rec.endpoint).host; } catch { host = "?"; }
|
|
118
|
+
return {
|
|
119
|
+
host,
|
|
120
|
+
tail: rec.endpoint.slice(-6),
|
|
121
|
+
ua: rec.ua,
|
|
122
|
+
createdAt: rec.createdAt,
|
|
123
|
+
lastOkAt: rec.lastOkAt,
|
|
124
|
+
fails: rec.fails,
|
|
125
|
+
lastError: rec.lastError,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/** How a finished turn reads on a lock screen: 「用时 1m12s」.
|
|
102
130
|
*
|
|
103
131
|
* The protocol's own answer (`stopReason: end_turn`) is a fact about the wire, not about the
|
|
@@ -166,6 +194,8 @@ export class NotifyCenter {
|
|
|
166
194
|
private onActionCb: ((e: ActionEvent) => void) | null = null;
|
|
167
195
|
/** The page channel's sink: index.ts fans this out to every cockpit websocket. */
|
|
168
196
|
private onNoticeCb: ((n: WebNotice) => void) | null = null;
|
|
197
|
+
/** The third channel: the only one that reaches a LOCKED screen (see webpush.ts). */
|
|
198
|
+
private readonly push: WebPushService;
|
|
169
199
|
private timers: NodeJS.Timeout[] = [];
|
|
170
200
|
private wss: WebSocketServer | null = null;
|
|
171
201
|
/** Which session the cockpit is showing, and whether its tab is visible. Deliberately in
|
|
@@ -278,6 +308,7 @@ export class NotifyCenter {
|
|
|
278
308
|
this.sessionPlan = opts.sessionPlan ?? null;
|
|
279
309
|
this.apkPath = opts.apkPath ?? null;
|
|
280
310
|
this.onFocus = opts.onFocus ?? null;
|
|
311
|
+
this.push = new WebPushService(opts.dataDir, { log: (l) => this.log(l) });
|
|
281
312
|
this.state = this.loadState();
|
|
282
313
|
this.events = this.loadEvents();
|
|
283
314
|
}
|
|
@@ -528,11 +559,56 @@ export class NotifyCenter {
|
|
|
528
559
|
this.onNoticeCb = cb;
|
|
529
560
|
}
|
|
530
561
|
|
|
531
|
-
/** Emit one page notice, gated on the web channel's own switches
|
|
562
|
+
/** Emit one page notice, gated on the web channel's own switches — and hand the same notice to the
|
|
563
|
+
* push services, so 「关屏也能收」 needs no second decision path (and cannot drift from the page's). */
|
|
532
564
|
private notice(n: Omit<WebNotice, "at">, rule: keyof Omit<NotifyWebRules, "enabled">): void {
|
|
533
565
|
const web = this.rules().web;
|
|
534
566
|
if (!web.enabled || !web[rule]) return;
|
|
567
|
+
this.emitNotice(n);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The page + push half of a notice, without any rule gate — for the two cleanups (「跑完了」撤掉
|
|
571
|
+
* 「运行中」卡、批准后撤掉批准卡) that must happen even when the rule that would have SENT the card
|
|
572
|
+
* is off. Taking a dead card off the screen is not an interruption, which is why these two calls
|
|
573
|
+
* skip the switches; the push side follows the same logic, or a locked phone would keep a card
|
|
574
|
+
* advertising a decision that was already made. */
|
|
575
|
+
private emitNotice(n: Omit<WebNotice, "at">): void {
|
|
535
576
|
this.onNoticeCb?.({ ...n, at: Date.now() });
|
|
577
|
+
this.pushNotice(n);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The push renderer of a notice. Narrower than the page channel on purpose:
|
|
581
|
+
*
|
|
582
|
+
* · 「运行中」 never pushes. A lock-screen card saying "still running" is noise by definition, and
|
|
583
|
+
* what was asked for is 完成 and 需要批准 — the two things that want a human.
|
|
584
|
+
* · a notice about the session he is LOOKING AT never pushes. The page channel leaves presence to
|
|
585
|
+
* the page (`document.hidden`); a push cannot see that, so the server judges here. A phone
|
|
586
|
+
* buzzing about the window already on screen is pure interruption.
|
|
587
|
+
* · a dismiss IS pushed: it takes the stale card off the lock screen (the worker closes the
|
|
588
|
+
* notification by tag), which is the same unconditional cleanup the other two channels do.
|
|
589
|
+
*
|
|
590
|
+
* Fire-and-forget: a push service being slow or dead must never delay the frame to the phone or the
|
|
591
|
+
* page. Each send records its own outcome on the subscription (fails / lastError) for the UI to show. */
|
|
592
|
+
private pushNotice(n: Omit<WebNotice, "at">): void {
|
|
593
|
+
if (!this.push.subscriptions().length) return;
|
|
594
|
+
if (n.kind === "agent_running") return; // never pushed, so a dismiss for it has nothing to close
|
|
595
|
+
const sessionId = sessionOfOpen(n.open);
|
|
596
|
+
if (sessionId && this.watching(sessionId)) return;
|
|
597
|
+
const dismiss = n.op === "dismiss";
|
|
598
|
+
const payload = dismiss
|
|
599
|
+
? { op: "dismiss", noticeId: n.noticeId }
|
|
600
|
+
: { op: "upsert", noticeId: n.noticeId, kind: n.kind, title: n.title, body: n.body, open: n.open, sticky: Boolean(n.sticky) };
|
|
601
|
+
// 审批卡要活到有人动手:TTL 一天,urgency high(叫醒设备)。完成/出错一小时就够。
|
|
602
|
+
const approval = n.kind === "approval";
|
|
603
|
+
void this.push
|
|
604
|
+
.broadcast(payload, dismiss
|
|
605
|
+
? { urgency: "very-low", ttlSec: 300 }
|
|
606
|
+
: { urgency: approval ? "high" : "normal", ttlSec: approval ? 86_400 : 3_600 })
|
|
607
|
+
.then((results) => {
|
|
608
|
+
const bad = results.filter((r) => !r.ok);
|
|
609
|
+
if (bad.length) this.log(`[notify] web push: ${bad.length}/${results.length} 未送达(${bad[0]?.error ?? ""})`);
|
|
610
|
+
})
|
|
611
|
+
.catch((e: Error) => this.log(`[notify] web push threw: ${e.message}`));
|
|
536
612
|
}
|
|
537
613
|
|
|
538
614
|
// ---- actions -------------------------------------------------------------
|
|
@@ -609,6 +685,54 @@ export class NotifyCenter {
|
|
|
609
685
|
}) && true;
|
|
610
686
|
}
|
|
611
687
|
|
|
688
|
+
// ---- 网页推送(关屏也能收)-----------------------------------------------------
|
|
689
|
+
// The public key has to reach the browser to subscribe; the SUBSCRIPTION LIST must not — it says
|
|
690
|
+
// how many devices listen to this cockpit. So the whole group sits behind the operator gate, the
|
|
691
|
+
// same reason the APK and the root certificate do.
|
|
692
|
+
if (p === "/api/notify/webpush" || p.startsWith("/api/notify/webpush/")) {
|
|
693
|
+
if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
|
|
694
|
+
const subs = () => this.push.subscriptions().map(publicSub);
|
|
695
|
+
|
|
696
|
+
if (p === "/api/notify/webpush" && req.method === "GET") {
|
|
697
|
+
return json(200, { ok: true, publicKey: this.push.publicKey(), subs: subs() }) && true;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (p === "/api/notify/webpush/subscribe" && req.method === "POST") {
|
|
701
|
+
const body = await readBody();
|
|
702
|
+
const rec = this.push.subscribe({
|
|
703
|
+
endpoint: body.endpoint,
|
|
704
|
+
keys: (body.keys ?? {}) as { p256dh?: unknown; auth?: unknown },
|
|
705
|
+
ua: body.ua,
|
|
706
|
+
});
|
|
707
|
+
if ("error" in rec) return json(400, { error: rec.error }) && true;
|
|
708
|
+
this.log(`[notify] 网页推送已订阅:${new URL(rec.endpoint).host}(共 ${this.push.subscriptions().length} 台)`);
|
|
709
|
+
return json(200, { ok: true, subs: subs() }) && true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (p === "/api/notify/webpush/unsubscribe" && req.method === "POST") {
|
|
713
|
+
const body = await readBody();
|
|
714
|
+
const removed = this.push.unsubscribe(String(body.endpoint ?? ""));
|
|
715
|
+
return json(200, { ok: true, removed, subs: subs() }) && true;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The test button: proves the WHOLE chain (our VAPID key → push service → service worker →
|
|
719
|
+
// lock screen). A test that only logged to the server would prove none of it.
|
|
720
|
+
if (p === "/api/notify/webpush/test" && req.method === "POST") {
|
|
721
|
+
if (!this.push.subscriptions().length) return json(400, { error: "这台服务器还没有任何浏览器订阅" }) && true;
|
|
722
|
+
const results = await this.push.broadcast({
|
|
723
|
+
op: "upsert", noticeId: "test:webpush", kind: "agent_done", sticky: false,
|
|
724
|
+
title: "推送测试", body: "锁屏上能看到这条,就说明关屏也能收", open: "/",
|
|
725
|
+
}, { urgency: "high", ttlSec: 300 });
|
|
726
|
+
return json(200, {
|
|
727
|
+
ok: results.every((r) => r.ok),
|
|
728
|
+
results: results.map((r) => ({ ok: r.ok, status: r.status, error: r.error, gone: r.gone })),
|
|
729
|
+
subs: subs(),
|
|
730
|
+
}) && true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return json(404, { error: "unknown webpush route" }) && true;
|
|
734
|
+
}
|
|
735
|
+
|
|
612
736
|
if (p === "/api/notify/presence" && req.method === "POST") {
|
|
613
737
|
// The cockpit telling us what it is showing. Operator-authenticated (the SPA has the
|
|
614
738
|
// cookie); a missing report just means presence goes stale and quiet-when-watching stops
|
|
@@ -1037,17 +1161,13 @@ export class NotifyCenter {
|
|
|
1037
1161
|
// taking a dead card off the screen is not an interruption, and gating it on a preference
|
|
1038
1162
|
// would leave a card nobody can answer.
|
|
1039
1163
|
this.dismiss(`perm:${evt.requestId}`);
|
|
1040
|
-
this.
|
|
1041
|
-
op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "", at: Date.now(),
|
|
1042
|
-
});
|
|
1164
|
+
this.emitNotice({ op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "" });
|
|
1043
1165
|
return;
|
|
1044
1166
|
case "turn-end": {
|
|
1045
1167
|
// The running card goes away unconditionally — a zombie "still running" on the phone is worse
|
|
1046
1168
|
// than no card at all, even when the frame that started it was suppressed.
|
|
1047
1169
|
this.dismiss(`turn:${evt.sessionId}`);
|
|
1048
|
-
this.
|
|
1049
|
-
op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "", at: Date.now(),
|
|
1050
|
-
});
|
|
1170
|
+
this.emitNotice({ op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "" });
|
|
1051
1171
|
// Both gates belong here and both were missing: `completion` was stored but never read, and
|
|
1052
1172
|
// presence was only consulted for turn-start — so the completion card fired while the operator was watching
|
|
1053
1173
|
// the very session (and, mid-call, with a sound: the reported bug).
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Web Push — the third renderer of the same notification decision.
|
|
2
|
+
//
|
|
3
|
+
// The three channels and why they exist:
|
|
4
|
+
//
|
|
5
|
+
// phone (WS) — the Android companion holds one long connection; only works while the phone is awake.
|
|
6
|
+
// page — the open tab draws a notification through the page's own Notification API.
|
|
7
|
+
// push (here) — the ONLY channel that survives a locked screen: the page is frozen by iOS/Android
|
|
8
|
+
// the moment it leaves the foreground, and no JS runs to notice anything. A push
|
|
9
|
+
// service holds the message until the device is reachable, wakes the service worker,
|
|
10
|
+
// and the worker draws the card. That is why this exists and the page renderer cannot
|
|
11
|
+
// replace it.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here decides WHAT is worth notifying — `center.ts` owns that (one decision, three renderers).
|
|
14
|
+
// This module only knows how to hand bytes to a push service: encryption (RFC 8291 / aes128gcm) and
|
|
15
|
+
// VAPID authorization (RFC 8292).
|
|
16
|
+
//
|
|
17
|
+
// No dependency on `web-push`: the whole job is ~120 lines of node:crypto, and a dependency here would
|
|
18
|
+
// mean an npm install on a machine whose registry access is already the flakiest part of a release.
|
|
19
|
+
// The RFC's own test vector is checked by `scripts/qa/webpush-crypto.mts` — see the note at encryptPush().
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import https from "node:https";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
/** One browser's subscription, as `pushManager.subscribe()` returned it. */
|
|
27
|
+
export interface PushSubscriptionRecord {
|
|
28
|
+
endpoint: string;
|
|
29
|
+
/** base64url of the UA's uncompressed P-256 point (65 bytes) — the `keys.p256dh` the browser gave us. */
|
|
30
|
+
p256dh: string;
|
|
31
|
+
/** base64url of the 16-byte auth secret — `keys.auth`. */
|
|
32
|
+
auth: string;
|
|
33
|
+
ua?: string;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
/** Last time a push was accepted by the push service (HTTP 201). */
|
|
36
|
+
lastOkAt?: number;
|
|
37
|
+
fails: number;
|
|
38
|
+
lastError?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface VapidFile {
|
|
42
|
+
/** base64url uncompressed point (65 bytes) — what the browser needs as applicationServerKey. */
|
|
43
|
+
publicKey: string;
|
|
44
|
+
/** The private half, kept as a JWK because that is what createPrivateKey() eats. */
|
|
45
|
+
jwk: { kty: string; crv: string; x: string; y: string; d: string };
|
|
46
|
+
subject: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SubscriptionFile {
|
|
50
|
+
subs: PushSubscriptionRecord[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- base64url ----------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const b64u = (buf: Buffer): string => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
56
|
+
|
|
57
|
+
export const unb64u = (str: string): Buffer => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
58
|
+
|
|
59
|
+
// ---- RFC 8291: encrypting one push message ------------------------------------
|
|
60
|
+
|
|
61
|
+
/** The message body a push service carries for us: `aes128gcm` single record (RFC 8188 §2, RFC 8291 §4).
|
|
62
|
+
*
|
|
63
|
+
* Layout of the body: 16-byte salt ‖ rs (u32 BE = 4096) ‖ idlen (u8 = 65) ‖ AS public key ‖ ciphertext.
|
|
64
|
+
* The plaintext gets the padding delimiter 0x02 appended, because it is the last (only) record.
|
|
65
|
+
*
|
|
66
|
+
* `opts` exists ONLY so the RFC 8291 §5 test vector can be reproduced byte-for-byte: it pins the salt
|
|
67
|
+
* and the application-server keypair. Production calls always pass no salt and a fresh keypair — the
|
|
68
|
+
* whole security property rests on both being random per message.
|
|
69
|
+
*
|
|
70
|
+
* Verified against that vector (Appendix A gives every intermediate: ecdh_secret, PRK_key, IKM, PRK,
|
|
71
|
+
* CEK, NONCE, and the final body). If this function and the vector disagree, the push is silently
|
|
72
|
+
* undecryptable by the browser and nothing else in the system would tell us — hence a test, not hope. */
|
|
73
|
+
export function encryptPush(
|
|
74
|
+
uaPublicRaw: Buffer,
|
|
75
|
+
authSecret: Buffer,
|
|
76
|
+
plaintext: Buffer,
|
|
77
|
+
opts: { salt?: Buffer; asKeys?: crypto.KeyObject[] } = {},
|
|
78
|
+
): Buffer {
|
|
79
|
+
const salt = opts.salt ?? crypto.randomBytes(16);
|
|
80
|
+
const pair = opts.asKeys ?? (() => {
|
|
81
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
82
|
+
return [privateKey, publicKey];
|
|
83
|
+
})();
|
|
84
|
+
const [asPrivate, asPublic] = pair;
|
|
85
|
+
|
|
86
|
+
const uaPublic = crypto.createPublicKey({
|
|
87
|
+
key: { kty: "EC", crv: "P-256", x: b64u(uaPublicRaw.subarray(1, 33)), y: b64u(uaPublicRaw.subarray(33, 65)) },
|
|
88
|
+
format: "jwk",
|
|
89
|
+
});
|
|
90
|
+
const ecdhSecret = crypto.diffieHellman({ privateKey: asPrivate, publicKey: uaPublic });
|
|
91
|
+
const asPoint = pointFromJwk(asPublic.export({ format: "jwk" }) as { x: string; y: string });
|
|
92
|
+
|
|
93
|
+
// IKM = HKDF-Extract(auth_secret, ecdh_secret) then Expand with "WebPush: info\0" ‖ ua ‖ as.
|
|
94
|
+
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0", "utf8"), uaPublicRaw, asPoint]);
|
|
95
|
+
const ikm = Buffer.from(crypto.hkdfSync("sha256", ecdhSecret, authSecret, keyInfo, 32));
|
|
96
|
+
|
|
97
|
+
// CEK/NONCE come from RFC 8188's HKDF with the per-message salt.
|
|
98
|
+
const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0", "utf8"), 16));
|
|
99
|
+
const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0", "utf8"), 12));
|
|
100
|
+
|
|
101
|
+
const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
|
|
102
|
+
const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce);
|
|
103
|
+
const ct = Buffer.concat([cipher.update(record), cipher.final(), cipher.getAuthTag()]);
|
|
104
|
+
|
|
105
|
+
const rs = Buffer.alloc(4);
|
|
106
|
+
rs.writeUInt32BE(4096, 0);
|
|
107
|
+
return Buffer.concat([salt, rs, Buffer.from([asPoint.length]), asPoint, ct]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pointFromJwk(jwk: { x: string; y: string }): Buffer {
|
|
111
|
+
return Buffer.concat([Buffer.from([0x04]), unb64u(jwk.x), unb64u(jwk.y)]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- VAPID + delivery ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface SendResult { ok: boolean; status: number; error?: string; gone?: boolean }
|
|
117
|
+
|
|
118
|
+
export class WebPushService {
|
|
119
|
+
private readonly dir: string;
|
|
120
|
+
private readonly subject: string;
|
|
121
|
+
private readonly log: (line: string) => void;
|
|
122
|
+
private vapidCache: VapidFile | null = null;
|
|
123
|
+
private subsCache: PushSubscriptionRecord[] | null = null;
|
|
124
|
+
|
|
125
|
+
constructor(dataDir: string, opts: { subject?: string; log?: (line: string) => void } = {}) {
|
|
126
|
+
this.dir = path.join(dataDir, "notify");
|
|
127
|
+
this.subject = opts.subject ?? process.env.AGENTUS_VAPID_SUBJECT ?? "mailto:agentus@localhost";
|
|
128
|
+
this.log = opts.log ?? ((l) => console.log(l));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private get vapidFile(): string { return path.join(this.dir, "vapid.json"); }
|
|
132
|
+
private get subsFile(): string { return path.join(this.dir, "webpush.json"); }
|
|
133
|
+
|
|
134
|
+
/** The server's identity to push services. Generated on first use and then never rotated by itself:
|
|
135
|
+
* rotating the VAPID key invalidates every existing subscription, so that must stay a deliberate act. */
|
|
136
|
+
private vapid(): VapidFile {
|
|
137
|
+
if (this.vapidCache) return this.vapidCache;
|
|
138
|
+
try {
|
|
139
|
+
this.vapidCache = JSON.parse(fs.readFileSync(this.vapidFile, "utf8")) as VapidFile;
|
|
140
|
+
return this.vapidCache;
|
|
141
|
+
} catch { /* first boot */ }
|
|
142
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
143
|
+
const jwk = privateKey.export({ format: "jwk" }) as VapidFile["jwk"];
|
|
144
|
+
const record: VapidFile = { publicKey: b64u(pointFromJwk(jwk)), jwk, subject: this.subject };
|
|
145
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
146
|
+
fs.writeFileSync(this.vapidFile, JSON.stringify(record, null, 1), { mode: 0o600 });
|
|
147
|
+
this.log(`[notify] generated a VAPID keypair (${this.vapidFile}) — back it up with the rest of <data>`);
|
|
148
|
+
this.vapidCache = record;
|
|
149
|
+
return record;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `applicationServerKey` for `pushManager.subscribe()`. */
|
|
153
|
+
publicKey(): string { return this.vapid().publicKey; }
|
|
154
|
+
|
|
155
|
+
subscriptions(): PushSubscriptionRecord[] {
|
|
156
|
+
if (this.subsCache) return this.subsCache;
|
|
157
|
+
try {
|
|
158
|
+
const raw = JSON.parse(fs.readFileSync(this.subsFile, "utf8")) as SubscriptionFile;
|
|
159
|
+
this.subsCache = Array.isArray(raw.subs) ? raw.subs : [];
|
|
160
|
+
} catch {
|
|
161
|
+
this.subsCache = [];
|
|
162
|
+
}
|
|
163
|
+
return this.subsCache;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private save(): void {
|
|
167
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(this.subsFile, JSON.stringify({ subs: this.subsCache ?? [] }, null, 1), { mode: 0o600 });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Idempotent by endpoint: re-subscribing the same browser replaces its keys instead of piling up rows.
|
|
172
|
+
* A browser re-subscribes on every page load in some engines, so this must not grow. */
|
|
173
|
+
subscribe(input: { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown }; ua?: unknown }): PushSubscriptionRecord | { error: string } {
|
|
174
|
+
const endpoint = typeof input.endpoint === "string" ? input.endpoint : "";
|
|
175
|
+
const p256dh = typeof input.keys?.p256dh === "string" ? input.keys.p256dh : "";
|
|
176
|
+
const auth = typeof input.keys?.auth === "string" ? input.keys.auth : "";
|
|
177
|
+
if (!/^https?:\/\//.test(endpoint)) return { error: "endpoint must be an https URL from the browser" };
|
|
178
|
+
if (unb64u(p256dh).length !== 65) return { error: "keys.p256dh must be an uncompressed P-256 point (65 bytes)" };
|
|
179
|
+
if (unb64u(auth).length !== 16) return { error: "keys.auth must be 16 bytes" };
|
|
180
|
+
const subs = this.subscriptions();
|
|
181
|
+
const prior = subs.find((s) => s.endpoint === endpoint);
|
|
182
|
+
const rec: PushSubscriptionRecord = {
|
|
183
|
+
endpoint, p256dh, auth,
|
|
184
|
+
ua: typeof input.ua === "string" ? input.ua.slice(0, 200) : undefined,
|
|
185
|
+
createdAt: prior?.createdAt ?? Date.now(),
|
|
186
|
+
lastOkAt: prior?.lastOkAt,
|
|
187
|
+
fails: 0,
|
|
188
|
+
};
|
|
189
|
+
this.subsCache = [...subs.filter((s) => s.endpoint !== endpoint), rec];
|
|
190
|
+
this.save();
|
|
191
|
+
return rec;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
unsubscribe(endpoint: string): boolean {
|
|
195
|
+
const subs = this.subscriptions();
|
|
196
|
+
const next = subs.filter((s) => s.endpoint !== endpoint);
|
|
197
|
+
if (next.length === subs.length) return false;
|
|
198
|
+
this.subsCache = next;
|
|
199
|
+
this.save();
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** ES256 JWT over the endpoint's own origin (RFC 8292 §2). `aud` is per-endpoint on purpose: a token
|
|
204
|
+
* minted for one push service must not be replayable at another. */
|
|
205
|
+
private vapidAuth(endpoint: string, now = Date.now()): string {
|
|
206
|
+
const { publicKey, jwk } = this.vapid();
|
|
207
|
+
const header = b64u(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" })));
|
|
208
|
+
const claims = b64u(Buffer.from(JSON.stringify({
|
|
209
|
+
aud: new URL(endpoint).origin,
|
|
210
|
+
exp: Math.floor(now / 1000) + 12 * 3600,
|
|
211
|
+
sub: this.subject,
|
|
212
|
+
})));
|
|
213
|
+
const signingInput = `${header}.${claims}`;
|
|
214
|
+
const key = crypto.createPrivateKey({ key: jwk, format: "jwk" });
|
|
215
|
+
// ieee-p1363 = raw R‖S; JWS wants exactly that, DER would be rejected.
|
|
216
|
+
const sig = crypto.sign("sha256", Buffer.from(signingInput), { key, dsaEncoding: "ieee-p1363" });
|
|
217
|
+
return `vapid t=${signingInput}.${b64u(sig)}, k=${publicKey}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Hand one message to one push service. `urgency` maps to the RFC 8030 header the platforms honour:
|
|
221
|
+
* a lock-screen approval is `high` (deliver immediately, wake the device), a "finished" is `normal`,
|
|
222
|
+
* and taking a stale card back is `very-low` (do not wake anything for it). */
|
|
223
|
+
async send(
|
|
224
|
+
rec: PushSubscriptionRecord,
|
|
225
|
+
payload: unknown,
|
|
226
|
+
opts: { ttlSec?: number; urgency?: "very-low" | "low" | "normal" | "high"; timeoutMs?: number } = {},
|
|
227
|
+
): Promise<SendResult> {
|
|
228
|
+
let body: Buffer;
|
|
229
|
+
try {
|
|
230
|
+
body = encryptPush(unb64u(rec.p256dh), unb64u(rec.auth), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return { ok: false, status: 0, error: `encrypt: ${(e as Error).message}` };
|
|
233
|
+
}
|
|
234
|
+
let auth: string;
|
|
235
|
+
try {
|
|
236
|
+
auth = this.vapidAuth(rec.endpoint);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return { ok: false, status: 0, error: `vapid: ${(e as Error).message}` };
|
|
239
|
+
}
|
|
240
|
+
const url = new URL(rec.endpoint);
|
|
241
|
+
const mod = url.protocol === "http:" ? http : https;
|
|
242
|
+
return await new Promise<SendResult>((resolve) => {
|
|
243
|
+
const req = mod.request({
|
|
244
|
+
protocol: url.protocol,
|
|
245
|
+
hostname: url.hostname,
|
|
246
|
+
port: url.port || (url.protocol === "http:" ? 80 : 443),
|
|
247
|
+
path: `${url.pathname}${url.search}`,
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: {
|
|
250
|
+
authorization: auth,
|
|
251
|
+
"content-encoding": "aes128gcm",
|
|
252
|
+
"content-type": "application/octet-stream",
|
|
253
|
+
"content-length": String(body.length),
|
|
254
|
+
ttl: String(opts.ttlSec ?? 3600),
|
|
255
|
+
urgency: opts.urgency ?? "normal",
|
|
256
|
+
},
|
|
257
|
+
timeout: opts.timeoutMs ?? 8000,
|
|
258
|
+
}, (res) => {
|
|
259
|
+
const chunks: Buffer[] = [];
|
|
260
|
+
res.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
261
|
+
res.on("end", () => {
|
|
262
|
+
const status = res.statusCode ?? 0;
|
|
263
|
+
const ok = status >= 200 && status < 300;
|
|
264
|
+
const text = Buffer.concat(chunks).toString("utf8").slice(0, 300);
|
|
265
|
+
const gone = status === 404 || status === 410;
|
|
266
|
+
if (ok) {
|
|
267
|
+
rec.lastOkAt = Date.now();
|
|
268
|
+
rec.fails = 0;
|
|
269
|
+
rec.lastError = undefined;
|
|
270
|
+
this.save();
|
|
271
|
+
} else {
|
|
272
|
+
rec.fails += 1;
|
|
273
|
+
rec.lastError = `${status} ${text}`.trim();
|
|
274
|
+
// 404/410 = the browser threw the subscription away (app uninstalled, permission reset).
|
|
275
|
+
// Keeping it would retry a dead endpoint forever and log a failure on every notification.
|
|
276
|
+
if (gone) this.unsubscribe(rec.endpoint);
|
|
277
|
+
else this.save();
|
|
278
|
+
}
|
|
279
|
+
resolve({ ok, status, error: ok ? undefined : `${status} ${text}`.trim(), gone });
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
req.on("timeout", () => { req.destroy(new Error("push service timed out")); });
|
|
283
|
+
req.on("error", (e) => resolve({ ok: false, status: 0, error: e.message }));
|
|
284
|
+
req.end(body);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Send the same payload to every subscription. Returns one result per device so the settings card can
|
|
289
|
+
* say "2 台设备都收到了" or which one is dead — a silent broadcast would hide a broken device. */
|
|
290
|
+
async broadcast(payload: unknown, opts: { ttlSec?: number; urgency?: "very-low" | "low" | "normal" | "high" } = {}): Promise<SendResult[]> {
|
|
291
|
+
const subs = this.subscriptions();
|
|
292
|
+
if (!subs.length) return [];
|
|
293
|
+
return await Promise.all(subs.map((s) => this.send(s, payload, opts)));
|
|
294
|
+
}
|
|
295
|
+
}
|