feat(notifications): Web Push delivers notifications to every device, even with TREK closed

A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
This commit is contained in:
Maurice
2026-09-24 23:17:03 +00:00
parent 7ce31756a3
commit 650451bdf7
177 changed files with 7809 additions and 271 deletions
+27 -2
View File
@@ -28,6 +28,11 @@ export const SENSITIVE_KEYS = new Set([
'code',
'smtp_pass',
'secretaccesskey',
// A Web Push subscription's keys: whoever holds them together with the
// endpoint can encrypt messages for that browser. No other request body has
// either field, and an `auth` anywhere else would be a credential as well.
'p256dh',
'auth',
]);
/**
@@ -43,7 +48,26 @@ function isSensitiveName(name: string): boolean {
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
/**
* The fields PushSubscription.toJSON() puts next to `endpoint`. Either one is
* enough to know the object is a subscription, so a body that arrives without
* its keys (which the route then refuses) still keeps its endpoint out of the log.
*/
const PUSH_SUBSCRIPTION_FIELDS = new Set(['keys', 'expirationtime']);
/**
* A Web Push endpoint is a capability URL: anyone holding it can post to that
* browser's push service. It arrives inside a subscription, or on its own in
* the body that forgets a device. Anywhere else `endpoint` is an ordinary
* setting (the S3 backend's URL, for one) and stays readable.
*/
function isPushEndpoint(entries: Record<string, unknown>, name: string): boolean {
if (name.toLowerCase() !== 'endpoint') return false;
const siblings = Object.keys(entries).filter((k) => k !== name);
return siblings.length === 0 || siblings.some((k) => PUSH_SUBSCRIPTION_FIELDS.has(k.toLowerCase()));
}
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) and every Web Push endpoint from a request-log value. */
export function redact(value: unknown): unknown {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return (value as unknown[]).map(redact);
@@ -54,7 +78,8 @@ export function redact(value: unknown): unknown {
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(entries)) {
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
const hidden = isSensitiveName(k) || (namedSecret && k === 'value') || isPushEndpoint(entries, k);
out[k] = hidden ? '[REDACTED]' : redact(v);
}
return out;
}