@crouton-kit/crouter 0.3.57 → 0.3.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import type { NodeLifeStatus } from './web-client/shared/protocol.js';
2
+ export interface PushPreferences {
3
+ doneNotifications: boolean;
4
+ }
5
+ export interface PushSubscriptionRecord {
6
+ id: string;
7
+ endpoint: string;
8
+ keys: {
9
+ p256dh: string;
10
+ auth: string;
11
+ };
12
+ authScope: string;
13
+ createdAt: number;
14
+ lastSeenAt: number;
15
+ prefs: PushPreferences;
16
+ }
17
+ export interface PushRegistryFile {
18
+ schemaVersion: number;
19
+ records: PushSubscriptionRecord[];
20
+ }
21
+ export interface PushLastSeenRootState {
22
+ status: NodeLifeStatus;
23
+ attention_count: number;
24
+ }
25
+ export interface PushLastSeenAskState {
26
+ present: boolean;
27
+ }
28
+ export interface PushLastSeenFile {
29
+ schemaVersion: number;
30
+ updatedAt: number;
31
+ roots: Record<string, PushLastSeenRootState>;
32
+ asks: Record<string, PushLastSeenAskState>;
33
+ }
34
+ /** A locally generated VAPID keypair, persisted so the same identity signs
35
+ * every outbound push across restarts. The private key never leaves this
36
+ * file/process — only `publicKey` is ever served to a client. */
37
+ export interface VapidKeypairFile {
38
+ schemaVersion: number;
39
+ publicKey: string;
40
+ privateKey: string;
41
+ subject: string;
42
+ }
43
+ export declare class PushRequestError extends Error {
44
+ readonly statusCode: number;
45
+ constructor(statusCode: number, message: string);
46
+ }
47
+ export declare function registryPath(): string;
48
+ export declare function lastSeenPath(): string;
49
+ export declare function vapidPath(): string;
50
+ export declare function pushBodyMaxBytes(): number;
51
+ /** Re-checkable at any later point (e.g. immediately before an outbound send,
52
+ * not just at subscribe time) with no dependency on DNS resolution — the
53
+ * allowlist match is a pure string check, so there is nothing to TOCTOU. */
54
+ export declare function isAllowedPushEndpoint(endpoint: string): boolean;
55
+ export declare function normalizeEndpoint(endpoint: unknown): string;
56
+ export declare function subscriptionIdForEndpoint(endpoint: string): string;
57
+ export declare function parsePushSubscriptionBody(rawBody: string, authScope: string, now?: number): PushSubscriptionRecord;
58
+ export declare function parsePushDeleteBody(rawBody: string): {
59
+ endpoint: string;
60
+ };
61
+ /** Load the persisted VAPID keypair, generating and persisting a fresh one on
62
+ * first run. One keypair per server identity (design D8/push contract) —
63
+ * every subscription is signed with the same public key for the life of this
64
+ * `crtrHome()`. The private key never leaves this file/process. */
65
+ export declare function loadOrCreateVapidKeypair(path?: string, subject?: string): VapidKeypairFile;
66
+ export declare class PushRegistryStore {
67
+ private readonly path;
68
+ private state;
69
+ private readonly byId;
70
+ constructor(path?: string);
71
+ snapshot(): PushSubscriptionRecord[];
72
+ listByScope(authScope: string): PushSubscriptionRecord[];
73
+ upsert(record: PushSubscriptionRecord): PushSubscriptionRecord;
74
+ touchById(id: string, lastSeenAt: number): PushSubscriptionRecord | null;
75
+ /** Scoped delete for the HTTP DELETE verb: a caller may only remove a
76
+ * subscription it owns (its authScope matches the stored record's). Pruning a
77
+ * service-reported-gone endpoint is a separate, unscoped path (`deleteById`). */
78
+ deleteByEndpoint(endpoint: string, authScope: string): boolean;
79
+ deleteById(id: string): boolean;
80
+ private persist;
81
+ }
82
+ export declare class PushLastSeenStore {
83
+ private readonly path;
84
+ private state;
85
+ constructor(path?: string);
86
+ snapshot(): PushLastSeenFile;
87
+ replace(next: PushLastSeenFile): void;
88
+ private persist;
89
+ }
@@ -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
+ }