@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.
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +20 -5
- package/dist/clients/attach/attach-cmd.js +5 -5
- 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/commands/memory/read.js +8 -1
- package/dist/core/canvas/paths.d.ts +10 -0
- package/dist/core/canvas/paths.js +14 -0
- package/dist/core/substrate/render.js +7 -2
- package/dist/web-client/assets/{index-FWBlgUV2.js → index-BOlkXTJY.js} +24 -24
- 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-CKvdUlkn.css +0 -2
|
@@ -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
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 */
|