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
@@ -4,6 +4,14 @@ import {
channelTestResultSchema,
inAppListResultSchema,
testNtfyRequestSchema,
pushSubscriptionSchema,
pushSubscribeRequestSchema,
pushUnsubscribeRequestSchema,
pushPublicKeyResultSchema,
pushSubscribeResultSchema,
pushUnsubscribeResultSchema,
pushPayloadSchema,
MAX_PUSH_ENDPOINT_LENGTH,
} from './notification.schema';
import { describe, it, expect } from 'vitest';
@@ -56,3 +64,140 @@ describe('inAppListResultSchema', () => {
).toBe(true);
});
});
// ── Web Push ────────────────────────────────────────────────────────────────
const SUBSCRIPTION = {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
expirationTime: null,
keys: { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA', auth: 'tBHItJI5svbpez7KI4CCXg' },
};
describe('pushSubscriptionSchema', () => {
it('accepts what PushSubscription.toJSON() hands over, with or without an expiry', () => {
expect(pushSubscriptionSchema.safeParse(SUBSCRIPTION).success).toBe(true);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, expirationTime: 1767225600000 }).success).toBe(true);
expect(pushSubscriptionSchema.safeParse({ endpoint: SUBSCRIPTION.endpoint, keys: SUBSCRIPTION.keys }).success).toBe(
true,
);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, expirationTime: 'tomorrow' }).success).toBe(false);
});
it('refuses a subscription without keys, or with one of them missing or empty', () => {
expect(pushSubscriptionSchema.safeParse({ endpoint: SUBSCRIPTION.endpoint, expirationTime: null }).success).toBe(
false,
);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: 'x' } }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { auth: 'x' } }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: '', auth: 'x' } }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: '' }).success).toBe(false);
});
it('refuses an endpoint that is not a string, and leaves checking the URL itself to the server', () => {
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: 42 }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: null }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ keys: SUBSCRIPTION.keys }).success).toBe(false);
// Only the shape is the contract's: a string that is not an https URL on a push
// service passes here, so the server can refuse it with its own 400 body.
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: 'not a url' }).success).toBe(true);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: 'http://example.com/push' }).success).toBe(
true,
);
});
it('refuses keys that are not strings', () => {
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: 'p256dh,auth' }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: 1, auth: 'x' } }).success).toBe(false);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: 'x', auth: null } }).success).toBe(
false,
);
});
it('caps the endpoint at MAX_PUSH_ENDPOINT_LENGTH characters and the keys at 200 and 100', () => {
const at = (n: number) => 'a'.repeat(n);
expect(MAX_PUSH_ENDPOINT_LENGTH).toBe(2048);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: at(MAX_PUSH_ENDPOINT_LENGTH) }).success).toBe(
true,
);
expect(
pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: at(MAX_PUSH_ENDPOINT_LENGTH + 1) }).success,
).toBe(false);
const keys = SUBSCRIPTION.keys;
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, p256dh: at(200) } }).success).toBe(
true,
);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, p256dh: at(201) } }).success).toBe(
false,
);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, auth: at(100) } }).success).toBe(true);
expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, auth: at(101) } }).success).toBe(false);
});
});
describe('pushSubscribeRequestSchema', () => {
it('wants the subscription under `subscription`', () => {
expect(pushSubscribeRequestSchema.safeParse({ subscription: SUBSCRIPTION }).success).toBe(true);
expect(pushSubscribeRequestSchema.safeParse(SUBSCRIPTION).success).toBe(false);
expect(pushSubscribeRequestSchema.safeParse({}).success).toBe(false);
});
it('checks the subscription inside it like a bare one', () => {
expect(pushSubscribeRequestSchema.safeParse({ subscription: { ...SUBSCRIPTION, keys: {} } }).success).toBe(false);
expect(
pushSubscribeRequestSchema.safeParse({
subscription: { ...SUBSCRIPTION, endpoint: 'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH + 1) },
}).success,
).toBe(false);
});
});
describe('pushUnsubscribeRequestSchema', () => {
it('takes one endpoint, non-empty and at most MAX_PUSH_ENDPOINT_LENGTH characters, like a subscription', () => {
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: SUBSCRIPTION.endpoint }).success).toBe(true);
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: '' }).success).toBe(false);
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: 'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH) }).success).toBe(
true,
);
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: 'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH + 1) }).success).toBe(
false,
);
expect(pushUnsubscribeRequestSchema.safeParse({}).success).toBe(false);
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: 42 }).success).toBe(false);
});
});
describe('pushPublicKeyResultSchema', () => {
it('is the key as a string', () => {
expect(pushPublicKeyResultSchema.safeParse({ publicKey: 'BP4z9KsN6nGRTbVY' }).success).toBe(true);
expect(pushPublicKeyResultSchema.safeParse({ publicKey: null }).success).toBe(false);
expect(pushPublicKeyResultSchema.safeParse({}).success).toBe(false);
});
});
describe('pushSubscribeResultSchema', () => {
it('is success true with the device count', () => {
expect(pushSubscribeResultSchema.safeParse({ success: true, devices: 2 }).success).toBe(true);
expect(pushSubscribeResultSchema.safeParse({ success: false, devices: 2 }).success).toBe(false);
expect(pushSubscribeResultSchema.safeParse({ success: true }).success).toBe(false);
});
});
describe('pushUnsubscribeResultSchema', () => {
it('is success true, nothing else', () => {
expect(pushUnsubscribeResultSchema.safeParse({ success: true }).success).toBe(true);
expect(pushUnsubscribeResultSchema.safeParse({ success: false }).success).toBe(false);
expect(pushUnsubscribeResultSchema.safeParse({}).success).toBe(false);
});
});
describe('pushPayloadSchema', () => {
it('needs a title and a body; url and tag are optional', () => {
expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B' }).success).toBe(true);
expect(
pushPayloadSchema.safeParse({ title: 'T', body: 'B', url: '/trips/1', tag: 'collab_message:/trips/1' }).success,
).toBe(true);
expect(pushPayloadSchema.safeParse({ title: 'T' }).success).toBe(false);
expect(pushPayloadSchema.safeParse({ body: 'B' }).success).toBe(false);
expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B', tag: 5 }).success).toBe(false);
});
});
@@ -55,3 +55,75 @@ export type InAppListResult = z.infer<typeof inAppListResultSchema>;
export const unreadCountResultSchema = z.object({ count: z.number() });
export type UnreadCountResult = z.infer<typeof unreadCountResultSchema>;
// ── Web Push (#894) ──────────────────────────────────────────────────────────
/** Id of the built-in Web Push channel in the preference matrix and the admin CSV. */
export const WEB_PUSH_CHANNEL_ID = 'push';
/**
* The longest push endpoint the server takes, well above what the push
* services hand out. One value for the contract and the server's own endpoint
* check, so a device that can register can always unsubscribe and be sent to.
*/
export const MAX_PUSH_ENDPOINT_LENGTH = 2048;
/**
* The browser's PushSubscription as PushSubscription.toJSON() hands it over.
* Only the shape is checked here; the service refuses an endpoint that is not
* https on a known push service and keys that do not decode to the RFC 8291
* sizes, with messages of its own.
*/
export const pushSubscriptionSchema = z.object({
endpoint: z.string().min(1).max(MAX_PUSH_ENDPOINT_LENGTH),
expirationTime: z.number().nullable().optional(),
keys: z.object({
p256dh: z.string().min(1).max(200),
auth: z.string().min(1).max(100),
}),
});
export type PushSubscriptionInput = z.infer<typeof pushSubscriptionSchema>;
/** Register this device for push; repeating it for the same endpoint only refreshes the row. */
export const pushSubscribeRequestSchema = z.object({
subscription: pushSubscriptionSchema,
});
export type PushSubscribeRequest = z.infer<typeof pushSubscribeRequestSchema>;
/** Forget one device (logout, or the user switching push off there). */
export const pushUnsubscribeRequestSchema = z.object({
endpoint: z.string().min(1).max(MAX_PUSH_ENDPOINT_LENGTH),
});
export type PushUnsubscribeRequest = z.infer<typeof pushUnsubscribeRequestSchema>;
/** The VAPID public key a browser subscribes with (base64url, uncompressed P-256 point). */
export const pushPublicKeyResultSchema = z.object({
publicKey: z.string(),
});
export type PushPublicKeyResult = z.infer<typeof pushPublicKeyResultSchema>;
export const pushSubscribeResultSchema = z.object({
success: z.literal(true),
/** How many devices this account now receives push on. */
devices: z.number(),
});
export type PushSubscribeResult = z.infer<typeof pushSubscribeResultSchema>;
/** Forgetting a device always succeeds, also for an endpoint the server no longer knows. */
export const pushUnsubscribeResultSchema = z.object({
success: z.literal(true),
});
export type PushUnsubscribeResult = z.infer<typeof pushUnsubscribeResultSchema>;
/**
* What the service worker receives. Title and body are rendered on the server
* in the recipient's language, because the worker has no translations; `url` is
* a same-origin path the click opens.
*/
export const pushPayloadSchema = z.object({
title: z.string(),
body: z.string(),
url: z.string().optional(),
tag: z.string().optional(),
});
export type PushPayload = z.infer<typeof pushPayloadSchema>;