@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.
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +20 -5
- package/dist/clients/attach/attach-cmd.js +2 -2
- package/dist/clients/attach/chat-view.js +5 -0
- package/dist/clients/web/__tests__/push-engine.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-engine.test.js +302 -0
- package/dist/clients/web/__tests__/push-registry.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-registry.test.js +177 -0
- package/dist/clients/web/events.d.ts +5 -0
- package/dist/clients/web/events.js +16 -0
- package/dist/clients/web/push-engine.d.ts +108 -0
- package/dist/clients/web/push-engine.js +256 -0
- package/dist/clients/web/push-registry.d.ts +89 -0
- package/dist/clients/web/push-registry.js +378 -0
- package/dist/clients/web/server.js +191 -8
- package/dist/clients/web/web-client/shared/protocol.d.ts +308 -0
- package/dist/clients/web/web-client/shared/protocol.js +8 -0
- package/dist/web-client/assets/{index-rvMEymEb.js → index-BOlkXTJY.js} +18 -18
- package/dist/web-client/assets/index-CllMPSPq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +3 -1
- package/dist/web-client/assets/index-C6rGKvf2.css +0 -2
|
@@ -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
|
+
}
|