@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,378 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import webpush from 'web-push';
5
+ import { crtrHome } from '../../core/canvas/paths.js';
6
+ const SCHEMA_VERSION = 1;
7
+ const PUSH_DIR = 'push';
8
+ const BODY_MAX_BYTES = 16 * 1024;
9
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
10
+ const P256DH_MIN_LEN = 20;
11
+ const P256DH_MAX_LEN = 256;
12
+ const AUTH_MIN_LEN = 8;
13
+ const AUTH_MAX_LEN = 128;
14
+ const DEFAULT_VAPID_SUBJECT = 'mailto:push@localhost';
15
+ export class PushRequestError extends Error {
16
+ statusCode;
17
+ constructor(statusCode, message) {
18
+ super(message);
19
+ this.name = 'PushRequestError';
20
+ this.statusCode = statusCode;
21
+ }
22
+ }
23
+ function pushDir() {
24
+ return join(crtrHome(), PUSH_DIR);
25
+ }
26
+ export function registryPath() {
27
+ return join(pushDir(), 'registry.json');
28
+ }
29
+ export function lastSeenPath() {
30
+ return join(pushDir(), 'last-seen.json');
31
+ }
32
+ export function vapidPath() {
33
+ return join(pushDir(), 'vapid.json');
34
+ }
35
+ export function pushBodyMaxBytes() {
36
+ return BODY_MAX_BYTES;
37
+ }
38
+ function atomicWriteJson(path, value) {
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ const tmp = `${path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
41
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
42
+ renameSync(tmp, path);
43
+ }
44
+ function readJsonFile(path) {
45
+ return JSON.parse(readFileSync(path, 'utf8'));
46
+ }
47
+ function isRecord(value) {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
49
+ }
50
+ function isString(value) {
51
+ return typeof value === 'string';
52
+ }
53
+ function isNumber(value) {
54
+ return typeof value === 'number';
55
+ }
56
+ function hasOnlyKeys(value, keys) {
57
+ const allowed = new Set(keys);
58
+ return Object.keys(value).every((key) => allowed.has(key));
59
+ }
60
+ // SSRF defense for the push endpoint: rather than trying to classify every
61
+ // non-public IP/DNS-rebinding shape (a hand-rolled private-IP blocklist is
62
+ // necessarily incomplete, and a hostname that resolves safely at subscribe
63
+ // time can rebind to loopback/private space before the actual send — a
64
+ // DNS TOCTOU no string/IP check at subscribe time alone can close), we instead
65
+ // allowlist the ENDPOINT HOST itself. Every real Web Push subscription's
66
+ // endpoint is minted by the browser vendor's own push service — there is no
67
+ // self-hosted push service in this deployment's threat model — so restricting
68
+ // outbound sends to that small, known set of hosts means no user/browser-
69
+ // controlled host, IP literal, or DNS name ever reaches the outbound sender.
70
+ // This subsumes the rebinding TOCTOU (only known-good vendor CDN hosts are
71
+ // ever contacted), a trailing-dot bypass (canonicalized away below), and an
72
+ // incomplete IP-range blocklist (no IP literal is ever allowlisted).
73
+ const PUSH_SERVICE_HOST_ALLOWLIST = [
74
+ 'fcm.googleapis.com', // Chromium/Chrome/Edge
75
+ 'android.googleapis.com', // legacy Chrome/Android (GCM)
76
+ 'updates.push.services.mozilla.com', // Firefox
77
+ 'web.push.apple.com', // Safari (macOS/iOS 16.4+)
78
+ 'notify.windows.com', // Windows/legacy Edge (WNS)
79
+ 'wns.windows.com', // Windows/legacy Edge (WNS)
80
+ ];
81
+ /** Lowercase and strip trailing DNS root dot(s) (`localhost.` -> `localhost`,
82
+ * `fcm.googleapis.com.` -> `fcm.googleapis.com`) so a canonicalization gap can
83
+ * never smuggle a host past a suffix check that only recognizes the
84
+ * dot-free form. */
85
+ function canonicalizeHostname(hostname) {
86
+ return hostname.toLowerCase().replace(/\.+$/, '');
87
+ }
88
+ function isAllowedPushHost(hostname) {
89
+ const host = canonicalizeHostname(hostname);
90
+ return PUSH_SERVICE_HOST_ALLOWLIST.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
91
+ }
92
+ /** Re-checkable at any later point (e.g. immediately before an outbound send,
93
+ * not just at subscribe time) with no dependency on DNS resolution — the
94
+ * allowlist match is a pure string check, so there is nothing to TOCTOU. */
95
+ export function isAllowedPushEndpoint(endpoint) {
96
+ let url;
97
+ try {
98
+ url = new URL(endpoint);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ return url.protocol === 'https:' && isAllowedPushHost(url.hostname);
104
+ }
105
+ export function normalizeEndpoint(endpoint) {
106
+ if (!isString(endpoint))
107
+ throw new PushRequestError(400, 'push endpoint must be a string');
108
+ const trimmed = endpoint.trim();
109
+ if (trimmed === '')
110
+ throw new PushRequestError(400, 'push endpoint must not be empty');
111
+ let url;
112
+ try {
113
+ url = new URL(trimmed);
114
+ }
115
+ catch {
116
+ throw new PushRequestError(400, 'push endpoint is not a valid URL');
117
+ }
118
+ if (url.protocol !== 'https:') {
119
+ throw new PushRequestError(400, 'push endpoint must use https:');
120
+ }
121
+ if (url.username !== '' || url.password !== '') {
122
+ throw new PushRequestError(400, 'push endpoint must not include credentials');
123
+ }
124
+ if (!isAllowedPushHost(url.hostname)) {
125
+ throw new PushRequestError(400, 'push endpoint host is not a recognized push service');
126
+ }
127
+ return url.toString();
128
+ }
129
+ export function subscriptionIdForEndpoint(endpoint) {
130
+ return createHash('sha256').update(endpoint).digest('base64url');
131
+ }
132
+ function normalizeBase64Url(value, field, minLen, maxLen) {
133
+ if (!isString(value))
134
+ throw new PushRequestError(400, `push subscription keys.${field} must be a string`);
135
+ const trimmed = value.trim();
136
+ if (trimmed.length < minLen || trimmed.length > maxLen) {
137
+ throw new PushRequestError(400, `push subscription keys.${field} must be between ${minLen} and ${maxLen} characters`);
138
+ }
139
+ if (!BASE64URL_RE.test(trimmed)) {
140
+ throw new PushRequestError(400, `push subscription keys.${field} must be base64url`);
141
+ }
142
+ return trimmed;
143
+ }
144
+ function normalizePrefs(value) {
145
+ if (value === undefined)
146
+ return { doneNotifications: true };
147
+ if (!isRecord(value) || !hasOnlyKeys(value, ['doneNotifications'])) {
148
+ throw new PushRequestError(400, 'push subscription prefs must contain only doneNotifications');
149
+ }
150
+ const doneNotifications = value['doneNotifications'];
151
+ if (doneNotifications === undefined)
152
+ return { doneNotifications: true };
153
+ if (typeof doneNotifications !== 'boolean') {
154
+ throw new PushRequestError(400, 'push subscription prefs.doneNotifications must be boolean');
155
+ }
156
+ return { doneNotifications };
157
+ }
158
+ function parsePushBody(rawBody) {
159
+ if (Buffer.byteLength(rawBody, 'utf8') > BODY_MAX_BYTES) {
160
+ throw new PushRequestError(413, 'push subscription body is too large');
161
+ }
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(rawBody);
165
+ }
166
+ catch {
167
+ throw new PushRequestError(400, 'push subscription body is not valid JSON');
168
+ }
169
+ if (!isRecord(parsed) || !hasOnlyKeys(parsed, ['endpoint', 'expirationTime', 'keys', 'prefs'])) {
170
+ throw new PushRequestError(400, 'push subscription body must be a browser PushSubscription shape');
171
+ }
172
+ return parsed;
173
+ }
174
+ export function parsePushSubscriptionBody(rawBody, authScope, now = Date.now()) {
175
+ const parsed = parsePushBody(rawBody);
176
+ const endpoint = normalizeEndpoint(parsed['endpoint']);
177
+ const keys = parsed['keys'];
178
+ if (!isRecord(keys) || !hasOnlyKeys(keys, ['p256dh', 'auth'])) {
179
+ throw new PushRequestError(400, 'push subscription keys must contain p256dh and auth');
180
+ }
181
+ const p256dh = normalizeBase64Url(keys['p256dh'], 'p256dh', P256DH_MIN_LEN, P256DH_MAX_LEN);
182
+ const auth = normalizeBase64Url(keys['auth'], 'auth', AUTH_MIN_LEN, AUTH_MAX_LEN);
183
+ const expirationTime = parsed['expirationTime'];
184
+ if (expirationTime !== undefined && expirationTime !== null && !isNumber(expirationTime)) {
185
+ throw new PushRequestError(400, 'push subscription expirationTime must be a number or null');
186
+ }
187
+ const prefs = normalizePrefs(parsed['prefs']);
188
+ return {
189
+ id: subscriptionIdForEndpoint(endpoint),
190
+ endpoint,
191
+ keys: { p256dh, auth },
192
+ authScope,
193
+ createdAt: now,
194
+ lastSeenAt: now,
195
+ prefs,
196
+ };
197
+ }
198
+ export function parsePushDeleteBody(rawBody) {
199
+ const parsed = parsePushBody(rawBody);
200
+ const endpoint = normalizeEndpoint(parsed['endpoint']);
201
+ return { endpoint };
202
+ }
203
+ function isValidRecord(value) {
204
+ return (isRecord(value) &&
205
+ isString(value.id) &&
206
+ isString(value.endpoint) &&
207
+ isRecord(value.keys) &&
208
+ isString(value.keys['p256dh']) &&
209
+ isString(value.keys['auth']) &&
210
+ isString(value.authScope) &&
211
+ isNumber(value.createdAt) &&
212
+ isNumber(value.lastSeenAt) &&
213
+ isRecord(value.prefs) &&
214
+ typeof value.prefs['doneNotifications'] === 'boolean');
215
+ }
216
+ function isValidRegistryFile(value) {
217
+ return (isRecord(value) &&
218
+ value.schemaVersion === SCHEMA_VERSION &&
219
+ Array.isArray(value.records) &&
220
+ value.records.every(isValidRecord));
221
+ }
222
+ function isValidLastSeenFile(value) {
223
+ if (!isRecord(value) || value.schemaVersion !== SCHEMA_VERSION || !isNumber(value.updatedAt))
224
+ return false;
225
+ if (!isRecord(value.roots) || !isRecord(value.asks))
226
+ return false;
227
+ for (const record of Object.values(value.roots)) {
228
+ if (!isRecord(record) || !isString(record.status) || !isNumber(record.attention_count))
229
+ return false;
230
+ }
231
+ for (const record of Object.values(value.asks)) {
232
+ if (!isRecord(record) || typeof record.present !== 'boolean')
233
+ return false;
234
+ }
235
+ return true;
236
+ }
237
+ function loadRegistryFile(path) {
238
+ if (!existsSync(path))
239
+ return { schemaVersion: SCHEMA_VERSION, records: [] };
240
+ const parsed = readJsonFile(path);
241
+ if (!isValidRegistryFile(parsed)) {
242
+ throw new Error(`invalid push registry file: ${path}`);
243
+ }
244
+ return parsed;
245
+ }
246
+ function loadLastSeenFile(path) {
247
+ if (!existsSync(path)) {
248
+ return { schemaVersion: SCHEMA_VERSION, updatedAt: 0, roots: {}, asks: {} };
249
+ }
250
+ const parsed = readJsonFile(path);
251
+ if (!isValidLastSeenFile(parsed)) {
252
+ throw new Error(`invalid push last-seen file: ${path}`);
253
+ }
254
+ return parsed;
255
+ }
256
+ function isValidVapidFile(value) {
257
+ return (isRecord(value) &&
258
+ value.schemaVersion === SCHEMA_VERSION &&
259
+ isString(value.publicKey) &&
260
+ isString(value.privateKey) &&
261
+ isString(value.subject));
262
+ }
263
+ /** Load the persisted VAPID keypair, generating and persisting a fresh one on
264
+ * first run. One keypair per server identity (design D8/push contract) —
265
+ * every subscription is signed with the same public key for the life of this
266
+ * `crtrHome()`. The private key never leaves this file/process. */
267
+ export function loadOrCreateVapidKeypair(path = vapidPath(), subject = DEFAULT_VAPID_SUBJECT) {
268
+ if (existsSync(path)) {
269
+ const parsed = readJsonFile(path);
270
+ if (!isValidVapidFile(parsed)) {
271
+ throw new Error(`invalid push VAPID keyfile: ${path}`);
272
+ }
273
+ return parsed;
274
+ }
275
+ const { publicKey, privateKey } = webpush.generateVAPIDKeys();
276
+ const file = { schemaVersion: SCHEMA_VERSION, publicKey, privateKey, subject };
277
+ atomicWriteJson(path, file);
278
+ return file;
279
+ }
280
+ function normalizeStoredRecord(record, existing) {
281
+ const endpoint = normalizeEndpoint(record.endpoint);
282
+ const id = subscriptionIdForEndpoint(endpoint);
283
+ const createdAt = existing?.createdAt ?? record.createdAt;
284
+ return {
285
+ id,
286
+ endpoint,
287
+ keys: {
288
+ p256dh: normalizeBase64Url(record.keys.p256dh, 'p256dh', P256DH_MIN_LEN, P256DH_MAX_LEN),
289
+ auth: normalizeBase64Url(record.keys.auth, 'auth', AUTH_MIN_LEN, AUTH_MAX_LEN),
290
+ },
291
+ authScope: record.authScope,
292
+ createdAt,
293
+ lastSeenAt: record.lastSeenAt,
294
+ prefs: { doneNotifications: record.prefs.doneNotifications },
295
+ };
296
+ }
297
+ export class PushRegistryStore {
298
+ path;
299
+ state;
300
+ byId = new Map();
301
+ constructor(path = registryPath()) {
302
+ this.path = path;
303
+ this.state = loadRegistryFile(path);
304
+ for (const record of this.state.records) {
305
+ const normalized = normalizeStoredRecord(record);
306
+ this.byId.set(normalized.id, normalized);
307
+ }
308
+ this.persist();
309
+ }
310
+ snapshot() {
311
+ return [...this.byId.values()];
312
+ }
313
+ listByScope(authScope) {
314
+ return [...this.byId.values()].filter((record) => record.authScope === authScope);
315
+ }
316
+ upsert(record) {
317
+ const normalized = normalizeStoredRecord(record, this.byId.get(record.id));
318
+ this.byId.set(normalized.id, normalized);
319
+ this.persist();
320
+ return normalized;
321
+ }
322
+ touchById(id, lastSeenAt) {
323
+ const existing = this.byId.get(id);
324
+ if (existing === undefined)
325
+ return null;
326
+ const next = { ...existing, lastSeenAt };
327
+ this.byId.set(id, next);
328
+ this.persist();
329
+ return next;
330
+ }
331
+ /** Scoped delete for the HTTP DELETE verb: a caller may only remove a
332
+ * subscription it owns (its authScope matches the stored record's). Pruning a
333
+ * service-reported-gone endpoint is a separate, unscoped path (`deleteById`). */
334
+ deleteByEndpoint(endpoint, authScope) {
335
+ const id = subscriptionIdForEndpoint(normalizeEndpoint(endpoint));
336
+ const existing = this.byId.get(id);
337
+ if (existing === undefined || existing.authScope !== authScope)
338
+ return false;
339
+ return this.deleteById(id);
340
+ }
341
+ deleteById(id) {
342
+ if (!this.byId.delete(id))
343
+ return false;
344
+ this.persist();
345
+ return true;
346
+ }
347
+ persist() {
348
+ this.state = { schemaVersion: SCHEMA_VERSION, records: [...this.byId.values()] };
349
+ atomicWriteJson(this.path, this.state);
350
+ }
351
+ }
352
+ export class PushLastSeenStore {
353
+ path;
354
+ state;
355
+ constructor(path = lastSeenPath()) {
356
+ this.path = path;
357
+ this.state = loadLastSeenFile(path);
358
+ this.persist();
359
+ }
360
+ snapshot() {
361
+ return {
362
+ schemaVersion: this.state.schemaVersion,
363
+ updatedAt: this.state.updatedAt,
364
+ roots: { ...this.state.roots },
365
+ asks: { ...this.state.asks },
366
+ };
367
+ }
368
+ replace(next) {
369
+ if (!isValidLastSeenFile(next)) {
370
+ throw new Error('attempted to persist an invalid push last-seen snapshot');
371
+ }
372
+ this.state = next;
373
+ this.persist();
374
+ }
375
+ persist() {
376
+ atomicWriteJson(this.path, this.state);
377
+ }
378
+ }
@@ -15,6 +15,13 @@
15
15
  // and relay frames VERBATIM both directions. The relay
16
16
  // adds NOTHING — the browser is the SAME protocol peer
17
17
  // as `crtr surface attach`.
18
+ // • Web push (§push) → GET /__crtr/push/vapid-public-key, POST/DELETE
19
+ // /__crtr/push/subscribe. A server-side diff engine
20
+ // (push-engine.ts) watches the SAME SSE invalidations
21
+ // above and sends web-push to a persisted, auth-scoped
22
+ // registry (push-registry.ts) — see D8 in the mobile-web
23
+ // design. Behind the identical auth boundary as the rest
24
+ // of this server.
18
25
  //
19
26
  // §0 ONE-WRITER INVARIANT: the WS relay is ONLY a socket relay. It NEVER calls
20
27
  // reviveNode, NEVER spawns `pi --session`, NEVER touches SessionManager, and
@@ -29,10 +36,12 @@
29
36
  // clients) — the broker fans out natively, so there is no bridge-side fan-out.
30
37
  import { createServer as createHttpServer } from 'node:http';
31
38
  import { createConnection, isIP } from 'node:net';
39
+ import { createHash } from 'node:crypto';
32
40
  import { createReadStream, existsSync, statSync } from 'node:fs';
33
41
  import { fileURLToPath } from 'node:url';
34
42
  import { dirname, extname, join, normalize, resolve } from 'node:path';
35
43
  import { WebSocketServer } from 'ws';
44
+ import webpush from 'web-push';
36
45
  import { viewSocketPath } from '../../core/canvas/paths.js';
37
46
  import { getNode } from '../../core/canvas/index.js';
38
47
  import { BROKER_READ_CAPS, CLIENT_READ_CAPS, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
@@ -41,9 +50,13 @@ import { clearFault, recordFault } from '../../core/runtime/fault.js';
41
50
  import { createLocalTransport } from '../../core/view/transport-local.js';
42
51
  import { createBurstCoalescingTransport } from '../../core/view/transport-cache.js';
43
52
  import { runSourceRequest } from '../../core/view/bridge.js';
53
+ import { canvasSnapshotLeaf } from '../../commands/canvas-snapshot.js';
54
+ import { humanList } from '../../commands/human/queue.js';
44
55
  import { startEventHub } from './events.js';
45
56
  import { createDevServer } from './dev-server.js';
46
57
  import { SOURCE_CACHE_TTL_MS, isCoalescableSourceRequest } from './source-cache.js';
58
+ import { PushLastSeenStore, PushRegistryStore, PushRequestError, loadOrCreateVapidKeypair, parsePushDeleteBody, parsePushSubscriptionBody, pushBodyMaxBytes, } from './push-registry.js';
59
+ import { PushDiffEngine } from './push-engine.js';
47
60
  const HERE = dirname(fileURLToPath(import.meta.url));
48
61
  /** Candidate dirs to serve the shell SPA bundle from, in priority order: an
49
62
  * explicit override, the compiled-module-relative `dist/web-client/` (what
@@ -158,6 +171,32 @@ function readBody(req) {
158
171
  req.on('error', rej);
159
172
  });
160
173
  }
174
+ /** Like `readBody`, but aborts the moment the accumulated body exceeds
175
+ * `maxBytes` — so a push endpoint never buffers arbitrary attacker memory
176
+ * before validation rejects it. Rejects with PushRequestError(413), which the
177
+ * push error handler maps to a 413 response. */
178
+ function readBodyCapped(req, maxBytes) {
179
+ return new Promise((resolve, reject) => {
180
+ const chunks = [];
181
+ let total = 0;
182
+ req.on('data', (c) => {
183
+ total += c.length;
184
+ if (total > maxBytes) {
185
+ reject(new PushRequestError(413, 'push subscription body is too large'));
186
+ req.destroy();
187
+ return;
188
+ }
189
+ chunks.push(c);
190
+ });
191
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
192
+ req.on('error', reject);
193
+ });
194
+ }
195
+ /** Derive the persisted auth scope from a bearer token: a short, stable hash so
196
+ * the raw secret never touches disk. */
197
+ function scopeForToken(secret) {
198
+ return `token:${createHash('sha256').update(secret).digest('base64url').slice(0, 16)}`;
199
+ }
161
200
  /** Serve a static file from the client dir, with SPA fallback to index.html for
162
201
  * any unmatched (non-asset) GET — so `/` and `/node/<id>` both boot the app
163
202
  * shell. Returns true if it wrote a response. */
@@ -385,13 +424,92 @@ export async function startWebServer(opts) {
385
424
  return true;
386
425
  return allowedOrigins.has(origin);
387
426
  };
388
- const requestAllowed = (req) => {
389
- if (requestTokenMatches(req, token))
390
- return true;
427
+ const authorizeRequest = (req) => {
428
+ if (token !== undefined && requestTokenMatches(req, token)) {
429
+ return { allowed: true, scope: scopeForToken(token) };
430
+ }
391
431
  if (tokenRequired)
392
- return false;
393
- return originAllowed(req.headers.origin);
432
+ return { allowed: false };
433
+ if (!originAllowed(req.headers.origin))
434
+ return { allowed: false };
435
+ return { allowed: true, scope: 'local' };
394
436
  };
437
+ // The set of auth scopes this server actually authorizes — the push diff
438
+ // engine sends ONLY to subscriptions whose authScope is in this set (design
439
+ // line 252: identical boundary to /__crtr/source). Mirrors authorizeRequest:
440
+ // a token authorizes scopeForToken(token); a tokenless request is authorized
441
+ // only when !tokenRequired, as scope 'local'. A record left by a rotated/old
442
+ // token has a different digest and is therefore excluded.
443
+ const targetScopes = new Set();
444
+ if (token !== undefined)
445
+ targetScopes.add(scopeForToken(token));
446
+ if (!tokenRequired)
447
+ targetScopes.add('local');
448
+ // §push — the server-side web-push registry + diff engine (design D8). One
449
+ // persisted, auth-scoped subscription registry; one locally-generated VAPID
450
+ // identity signing every send; one diff engine that re-reads canvas+decks on
451
+ // the SAME SSE invalidations the browser clients get (via eventHub.subscribe,
452
+ // no HTTP self-loop) and pushes 'done'/'needs-you' notifications.
453
+ const pushRegistry = new PushRegistryStore();
454
+ const pushLastSeen = new PushLastSeenStore();
455
+ const vapid = loadOrCreateVapidKeypair();
456
+ webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey);
457
+ // Page through every pending human ask, keeping only entries with the deck
458
+ // provenance (job + conversation) the payload/dedupe logic needs.
459
+ const collectPendingAsks = async () => {
460
+ const out = [];
461
+ let cursor;
462
+ for (let page = 0; page < 50; page++) {
463
+ const result = (await humanList.run({
464
+ limit: 100,
465
+ ...(cursor !== undefined ? { cursor } : {}),
466
+ }));
467
+ for (const item of result.items) {
468
+ if (item.job_id === undefined || item.conversation_id === undefined)
469
+ continue;
470
+ out.push({
471
+ id: item.id,
472
+ jobId: item.job_id,
473
+ title: item.title ?? item.job_id,
474
+ conversationId: item.conversation_id,
475
+ conversationTitle: item.conversation_title ?? item.conversation_id,
476
+ blockedSince: item.blocked_since,
477
+ });
478
+ }
479
+ if (result.next_cursor === null)
480
+ break;
481
+ cursor = result.next_cursor;
482
+ }
483
+ return out;
484
+ };
485
+ const pushEngine = new PushDiffEngine({
486
+ registry: pushRegistry,
487
+ lastSeen: pushLastSeen,
488
+ readCanvasNodes: async () => {
489
+ const snap = (await canvasSnapshotLeaf.run({}));
490
+ return snap.nodes;
491
+ },
492
+ readPendingAsks: collectPendingAsks,
493
+ isTargetScope: (s) => targetScopes.has(s),
494
+ sendPush: async (record, payload) => {
495
+ try {
496
+ await webpush.sendNotification({ endpoint: record.endpoint, keys: record.keys }, JSON.stringify(payload));
497
+ return 'sent';
498
+ }
499
+ catch (err) {
500
+ const statusCode = err.statusCode;
501
+ return statusCode === 404 || statusCode === 410 ? 'gone' : 'error';
502
+ }
503
+ },
504
+ });
505
+ // A restart is not an attention event — record current state, send nothing.
506
+ await pushEngine.seed();
507
+ const unsubscribePush = eventHub.subscribe((kind) => {
508
+ if (kind === 'nodes')
509
+ void pushEngine.onNodesInvalidation();
510
+ else if (kind === 'inbox')
511
+ void pushEngine.onInboxInvalidation();
512
+ });
395
513
  // Assigned before listen() when --dev; the request handler closes over it.
396
514
  let vite;
397
515
  const httpServer = createHttpServer((req, res) => {
@@ -408,7 +526,7 @@ export async function startWebServer(opts) {
408
526
  res.end('method not allowed\n');
409
527
  return;
410
528
  }
411
- if (!requestAllowed(req)) {
529
+ if (!authorizeRequest(req).allowed) {
412
530
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
413
531
  res.end('forbidden\n');
414
532
  return;
@@ -434,7 +552,7 @@ export async function startWebServer(opts) {
434
552
  res.end('method not allowed\n');
435
553
  return;
436
554
  }
437
- if (!requestAllowed(req)) {
555
+ if (!authorizeRequest(req).allowed) {
438
556
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
439
557
  res.end('forbidden\n');
440
558
  return;
@@ -442,6 +560,70 @@ export async function startWebServer(opts) {
442
560
  eventHub.addClient(res);
443
561
  return;
444
562
  }
563
+ // §push — the VAPID public key a browser needs to subscribe. Read-only,
564
+ // non-secret, but behind the same auth boundary as everything else so a
565
+ // foreign page can't even enumerate the surface.
566
+ if (pathname === '/__crtr/push/vapid-public-key') {
567
+ if (req.method !== 'GET') {
568
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
569
+ res.end('method not allowed\n');
570
+ return;
571
+ }
572
+ if (!authorizeRequest(req).allowed) {
573
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
574
+ res.end('forbidden\n');
575
+ return;
576
+ }
577
+ res.writeHead(200, { 'content-type': 'application/json' });
578
+ res.end(JSON.stringify({ publicKey: vapid.publicKey }));
579
+ return;
580
+ }
581
+ // §push — subscribe (POST) / unsubscribe (DELETE). Validation lives in
582
+ // parsePush*Body, which rejects oversized/malformed/private/http/file
583
+ // shapes BEFORE any persistence, throwing PushRequestError with a status.
584
+ if (pathname === '/__crtr/push/subscribe') {
585
+ const auth = authorizeRequest(req);
586
+ if (!auth.allowed) {
587
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
588
+ res.end('forbidden\n');
589
+ return;
590
+ }
591
+ const handlePushError = (e) => {
592
+ if (e instanceof PushRequestError) {
593
+ res.writeHead(e.statusCode, { 'content-type': 'application/json' });
594
+ res.end(JSON.stringify({ ok: false, error: e.message }));
595
+ return;
596
+ }
597
+ res.writeHead(500, { 'content-type': 'application/json' });
598
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
599
+ };
600
+ if (req.method === 'POST') {
601
+ void readBodyCapped(req, pushBodyMaxBytes())
602
+ .then((body) => {
603
+ const record = parsePushSubscriptionBody(body, auth.scope);
604
+ const stored = pushRegistry.upsert(record);
605
+ res.writeHead(200, { 'content-type': 'application/json' });
606
+ res.end(JSON.stringify({ ok: true, id: stored.id, authScope: stored.authScope }));
607
+ })
608
+ .catch(handlePushError);
609
+ return;
610
+ }
611
+ if (req.method === 'DELETE') {
612
+ void readBodyCapped(req, pushBodyMaxBytes())
613
+ .then((body) => {
614
+ const { endpoint } = parsePushDeleteBody(body);
615
+ // Scoped: a caller may only delete a subscription it owns.
616
+ const deleted = pushRegistry.deleteByEndpoint(endpoint, auth.scope);
617
+ res.writeHead(200, { 'content-type': 'application/json' });
618
+ res.end(JSON.stringify({ ok: true, deleted }));
619
+ })
620
+ .catch(handlePushError);
621
+ return;
622
+ }
623
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'POST, DELETE' });
624
+ res.end('method not allowed\n');
625
+ return;
626
+ }
445
627
  // --dev: Vite middleware owns all remaining asset/HTML serving (incl. its
446
628
  // own SPA fallback). It is mounted AFTER the bridge + SSE checks above, so
447
629
  // those never fall through to Vite.
@@ -490,7 +672,7 @@ export async function startWebServer(opts) {
490
672
  socket.destroy();
491
673
  return;
492
674
  }
493
- if (!requestAllowed(req)) {
675
+ if (!authorizeRequest(req).allowed) {
494
676
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
495
677
  socket.destroy();
496
678
  return;
@@ -533,6 +715,7 @@ export async function startWebServer(opts) {
533
715
  `http://localhost:${port}`,
534
716
  ]);
535
717
  const close = () => new Promise((done) => {
718
+ unsubscribePush();
536
719
  eventHub.close();
537
720
  void vite?.close().catch(() => {
538
721
  /* best-effort */