Files
TREK/shared/src/notification/notification.schema.ts
T
Maurice 650451bdf7 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.
2026-09-24 23:17:03 +00:00

130 lines
5.3 KiB
TypeScript

import { z } from 'zod';
/**
* Notification API contract — single source of truth for the /api/notifications
* endpoints (channel-preference matrix, channel test pings, and in-app
* notifications).
*
* The notification row and the preferences matrix are wide, DB- and
* registry-derived shapes; the response schemas keep them as open records and
* pin the stable envelope fields, while the request schemas and the bespoke
* 400/403/404 controller messages capture the parts the client depends on.
* Real-time delivery happens over the existing WebSocket path inside the
* services and is untouched by this contract.
*/
/** Channel preference matrix update: { eventType: { channel: enabled } }. */
export const preferencesUpdateRequestSchema = z.record(z.string(), z.record(z.string(), z.boolean()));
export type PreferencesUpdateRequest = z.infer<typeof preferencesUpdateRequestSchema>;
export const testSmtpRequestSchema = z.object({ email: z.string().optional() });
export const testWebhookRequestSchema = z.object({
url: z.string().optional(),
});
// server/token are nullable: the client deliberately sends null to mean
// "fall back to the saved value" (a stored token is only masked in the
// placeholder — sending null keeps the saved one).
export const testNtfyRequestSchema = z.object({
topic: z.string().optional(),
server: z.string().nullable().optional(),
token: z.string().nullable().optional(),
});
/** Result of a channel test ping. */
export const channelTestResultSchema = z.object({
success: z.boolean(),
error: z.string().optional(),
});
export type ChannelTestResult = z.infer<typeof channelTestResultSchema>;
/** Respond to a boolean (yes/no) notification. */
export const notificationRespondRequestSchema = z.object({
response: z.enum(['positive', 'negative']),
});
export type NotificationRespondRequest = z.infer<typeof notificationRespondRequestSchema>;
/** A single in-app notification row (DB-shaped; kept open). */
export const notificationRowSchema = z.record(z.string(), z.unknown());
export const inAppListResultSchema = z.object({
notifications: z.array(notificationRowSchema),
total: z.number(),
unread_count: z.number(),
});
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>;