@crouton-kit/crouter 0.3.57 → 0.3.59

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,302 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { PushDiffEngine } from '../push-engine.js';
7
+ import { PushLastSeenStore, PushRegistryStore } from '../push-registry.js';
8
+ function tmpDir() {
9
+ return mkdtempSync(join(tmpdir(), 'crtr-push-engine-'));
10
+ }
11
+ function makeStores(dir) {
12
+ return {
13
+ registry: new PushRegistryStore(join(dir, 'registry.json')),
14
+ lastSeen: new PushLastSeenStore(join(dir, 'last-seen.json')),
15
+ };
16
+ }
17
+ // A real, allowlisted vendor push-service endpoint shape (Chromium/FCM) —
18
+ // endpoints must resolve to one of the small set of known browser-vendor push
19
+ // services (see push-registry.ts's PUSH_SERVICE_HOST_ALLOWLIST).
20
+ function fakeSubscription(id, authScope = 'local') {
21
+ return {
22
+ id,
23
+ endpoint: `https://fcm.googleapis.com/fcm/send/${id}`,
24
+ keys: { p256dh: 'p'.repeat(32), auth: 'a'.repeat(16) },
25
+ authScope,
26
+ createdAt: 0,
27
+ lastSeenAt: 0,
28
+ prefs: { doneNotifications: true },
29
+ };
30
+ }
31
+ /** Seed a registry with one subscription directly (bypassing HTTP validation,
32
+ * which is covered in push-registry.test.ts). */
33
+ function seedRegistryWithOneSubscription(registry) {
34
+ registry.upsert(fakeSubscription('sub-1'));
35
+ }
36
+ function buildHarness(dir, opts = {}) {
37
+ const { registry, lastSeen } = makeStores(dir);
38
+ const sent = [];
39
+ const sentTo = [];
40
+ const nodes = [];
41
+ const asks = [];
42
+ const clock = { now: 1_000_000 };
43
+ const defaultSend = async (record, payload) => {
44
+ sent.push(payload);
45
+ sentTo.push(record.endpoint);
46
+ return 'sent';
47
+ };
48
+ const engine = new PushDiffEngine({
49
+ registry,
50
+ lastSeen,
51
+ readCanvasNodes: () => nodes,
52
+ readPendingAsks: () => asks,
53
+ sendPush: opts.sendPush ?? defaultSend,
54
+ isTargetScope: opts.isTargetScope ?? (() => true),
55
+ now: () => clock.now,
56
+ rateWindowMs: opts.rateWindowMs,
57
+ });
58
+ return { engine, sent, sentTo, registry, lastSeen, nodes, asks, clock };
59
+ }
60
+ test('seed() sends nothing regardless of current state (no restart storm)', async () => {
61
+ const dir = tmpDir();
62
+ try {
63
+ const h = buildHarness(dir);
64
+ seedRegistryWithOneSubscription(h.registry);
65
+ h.nodes.push({ node_id: 'root-1', parent: null, status: 'done', attention_count: 0 });
66
+ h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
67
+ await h.engine.seed();
68
+ assert.equal(h.sent.length, 0);
69
+ const snap = h.lastSeen.snapshot();
70
+ assert.deepEqual(snap.roots['root-1'], { status: 'done', attention_count: 0 });
71
+ assert.deepEqual(snap.asks['ask-1'], { present: true });
72
+ }
73
+ finally {
74
+ rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ });
77
+ test('active -> done emits exactly one done push, and a repeat pass does not duplicate', async () => {
78
+ const dir = tmpDir();
79
+ try {
80
+ const h = buildHarness(dir);
81
+ seedRegistryWithOneSubscription(h.registry);
82
+ h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
83
+ await h.engine.seed();
84
+ assert.equal(h.sent.length, 0);
85
+ h.nodes[0].status = 'done';
86
+ await h.engine.onNodesInvalidation();
87
+ assert.equal(h.sent.length, 1);
88
+ assert.equal(h.sent[0].kind, 'done');
89
+ assert.equal(h.sent[0].conversationId, 'root-1');
90
+ // Repeated invalidation with the same (already-done) status must not re-fire.
91
+ await h.engine.onNodesInvalidation();
92
+ assert.equal(h.sent.length, 1);
93
+ }
94
+ finally {
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+ test('a new ask emits exactly one needs-you push, and repeated invalidations do not duplicate it', async () => {
99
+ const dir = tmpDir();
100
+ try {
101
+ const h = buildHarness(dir);
102
+ seedRegistryWithOneSubscription(h.registry);
103
+ await h.engine.seed(); // no asks yet
104
+ h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
105
+ await h.engine.onInboxInvalidation();
106
+ assert.equal(h.sent.length, 1);
107
+ assert.equal(h.sent[0].kind, 'needs-you');
108
+ assert.equal(h.sent[0].askId, 'job-1');
109
+ // Same ask still pending on a second invalidation — no duplicate.
110
+ await h.engine.onInboxInvalidation();
111
+ assert.equal(h.sent.length, 1);
112
+ }
113
+ finally {
114
+ rmSync(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+ test('a resolved/missing ask never notifies', async () => {
118
+ const dir = tmpDir();
119
+ try {
120
+ const h = buildHarness(dir);
121
+ seedRegistryWithOneSubscription(h.registry);
122
+ await h.engine.seed();
123
+ h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
124
+ await h.engine.onInboxInvalidation();
125
+ assert.equal(h.sent.length, 1);
126
+ // The ask resolves — it disappears from the pending list.
127
+ h.asks.length = 0;
128
+ await h.engine.onInboxInvalidation();
129
+ assert.equal(h.sent.length, 1); // no new push
130
+ assert.deepEqual(h.lastSeen.snapshot().asks, {});
131
+ }
132
+ finally {
133
+ rmSync(dir, { recursive: true, force: true });
134
+ }
135
+ });
136
+ test('multiple new asks in the same conversation within the rate window coalesce into one push', async () => {
137
+ const dir = tmpDir();
138
+ try {
139
+ const h = buildHarness(dir, { rateWindowMs: 30_000 });
140
+ seedRegistryWithOneSubscription(h.registry);
141
+ await h.engine.seed();
142
+ h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' }, { id: 'ask-2', jobId: 'job-2', title: 'Approve deploy', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:01.000Z' });
143
+ await h.engine.onInboxInvalidation();
144
+ assert.equal(h.sent.length, 1);
145
+ assert.match(h.sent[0].snippet, /\+1 more/);
146
+ }
147
+ finally {
148
+ rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ });
151
+ test('push targeting is scope-filtered: only subscriptions on the watched auth boundary are sent to', async () => {
152
+ const dir = tmpDir();
153
+ try {
154
+ const h = buildHarness(dir, { isTargetScope: (s) => s === 'token:abc' });
155
+ // One local-scope sub and one token-scope sub in the SAME registry.
156
+ h.registry.upsert(fakeSubscription('sub-local', 'local'));
157
+ h.registry.upsert(fakeSubscription('sub-token', 'token:abc'));
158
+ h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
159
+ await h.engine.seed();
160
+ h.nodes[0].status = 'done';
161
+ await h.engine.onNodesInvalidation();
162
+ // Exactly one send, and it went to the token-scoped endpoint only.
163
+ assert.equal(h.sent.length, 1);
164
+ assert.deepEqual(h.sentTo, ['https://fcm.googleapis.com/fcm/send/sub-token']);
165
+ }
166
+ finally {
167
+ rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ });
170
+ test('a transient send failure does not advance last-seen and is retried next pass', async () => {
171
+ const dir = tmpDir();
172
+ try {
173
+ let call = 0;
174
+ const attempts = [];
175
+ const h = buildHarness(dir, {
176
+ rateWindowMs: 30_000,
177
+ sendPush: async (_r, payload) => {
178
+ attempts.push(payload);
179
+ call += 1;
180
+ return call === 1 ? 'error' : 'sent';
181
+ },
182
+ });
183
+ seedRegistryWithOneSubscription(h.registry);
184
+ h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
185
+ await h.engine.seed();
186
+ h.nodes[0].status = 'done';
187
+ await h.engine.onNodesInvalidation();
188
+ // The send was attempted but failed transiently — last-seen root must stay
189
+ // pre-transition ('active') so the edge is re-detected next pass.
190
+ assert.equal(attempts.length, 1);
191
+ assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'active');
192
+ // Advance past the rate window (the failed pass set lastPushAt) and retry.
193
+ h.clock.now += 60_000;
194
+ await h.engine.onNodesInvalidation();
195
+ assert.equal(attempts.length, 2);
196
+ assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'done');
197
+ // A further pass does not re-fire.
198
+ h.clock.now += 60_000;
199
+ await h.engine.onNodesInvalidation();
200
+ assert.equal(attempts.length, 2);
201
+ }
202
+ finally {
203
+ rmSync(dir, { recursive: true, force: true });
204
+ }
205
+ });
206
+ test('a transient failure on one subscription does not cause a retry to re-send to an already-delivered subscription', async () => {
207
+ const dir = tmpDir();
208
+ try {
209
+ // Keyed by endpoint, not `record.id` — upsert() derives the persisted id
210
+ // from `subscriptionIdForEndpoint(endpoint)`, discarding the fixture's
211
+ // caller-supplied id, so the endpoint is the stable fixture identity here.
212
+ const attemptsBySub = new Map();
213
+ const h = buildHarness(dir, {
214
+ rateWindowMs: 30_000,
215
+ sendPush: async (record, _payload) => {
216
+ const n = (attemptsBySub.get(record.endpoint) ?? 0) + 1;
217
+ attemptsBySub.set(record.endpoint, n);
218
+ // sub-ok always succeeds; sub-fail fails transiently on its first
219
+ // attempt only, then succeeds on retry — so the second pass proves
220
+ // sub-ok is never touched again while sub-fail finally lands.
221
+ return record.endpoint.includes('sub-fail') && n === 1 ? 'error' : 'sent';
222
+ },
223
+ });
224
+ h.registry.upsert(fakeSubscription('sub-ok'));
225
+ h.registry.upsert(fakeSubscription('sub-fail'));
226
+ h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
227
+ await h.engine.seed();
228
+ h.nodes[0].status = 'done';
229
+ await h.engine.onNodesInvalidation();
230
+ // Both subscriptions were attempted once; the event is not fully delivered
231
+ // (sub-fail errored), so last-seen must stay pre-transition.
232
+ assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-ok'), 1);
233
+ assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-fail'), 1);
234
+ assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'active');
235
+ // Retry next pass (past the rate window): sub-ok must NOT be re-sent to —
236
+ // only sub-fail, which never succeeded, is retried.
237
+ h.clock.now += 60_000;
238
+ await h.engine.onNodesInvalidation();
239
+ assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-ok'), 1, 'an already-delivered subscription must not be re-sent to on retry');
240
+ assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-fail'), 2);
241
+ assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'done');
242
+ // Fully delivered now — a further pass does not re-fire for anyone.
243
+ h.clock.now += 60_000;
244
+ await h.engine.onNodesInvalidation();
245
+ assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-ok'), 1);
246
+ assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-fail'), 2);
247
+ }
248
+ finally {
249
+ rmSync(dir, { recursive: true, force: true });
250
+ }
251
+ });
252
+ test('overlapping inbox invalidations serialize — each distinct ask notifies exactly once', async () => {
253
+ const dir = tmpDir();
254
+ try {
255
+ const h = buildHarness(dir);
256
+ seedRegistryWithOneSubscription(h.registry);
257
+ await h.engine.seed();
258
+ // First invalidation kicked off but NOT awaited; a second ask + invalidation
259
+ // land while the first pass is in-flight. The drain queue must serialize
260
+ // them so neither ask is notified twice.
261
+ h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
262
+ const p1 = h.engine.onInboxInvalidation();
263
+ h.asks.push({ id: 'ask-2', jobId: 'job-2', title: 'Approve deploy', conversationId: 'root-2', conversationTitle: 'Root Two', blockedSince: '2026-01-01T00:00:01.000Z' });
264
+ const p2 = h.engine.onInboxInvalidation();
265
+ await Promise.all([p1, p2]);
266
+ const askIds = h.sent.map((p) => p.askId).sort();
267
+ assert.deepEqual(askIds, ['job-1', 'job-2']);
268
+ assert.deepEqual(Object.keys(h.lastSeen.snapshot().asks).sort(), ['ask-1', 'ask-2']);
269
+ }
270
+ finally {
271
+ rmSync(dir, { recursive: true, force: true });
272
+ }
273
+ });
274
+ test('a rate-capped ask is suppressed within the window then retried once it clears — never lost, never duplicated', async () => {
275
+ const dir = tmpDir();
276
+ try {
277
+ const h = buildHarness(dir, { rateWindowMs: 30_000 });
278
+ seedRegistryWithOneSubscription(h.registry);
279
+ await h.engine.seed();
280
+ h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
281
+ await h.engine.onInboxInvalidation();
282
+ assert.equal(h.sent.length, 1); // first ask fires immediately
283
+ // A second, distinct ask lands in the same conversation moments later —
284
+ // still inside the rate window, so it must be suppressed, not sent.
285
+ h.asks.push({ id: 'ask-2', jobId: 'job-2', title: 'Approve deploy', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:05.000Z' });
286
+ h.clock.now += 5_000;
287
+ await h.engine.onInboxInvalidation();
288
+ assert.equal(h.sent.length, 1); // still suppressed
289
+ assert.equal(h.lastSeen.snapshot().asks['ask-2'], undefined); // left untracked so it retries
290
+ // Once the rate window clears, the still-pending ask-2 is retried exactly once.
291
+ h.clock.now += 30_000;
292
+ await h.engine.onInboxInvalidation();
293
+ assert.equal(h.sent.length, 2);
294
+ assert.equal(h.sent[1].askId, 'job-2');
295
+ // And it never fires a third time on a further no-op pass.
296
+ await h.engine.onInboxInvalidation();
297
+ assert.equal(h.sent.length, 2);
298
+ }
299
+ finally {
300
+ rmSync(dir, { recursive: true, force: true });
301
+ }
302
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ }