@lelouchhe/webagent 0.3.0 → 0.4.0

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.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +96 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +123 -9
  31. package/lib/daemon.js +175 -41
  32. package/lib/event-handler.js +209 -91
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1202 -144
  40. package/lib/server.js +149 -33
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +624 -30
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.2562YGRO.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
@@ -1,21 +1,78 @@
1
1
  import webpush from "web-push";
2
2
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import { join } from "node:path";
5
+ import { log } from "./log.js";
6
+ const slog = log.scope("push");
7
+ const elog = log.scope("egress");
4
8
  const VAPID_FILE = "vapid.json";
5
9
  /** Remove a subscription after this many consecutive send failures. */
6
10
  const MAX_CONSECUTIVE_FAILURES = 5;
11
+ /** Derive the push tag for an ACP event. Kept module-level so it can be
12
+ * reused by the close-on-handle path without instantiating the service. */
13
+ export function pushTagForEvent(sessionId, event) {
14
+ switch (event.type) {
15
+ case "prompt_done":
16
+ return `sess-${sessionId}-done`;
17
+ case "permission_request":
18
+ return `sess-${sessionId}-perm-${event.eventId ?? "0"}`;
19
+ case "bash_done":
20
+ return `sess-${sessionId}-bash-${event.eventId ?? "0"}`;
21
+ default:
22
+ return `sess-${sessionId}-${event.type}`;
23
+ }
24
+ }
25
+ /**
26
+ * True iff the push endpoint is an Apple (APNs / Web Push on Apple) host.
27
+ *
28
+ * We deliberately filter silent `kind:"close"` pushes to these endpoints in
29
+ * `sendToAll` — iOS Safari PWA enforces an undocumented silent-push budget
30
+ * that, once exhausted, causes WebKit to drop ALL subsequent pushes for the
31
+ * subscription (including user-visible `kind:"notify"`). APNs returns 201
32
+ * throughout, so the server has zero visibility.
33
+ *
34
+ * macOS Safari shares the same host but is not subject to that budget; the
35
+ * conflation here is accepted collateral damage until we add a `platform`
36
+ * column to `push_subscriptions`.
37
+ */
38
+ export function isAppleEndpoint(endpoint) {
39
+ let host;
40
+ try {
41
+ host = new URL(endpoint).hostname.toLowerCase();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
47
+ }
48
+ function emptyClientState() {
49
+ return { visible: false, sessionId: null, endpoint: null, visibleSince: 0 };
50
+ }
7
51
  export class PushService {
8
52
  store;
9
53
  vapidKeys;
10
- clientVisibility = new Map(); // clientId → visible
11
- clientEndpoints = new Map(); // clientId push endpoint
12
- clientSessions = new Map(); // clientId → currently viewed sessionId
54
+ /**
55
+ * Consolidated per-client state. Previously 3 separate Maps
56
+ * (clientVisibility/clientEndpoints/clientSessions) which drifted under
57
+ * partial updates. v2 merges them; `visibleSince` stamps when the client
58
+ * transitioned to visible so the server can TTL-expire "ghost" visibility
59
+ * records left by iOS PWA process suspension (where the client never gets
60
+ * to POST visible:false).
61
+ */
62
+ clients = new Map();
13
63
  /** endpoint → consecutive failure count (absent or 0 = healthy) */
14
64
  failureCounts = new Map();
15
- constructor(store, dataDir, vapidSubject) {
65
+ globalVisibilitySuppression;
66
+ visibilityTtlMs;
67
+ now;
68
+ constructor(store, dataDir, vapidSubject, options = {}) {
16
69
  this.store = store;
17
70
  this.vapidKeys = this.loadOrGenerateKeys(dataDir);
18
71
  webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
72
+ this.globalVisibilitySuppression =
73
+ options.globalVisibilitySuppression ?? true;
74
+ this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
75
+ this.now = options.now ?? (() => Date.now());
19
76
  }
20
77
  // ---------------------------------------------------------------------------
21
78
  // VAPID keys
@@ -25,12 +82,14 @@ export class PushService {
25
82
  if (existsSync(filePath)) {
26
83
  chmodSync(filePath, 0o600);
27
84
  const keys = JSON.parse(readFileSync(filePath, "utf8"));
28
- console.log("[push] loaded VAPID keys");
85
+ slog.info("loaded VAPID keys");
29
86
  return keys;
30
87
  }
31
88
  const keys = webpush.generateVAPIDKeys();
32
- writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", { mode: 0o600 });
33
- console.log("[push] generated new VAPID keys");
89
+ writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", {
90
+ mode: 0o600,
91
+ });
92
+ slog.info("generated new VAPID keys");
34
93
  return keys;
35
94
  }
36
95
  getPublicKey() {
@@ -39,81 +98,153 @@ export class PushService {
39
98
  // ---------------------------------------------------------------------------
40
99
  // Notification formatting
41
100
  // ---------------------------------------------------------------------------
42
- formatNotification(sessionId, sessionTitle, eventType, eventData) {
43
- const title = sessionTitle || "WebAgent";
101
+ formatNotification(sessionId, sessionTitle, eventType, eventData, tag) {
102
+ const title = sessionTitle ?? "WebAgent";
44
103
  let body;
45
104
  switch (eventType) {
46
105
  case "permission_request":
47
- body = `⚿ ${eventData.description ?? "Permission requested"}`;
106
+ body = `⚿ ${typeof eventData.description === "string" ? eventData.description : "Permission requested"}`;
48
107
  break;
49
108
  case "prompt_done":
50
109
  body = "✓ Task complete";
51
110
  break;
52
111
  case "bash_done": {
53
- const cmd = eventData.command ?? "command";
54
- const code = eventData.exitCode ?? "?";
112
+ const cmd = typeof eventData.command === "string" ? eventData.command : "command";
113
+ const code = typeof eventData.exitCode === "number" ||
114
+ typeof eventData.exitCode === "string"
115
+ ? String(eventData.exitCode)
116
+ : "?";
55
117
  body = `$ ${cmd} — exit ${code}`;
56
118
  break;
57
119
  }
58
120
  default:
59
121
  body = eventType;
60
122
  }
61
- return { title, body, data: { sessionId } };
123
+ return { kind: "notify", title, body, tag, data: { sessionId } };
62
124
  }
63
125
  // ---------------------------------------------------------------------------
64
126
  // Client visibility tracking
65
127
  // ---------------------------------------------------------------------------
128
+ /**
129
+ * Atomic consolidated setter. All visibility/session/endpoint updates
130
+ * should route through here. Callers distinguish "preserve" from "clear"
131
+ * by omitting the key vs passing `null`. Returns an edge flag so the
132
+ * caller can fire edge-triggered side effects (e.g. sendClose) without
133
+ * double-firing on every heartbeat refresh.
134
+ */
135
+ updateClient(clientId, patch) {
136
+ const prev = this.clients.get(clientId) ?? emptyClientState();
137
+ const wasVisibleForSession = prev.visible && prev.sessionId != null ? prev.sessionId : null;
138
+ const next = { ...prev };
139
+ if (patch.visible !== undefined) {
140
+ next.visible = patch.visible;
141
+ next.visibleSince = patch.visible ? this.now() : 0;
142
+ }
143
+ if (patch.sessionId !== undefined)
144
+ next.sessionId = patch.sessionId;
145
+ if (patch.endpoint !== undefined)
146
+ next.endpoint = patch.endpoint;
147
+ const becameVisibleForSession = next.visible &&
148
+ next.sessionId != null &&
149
+ next.sessionId !== wasVisibleForSession
150
+ ? next.sessionId
151
+ : null;
152
+ // Any transition into "visible + session X" restarts the TTL clock,
153
+ // including a session-switch that arrives without an explicit
154
+ // visible:true in the patch (e.g. a session_created POST that only
155
+ // carries sessionId). Otherwise the TTL would keep counting from the
156
+ // previous session's first-visible moment and could prematurely
157
+ // declare the newly-focused session "stale".
158
+ if (becameVisibleForSession) {
159
+ next.visibleSince = this.now();
160
+ }
161
+ this.clients.set(clientId, next);
162
+ return { becameVisibleForSession };
163
+ }
164
+ /**
165
+ * Read-only snapshot for tests and diagnostics. Do NOT mutate the
166
+ * returned object.
167
+ */
168
+ getClientState(clientId) {
169
+ return this.clients.get(clientId) ?? null;
170
+ }
171
+ /** @deprecated Shim: prefer `updateClient({ visible })`. */
66
172
  setClientVisibility(clientId, visible) {
67
- this.clientVisibility.set(clientId, visible);
173
+ this.updateClient(clientId, { visible });
68
174
  }
175
+ /** @deprecated Shim: prefer `updateClient({ sessionId })`. */
69
176
  setClientSession(clientId, sessionId) {
70
- this.clientSessions.set(clientId, sessionId);
177
+ this.updateClient(clientId, { sessionId });
71
178
  }
179
+ /** @deprecated Shim: prefer `updateClient({ endpoint })`. */
72
180
  registerClient(clientId, endpoint) {
73
- this.clientEndpoints.set(clientId, endpoint);
181
+ this.updateClient(clientId, { endpoint });
74
182
  }
75
183
  removeClient(clientId) {
76
- this.clientVisibility.delete(clientId);
77
- this.clientEndpoints.delete(clientId);
78
- this.clientSessions.delete(clientId);
184
+ this.clients.delete(clientId);
79
185
  }
80
186
  hasVisibleClient() {
81
- for (const visible of this.clientVisibility.values()) {
82
- if (visible)
187
+ const now = this.now();
188
+ for (const s of this.clients.values()) {
189
+ if (s.visible && now - s.visibleSince <= this.visibilityTtlMs)
83
190
  return true;
84
191
  }
85
192
  return false;
86
193
  }
87
- /** Check if a specific endpoint has at least one visible client. */
194
+ /** Check if a specific endpoint has at least one visible (non-stale) client. */
88
195
  isEndpointVisible(endpoint) {
89
- for (const [clientId, ep] of this.clientEndpoints) {
90
- if (ep === endpoint && this.clientVisibility.get(clientId))
91
- return true;
196
+ const now = this.now();
197
+ for (const s of this.clients.values()) {
198
+ if (s.endpoint !== endpoint)
199
+ continue;
200
+ if (!s.visible)
201
+ continue;
202
+ if (now - s.visibleSince > this.visibilityTtlMs)
203
+ continue;
204
+ return true;
92
205
  }
93
206
  return false;
94
207
  }
95
208
  /**
96
- * Check if any client (across all endpoints) is visible and viewing the given session.
97
- * A client with no session set does not suppress any session's push.
98
- * Used for global suppression: if any client sees this session, all endpoints are skipped.
209
+ * Check if any client (across all endpoints) is visible and viewing the
210
+ * given session. A client with no session set does not suppress any
211
+ * session's push. Stale records (older than `visibilityTtlMs` since the
212
+ * last heartbeat refresh) are ignored — this is the server-side safety
213
+ * net for iOS PWA suspension, where the client's `visible:false` POST may
214
+ * never leave the device.
215
+ *
216
+ * Returns false unconditionally when global suppression is disabled via
217
+ * the `globalVisibilitySuppression` option (kill switch).
99
218
  */
100
219
  isSessionVisibleToAnyClient(sessionId) {
101
- for (const [clientId, visible] of this.clientVisibility) {
102
- if (visible && this.clientSessions.get(clientId) === sessionId)
103
- return true;
220
+ if (!this.globalVisibilitySuppression)
221
+ return false;
222
+ const now = this.now();
223
+ for (const s of this.clients.values()) {
224
+ if (!s.visible)
225
+ continue;
226
+ if (s.sessionId !== sessionId)
227
+ continue;
228
+ if (now - s.visibleSince > this.visibilityTtlMs)
229
+ continue;
230
+ return true;
104
231
  }
105
232
  return false;
106
233
  }
107
234
  // ---------------------------------------------------------------------------
108
235
  // High-level: decide whether to push, and if so, send
109
236
  // ---------------------------------------------------------------------------
110
- static NOTIFIABLE = new Set(["permission_request", "prompt_done", "bash_done"]);
237
+ static NOTIFIABLE = new Set([
238
+ "permission_request",
239
+ "prompt_done",
240
+ "bash_done",
241
+ ]);
111
242
  /**
112
243
  * Check if this event should trigger a push notification.
113
244
  * Returns true if a notification should be sent (caller should then call sendToAll).
114
245
  * Global session visibility suppression happens inside sendToAll.
115
246
  */
116
- maybeNotify(sessionId, sessionTitle, eventType, eventData) {
247
+ maybeNotify(sessionId, sessionTitle, eventType, _eventData) {
117
248
  if (!PushService.NOTIFIABLE.has(eventType))
118
249
  return false;
119
250
  return true;
@@ -122,45 +253,207 @@ export class PushService {
122
253
  // Send push to all subscriptions
123
254
  // ---------------------------------------------------------------------------
124
255
  async sendToAll(notification) {
125
- const subs = this.store.getAllSubscriptions();
126
- if (subs.length === 0)
256
+ const allSubs = this.store.getAllSubscriptions();
257
+ if (allSubs.length === 0)
127
258
  return;
259
+ // Global visibility: if any client is viewing this session, suppress notify pushes.
260
+ // Close pushes are never suppressed — they're silent and are the mechanism
261
+ // by which cross-device recall actually closes banners on the "losing" devices.
262
+ if (notification.kind === "notify") {
263
+ const targetSession = notification.data.sessionId;
264
+ if (targetSession && this.isSessionVisibleToAnyClient(targetSession))
265
+ return;
266
+ }
267
+ // Skip Apple endpoints for silent close pushes to preserve iOS PWA's
268
+ // silent-push budget. See isAppleEndpoint() for rationale.
269
+ let subs = allSubs;
270
+ let filteredApple = 0;
271
+ if (notification.kind === "close") {
272
+ subs = allSubs.filter((s) => {
273
+ if (isAppleEndpoint(s.endpoint)) {
274
+ filteredApple++;
275
+ return false;
276
+ }
277
+ return true;
278
+ });
279
+ if (subs.length === 0) {
280
+ elog.info("sendClose", {
281
+ tag: notification.tag,
282
+ endpoints: 0,
283
+ ok: 0,
284
+ fail: 0,
285
+ fail_410: 0,
286
+ filtered_apple: filteredApple,
287
+ });
288
+ return;
289
+ }
290
+ }
128
291
  const payload = JSON.stringify(notification);
129
- // Global visibility: if any client is viewing this session, suppress all push
130
- if (this.isSessionVisibleToAnyClient(notification.data.sessionId))
131
- return;
132
- const results = await Promise.allSettled(subs.map((sub) => this.sendOne({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, payload)));
292
+ // Derive an RFC 8030 Topic so push services can collapse undelivered
293
+ // pushes on the wire. FCM (Chrome/Firefox desktop + Android) honors
294
+ // this and collapses correctly. APNs (iOS Safari PWA) does NOT — we
295
+ // verified in dogfood that two same-Topic pushes still surface as
296
+ // two stacked banners on iOS 17, even combined with the SW-side
297
+ // close-before-show workaround (see public/sw.js). Keep the header
298
+ // anyway: it's spec-compliant, cheap, and benefits every non-Apple
299
+ // client. iOS banner stacking is a platform limitation we accept.
300
+ const topic = tagToTopic(notification.tag);
301
+ const results = await Promise.allSettled(subs.map((sub) => this.sendOne({
302
+ endpoint: sub.endpoint,
303
+ keys: { auth: sub.auth, p256dh: sub.p256dh },
304
+ }, payload, { topic })));
305
+ let ok = 0;
306
+ let fail = 0;
307
+ let fail410 = 0;
133
308
  for (let i = 0; i < results.length; i++) {
134
309
  const result = results[i];
135
310
  const endpoint = subs[i].endpoint;
136
311
  if (result.status === "fulfilled") {
137
312
  this.failureCounts.delete(endpoint);
313
+ ok++;
138
314
  }
139
315
  else {
316
+ fail++;
140
317
  const err = result.reason;
141
318
  if (err.statusCode === 410) {
142
- // Subscription expired — clean up immediately
319
+ fail410++;
143
320
  this.store.removeSubscription(endpoint);
144
321
  this.failureCounts.delete(endpoint);
145
- console.log(`[push] removed expired subscription (410): ${endpoint.slice(0, 60)}…`);
322
+ slog.info("removed expired subscription (410)", {
323
+ endpoint: endpoint.slice(0, 60) + "…",
324
+ });
146
325
  }
147
326
  else {
148
327
  const count = (this.failureCounts.get(endpoint) ?? 0) + 1;
149
328
  if (count >= MAX_CONSECUTIVE_FAILURES) {
150
329
  this.store.removeSubscription(endpoint);
151
330
  this.failureCounts.delete(endpoint);
152
- console.log(`[push] removed subscription after ${count} consecutive failures: ${endpoint.slice(0, 60)}…`);
331
+ slog.info("removed subscription after consecutive failures", {
332
+ count,
333
+ endpoint: endpoint.slice(0, 60) + "…",
334
+ });
153
335
  }
154
336
  else {
155
337
  this.failureCounts.set(endpoint, count);
156
- console.error(`[push] send failed (${count}/${MAX_CONSECUTIVE_FAILURES}) for ${endpoint.slice(0, 60)}…:`, result.reason);
338
+ slog.error("send failed", {
339
+ count,
340
+ max: MAX_CONSECUTIVE_FAILURES,
341
+ endpoint: endpoint.slice(0, 60) + "…",
342
+ error: result.reason,
343
+ });
157
344
  }
158
345
  }
159
346
  }
160
347
  }
348
+ if (notification.kind === "close") {
349
+ // Observability signal #6 — aggregated per-call close outcome.
350
+ elog.info("sendClose", {
351
+ tag: notification.tag,
352
+ endpoints: subs.length,
353
+ ok,
354
+ fail,
355
+ fail_410: fail410,
356
+ filtered_apple: filteredApple,
357
+ });
358
+ }
359
+ }
360
+ /** Send a silent close push for the given tag. Never visibility-suppressed. */
361
+ async sendClose(tag) {
362
+ await this.sendToAll({ kind: "close", tag });
363
+ }
364
+ /**
365
+ * Send a push for an external message. Respects the message's `deliver`
366
+ * intent: `silent` sends nothing, `inapp` and `push` both send through
367
+ * web-push (the `inapp` vs `push` distinction is enforced by the SW /
368
+ * frontend rendering, not by the server).
369
+ *
370
+ * Tag = `msg-<id>` for unbound messages; bound messages get the
371
+ * `sess-<sid>-msg-<eid>` tag by taking a different code path in the
372
+ * consume handler.
373
+ */
374
+ async sendForMessage(msg) {
375
+ const deliver = msg.deliver ?? "push";
376
+ if (deliver === "silent")
377
+ return false;
378
+ const subs = this.store.getAllSubscriptions();
379
+ const title = msg.from_label ?? msg.from_ref ?? "Message";
380
+ const body = msg.body.length > 140 ? msg.body.slice(0, 137) + "…" : msg.body;
381
+ const tag = msg.dedup_key ? `dedup-${msg.to}-${msg.dedup_key}` : msg.id;
382
+ // If this message targets a specific session, surface the sid in the
383
+ // push data so SW notificationclick can route the user there. Without
384
+ // this, clicks fall back to "/" and land on whatever session was last
385
+ // open — a confusing UX when multiple sessions get background pushes.
386
+ const sessionId = msg.to.startsWith("session:")
387
+ ? msg.to.slice("session:".length)
388
+ : undefined;
389
+ // Observability signal #7 — sendForMessage entry.
390
+ elog.info("sendForMessage", {
391
+ msg_id: msg.id,
392
+ tag,
393
+ deliver,
394
+ endpoints: subs.length,
395
+ suppressed_by_visibility: false,
396
+ });
397
+ await this.sendToAll({
398
+ kind: "notify",
399
+ title,
400
+ body,
401
+ tag,
402
+ data: sessionId
403
+ ? { messageId: msg.id, sessionId }
404
+ : { messageId: msg.id },
405
+ });
406
+ return true;
407
+ }
408
+ /**
409
+ * Send a push for an ACP session event (permission_request / prompt_done /
410
+ * bash_done). Handles tag derivation, visibility suppression, and session-
411
+ * title lookup. Returns `true` if a push attempt was made.
412
+ */
413
+ async sendForEvent(sessionId, event) {
414
+ if (!PushService.NOTIFIABLE.has(event.type))
415
+ return false;
416
+ const session = this.store.getSession(sessionId);
417
+ const sessionTitle = session?.title ?? null;
418
+ const tag = pushTagForEvent(sessionId, event);
419
+ const eventData = {};
420
+ if (event.type === "permission_request" && event.title !== undefined) {
421
+ eventData.description = event.title;
422
+ }
423
+ if (event.type === "bash_done") {
424
+ if (event.command !== undefined)
425
+ eventData.command = event.command;
426
+ if (event.exitCode !== undefined)
427
+ eventData.exitCode = event.exitCode;
428
+ }
429
+ const suppressed = this.isSessionVisibleToAnyClient(sessionId);
430
+ const subs = this.store.getAllSubscriptions();
431
+ elog.info("sendForEvent", {
432
+ sess_id: sessionId.slice(0, 8),
433
+ type: event.type,
434
+ tag,
435
+ endpoints: subs.length,
436
+ suppressed_by_visibility: suppressed,
437
+ });
438
+ const notification = this.formatNotification(sessionId, sessionTitle, event.type, eventData, tag);
439
+ await this.sendToAll(notification);
440
+ return true;
161
441
  }
162
442
  /** Send a single push notification. Extracted for testability. */
163
- sendOne(sub, payload) {
164
- return webpush.sendNotification(sub, payload);
443
+ sendOne(sub, payload, options) {
444
+ return webpush.sendNotification(sub, payload, options);
165
445
  }
166
446
  }
447
+ /**
448
+ * Derive an RFC 8030 `Topic` header value from a notification tag. The
449
+ * spec limits topic to ≤32 chars of URL-safe Base64, so we hash and
450
+ * truncate. FCM maps this to its on-wire collapse key and works
451
+ * correctly. APNs is documented to map Topic → `apns-collapse-id`,
452
+ * but in practice iOS Safari PWA (≤17 at least) still surfaces
453
+ * stacked banners for same-Topic pushes — confirmed in dogfood.
454
+ * We keep the header for the platforms where it does work and for
455
+ * spec compliance; don't expect it to fix iOS.
456
+ */
457
+ function tagToTopic(tag) {
458
+ return createHash("sha256").update(tag).digest("base64url").slice(0, 22);
459
+ }