mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user