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
+6 -1
View File
@@ -31,6 +31,10 @@ tokens, `RuntimeEnvService`) and consumes the SAME derive functions.
together wrong. It is empty without the switch, so a self-hoster never meets
it. Put a rule there only if booting anyway would be a security or data
problem — not to enforce a preference.
The report prints each malformed value, because that is what makes a typo
findable, except for the credentials in `SECRET_ENV_KEYS` (`env.ts`), which
print as `***`. A new password, token, key or secret variable goes into that
set; `validate.test.ts` fails on a credential-sounding name missing from it.
3. **Parity is law — with one deliberate exception.** Every derived field pins
the exact coercion of the call site(s) it replaced (`Number(x) || d`
treating `"0"` as unset, per-site defaults for the same variable, …). Do not
@@ -82,7 +86,8 @@ TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
LLM_TIMEOUT_MS, NOMINATIM_URL.
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
service, which the notification suites build without the container).
## Exemptions — raw `process.env` stays
+11
View File
@@ -334,6 +334,16 @@ export function deriveNet(raw: RawEnv) {
};
}
export function derivePush(raw: RawEnv) {
return {
// Trimmed, and blank counts as unset: the schema validated the trimmed value,
// so a padded key must not reach the crypto as a different string.
vapidPublicKey: raw.VAPID_PUBLIC_KEY?.trim() || undefined,
vapidPrivateKey: raw.VAPID_PRIVATE_KEY?.trim() || undefined,
vapidSubject: raw.VAPID_SUBJECT?.trim() || undefined,
};
}
export function deriveAll(raw: RawEnv) {
return {
app: deriveApp(raw),
@@ -353,6 +363,7 @@ export function deriveAll(raw: RawEnv) {
db: deriveDb(raw),
paths: derivePaths(raw),
net: deriveNet(raw),
push: derivePush(raw),
};
}
+19 -1
View File
@@ -11,7 +11,13 @@
*/
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import { parseDurationMs, parseLinkLocalAllowList } from './parsers';
import {
isP256PrivateKey,
isUncompressedP256Key,
isVapidSubject,
parseDurationMs,
parseLinkLocalAllowList,
} from './parsers';
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
function optionalWith(test: (v: string) => boolean, message: string) {
@@ -104,6 +110,18 @@ export const envSchema = z.object({
WEBAUTHN_RP_ID: anyString,
WEBAUTHN_ORIGINS: anyString,
// Web Push (VAPID). All optional: without them the server keeps a pair of its
// own in the database. Only the shape is checked here; whether the two keys
// belong together is checked where they are used, which turns push off while
// they do not (never signing with a stored pair in their place) and says so
// in the log instead of refusing to boot.
VAPID_PUBLIC_KEY: optionalWith(
isUncompressedP256Key,
'must be a base64url-encoded P-256 public key (65 bytes, uncompressed, starting with 0x04)',
),
VAPID_PRIVATE_KEY: optionalWith(isP256PrivateKey, 'must be a base64url-encoded P-256 private key (32 bytes)'),
VAPID_SUBJECT: optionalWith(isVapidSubject, 'must be a mailto: address or an https:// URL'),
// MCP
MCP_SESSION_TTL: positiveNumber,
MCP_MAX_SESSION_PER_USER: positiveNumber,
+26 -1
View File
@@ -14,6 +14,31 @@ export function readEnv(): AppEnv {
return deriveAll(process.env as RawEnv);
}
/**
* Variables whose value never goes into the boot report, even when it is the
* malformed one: the report lands in container logs, and a key that is merely
* mistyped is still a real key. The name and the problem are enough to fix it.
*/
export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
'ENCRYPTION_KEY',
'OIDC_CLIENT_SECRET',
'SMTP_PASS',
'VAPID_PRIVATE_KEY',
'UNSPLASH_ACCESS_KEY',
'ADMIN_PASSWORD',
'PLACES_API_KEY',
'AMAP_API_KEY',
'AMAP_API_SECRET',
'MAPBOX_ACCESS_TOKEN',
'CARTO_API_KEY',
'DEMO_ADMIN_PASS',
]);
/** How a variable's value appears in the boot report. */
function reportedValue(key: string, value: string | undefined): string {
return SECRET_ENV_KEYS.has(key) ? '***' : JSON.stringify(value);
}
/**
* Fail-fast startup validation: a variable that is PRESENT but malformed aborts
* boot with an aggregated report; unset/blank variables always pass (their
@@ -28,7 +53,7 @@ export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void {
? []
: result.error.issues.map((issue) => {
const key = String(issue.path[0]);
return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`;
return ` - ${key}=${reportedValue(key, raw[key])}: ${issue.message}`;
});
lines.push(...managedPreconditions(raw));
if (lines.length === 0) return;
+1
View File
@@ -20,6 +20,7 @@ export {
deriveDb,
derivePaths,
deriveNet,
derivePush,
} from './derive';
export * from './parsers';
export { envSchema } from './env.schema';
+43
View File
@@ -209,3 +209,46 @@ export function parseLinkLocalAllowList(raw: string | undefined): { ips: string[
}
return { ips, invalid };
}
// ── Web Push key material ──────────────────────────────────────────────────
const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/;
/**
* Strict base64url decode, null for anything that is not base64url. Web Push
* keys arrive in this alphabet from the browser and from the VAPID_* variables.
* Buffer.from(value, 'base64url') alone skips characters it does not know, so a
* mangled key would decode to fewer bytes instead of being refused, and the size
* checks below would then report the wrong problem.
*/
export function decodeBase64Url(value: string): Buffer | null {
const trimmed = value.trim();
if (!BASE64URL.test(trimmed)) return null;
return Buffer.from(trimmed, 'base64url');
}
/** An uncompressed P-256 point: 65 bytes, the first one 0x04 (VAPID public key, subscription p256dh). */
export function isUncompressedP256Key(value: string): boolean {
const bytes = decodeBase64Url(value);
return bytes?.length === 65 && bytes[0] === 0x04;
}
/** A P-256 private scalar as the Web Push tools print it: 32 bytes. */
export function isP256PrivateKey(value: string): boolean {
return decodeBase64Url(value)?.length === 32;
}
/**
* The VAPID `sub` claim (RFC 8292 section 2.1): a mailto: address or an https:
* URL. Apple refuses anything else, and so does this check, so a subject that
* boots is one every push service accepts.
*/
export function isVapidSubject(value: string): boolean {
const trimmed = value.trim();
if (/^mailto:[^\s@]+@[^\s@]+$/i.test(trimmed)) return true;
try {
return new URL(trimmed).protocol === 'https:';
} catch {
return false;
}
}
+30
View File
@@ -5257,6 +5257,36 @@ function runMigrations(db: Database.Database): void {
`);
}
},
/*
* Web Push (#894): one row per browser a user switched push on in.
*
* The endpoint is the push service URL that browser handed out, so it is
* unique: a browser shared by two accounts belongs to whoever subscribed on
* it last. p256dh and auth are the browser's keys for the RFC 8291 message
* encryption. vapid_public_key is the server key the subscription was made
* against, so a changed key pair shows up as a mismatch the sender can clean
* up, rather than as a push service refusing every message. failure_count
* and last_success_at are bookkeeping for the sender; the rows go with the
* user.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
vapid_public_key TEXT NOT NULL,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_success_at TEXT,
failure_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
`);
},
];
if (currentVersion < migrations.length) {
+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;
}
+3
View File
@@ -226,6 +226,9 @@ export class AdminService {
id,
);
} catch { /* very old installs predate oauth_tokens */ }
// Push devices outlive every session, so the intruder's browser would
// keep receiving this account's notifications. They go with the rest.
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', id);
}
});
+11 -1
View File
@@ -7,6 +7,7 @@ import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { randomBytes, createHash } from 'crypto';
import type { Request, Response } from 'express';
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { DatabaseService } from '../database/database.service';
@@ -285,6 +286,7 @@ export class AuthService {
const notifChannelsRaw = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channels'")?.value || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const hasPushEnabled = activeChannels.includes(WEB_PUSH_CHANNEL_ID);
const tripRemindersEnabled = tripReminderSetting !== 'false';
const placesPhotosSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'")?.value;
const placesPhotosEnabled = placesPhotosSetting !== 'false';
@@ -343,7 +345,7 @@ export class AuthService {
timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
notification_channels: activeChannels,
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, push: hasPushEnabled, inapp: true },
trip_reminders_enabled: tripRemindersEnabled,
places_photos_enabled: placesPhotosEnabled,
places_autocomplete_enabled: placesAutocompleteEnabled,
@@ -596,6 +598,11 @@ export class AuthService {
try {
this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId);
} catch { /* oauth_tokens table may not exist in very old installs */ }
// Push devices keep receiving notifications without any session, so they
// go too. The device the change was made on registers again right away:
// the client reloads the user after the change, and that re-syncs its
// subscription. Other devices do so after their next sign-in.
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', userId);
});
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
@@ -1056,6 +1063,9 @@ export class AuthService {
user.id
);
} catch { /* oauth_tokens table may not exist in very old installs */ }
// Push devices are a delivery channel that outlives every session, so an
// intruder's browser would keep reading this account's notifications.
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', user.id);
});
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
@@ -1,13 +1,16 @@
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
import { registerChannel } from '../channel-registry';
import type { ChannelMessage, ExternalChannel } from '../notification-events';
import { ADMIN_SCOPED_EVENTS, type ChannelMessage, type ExternalChannel } from '../notification-events';
import type { MailerService } from '../mailer/mailer.service';
import { resolveAdminNtfyUrl, resolveNtfyToken, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
import type { WebhookService } from '../transports/webhook.service';
import type { WebPushService } from '../transports/web-push.service';
// The three built-in external channels, wrapping the transports that were free
// functions in services/notifications.ts before the fold. No delivery logic is
// rewritten here - it is only relocated behind the ExternalChannel interface so
// NotificationsService.send() can iterate instead of branching.
// The built-in external channels. Email, webhook and ntfy wrap the transports
// that were free functions in services/notifications.ts before the fold; no
// delivery logic is rewritten here, it is only relocated behind the
// ExternalChannel interface so NotificationsService.send() can iterate instead
// of branching. Web Push (#894) joined later on the same terms.
function supportsAllButSynology(event: string): boolean {
return event !== 'synology_session_cleared';
@@ -17,10 +20,11 @@ export interface BuiltinChannelDeps {
mailer: MailerService;
webhook: WebhookService;
ntfy: NtfyService;
push: WebPushService;
}
/**
* Build the three built-in channels over the given transports.
* Build the built-in channels over the given transports.
*
* They take their dependencies as an argument rather than importing them,
* because the registry they land in is a module singleton while the transports
@@ -32,7 +36,7 @@ export interface BuiltinChannelDeps {
* preferences; dropping that import would have silenced email, webhook and ntfy
* without a single error.
*/
export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDeps): ExternalChannel[] {
export function buildBuiltinChannels({ mailer, webhook, ntfy, push }: BuiltinChannelDeps): ExternalChannel[] {
const emailChannel: ExternalChannel = {
id: 'email',
source: 'builtin',
@@ -114,7 +118,26 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
},
};
return [emailChannel, webhookChannel, ntfyChannel];
// Per user and per browser. It carries no admin-scoped events: those have
// their own admin-global copy on the channels above, and a push column in
// the admin matrix could never be switched on. There is no sendGlobal for the
// same reason, and no credentials to check beyond the user having turned it
// on in at least one browser. Subscribing is a browser-only act, so there is
// no MCP twin for the routes behind it. While the server's key pair cannot be
// used, push is not sent and not offered, and every device stays subscribed.
const pushChannel: ExternalChannel = {
id: WEB_PUSH_CHANNEL_ID,
source: 'builtin',
labelKey: 'settings.notificationPreferences.push',
hiddenWhileInstanceUnconfigured: true,
supportsEvent: (event) => supportsAllButSynology(event) && !ADMIN_SCOPED_EVENTS.has(event),
isInstanceConfigured: () => push.isAvailable(),
isConfiguredFor: (userId) => push.hasDevices(userId),
sendToUser: (userId, msg) => push.sendToUser(userId, msg),
test: (userId) => push.sendTest(userId),
};
return [emailChannel, webhookChannel, ntfyChannel, pushChannel];
}
/** Idempotent - safe to call from every entry point that needs the registry populated. */
@@ -127,6 +127,13 @@ export interface ExternalChannel {
readonly bypassesActiveToggleForAdminEvents?: boolean;
/** Delivers the one admin-scoped global copy (not per-recipient). */
readonly supportsAdminGlobal?: boolean;
/**
* The user's matrix marks this channel inactive (no column, no settings card)
* while isInstanceConfigured() says no. Web Push sets it: without a usable key
* pair a browser could not even subscribe. Email does not, and keeps its
* column while SMTP is not set up, as it always has.
*/
readonly hiddenWhileInstanceUnconfigured?: boolean;
supportsEvent(event: NotifEventType): boolean;
/**
@@ -80,6 +80,16 @@ export class NotificationPreferencesService {
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
}
/**
* Is the channel offered to users at all right now? Switched on, and for a
* channel that says so, ready on this instance too: Web Push without a usable
* key pair is off for everyone, whatever the admin switch says.
*/
private isChannelOffered(channel: ExternalChannel): boolean {
if (!this.isChannelActive(channel)) return false;
return !channel.hiddenWhileInstanceUnconfigured || channel.isInstanceConfigured?.() !== false;
}
// ── Per-user preference checks ─────────────────────────────────────────────
/**
@@ -123,8 +133,10 @@ export class NotificationPreferencesService {
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
for (const channel of listChannels()) {
// Plugin channels are user-scoped only — they never carry admin-global events.
if (channel.source !== 'builtin') continue;
// Only the channels with an admin-global copy. Plugin channels and push are
// user-scoped: they never carry an admin-scoped event, so a column for them
// here could never be switched on.
if (channel.source !== 'builtin' || !isAdminGlobalChannel(channel.id)) continue;
const active = adminActive[channel.id] ?? false;
out.push({
id: channel.id,
@@ -147,7 +159,7 @@ export class NotificationPreferencesService {
settingsPath: channel.settingsPath,
// A live plugin channel is always a column. `configured` tells the user whether
// they still need to enter credentials — it does not hide the channel from them.
active: this.isChannelActive(channel),
active: this.isChannelOffered(channel),
configured: channel.isConfiguredFor(userId),
});
}
@@ -5,6 +5,8 @@ import {
testWebhookRequestSchema,
testNtfyRequestSchema,
notificationRespondRequestSchema,
pushSubscribeRequestSchema,
pushUnsubscribeRequestSchema,
} from '@trek/shared';
/**
@@ -19,3 +21,5 @@ export class TestSmtpDto extends createZodDto(testSmtpRequestSchema) {}
export class TestWebhookDto extends createZodDto(testWebhookRequestSchema) {}
export class TestNtfyDto extends createZodDto(testNtfyRequestSchema) {}
export class NotificationRespondDto extends createZodDto(notificationRespondRequestSchema) {}
export class PushSubscribeDto extends createZodDto(pushSubscribeRequestSchema) {}
export class PushUnsubscribeDto extends createZodDto(pushUnsubscribeRequestSchema) {}
@@ -7,6 +7,10 @@ import { ReminderJobsService } from './reminder-jobs.service';
import { StorageHealthNotifierService } from './storage-health-notifier.service';
import { NtfyService } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { WebPushService } from './transports/web-push.service';
import { PushController } from './push/push.controller';
import { PushSubscriptionsService } from './push/push-subscriptions.service';
import { VapidKeysService } from './push/vapid-keys.service';
import { MailerModule } from './mailer/mailer.module';
import { AuthModule } from '../auth/auth.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
@@ -19,17 +23,23 @@ import { StorageModule } from '../storage/storage.module';
* StorageModule feeds StorageHealthNotifierService, which bridges replica
* failures into admin notifications — this direction has no cycle (Storage
* imports only AppConfig+Audit+Scheduling).
* Web Push (#894) lives in push/: VapidKeysService holds the server's key
* pair, PushSubscriptionsService the browsers, PushController the routes
* a browser signs up through; the transport is transports/web-push.service.ts.
* NotificationsService and NotificationPreferencesService are exported for
* in-container consumers (AdminController's dev test send and preferences tab,
* the plugin RPC surface, HostSurfaceRpc). */
@Module({
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
controllers: [NotificationsController, AdminNotificationPreferencesController],
controllers: [NotificationsController, AdminNotificationPreferencesController, PushController],
providers: [
NotificationsService,
NotificationPreferencesService,
WebhookService,
NtfyService,
WebPushService,
VapidKeysService,
PushSubscriptionsService,
NotificationsMcp,
ReminderJobsService,
StorageHealthNotifierService,
@@ -6,6 +6,7 @@ import { getEventText } from './mailer/email-html';
import { MailerService } from './mailer/mailer.service';
import { NtfyService, type NtfyConfig } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { WebPushService } from './transports/web-push.service';
import { NotificationPreferencesService, type PreferencesMatrix } from './notification-preferences.service';
import { registerBuiltinChannels } from './channels/builtins';
import {
@@ -272,7 +273,6 @@ function shouldSendToUser(
prefs: NotificationPreferencesService,
): boolean {
if (!channel.supportsEvent(event)) return false;
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
if (ADMIN_SCOPED_EVENTS.has(event)) {
if (!channel.bypassesActiveToggleForAdminEvents) return false;
@@ -285,7 +285,10 @@ function shouldSendToUser(
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
}
return channel.isConfiguredFor(recipientId);
if (!channel.isConfiguredFor(recipientId)) return false;
// Last, since it is the costly check: Web Push reads and verifies the server's
// key pair. Every check here is a plain AND, so the order changes no answer.
return !channel.isInstanceConfigured || channel.isInstanceConfigured();
}
/**
@@ -316,12 +319,13 @@ export class NotificationsService {
private readonly webhook: WebhookService,
private readonly ntfy: NtfyService,
private readonly prefs: NotificationPreferencesService,
push: WebPushService,
) {
// The registry is a module singleton shared with the plugin runtime and any
// separately-constructed instance (which never runs onModuleInit), so
// registering from here means every path that can dispatch has the
// built-ins. registerChannel is an idempotent Map.set.
registerBuiltinChannels({ mailer, webhook, ntfy });
registerBuiltinChannels({ mailer, webhook, ntfy, push });
}
getPreferences(userId: number, role: string): PreferencesMatrix {
@@ -779,7 +783,7 @@ export class NotificationsService {
);
}
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
// ── External channels (email, webhook, ntfy, push, plugin:*) ─────────
// One loop over the registry. The message is rendered once per recipient, in
// their language, and handed to every channel that wants it — so a plugin
// channel never touches i18n.
@@ -0,0 +1,97 @@
import { MAX_PUSH_ENDPOINT_LENGTH, type PushSubscriptionInput } from '@trek/shared';
import { decodeBase64Url } from '../../../app-config';
import { isP256Point } from './web-push-crypto';
/**
* The browser hands us a URL and the server POSTs to it for as long as the row
* lives, so the URL is checked like any other user-supplied target, and more
* narrowly: it has to be one of the push services browsers actually use. The
* SSRF guard still runs on every send; this list is what keeps a subscription
* from turning the server into a POST relay towards some other public host.
*/
export const PUSH_SERVICE_HOSTS: readonly string[] = [
// Chrome, Edge on Android, and every Chromium browser that uses Google's push.
'fcm.googleapis.com',
'android.googleapis.com',
// Firefox (autopush).
'updates.push.services.mozilla.com',
'push.services.mozilla.com',
// Safari on macOS, and Home Screen apps on iOS and iPadOS 16.4+.
'web.push.apple.com',
];
/** Services that hand out per-region or per-cluster hosts under one domain. */
export const PUSH_SERVICE_HOST_SUFFIXES: readonly string[] = [
'.push.apple.com',
// Edge on Windows (WNS).
'.notify.windows.com',
];
/** The bespoke 400 bodies for a subscription the browser should not have sent. */
export const PUSH_SUBSCRIPTION_ERRORS = {
endpoint: 'Invalid push endpoint',
service: 'Push endpoint is not a known push service',
p256dh: 'Invalid push subscription key',
auth: 'Invalid push subscription auth secret',
} as const;
export interface CheckedPushSubscription {
endpoint: string;
/** Canonical base64url, no padding. */
p256dh: string;
/** Canonical base64url, no padding. */
auth: string;
}
export type PushSubscriptionCheck = { ok: true; value: CheckedPushSubscription } | { ok: false; error: string };
function isKnownPushServiceHost(host: string): boolean {
return (
PUSH_SERVICE_HOSTS.includes(host) ||
PUSH_SERVICE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix) && host.length > suffix.length)
);
}
/**
* The endpoint half of the check, shared with the sender so a row that no
* longer passes (a host dropped from the list) is not posted to again.
* Answers 'ok', or which of the errors applies: 'endpoint' for anything that is
* not a plain https URL on the default port without credentials, 'service' for
* a host that is not on the list.
*/
export function checkPushEndpoint(endpoint: string): 'ok' | 'endpoint' | 'service' {
if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) return 'endpoint';
let url: URL;
try {
url = new URL(endpoint);
} catch {
return 'endpoint';
}
if (url.protocol !== 'https:' || url.username || url.password || url.port) return 'endpoint';
return isKnownPushServiceHost(url.hostname) ? 'ok' : 'service';
}
export function isPushServiceEndpoint(endpoint: string): boolean {
return checkPushEndpoint(endpoint) === 'ok';
}
/**
* Everything the Zod contract cannot say about a subscription: where it points,
* and that its keys have the sizes RFC 8291 needs (and the p256dh a point on
* the curve), so the sender never meets a row it cannot encrypt for.
*/
export function checkPushSubscription(input: PushSubscriptionInput): PushSubscriptionCheck {
const endpointVerdict = checkPushEndpoint(input.endpoint);
if (endpointVerdict !== 'ok') return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS[endpointVerdict] };
const p256dh = decodeBase64Url(input.keys.p256dh);
if (!p256dh || !isP256Point(p256dh)) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.p256dh };
const auth = decodeBase64Url(input.keys.auth);
if (auth?.length !== 16) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.auth };
return {
ok: true,
value: { endpoint: input.endpoint, p256dh: p256dh.toString('base64url'), auth: auth.toString('base64url') },
};
}
@@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import type { CheckedPushSubscription } from './push-subscription.helpers';
export interface PushSubscriptionRow {
id: number;
user_id: number;
endpoint: string;
p256dh: string;
auth: string;
vapid_public_key: string;
user_agent: string | null;
created_at: string;
last_success_at: string | null;
failure_count: number;
}
/**
* How many browsers one account can receive push on. Generous for phones,
* tablets and a few desktop profiles; the cap only exists so a client that
* subscribes in a loop cannot grow the table or the fan-out without bound.
*/
export const MAX_PUSH_DEVICES_PER_USER = 20;
/** Only there to tell one user's devices apart in the table, never parsed, so a cut-off one does no harm. */
const USER_AGENT_MAX_LENGTH = 256;
/**
* The push_subscriptions table: one row per browser a user switched push on
* in. Only rows the checks in push-subscription.helpers.ts accepted get here.
*/
@Injectable()
export class PushSubscriptionsService {
constructor(private readonly db: DatabaseService) {}
/**
* Register a browser, or refresh it when it subscribes again. The endpoint
* is unique, so a browser shared by two accounts moves to whoever subscribed
* on it last instead of notifying both. A refresh renews created_at, which
* makes the cap drop the device that registered longest ago rather than one
* that is in daily use. Answers how many devices the user now has.
*/
upsert(
userId: number,
subscription: CheckedPushSubscription,
vapidPublicKey: string,
userAgent?: string | null,
): number {
const agent = userAgent ? userAgent.slice(0, USER_AGENT_MAX_LENGTH) : null;
return this.db.transaction(() => {
this.db.run(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
vapid_public_key = excluded.vapid_public_key,
user_agent = excluded.user_agent,
created_at = CURRENT_TIMESTAMP,
failure_count = 0`,
userId,
subscription.endpoint,
subscription.p256dh,
subscription.auth,
vapidPublicKey,
agent,
);
const overflow = this.countForUser(userId) - MAX_PUSH_DEVICES_PER_USER;
if (overflow > 0) {
this.db.run(
`DELETE FROM push_subscriptions WHERE id IN (
SELECT id FROM push_subscriptions
WHERE user_id = ? AND endpoint <> ?
ORDER BY created_at ASC, id ASC
LIMIT ?
)`,
userId,
subscription.endpoint,
overflow,
);
}
return this.countForUser(userId);
});
}
/** Forget one of the caller's own browsers. Someone else's endpoint is never touched. */
removeForUser(userId: number, endpoint: string): boolean {
return (
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?', userId, endpoint).changes > 0
);
}
listForUser(userId: number): PushSubscriptionRow[] {
return this.db.all<PushSubscriptionRow>('SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY id', userId);
}
countForUser(userId: number): number {
return this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?', userId)?.n ?? 0;
}
hasAny(userId: number): boolean {
return !!this.db.get('SELECT 1 FROM push_subscriptions WHERE user_id = ? LIMIT 1', userId);
}
/** The sender's clean-up: the push service said the browser is gone, or the row can no longer be sent to. */
deleteById(id: number): void {
this.db.run('DELETE FROM push_subscriptions WHERE id = ?', id);
}
recordSuccess(id: number): void {
this.db.run(
'UPDATE push_subscriptions SET last_success_at = CURRENT_TIMESTAMP, failure_count = 0 WHERE id = ?',
id,
);
}
/**
* Counts one more failed send since the last success and answers the new
* count, read in the same statement so two sends at once cannot both see
* the old value. 0 when the row is gone already.
*/
recordFailure(id: number): number {
return (
this.db.get<{ failure_count: number }>(
'UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ? RETURNING failure_count',
id,
)?.failure_count ?? 0
);
}
}
@@ -0,0 +1,77 @@
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Post, UseGuards } from '@nestjs/common';
import type { PushPublicKeyResult, PushSubscribeResult, PushUnsubscribeResult } from '@trek/shared';
import type { User } from '../../../types';
import { CurrentUser } from '../../auth/current-user.decorator';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { PushSubscribeDto, PushUnsubscribeDto } from '../notifications.dto';
import { checkPushSubscription } from './push-subscription.helpers';
import { PushSubscriptionsService } from './push-subscriptions.service';
import { PUSH_UNAVAILABLE_ERROR, PushUnavailableError, VapidKeysService } from './vapid-keys.service';
/**
* /api/notifications/push: how a browser signs up for Web Push (#894).
*
* The browser fetches the server's public key, subscribes with its push
* service, and hands the subscription over here; logging out or switching push
* off in that browser deletes it again. Which events arrive is the ordinary
* preference matrix ('push' column), and the test send is the generic
* POST /api/notifications/test/push.
*
* No MCP tool mirrors these routes on purpose: a subscription is created by a
* browser's push manager for that one browser, and there is nothing an MCP
* client could subscribe with.
*/
@Controller('api/notifications/push')
@UseGuards(JwtAuthGuard)
export class PushController {
constructor(
private readonly keys: VapidKeysService,
private readonly subscriptions: PushSubscriptionsService,
) {}
@Get('public-key')
publicKey(): PushPublicKeyResult {
return { publicKey: this.currentPublicKey() };
}
@Post('subscriptions')
@HttpCode(200)
subscribe(
@CurrentUser() user: User,
@Body() body: PushSubscribeDto,
@Headers('user-agent') userAgent?: string,
): PushSubscribeResult {
const checked = checkPushSubscription(body.subscription);
if ('error' in checked) throw new HttpException({ error: checked.error }, 400);
const devices = this.subscriptions.upsert(user.id, checked.value, this.currentPublicKey(), userAgent);
return { success: true, devices };
}
/**
* Idempotent: the push service may already have expired the row, and a
* browser logging out must not get an error for that. Only the caller's own
* endpoint can go; the same URL on someone else's account is left alone.
* Works while push is unavailable too, so a device can always be forgotten.
*/
@Delete('subscriptions')
@HttpCode(200)
unsubscribe(@CurrentUser() user: User, @Body() body: PushUnsubscribeDto): PushUnsubscribeResult {
this.subscriptions.removeForUser(user.id, body.endpoint);
return { success: true };
}
/**
* The key browsers subscribe with. While the key pair cannot be used, stored
* or from VAPID_* (the log says why), the answer is a 503: nothing could be
* signed for a new subscription, and the devices already subscribed stay as
* they are.
*/
private currentPublicKey(): string {
try {
return this.keys.getPublicKey();
} catch (err) {
if (err instanceof PushUnavailableError) throw new HttpException({ error: PUSH_UNAVAILABLE_ERROR }, 503);
throw err;
}
}
}
@@ -0,0 +1,301 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { decodeBase64Url, isVapidSubject, readEnv, stripTrailingSlashes } from '../../../app-config';
import { logError, logInfo } from '../../audit/audit-log.logger';
import { decrypt_api_key, encrypt_api_key } from '../../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../../database/database.service';
import { generateVapidKeyPair, isVapidKeyPair, type VapidKeyPair } from './web-push-crypto';
/** app_settings rows holding the generated pair. The private half is stored encrypted. */
export const VAPID_PUBLIC_KEY_SETTING = 'web_push_vapid_public_key';
export const VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key';
/** The VAPID contact when nothing better is configured: a real https URL every push service accepts. */
export const FALLBACK_VAPID_SUBJECT = 'https://github.com/liketrek/TREK';
/** What the push routes answer while the key pair cannot be used. */
export const PUSH_UNAVAILABLE_ERROR = 'Web Push is currently unavailable on this server';
export interface ResolvedVapidKeys extends VapidKeyPair {
source: 'env' | 'database';
}
/**
* Thrown by getKeys() while push is off because the pair it would sign with
* cannot be used: the VAPID_* variables name one that cannot, or, with them
* unset, the stored one cannot. The reason is in the server log (once, without
* key material); this error only says that nothing can be signed right now.
*/
export class PushUnavailableError extends Error {
constructor() {
super('there is no usable VAPID key pair; see the earlier Web Push error in the log');
this.name = 'PushUnavailableError';
}
}
type UnusableReason = 'incomplete' | 'undecryptable' | 'mismatched';
type StoredPair =
| { state: 'absent' }
| { state: 'usable'; keys: VapidKeyPair }
| { state: UnusableReason; fingerprint: string };
type PresentPair = Exclude<StoredPair, { state: 'absent' }>;
type EnvPair =
| { state: 'unset' }
| { state: 'usable'; keys: VapidKeyPair }
| { state: 'unusable'; fingerprint: string; problem: string };
const DELETE_BOTH_ROWS =
`delete both app_settings rows ${VAPID_PUBLIC_KEY_SETTING} and ${VAPID_PRIVATE_KEY_SETTING} ` +
'to generate a new pair (every device then has to turn push on again)';
/** The admin-facing explanation for each unusable state. Never contains key material. */
const UNUSABLE_MESSAGES: Record<UnusableReason, string> = {
incomplete: `only one half of the stored VAPID key pair exists. To start over, ${DELETE_BOTH_ROWS}.`,
undecryptable:
'the stored VAPID private key cannot be decrypted with the current ENCRYPTION_KEY (for example after a ' +
'restore under a different key). Start the server with the ENCRYPTION_KEY it was encrypted with to bring ' +
`push back as it was, or ${DELETE_BOTH_ROWS}.`,
mismatched: `the stored VAPID private key does not belong to the stored public key. To start over, ${DELETE_BOTH_ROWS}.`,
};
/** Re-encoded without padding, so the key the browser gets and the one rows are compared with is one string. */
function canonical(value: string): string {
return decodeBase64Url(value)?.toString('base64url') ?? value;
}
/** An https URL on a host other than localhost: what APP_URL has to be to serve as the VAPID contact. */
function isPublicHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'https:' && url.hostname !== 'localhost';
} catch {
return false;
}
}
/**
* The server's VAPID identity: the key pair every subscription is bound to, and
* the contact (`sub`) sent with each request.
*
* VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY win when both are set and belong
* together. Otherwise the pair lives in app_settings, created once and then
* kept: it travels with the database into every backup, where a key file in
* data/ would not, and a restore that minted a new pair would silently cut off
* every device.
*
* When the pair push should sign with cannot be used, push is off: it never
* falls back on another pair. A pair in the environment that does not fit
* together (or only one half of it) is logged rather than refusing to boot,
* and push stays off until the variables are fixed, even while a usable pair
* is stored: the devices subscribed with the environment key, so the first
* send under any other pair would drop every one of them. A stored pair that
* is there but cannot be used (a private key the current ENCRYPTION_KEY cannot
* open, halves that do not match, one half missing) is never replaced either.
* Either way the log says why, every device stays subscribed, and fixing the
* variables or starting with the right ENCRYPTION_KEY again brings everything
* back unchanged. A new pair is only ever generated while neither row exists
* and neither VAPID_* variable is set.
*/
@Injectable()
export class VapidKeysService implements OnApplicationBootstrap {
/** The last environment problem logged, so a bad pair is reported once and not on every send. */
private reportedEnvProblem: string | null = null;
/** The last stored-pair problem logged, for the same reason. */
private reportedStoredProblem: string | null = null;
/** The decrypted stored pair, keyed by the ciphertext it came from. */
private stored: { ciphertext: string; keys: VapidKeyPair } | null = null;
constructor(private readonly db: DatabaseService) {}
/**
* Make sure the pair exists before the first request. The public-key GET is
* the first thing a browser asks for, and a GET must not write; creating the
* pair here keeps that route a read. The lazy path in getKeys() covers
* instances built without the Nest lifecycle and a database restored under a
* running server.
*/
onApplicationBootstrap(): void {
this.succeeds(() => this.getKeys());
}
/**
* Whether there is a pair to sign with right now. Only reads, so the
* dispatcher and the preference matrix GET can ask on every call: while
* neither row exists the answer is yes and nothing is written, because the
* next send or subscribe creates the pair through getKeys(). Never throws;
* the reason for a no is in the log.
*/
isAvailable(): boolean {
return this.succeeds(() => this.checkUsable());
}
/**
* The pair to sign with, creating the stored one first while neither row
* exists. Throws PushUnavailableError while the pair it would use cannot be.
*/
getKeys(): ResolvedVapidKeys {
const fromEnv = this.envKeys();
if (fromEnv) return { ...fromEnv, source: 'env' };
return { ...this.usableStored(this.readOrCreateStored()), source: 'database' };
}
getPublicKey(): string {
return this.getKeys().publicKey;
}
/**
* The `sub` claim: VAPID_SUBJECT, else APP_URL when it is public https, else
* the project URL. Push services use it to reach whoever runs the sender, and
* Apple refuses a token without a valid one. Only APP_URL itself counts, not
* getAppUrl()'s stand-ins: the first ALLOWED_ORIGINS entry can be another
* site that is merely allowed to call this API, and http://localhost is no
* valid subject.
*/
getSubject(): string {
const env = readEnv();
const configured = env.push.vapidSubject;
if (configured && isVapidSubject(configured)) return configured;
const appUrl = env.app.appUrl?.trim();
return appUrl && isPublicHttpsUrl(appUrl) ? stripTrailingSlashes(appUrl) : FALLBACK_VAPID_SUBJECT;
}
/** getKeys() without the create: the same refusals, and a missing stored pair counts as usable. */
private checkUsable(): void {
if (this.envKeys()) return;
const stored = this.readStored();
if (stored.state !== 'absent') this.usableStored(stored);
}
private succeeds(attempt: () => unknown): boolean {
try {
attempt();
return true;
} catch (err) {
// An unusable pair has already been explained where it was found.
if (!(err instanceof PushUnavailableError)) {
logError(`Web Push: could not prepare the VAPID key pair: ${err instanceof Error ? err.message : err}`);
}
return false;
}
}
/**
* The pair from VAPID_*, or null while neither variable is set. A pair that
* is set but cannot be used turns push off rather than handing out the
* stored pair (or a new one) in its place: the devices subscribed with the
* environment key, and the first send under another pair would drop them
* all. Push comes back unchanged once the variables are fixed.
*/
private envKeys(): VapidKeyPair | null {
const env = this.readEnvPair();
if (env.state !== 'unusable') {
this.reportedEnvProblem = null;
return env.state === 'usable' ? env.keys : null;
}
if (this.reportedEnvProblem !== env.fingerprint) {
this.reportedEnvProblem = env.fingerprint;
logError(
`Web Push is off: ${env.problem}. Set both variables to the two halves of one pair to turn it back on. ` +
'Until then nothing is sent, every device stays subscribed, and no pair stored in the database is ' +
'used in its place.',
);
}
throw new PushUnavailableError();
}
/** What the VAPID_* variables hold. The fingerprint only tells one broken state from the next. */
private readEnvPair(): EnvPair {
const { vapidPublicKey, vapidPrivateKey } = readEnv().push;
if (!vapidPublicKey && !vapidPrivateKey) return { state: 'unset' };
if (!vapidPublicKey || !vapidPrivateKey) {
return {
state: 'unusable',
fingerprint: `half:${vapidPublicKey ?? ''}`,
problem: `${vapidPublicKey ? 'VAPID_PUBLIC_KEY' : 'VAPID_PRIVATE_KEY'} is set without its other half`,
};
}
if (!isVapidKeyPair(vapidPublicKey, vapidPrivateKey)) {
return {
state: 'unusable',
fingerprint: `pair:${vapidPublicKey}`,
problem: 'VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY',
};
}
return { state: 'usable', keys: { publicKey: canonical(vapidPublicKey), privateKey: canonical(vapidPrivateKey) } };
}
private setting(key: string): string | null {
return this.db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
}
private writeSetting(key: string, value: string): void {
this.db.run(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key,
value,
);
}
/** What app_settings holds right now. The fingerprint only tells one broken state from the next. */
private readStored(): StoredPair {
const publicKey = this.setting(VAPID_PUBLIC_KEY_SETTING);
const ciphertext = this.setting(VAPID_PRIVATE_KEY_SETTING);
if (!publicKey && !ciphertext) return { state: 'absent' };
const fingerprint = `${publicKey ?? ''}\n${ciphertext ?? ''}`;
if (!publicKey || !ciphertext) return { state: 'incomplete', fingerprint };
if (this.stored?.ciphertext === ciphertext && this.stored.keys.publicKey === publicKey) {
return { state: 'usable', keys: this.stored.keys };
}
const privateKey = decrypt_api_key(ciphertext);
if (!privateKey) return { state: 'undecryptable', fingerprint };
if (!isVapidKeyPair(publicKey, privateKey)) return { state: 'mismatched', fingerprint };
const keys = { publicKey, privateKey };
this.stored = { ciphertext, keys };
return { state: 'usable', keys };
}
/**
* The stored pair, created first only when neither row exists: checked again
* inside the transaction and written in it, both rows or neither, so the
* pair is created exactly once.
*/
private readOrCreateStored(): PresentPair {
const first = this.readStored();
if (first.state !== 'absent') return first;
return this.db.transaction((): PresentPair => {
const again = this.readStored();
if (again.state !== 'absent') return again;
return { state: 'usable', keys: this.generateAndStore() };
});
}
/** The keys of a stored pair, or PushUnavailableError (logged once per state) when it cannot be used. */
private usableStored(found: PresentPair): VapidKeyPair {
if (found.state === 'usable') {
this.reportedStoredProblem = null;
return found.keys;
}
this.reportStoredProblem(found.state, found.fingerprint);
throw new PushUnavailableError();
}
private reportStoredProblem(reason: UnusableReason, fingerprint: string): void {
const key = `${reason}:${fingerprint}`;
if (this.reportedStoredProblem === key) return;
this.reportedStoredProblem = key;
logError(`Web Push is off: ${UNUSABLE_MESSAGES[reason]}`);
}
private generateAndStore(): VapidKeyPair {
const keys = generateVapidKeyPair();
const ciphertext = encrypt_api_key(keys.privateKey);
this.writeSetting(VAPID_PUBLIC_KEY_SETTING, keys.publicKey);
this.writeSetting(VAPID_PRIVATE_KEY_SETTING, ciphertext);
this.stored = { ciphertext, keys };
logInfo('Web Push: generated the VAPID key pair for this instance.');
return keys;
}
}
@@ -0,0 +1,214 @@
import {
createCipheriv,
createECDH,
createPrivateKey,
ECDH,
generateKeyPairSync,
hkdfSync,
randomBytes,
type KeyObject,
} from 'node:crypto';
import jwt from 'jsonwebtoken';
import { decodeBase64Url } from '../../../app-config';
/**
* Web Push message encryption (RFC 8291, `aes128gcm` from RFC 8188) and the
* VAPID Authorization header (RFC 8292), on node:crypto and the jsonwebtoken
* the server already signs its sessions with.
*
* Pure: no database, no network, no DI. The `web-push` package does the same
* arithmetic, but it posts through its own https.request and so past the SSRF
* guard; the request is the part this domain has to own, and what is left of
* the package is this file.
*/
/** Record size announced in the header. One record carries the whole message. */
export const PUSH_RECORD_SIZE = 4096;
const P256_POINT_BYTES = 65;
const AUTH_SECRET_BYTES = 16;
const SALT_BYTES = 16;
const GCM_TAG_BYTES = 16;
/** salt (16) + rs (4) + idlen (1) + keyid (the 65-byte sender key). */
const HEADER_BYTES = SALT_BYTES + 4 + 1 + P256_POINT_BYTES;
const KEY_INFO_PREFIX = Buffer.from('WebPush: info\0', 'latin1');
const CEK_INFO = Buffer.from('Content-Encoding: aes128gcm\0', 'latin1');
const NONCE_INFO = Buffer.from('Content-Encoding: nonce\0', 'latin1');
/** Padding delimiter of the last (here: only) record, RFC 8188 section 2. */
const LAST_RECORD_DELIMITER = Buffer.from([0x02]);
/** Largest plaintext that still fits one record next to its delimiter and tag. */
export const MAX_PUSH_PLAINTEXT_BYTES = PUSH_RECORD_SIZE - HEADER_BYTES - 1 - GCM_TAG_BYTES;
/**
* How long a VAPID token is good for. RFC 8292 allows up to 24 hours and Apple
* refuses anything longer, so this stays well inside it.
*/
export const VAPID_TOKEN_TTL_SECONDS = 12 * 60 * 60;
/** A VAPID key pair in the form browsers and the Web Push tools use: base64url, raw. */
export interface VapidKeyPair {
/** Uncompressed P-256 point, 65 bytes. What the browser subscribes with. */
publicKey: string;
/** The 32-byte private scalar. */
privateKey: string;
}
/** The two secrets a browser hands over with its subscription. */
export interface PushEncryptionKeys {
/** The browser's P-256 public key, uncompressed. */
p256dh: Buffer;
/** The 16-byte authentication secret. */
auth: Buffer;
}
/**
* Fixed inputs for the RFC 8291 test vector. Leave both unset for real
* messages: every message needs a fresh salt and a fresh sender key, or the
* same key and nonce would encrypt two plaintexts.
*/
export interface PushEncryptionOptions {
salt?: Buffer;
senderPrivateKey?: Buffer;
}
function hkdf(ikm: Buffer, salt: Buffer, info: Buffer, length: number): Buffer {
return Buffer.from(hkdfSync('sha256', ikm, salt, info, length));
}
/** True for a point that is actually on P-256, not just 65 bytes that start with 0x04. */
export function isP256Point(bytes: Buffer): boolean {
if (bytes.length !== P256_POINT_BYTES || bytes[0] !== 0x04) return false;
try {
ECDH.convertKey(bytes, 'prime256v1');
return true;
} catch {
return false;
}
}
/** A fresh P-256 pair. Exported as JWK so both halves come out at full length. */
export function generateVapidKeyPair(): VapidKeyPair {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const { x, y, d } = privateKey.export({ format: 'jwk' });
if (!x || !y || !d) throw new Error('P-256 key export did not produce x, y and d');
return {
publicKey: Buffer.concat([Buffer.from([0x04]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')]).toString(
'base64url',
),
privateKey: d,
};
}
/** The public key a private scalar belongs to, or null when the scalar is not a usable P-256 key. */
export function derivePublicKey(privateKey: string): Buffer | null {
const scalar = decodeBase64Url(privateKey);
if (scalar?.length !== 32) return null;
try {
const ecdh = createECDH('prime256v1');
ecdh.setPrivateKey(scalar);
return ecdh.getPublicKey();
} catch {
return null;
}
}
/** Whether the two halves belong together. Compared as bytes, so padding and the like do not matter. */
export function isVapidKeyPair(publicKey: string, privateKey: string): boolean {
const expected = derivePublicKey(privateKey);
const given = decodeBase64Url(publicKey);
return !!expected && !!given && given.equals(expected);
}
/**
* Encrypt one push message for one browser (RFC 8291 section 3.4): ECDH with
* the browser key, HKDF over the shared secret and the auth secret, then a
* single AES-128-GCM record behind the aes128gcm header, whose key id is the
* sender's public key.
*/
export function encryptPushMessage(
plaintext: Buffer,
keys: PushEncryptionKeys,
options: PushEncryptionOptions = {},
): Buffer {
if (!isP256Point(keys.p256dh)) throw new Error('p256dh is not an uncompressed P-256 public key');
if (keys.auth.length !== AUTH_SECRET_BYTES) throw new Error('auth secret must be 16 bytes');
if (plaintext.length > MAX_PUSH_PLAINTEXT_BYTES) {
throw new Error(`push payload of ${plaintext.length} bytes does not fit one record`);
}
const salt = options.salt ?? randomBytes(SALT_BYTES);
if (salt.length !== SALT_BYTES) throw new Error('salt must be 16 bytes');
const sender = createECDH('prime256v1');
if (options.senderPrivateKey) sender.setPrivateKey(options.senderPrivateKey);
else sender.generateKeys();
const senderPublicKey = sender.getPublicKey();
const ecdhSecret = sender.computeSecret(keys.p256dh);
const keyInfo = Buffer.concat([KEY_INFO_PREFIX, keys.p256dh, senderPublicKey]);
const ikm = hkdf(ecdhSecret, keys.auth, keyInfo, 32);
const contentKey = hkdf(ikm, salt, CEK_INFO, 16);
const nonce = hkdf(ikm, salt, NONCE_INFO, 12);
const cipher = createCipheriv('aes-128-gcm', contentKey, nonce);
const record = Buffer.concat([
cipher.update(plaintext),
cipher.update(LAST_RECORD_DELIMITER),
cipher.final(),
cipher.getAuthTag(),
]);
const header = Buffer.alloc(SALT_BYTES + 4 + 1);
salt.copy(header, 0);
header.writeUInt32BE(PUSH_RECORD_SIZE, SALT_BYTES);
header.writeUInt8(senderPublicKey.length, SALT_BYTES + 4);
return Buffer.concat([header, senderPublicKey, record]);
}
/** The signing key, and the public key re-encoded the way the `k=` parameter wants it (no padding). */
function vapidSigningKey(keys: VapidKeyPair): { signingKey: KeyObject; publicKey: string } {
const point = decodeBase64Url(keys.publicKey);
const scalar = decodeBase64Url(keys.privateKey);
if (point?.length !== P256_POINT_BYTES || scalar?.length !== 32) throw new Error('VAPID key pair is malformed');
const signingKey = createPrivateKey({
key: {
kty: 'EC',
crv: 'P-256',
x: point.subarray(1, 33).toString('base64url'),
y: point.subarray(33).toString('base64url'),
d: scalar.toString('base64url'),
},
format: 'jwk',
});
return { signingKey, publicKey: point.toString('base64url') };
}
/**
* Returns a function that builds the Authorization header for an endpoint
* (RFC 8292): `vapid t=<ES256 JWT>, k=<public key>`, the token's audience being
* the endpoint's origin. Tokens are reused per origin for the life of the
* signer, which is one delivery, so a user with three Chrome devices signs once.
*/
export function createVapidSigner(keys: VapidKeyPair, subject: string, now: () => number = Date.now) {
const { signingKey, publicKey } = vapidSigningKey(keys);
const byOrigin = new Map<string, string>();
return (endpoint: string): string => {
const audience = new URL(endpoint).origin;
let header = byOrigin.get(audience);
if (!header) {
const token = jwt.sign(
{ aud: audience, exp: Math.floor(now() / 1000) + VAPID_TOKEN_TTL_SECONDS, sub: subject },
signingKey,
// The three claims RFC 8292 names and nothing else: an extra iat buys
// nothing and is one more thing a strict push service could object to.
{ algorithm: 'ES256', noTimestamp: true },
);
header = `vapid t=${token}, k=${publicKey}`;
byOrigin.set(audience, header);
}
return header;
};
}
export type VapidSigner = ReturnType<typeof createVapidSigner>;
@@ -0,0 +1,292 @@
import { Injectable } from '@nestjs/common';
import type { ChannelTestResult, PushPayload } from '@trek/shared';
import { decodeBase64Url } from '../../../app-config';
import { discardBody, readCappedText } from '../../../utils/cappedFetch';
import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
import { logError, logInfo } from '../../audit/audit-log.logger';
import type { ChannelMessage } from '../notification-events';
import { isPushServiceEndpoint } from '../push/push-subscription.helpers';
import { PushSubscriptionsService, type PushSubscriptionRow } from '../push/push-subscriptions.service';
import { PUSH_UNAVAILABLE_ERROR, VapidKeysService } from '../push/vapid-keys.service';
import { createVapidSigner, encryptPushMessage, type VapidSigner } from '../push/web-push-crypto';
/**
* Ceiling for the JSON the service worker receives. A single aes128gcm record
* can carry just under 4 KB and some push services refuse anything larger, so
* the payload stays well clear of that and the body gives way first.
*/
export const MAX_PUSH_PAYLOAD_BYTES = 3000;
const TITLE_MAX_CHARS = 120;
/** A navigate target is an app path like /trips/12; anything longer is not one. */
const URL_MAX_LENGTH = 256;
const ELLIPSIS = '…';
const PUSH_TIMEOUT_MS = 10_000;
/** A day: a reminder that reaches a phone after a longer flight is still worth showing, a week-old one is not. */
const PUSH_TTL_SECONDS = 24 * 60 * 60;
const ERROR_BODY_MAX_BYTES = 1024;
/**
* How many failed sends in a row (with no success between them) a device gets
* before a 403 removes it. A 403 is not proof the subscription is dead: Apple
* also answers it for a token it rejects (BadJwtToken, a bad subject, clock
* skew), which is the server's problem and must not cost the user their device
* on the first try.
*/
export const FORBIDDEN_FAILURES_BEFORE_REMOVAL = 3;
/**
* The one event whose notifications replace each other: a burst of chat
* messages in one trip shows as one notification that keeps updating. Every
* other event stands on its own, so two todos, two bookings or two plugin
* notices never hide one another.
*/
const COLLAPSING_EVENT = 'collab_message';
type Urgency = 'normal' | 'high';
/** The events that are about a date, where arriving late defeats the point (RFC 8030 section 5.3). */
const HIGH_URGENCY_EVENTS: ReadonlySet<string> = new Set(['trip_reminder', 'todo_due']);
/** No tag either: two presses of Send test are two notifications, like two of anything but chat. */
const TEST_PAYLOAD: PushPayload = {
title: 'Test Notification',
body: 'This is a test notification from TREK. If you received this, push notifications are working on this device.',
url: '/settings?tab=notifications',
};
/**
* The target the worker may open: a path on this origin only. Absolute URLs
* and protocol-relative ones (`//host`, `/\host`) would take the click to
* another site, so they are dropped rather than passed on.
*/
export function samePagePath(target: string | undefined): string | undefined {
if (!target || target.length > URL_MAX_LENGTH) return undefined;
if (!/^\/(?![/\\])[\x21-\x7e]*$/.test(target)) return undefined;
return target;
}
function truncateChars(value: string, max: number): string {
const chars = Array.from(value);
return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}${ELLIPSIS}`;
}
function payloadBytes(payload: PushPayload): number {
return Buffer.byteLength(JSON.stringify(payload), 'utf8');
}
/**
* The rendered notification as the service worker gets it. Title and body are
* already in the recipient's language; the worker has no translations. Only
* chat messages carry a tag, `collab_message:<trip path>`, so a burst of them in
* one trip shows as one notification that keeps updating instead of a pile of
* them. Everything else has no tag and never replaces an earlier notification.
*
* The body is cut, at a character boundary and with an ellipsis, until the
* JSON fits MAX_PUSH_PAYLOAD_BYTES. The title and the path are capped first, so
* whatever they are, the body always keeps room of its own.
*/
export function buildPushPayload(
msg: Pick<ChannelMessage, 'event' | 'title' | 'body' | 'navigateTarget'>,
): PushPayload {
const url = samePagePath(msg.navigateTarget);
const base: PushPayload = {
title: truncateChars(msg.title, TITLE_MAX_CHARS),
body: '',
...(url ? { url } : {}),
...(url && msg.event === COLLAPSING_EVENT ? { tag: `${COLLAPSING_EVENT}:${url}` } : {}),
};
const full = { ...base, body: msg.body };
if (payloadBytes(full) <= MAX_PUSH_PAYLOAD_BYTES) return full;
const chars = Array.from(msg.body);
let fits = 0;
let tooLong = chars.length;
// Largest prefix that still fits with the ellipsis behind it.
while (tooLong - fits > 1) {
const mid = Math.floor((fits + tooLong) / 2);
if (payloadBytes({ ...base, body: `${chars.slice(0, mid).join('')}${ELLIPSIS}` }) <= MAX_PUSH_PAYLOAD_BYTES)
fits = mid;
else tooLong = mid;
}
return { ...base, body: `${chars.slice(0, fits).join('')}${ELLIPSIS}` };
}
function hostOf(endpoint: string): string {
try {
return new URL(endpoint).hostname;
} catch {
return 'invalid-endpoint';
}
}
/**
* Web Push delivery: encrypt the payload for each of a user's browsers and
* POST it to that browser's push service, through the SSRF guard.
*
* Mirrors the ntfy and webhook transports: every failure is logged and turned
* into `false`, nothing throws into the dispatcher. It also tends the table as
* it goes, because the push services are the only ones who know a browser is
* gone: 404 and 410 mean the subscription expired or was revoked, and the row
* is deleted at once. A 403 is counted like any other failure and only removes
* the row once it has failed FORBIDDEN_FAILURES_BEFORE_REMOVAL times in a row,
* because the same status also means a rejected token. While the server's key
* pair is unusable (VapidKeysService logs why) nothing is sent, every row
* stays and the test send says so. Endpoints are capability URLs, so the log
* names the push service host, never the URL.
*/
@Injectable()
export class WebPushService {
constructor(
private readonly keys: VapidKeysService,
private readonly subscriptions: PushSubscriptionsService,
) {}
hasDevices(userId: number): boolean {
return this.subscriptions.hasAny(userId);
}
/**
* False while the server has no key pair it can sign with. The channel
* reports it as its instance readiness, so the dispatcher skips push for
* everyone (every device stays subscribed) and the user's settings drop the
* Push card and column instead of offering a switch that could only fail.
*/
isAvailable(): boolean {
return this.keys.isAvailable();
}
/** True when at least one of the user's browsers accepted the message. */
sendToUser(userId: number, msg: ChannelMessage): Promise<boolean> {
const urgency: Urgency = HIGH_URGENCY_EVENTS.has(msg.event) ? 'high' : 'normal';
return this.deliver(userId, buildPushPayload(msg), urgency, msg.event);
}
/** The Send test button: a short fixed message to every browser the user turned push on in. */
async sendTest(userId: number): Promise<ChannelTestResult> {
if (!this.isAvailable()) return { success: false, error: PUSH_UNAVAILABLE_ERROR };
const sent = await this.deliver(userId, TEST_PAYLOAD, 'normal', 'test');
return sent ? { success: true } : { success: false, error: 'Failed to send push notification' };
}
private async deliver(userId: number, payload: PushPayload, urgency: Urgency, event: string): Promise<boolean> {
try {
const rows = this.subscriptions.listForUser(userId);
if (rows.length === 0) return false;
const keys = this.keys.getKeys();
const sign = createVapidSigner(keys, this.keys.getSubject());
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
const results = await Promise.all(
rows.map((row) => this.deliverTo(row, plaintext, keys.publicKey, sign, urgency, event)),
);
return results.includes(true);
} catch (err) {
logError(`Web Push failed event=${event} user=${userId}: ${err instanceof Error ? err.message : err}`);
return false;
}
}
private async deliverTo(
row: PushSubscriptionRow,
plaintext: Buffer,
publicKey: string,
sign: VapidSigner,
urgency: Urgency,
event: string,
): Promise<boolean> {
const host = hostOf(row.endpoint);
if (row.vapid_public_key !== publicKey) {
// Subscribed against a key this server no longer signs with, so the push
// service would refuse every message. Until the browser subscribes again
// with the current key there is nothing to send to.
this.subscriptions.deleteById(row.id);
logInfo(`Web Push dropped a device subscribed with a previous server key user=${row.user_id} host=${host}`);
return false;
}
if (!isPushServiceEndpoint(row.endpoint)) {
this.subscriptions.deleteById(row.id);
logError(`Web Push dropped a device whose endpoint is not a known push service user=${row.user_id} host=${host}`);
return false;
}
try {
const p256dh = decodeBase64Url(row.p256dh);
const auth = decodeBase64Url(row.auth);
if (!p256dh || !auth) throw new Error('stored subscription keys are not base64url');
const body = encryptPushMessage(plaintext, { p256dh, auth });
// maxRedirects 0: a push service answers, it does not redirect, and a
// redirect would carry the VAPID token somewhere it was not minted for.
// bypassInternalIpAllowed keeps private addresses closed even on an
// install that opened ALLOW_INTERNAL_NETWORK for its own services.
const res = await safeFetchFollow(
row.endpoint,
{
method: 'POST',
headers: {
TTL: String(PUSH_TTL_SECONDS),
Urgency: urgency,
'Content-Encoding': 'aes128gcm',
'Content-Type': 'application/octet-stream',
Authorization: sign(row.endpoint),
},
body: new Uint8Array(body),
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
},
{ maxRedirects: 0, bypassInternalIpAllowed: true },
);
return await this.settle(row, res, host, event);
} catch (err) {
this.subscriptions.recordFailure(row.id);
if (err instanceof SsrfBlockedError) {
logError(
`Web Push blocked by SSRF guard event=${event} user=${row.user_id} host=${host} reason=${err.message}`,
);
} else {
logError(
`Web Push failed event=${event} user=${row.user_id} host=${host}: ${err instanceof Error ? err.message : err}`,
);
}
return false;
}
}
private async settle(row: PushSubscriptionRow, res: Response, host: string, event: string): Promise<boolean> {
if (res.ok) {
discardBody(res);
this.subscriptions.recordSuccess(row.id);
logInfo(`Web Push sent event=${event} user=${row.user_id} host=${host}`);
return true;
}
const { text } = await readCappedText(res, ERROR_BODY_MAX_BYTES).catch(() => ({ text: '' }));
if (res.status === 404 || res.status === 410) {
this.subscriptions.deleteById(row.id);
logInfo(`Web Push removed an expired device (HTTP ${res.status}) user=${row.user_id} host=${host}`);
return false;
}
if (res.status === 403) {
// Either the subscription was made for a different key, or the push
// service rejects the token itself (Apple's BadJwtToken), which is a
// configuration problem and not the device's. Only a run of refusals
// with no success between them removes the row; the reason is logged
// every time so the second case can be told apart.
const failures = this.subscriptions.recordFailure(row.id);
if (failures >= FORBIDDEN_FAILURES_BEFORE_REMOVAL) {
this.subscriptions.deleteById(row.id);
logError(
`Web Push removed a device after ${failures} failed sends in a row ending in HTTP 403 ` +
`user=${row.user_id} host=${host}: ${text}`,
);
} else {
logError(
`Web Push HTTP 403 event=${event} user=${row.user_id} host=${host} ` +
`(failure ${failures} of ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} before removal): ${text}`,
);
}
return false;
}
this.subscriptions.recordFailure(row.id);
logError(`Web Push HTTP ${res.status} event=${event} user=${row.user_id} host=${host}: ${text}`);
return false;
}
}