@crouton-kit/crouter 0.3.56 → 0.3.58

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.
@@ -0,0 +1,177 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { PushRegistryStore, PushRequestError, isAllowedPushEndpoint, parsePushDeleteBody, parsePushSubscriptionBody, pushBodyMaxBytes, subscriptionIdForEndpoint, } from '../push-registry.js';
8
+ function tmpDir() {
9
+ return mkdtempSync(join(tmpdir(), 'crtr-push-registry-'));
10
+ }
11
+ // A real, allowlisted vendor push-service endpoint shape (Chromium/FCM) —
12
+ // endpoints must resolve to one of the small set of known browser-vendor push
13
+ // services; arbitrary hosts (including this suite's old `push.example.com`
14
+ // placeholder) are no longer accepted.
15
+ const VALID_ENDPOINT = 'https://fcm.googleapis.com/fcm/send/abc123';
16
+ const VALID_P256DH = 'p'.repeat(64);
17
+ const VALID_AUTH = 'a'.repeat(22);
18
+ function validBody(overrides = {}) {
19
+ return JSON.stringify({
20
+ endpoint: VALID_ENDPOINT,
21
+ keys: { p256dh: VALID_P256DH, auth: VALID_AUTH },
22
+ ...overrides,
23
+ });
24
+ }
25
+ test('a well-formed subscription body parses and persists behind a scoped, non-secret authScope', () => {
26
+ const dir = tmpDir();
27
+ try {
28
+ const record = parsePushSubscriptionBody(validBody(), 'token:deadbeef');
29
+ assert.equal(record.endpoint, VALID_ENDPOINT);
30
+ assert.equal(record.authScope, 'token:deadbeef');
31
+ assert.equal(record.id, subscriptionIdForEndpoint(VALID_ENDPOINT));
32
+ assert.equal(record.prefs.doneNotifications, true);
33
+ const registry = new PushRegistryStore(join(dir, 'registry.json'));
34
+ registry.upsert(record);
35
+ assert.equal(registry.listByScope('token:deadbeef').length, 1);
36
+ // The persisted file never carries a raw bearer token \u2014 only the derived
37
+ // scope string, which is exactly what was passed in above.
38
+ const onDisk = readFileSync(join(dir, 'registry.json'), 'utf8');
39
+ assert.ok(onDisk.includes('token:deadbeef'));
40
+ assert.ok(!onDisk.toLowerCase().includes('bearer'));
41
+ }
42
+ finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+ test('the registry persists only the derived scope digest, never the raw token pre-image', () => {
47
+ const dir = tmpDir();
48
+ try {
49
+ // Mirror scopeForToken (server.ts): scope = 'token:' + sha256(raw).base64url[:16].
50
+ const raw = 'SUPERSECRET-TOKEN-VALUE';
51
+ const scope = `token:${createHash('sha256').update(raw).digest('base64url').slice(0, 16)}`;
52
+ const record = parsePushSubscriptionBody(validBody(), scope);
53
+ const registry = new PushRegistryStore(join(dir, 'registry.json'));
54
+ registry.upsert(record);
55
+ const onDisk = readFileSync(join(dir, 'registry.json'), 'utf8');
56
+ assert.ok(onDisk.includes(scope));
57
+ assert.ok(!onDisk.includes(raw));
58
+ }
59
+ finally {
60
+ rmSync(dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ test('rejects IPv6 and v4-mapped private/localhost endpoints before persistence', () => {
64
+ for (const endpoint of [
65
+ 'https://[::1]/x',
66
+ 'https://[fc00::1]/x',
67
+ 'https://[fe80::1]/x',
68
+ 'https://[::ffff:127.0.0.1]/x',
69
+ ]) {
70
+ assert.throws(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), PushRequestError, endpoint);
71
+ }
72
+ });
73
+ test('the endpoint host must be an allowlisted push service — an arbitrary host is rejected even over https', () => {
74
+ for (const endpoint of [
75
+ 'https://push.example.com/abc123', // not a recognized push-service host
76
+ 'https://evil.attacker.example/abc123',
77
+ 'https://fcm.googleapis.com.attacker.example/abc123', // suffix-spoof attempt
78
+ 'https://notgooglapis.com/fcm/send/abc123',
79
+ ]) {
80
+ assert.throws(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), PushRequestError, endpoint);
81
+ assert.equal(isAllowedPushEndpoint(endpoint), false, endpoint);
82
+ }
83
+ });
84
+ test('a trailing DNS root dot never bypasses the allowlist/localhost check', () => {
85
+ for (const endpoint of ['https://localhost./x', 'https://foo.localhost./x', 'https://fcm.googleapis.com./fcm/send/abc']) {
86
+ // The last one is a real allowlisted host WITH a trailing dot: it must
87
+ // canonicalize to the same allowed match, not be rejected as a foreign
88
+ // host, and localhost-with-trailing-dot must still be rejected.
89
+ if (endpoint.includes('localhost')) {
90
+ assert.throws(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), PushRequestError, endpoint);
91
+ }
92
+ else {
93
+ assert.doesNotThrow(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), endpoint);
94
+ }
95
+ }
96
+ });
97
+ test('the private/reserved IP ranges the old blocklist missed are moot under the allowlist — no IP literal is ever allowed', () => {
98
+ for (const endpoint of [
99
+ 'https://0.0.0.0/x', // 0.0.0.0/8
100
+ 'https://100.64.0.1/x', // CGNAT 100.64.0.0/10
101
+ 'https://192.0.0.1/x', // 192.0.0.0/24
102
+ 'https://198.18.0.1/x', // 198.18.0.0/15
103
+ 'https://240.0.0.1/x', // 240.0.0.0/4
104
+ 'https://224.0.0.1/x', // multicast
105
+ 'https://[::]/x', // IPv6 unspecified
106
+ 'https://[fe90::1]/x', // fe80::/10, beyond the fe80* prefix
107
+ 'https://[ff02::1]/x', // IPv6 multicast
108
+ 'https://8.8.8.8/x', // a genuinely PUBLIC IP — still not allowlisted
109
+ ]) {
110
+ assert.throws(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), PushRequestError, endpoint);
111
+ }
112
+ });
113
+ test('every real browser-vendor push-service endpoint shape is allowlisted, including a subdomain form', () => {
114
+ for (const endpoint of [
115
+ 'https://fcm.googleapis.com/fcm/send/abc123',
116
+ 'https://android.googleapis.com/gcm/send/abc123',
117
+ 'https://updates.push.services.mozilla.com/wpush/v2/abc123',
118
+ 'https://web.push.apple.com/abc123',
119
+ 'https://notify.windows.com/w/abc123',
120
+ 'https://sub.notify.windows.com/w/abc123',
121
+ 'https://wns.windows.com/w/abc123',
122
+ 'https://sub.wns.windows.com/w/abc123',
123
+ ]) {
124
+ assert.equal(isAllowedPushEndpoint(endpoint), true, endpoint);
125
+ assert.doesNotThrow(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), endpoint);
126
+ }
127
+ });
128
+ test('deleteByEndpoint is scoped: a caller can only remove a subscription it owns', () => {
129
+ const dir = tmpDir();
130
+ try {
131
+ const registry = new PushRegistryStore(join(dir, 'registry.json'));
132
+ registry.upsert(parsePushSubscriptionBody(validBody(), 'scope-A'));
133
+ assert.equal(registry.deleteByEndpoint(VALID_ENDPOINT, 'scope-B'), false);
134
+ assert.equal(registry.listByScope('scope-A').length, 1);
135
+ assert.equal(registry.deleteByEndpoint(VALID_ENDPOINT, 'scope-A'), true);
136
+ assert.equal(registry.listByScope('scope-A').length, 0);
137
+ }
138
+ finally {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ });
142
+ test('rejects a malformed body shape (unknown keys, missing fields)', () => {
143
+ assert.throws(() => parsePushSubscriptionBody(JSON.stringify({ endpoint: VALID_ENDPOINT }), 'local'), PushRequestError);
144
+ assert.throws(() => parsePushSubscriptionBody(JSON.stringify({ endpoint: VALID_ENDPOINT, keys: { p256dh: VALID_P256DH, auth: VALID_AUTH }, extra: 'nope' }), 'local'), PushRequestError);
145
+ assert.throws(() => parsePushSubscriptionBody('not json', 'local'), PushRequestError);
146
+ });
147
+ test('rejects an oversized body before persistence', () => {
148
+ const oversized = validBody({ prefs: { doneNotifications: true, pad: 'x'.repeat(pushBodyMaxBytes()) } });
149
+ assert.throws(() => parsePushSubscriptionBody(oversized, 'local'), PushRequestError);
150
+ });
151
+ test('rejects private/localhost/file/http endpoints before persistence', () => {
152
+ for (const endpoint of [
153
+ 'http://push.example.com/abc', // not https
154
+ 'https://localhost/abc',
155
+ 'https://127.0.0.1/abc',
156
+ 'https://10.0.0.5/abc',
157
+ 'https://192.168.1.5/abc',
158
+ 'file:///etc/passwd',
159
+ 'not-a-url',
160
+ ]) {
161
+ assert.throws(() => parsePushSubscriptionBody(validBody({ endpoint }), 'local'), PushRequestError, endpoint);
162
+ }
163
+ });
164
+ test('rejects malformed base64url subscription keys', () => {
165
+ assert.throws(() => parsePushSubscriptionBody(validBody({ keys: { p256dh: 'not base64url!!', auth: VALID_AUTH } }), 'local'), PushRequestError);
166
+ assert.throws(() => parsePushSubscriptionBody(validBody({ keys: { p256dh: VALID_P256DH, auth: 'short' } }), 'local'), PushRequestError);
167
+ });
168
+ test('DELETE body parses down to just the endpoint', () => {
169
+ const { endpoint } = parsePushDeleteBody(JSON.stringify({ endpoint: VALID_ENDPOINT }));
170
+ assert.equal(endpoint, VALID_ENDPOINT);
171
+ assert.throws(() => parsePushDeleteBody(JSON.stringify({ endpoint: 'https://localhost/x' })), PushRequestError);
172
+ });
173
+ test('the id is a stable hash of the normalized endpoint, independent of scope', () => {
174
+ const a = parsePushSubscriptionBody(validBody(), 'local');
175
+ const b = parsePushSubscriptionBody(validBody(), 'token:other');
176
+ assert.equal(a.id, b.id);
177
+ });
@@ -4,6 +4,11 @@ export interface EventHub {
4
4
  /** Register an SSE client: writes the event-stream headers, streams events
5
5
  * until the connection closes, and self-removes on close. */
6
6
  addClient: (res: ServerResponse) => void;
7
+ /** Internal, in-process subscription to the same invalidations SSE clients
8
+ * receive — for a server-side consumer (the push diff engine) that needs
9
+ * the trigger without opening an HTTP/SSE connection to itself. Returns an
10
+ * unsubscribe function. */
11
+ subscribe: (listener: (kind: ChangeKind) => void) => () => void;
7
12
  /** Tear down watchers + heartbeat and end all open streams. */
8
13
  close: () => void;
9
14
  }
@@ -36,6 +36,7 @@ export function startEventHub() {
36
36
  const clients = new Set();
37
37
  const watchers = [];
38
38
  const timers = new Map();
39
+ const listeners = new Set();
39
40
  const broadcast = (kind) => {
40
41
  const line = `data: ${JSON.stringify({ kind, ts: Date.now() })}\n\n`;
41
42
  for (const res of clients) {
@@ -46,6 +47,14 @@ export function startEventHub() {
46
47
  /* a dead stream is reaped by its own 'close' handler */
47
48
  }
48
49
  }
50
+ for (const listener of listeners) {
51
+ try {
52
+ listener(kind);
53
+ }
54
+ catch {
55
+ /* an in-process consumer's failure must never break SSE fan-out */
56
+ }
57
+ }
49
58
  };
50
59
  // Debounce per kind: an fs event arms a trailing timer; a fresh event within
51
60
  // the window resets it, so a write cluster emits exactly once.
@@ -122,6 +131,12 @@ export function startEventHub() {
122
131
  res.on('close', drop);
123
132
  res.on('error', drop);
124
133
  },
134
+ subscribe(listener) {
135
+ listeners.add(listener);
136
+ return () => {
137
+ listeners.delete(listener);
138
+ };
139
+ },
125
140
  close() {
126
141
  clearInterval(heartbeat);
127
142
  for (const t of timers.values())
@@ -144,6 +159,7 @@ export function startEventHub() {
144
159
  }
145
160
  }
146
161
  clients.clear();
162
+ listeners.clear();
147
163
  },
148
164
  };
149
165
  }
@@ -0,0 +1,108 @@
1
+ import type { NodeLifeStatus } from './web-client/shared/protocol.js';
2
+ import type { PushLastSeenStore, PushRegistryStore, PushSubscriptionRecord } from './push-registry.js';
3
+ /** The subset of `NodeSummary` the diff engine needs — kept minimal and
4
+ * decoupled from the full wire type so the engine can be fed by any canvas
5
+ * reader (production: `canvas snapshot`; tests: a fixture array). */
6
+ export interface CanvasNodeRow {
7
+ node_id: string;
8
+ parent: string | null;
9
+ status: NodeLifeStatus;
10
+ attention_count: number;
11
+ }
12
+ /** A pending human ask with just the `DeckSummary` provenance the payload/dedupe
13
+ * logic needs (design: "ask provenance comes from DeckSummary"). */
14
+ export interface PendingAsk {
15
+ /** Opaque, stable inbox-entry id — the dedupe key (never re-notify the same
16
+ * unresolved ask). */
17
+ id: string;
18
+ /** The interaction job id — carried in the payload as `askId` for the deep-link. */
19
+ jobId: string;
20
+ title: string;
21
+ conversationId: string;
22
+ conversationTitle: string;
23
+ blockedSince: string;
24
+ }
25
+ export type PushSendResult = 'sent' | 'gone' | 'error';
26
+ export interface PushPayload {
27
+ conversationId: string;
28
+ kind: 'needs-you' | 'done';
29
+ askId?: string;
30
+ title: string;
31
+ snippet: string;
32
+ }
33
+ export interface PushEngineDeps {
34
+ registry: PushRegistryStore;
35
+ lastSeen: PushLastSeenStore;
36
+ /** Re-read the live canvas roster. */
37
+ readCanvasNodes: () => Promise<CanvasNodeRow[]> | CanvasNodeRow[];
38
+ /** Re-read every currently pending ask (deck provenance). */
39
+ readPendingAsks: () => Promise<PendingAsk[]> | PendingAsk[];
40
+ /** Deliver one push to one subscription. Implementations classify a
41
+ * service-side 404/410 as `'gone'` so the engine can prune the record. */
42
+ sendPush: (record: PushSubscriptionRecord, payload: PushPayload) => Promise<PushSendResult>;
43
+ /** True iff a subscription's persisted authScope matches the canvas/user this
44
+ * engine is watching (design line 252: push is sent ONLY to subscriptions
45
+ * whose authScope is on the identical boundary that protects /__crtr/source).
46
+ * Required — no lenient default — so a foreign scope can never be targeted. */
47
+ isTargetScope: (authScope: string) => boolean;
48
+ now?: () => number;
49
+ rateWindowMs?: number;
50
+ }
51
+ export declare class PushDiffEngine {
52
+ private readonly registry;
53
+ private readonly lastSeen;
54
+ private readonly readCanvasNodes;
55
+ private readonly readPendingAsks;
56
+ private readonly sendPush;
57
+ private readonly isTargetScope;
58
+ private readonly now;
59
+ private readonly rateWindowMs;
60
+ /** Drain-queue state: only one diff pass mutates last-seen at a time. An
61
+ * invalidation arriving mid-pass sets its pending flag and is reprocessed
62
+ * when the current pass finishes — no two passes race the same snapshot. */
63
+ private draining;
64
+ private pendingNodes;
65
+ private pendingInbox;
66
+ /** Per-conversation rate-cap clock. Deliberately in-memory/process-lifetime
67
+ * only (not persisted) — a restart re-seeding silently is already the
68
+ * no-storm guarantee; the rate cap only smooths a live process's bursts. */
69
+ private readonly lastPushAt;
70
+ /** Per-(conversationId+kind) set of subscription ids already delivered for
71
+ * the CURRENT still-pending notification. A transient failure on one
72
+ * subscription must never cause a later retry to re-send to subscriptions
73
+ * that already got `'sent'` — this is exactly what remembers them across
74
+ * passes. Cleared the moment the event fully succeeds (or has no eligible
75
+ * subscriber left), since a later, distinct event reusing the same key
76
+ * must start fresh. */
77
+ private readonly deliveredForKey;
78
+ constructor(deps: PushEngineDeps);
79
+ /** Seed last-seen from a fresh canvas+deck read and send nothing. Call once
80
+ * on server start — a restart must never be an attention event. */
81
+ seed(): Promise<void>;
82
+ /** 'nodes' invalidation: re-read the canvas roster, emit a 'done' push for
83
+ * every conversation root that just crossed into a done|dead|canceled
84
+ * status from a non-done status. */
85
+ onNodesInvalidation(): Promise<void>;
86
+ onInboxInvalidation(): Promise<void>;
87
+ /** Serialize passes: if a pass is already running, just set the pending flag
88
+ * and return — the running drain loop will pick it up. Otherwise run pending
89
+ * passes until none remain. */
90
+ private drain;
91
+ private runNodesPass;
92
+ /** 'inbox' invalidation: re-read pending asks, emit exactly one 'needs-you'
93
+ * push per conversation for its NEW (not-yet-notified) asks, coalesced. An
94
+ * ask no longer present (resolved/missing) is simply dropped, never
95
+ * notified. */
96
+ private runInboxPass;
97
+ /** Send one push per ELIGIBLE subscription — one whose authScope is on the
98
+ * watched boundary (`isTargetScope`) AND that opted in for this payload kind.
99
+ * Respects the per-conversation rate cap; prunes any subscription the push
100
+ * service reports gone (404/410) or whose endpoint fails the push-service
101
+ * allowlist re-check. Tracks per-subscription delivery for this notification
102
+ * (`deliveredForKey`) so a retry after a transient failure on one
103
+ * subscription never re-sends to a subscription that already got `'sent'`.
104
+ * Returns true iff last-seen may advance: false when suppressed by the rate
105
+ * cap OR when a transient send error means the item must be retried next
106
+ * pass. A no-eligible pass returns true (nothing to retry). */
107
+ private emit;
108
+ }
@@ -0,0 +1,256 @@
1
+ // push-engine.ts — the server-side web-push DIFF ENGINE (design D8/push
2
+ // contract). SSE (`events.ts`) is a TRIGGER ONLY (`{kind, ts}`, never data);
3
+ // this module is the one authority that re-reads canvas + decks on that
4
+ // trigger, diffs against persisted last-seen state, and decides what (if
5
+ // anything) to push. It never invents a second attention signal — decks/canvas
6
+ // stay the truth.
7
+ //
8
+ // Two invalidation kinds drive two independent diff passes:
9
+ // - 'nodes' -> re-read the canvas roster, detect a conversation ROOT
10
+ // (parent === null) crossing into a done|dead|canceled status
11
+ // from a non-done status, emit one 'done' push per transition.
12
+ // - 'inbox' -> re-read pending asks (deck provenance), detect an ask id that
13
+ // is present now but was not known-notified before, emit one
14
+ // 'needs-you' push per NEW ask, coalesced per conversation.
15
+ //
16
+ // On server start, `seed()` records the current state and sends NOTHING — a
17
+ // restart is not an attention event.
18
+ //
19
+ // Last-seen state is only advanced past a suppressed (rate-capped) item when
20
+ // that item was not actually the reason for suppression; a rate-capped
21
+ // transition/ask is deliberately left out of the persisted state so the NEXT
22
+ // invalidation retries it rather than silently losing it forever.
23
+ import { isAllowedPushEndpoint } from './push-registry.js';
24
+ const DONE_STATUSES = new Set(['done', 'dead', 'canceled']);
25
+ /** Minimum interval between pushes for the same conversation root — coalesces
26
+ * a burst of distinct events (several new asks, or an ask+done in close
27
+ * succession) into at most one send per window, never a storm. */
28
+ const DEFAULT_RATE_WINDOW_MS = 30_000;
29
+ export class PushDiffEngine {
30
+ registry;
31
+ lastSeen;
32
+ readCanvasNodes;
33
+ readPendingAsks;
34
+ sendPush;
35
+ isTargetScope;
36
+ now;
37
+ rateWindowMs;
38
+ /** Drain-queue state: only one diff pass mutates last-seen at a time. An
39
+ * invalidation arriving mid-pass sets its pending flag and is reprocessed
40
+ * when the current pass finishes — no two passes race the same snapshot. */
41
+ draining = false;
42
+ pendingNodes = false;
43
+ pendingInbox = false;
44
+ /** Per-conversation rate-cap clock. Deliberately in-memory/process-lifetime
45
+ * only (not persisted) — a restart re-seeding silently is already the
46
+ * no-storm guarantee; the rate cap only smooths a live process's bursts. */
47
+ lastPushAt = new Map();
48
+ /** Per-(conversationId+kind) set of subscription ids already delivered for
49
+ * the CURRENT still-pending notification. A transient failure on one
50
+ * subscription must never cause a later retry to re-send to subscriptions
51
+ * that already got `'sent'` — this is exactly what remembers them across
52
+ * passes. Cleared the moment the event fully succeeds (or has no eligible
53
+ * subscriber left), since a later, distinct event reusing the same key
54
+ * must start fresh. */
55
+ deliveredForKey = new Map();
56
+ constructor(deps) {
57
+ this.registry = deps.registry;
58
+ this.lastSeen = deps.lastSeen;
59
+ this.readCanvasNodes = deps.readCanvasNodes;
60
+ this.readPendingAsks = deps.readPendingAsks;
61
+ this.sendPush = deps.sendPush;
62
+ this.isTargetScope = deps.isTargetScope;
63
+ this.now = deps.now ?? (() => Date.now());
64
+ this.rateWindowMs = deps.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
65
+ }
66
+ /** Seed last-seen from a fresh canvas+deck read and send nothing. Call once
67
+ * on server start — a restart must never be an attention event. */
68
+ async seed() {
69
+ const [nodes, asks] = await Promise.all([this.readCanvasNodes(), this.readPendingAsks()]);
70
+ const roots = {};
71
+ for (const n of nodes) {
72
+ if (n.parent !== null)
73
+ continue;
74
+ roots[n.node_id] = { status: n.status, attention_count: n.attention_count };
75
+ }
76
+ const askState = {};
77
+ for (const a of asks)
78
+ askState[a.id] = { present: true };
79
+ this.lastSeen.replace({ schemaVersion: 1, updatedAt: this.now(), roots, asks: askState });
80
+ }
81
+ /** 'nodes' invalidation: re-read the canvas roster, emit a 'done' push for
82
+ * every conversation root that just crossed into a done|dead|canceled
83
+ * status from a non-done status. */
84
+ async onNodesInvalidation() {
85
+ this.pendingNodes = true;
86
+ await this.drain();
87
+ }
88
+ async onInboxInvalidation() {
89
+ this.pendingInbox = true;
90
+ await this.drain();
91
+ }
92
+ /** Serialize passes: if a pass is already running, just set the pending flag
93
+ * and return — the running drain loop will pick it up. Otherwise run pending
94
+ * passes until none remain. */
95
+ async drain() {
96
+ if (this.draining)
97
+ return;
98
+ this.draining = true;
99
+ try {
100
+ while (this.pendingNodes || this.pendingInbox) {
101
+ if (this.pendingNodes) {
102
+ this.pendingNodes = false;
103
+ await this.runNodesPass();
104
+ }
105
+ if (this.pendingInbox) {
106
+ this.pendingInbox = false;
107
+ await this.runInboxPass();
108
+ }
109
+ }
110
+ }
111
+ finally {
112
+ this.draining = false;
113
+ }
114
+ }
115
+ async runNodesPass() {
116
+ const nodes = await this.readCanvasNodes();
117
+ const prev = this.lastSeen.snapshot();
118
+ const nextRoots = {};
119
+ for (const n of nodes) {
120
+ if (n.parent !== null)
121
+ continue;
122
+ const before = prev.roots[n.node_id];
123
+ const isTransition = before !== undefined && !DONE_STATUSES.has(before.status) && DONE_STATUSES.has(n.status);
124
+ if (!isTransition) {
125
+ nextRoots[n.node_id] = { status: n.status, attention_count: n.attention_count };
126
+ continue;
127
+ }
128
+ const sent = await this.emit(n.node_id, {
129
+ conversationId: n.node_id,
130
+ kind: 'done',
131
+ title: 'Conversation finished',
132
+ snippet: `This conversation is now ${n.status}.`,
133
+ });
134
+ // Rate-capped: keep the PRE-transition state so the next 'nodes'
135
+ // invalidation still sees a non-done -> done edge and retries.
136
+ nextRoots[n.node_id] = sent ? { status: n.status, attention_count: n.attention_count } : before;
137
+ }
138
+ this.lastSeen.replace({ schemaVersion: prev.schemaVersion, updatedAt: this.now(), roots: nextRoots, asks: prev.asks });
139
+ }
140
+ /** 'inbox' invalidation: re-read pending asks, emit exactly one 'needs-you'
141
+ * push per conversation for its NEW (not-yet-notified) asks, coalesced. An
142
+ * ask no longer present (resolved/missing) is simply dropped, never
143
+ * notified. */
144
+ async runInboxPass() {
145
+ const asks = await this.readPendingAsks();
146
+ const prev = this.lastSeen.snapshot();
147
+ const nextAsks = {};
148
+ const newByConversation = new Map();
149
+ for (const ask of asks) {
150
+ if (prev.asks[ask.id]?.present === true) {
151
+ // Already known + already notified at some point — keep tracked, never
152
+ // re-notify the same unresolved ask.
153
+ nextAsks[ask.id] = { present: true };
154
+ continue;
155
+ }
156
+ const list = newByConversation.get(ask.conversationId) ?? [];
157
+ list.push(ask);
158
+ newByConversation.set(ask.conversationId, list);
159
+ }
160
+ for (const [conversationId, list] of newByConversation) {
161
+ const first = list[0];
162
+ const extra = list.length - 1;
163
+ const sent = await this.emit(conversationId, {
164
+ conversationId,
165
+ kind: 'needs-you',
166
+ askId: first.jobId,
167
+ title: first.conversationTitle,
168
+ snippet: extra > 0 ? `${first.title} (+${extra} more)` : first.title,
169
+ });
170
+ if (sent) {
171
+ for (const ask of list)
172
+ nextAsks[ask.id] = { present: true };
173
+ }
174
+ // Rate-capped: leave these ask ids OUT of nextAsks so the next 'inbox'
175
+ // invalidation still sees them as new and retries once the window clears.
176
+ }
177
+ this.lastSeen.replace({ schemaVersion: prev.schemaVersion, updatedAt: this.now(), roots: prev.roots, asks: nextAsks });
178
+ }
179
+ /** Send one push per ELIGIBLE subscription — one whose authScope is on the
180
+ * watched boundary (`isTargetScope`) AND that opted in for this payload kind.
181
+ * Respects the per-conversation rate cap; prunes any subscription the push
182
+ * service reports gone (404/410) or whose endpoint fails the push-service
183
+ * allowlist re-check. Tracks per-subscription delivery for this notification
184
+ * (`deliveredForKey`) so a retry after a transient failure on one
185
+ * subscription never re-sends to a subscription that already got `'sent'`.
186
+ * Returns true iff last-seen may advance: false when suppressed by the rate
187
+ * cap OR when a transient send error means the item must be retried next
188
+ * pass. A no-eligible pass returns true (nothing to retry). */
189
+ async emit(conversationId, payload) {
190
+ // Eligible = in the watched auth scope AND opted-in for this payload kind.
191
+ const eligible = this.registry.snapshot().filter((r) => {
192
+ if (!this.isTargetScope(r.authScope))
193
+ return false;
194
+ if (payload.kind === 'done' && !r.prefs.doneNotifications)
195
+ return false;
196
+ return true;
197
+ });
198
+ // Keyed by conversation + kind: a later, distinct event reusing this key
199
+ // (once cleared below) must start with a fresh delivered set.
200
+ const key = `${conversationId}:${payload.kind}`;
201
+ // No eligible subscriber -> the event is fully handled (nothing to retry);
202
+ // advance last-seen. Checked BEFORE the rate cap so a no-eligible pass never
203
+ // stalls the state on a phantom retry.
204
+ if (eligible.length === 0) {
205
+ this.deliveredForKey.delete(key);
206
+ return true;
207
+ }
208
+ const now = this.now();
209
+ const last = this.lastPushAt.get(conversationId) ?? 0;
210
+ if (now - last < this.rateWindowMs)
211
+ return false; // rate-capped -> retry next pass
212
+ this.lastPushAt.set(conversationId, now);
213
+ const delivered = this.deliveredForKey.get(key) ?? new Set();
214
+ let transientFailure = false;
215
+ for (const record of eligible) {
216
+ // Already delivered to this subscription for this event on an earlier
217
+ // pass — never re-send it (this is the fix for duplicate pushes on
218
+ // transient-failure retry: only the subscriptions that actually failed
219
+ // are retried).
220
+ if (delivered.has(record.id))
221
+ continue;
222
+ if (!isAllowedPushEndpoint(record.endpoint)) {
223
+ // Defense in depth: re-check the push-service allowlist immediately
224
+ // before every outbound connect (no DNS/host TOCTOU), even though
225
+ // normalizeEndpoint already enforced it at subscribe time. A record
226
+ // that fails this can never be sent to safely — prune it.
227
+ this.registry.deleteById(record.id);
228
+ continue;
229
+ }
230
+ let result;
231
+ try {
232
+ result = await this.sendPush(record, payload);
233
+ }
234
+ catch {
235
+ result = 'error';
236
+ }
237
+ if (result === 'gone')
238
+ this.registry.deleteById(record.id);
239
+ else if (result === 'sent')
240
+ delivered.add(record.id);
241
+ else
242
+ transientFailure = true;
243
+ }
244
+ // A transient send failure must NOT advance last-seen — retry next pass
245
+ // (design: "update last-seen only after a successful diff pass"), and the
246
+ // subscriptions already delivered above must stay remembered so that retry
247
+ // only reaches the ones that failed. A pruned 'gone'/disallowed record is
248
+ // not a failure: nothing left to retry for it.
249
+ if (transientFailure) {
250
+ this.deliveredForKey.set(key, delivered);
251
+ return false;
252
+ }
253
+ this.deliveredForKey.delete(key);
254
+ return true;
255
+ }
256
+ }