@12-apps/notifications 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/ADOPTING.md +316 -0
  2. package/README.md +153 -0
  3. package/package.json +94 -0
  4. package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
  5. package/prisma/notifications.prisma +141 -0
  6. package/scripts/sync-notifications-schema.mjs +60 -0
  7. package/src/errors.ts +21 -0
  8. package/src/generators.ts +44 -0
  9. package/src/hono/index.ts +121 -0
  10. package/src/index.ts +73 -0
  11. package/src/messages.ts +156 -0
  12. package/src/phone.ts +57 -0
  13. package/src/preferences-core.ts +89 -0
  14. package/src/react/api.ts +111 -0
  15. package/src/react/bell-button.tsx +78 -0
  16. package/src/react/bell-icon.tsx +33 -0
  17. package/src/react/create-web-notifications.tsx +127 -0
  18. package/src/react/hooks.ts +74 -0
  19. package/src/react/inbox-state.ts +216 -0
  20. package/src/react/index.ts +61 -0
  21. package/src/react/panel.tsx +181 -0
  22. package/src/react/preferences-screen.tsx +242 -0
  23. package/src/react/relative-time.ts +18 -0
  24. package/src/react/row.tsx +98 -0
  25. package/src/react/transport.ts +72 -0
  26. package/src/react/web-push-client.ts +113 -0
  27. package/src/react/web-push-setup.tsx +167 -0
  28. package/src/server/by-permission.ts +255 -0
  29. package/src/server/context.ts +269 -0
  30. package/src/server/create-api-notifications.ts +215 -0
  31. package/src/server/db.ts +252 -0
  32. package/src/server/dispatch.ts +298 -0
  33. package/src/server/inbox.ts +155 -0
  34. package/src/server/index.ts +115 -0
  35. package/src/server/preferences.ts +103 -0
  36. package/src/server/push-subscriptions.ts +121 -0
  37. package/src/server/router.ts +275 -0
  38. package/src/server/routes.ts +218 -0
  39. package/src/server/transports/drivers.ts +148 -0
  40. package/src/server/transports/email.ts +141 -0
  41. package/src/server/transports/registry.ts +106 -0
  42. package/src/server/transports/sms.ts +120 -0
  43. package/src/server/transports/web-push.ts +168 -0
  44. package/src/server/transports/whatsapp.ts +183 -0
  45. package/src/types.ts +158 -0
  46. package/src/web-push/index.ts +70 -0
  47. package/src/wire.ts +62 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The per-BROWSER Web Push enable step, which sits above the preference matrix
3
+ * because a preference alone cannot reach a device that never subscribed.
4
+ */
5
+ import { useEffect, useState, type JSX } from 'react';
6
+
7
+ import { Button } from '@12-apps/ui/form/Button';
8
+ import { Box } from '@12-apps/ui/mui/Box';
9
+ import { Text } from '@12-apps/ui/typography/Text';
10
+
11
+ import type { NotificationMessages } from '../messages';
12
+
13
+ import type { NotificationsApiClient } from './api';
14
+ import { enableWebPush, getExistingPushSubscription } from './web-push-client';
15
+
16
+ /** The panel's copy for a host whose platform blocks browser-level push. */
17
+ export interface WebPushPlatformHint {
18
+ title: string;
19
+ body: string;
20
+ }
21
+
22
+ export interface WebPushSetupConfig {
23
+ /**
24
+ * The host's service-worker path. Path-routed SPAs each control their own
25
+ * scope, and the file itself is the host's.
26
+ */
27
+ swPath?: string;
28
+ /**
29
+ * Whether THIS platform must be installed to the home screen before a
30
+ * subscription can exist at all.
31
+ *
32
+ * iOS is the case: Safari has no browser-level Web Push, so "Ativar" there
33
+ * asks no permission, creates no subscription, and fails with nothing a user
34
+ * could act on. The check is a config seam rather than a dependency because
35
+ * "is this an installable iOS browser" is a question a host's PWA layer
36
+ * already answers (future-pay passes
37
+ * `() => isIosInstallable() && !isStandalone()` from `@12-apps/pwa`).
38
+ */
39
+ needsInstallFirst?: () => boolean;
40
+ /** What to say instead of the button when the check above is true. */
41
+ installHint?: WebPushPlatformHint;
42
+ }
43
+
44
+ const cardSx = {
45
+ p: 2,
46
+ border: '1px solid',
47
+ borderColor: 'divider',
48
+ borderRadius: 2,
49
+ } as const;
50
+
51
+ function InstallFirstHint({ hint }: { hint: WebPushPlatformHint }): JSX.Element {
52
+ return (
53
+ <Box sx={cardSx} data-testid="web-push-install-hint">
54
+ <Text variant="body" size="sm" weight="semibold" as="p">
55
+ {hint.title}
56
+ </Text>
57
+ <Text variant="caption" size="xs" color="secondary" as="p">
58
+ {hint.body}
59
+ </Text>
60
+ </Box>
61
+ );
62
+ }
63
+
64
+ type SetupState = 'idle' | 'checking' | 'on' | 'busy' | 'failed' | 'denied';
65
+
66
+ /**
67
+ * "Is this browser receiving alerts?" — asked of the browser AND of the server.
68
+ *
69
+ * The browser alone is not enough, and answering from it alone is how a real
70
+ * person is silenced while being told they are fine. Two users share a counter
71
+ * PC: Ana enables push, then Otávio signs in on the same browser and enables it
72
+ * too. `PushManager` hands out the SAME endpoint for the same browser profile,
73
+ * so the POST re-owns the row and Ana's is gone. Ana signs back in, her browser
74
+ * still holds the subscription object, and a browser-only check renders "Este
75
+ * navegador está recebendo alertas." with no button to fix it — forever. The
76
+ * 404/410 prune reaches the same state from the other direction.
77
+ *
78
+ * So the server is asked whether it still has THIS endpoint under THIS user. A
79
+ * `false` (or an unreachable server) shows *Ativar* again, and one click
80
+ * re-registers the row — which is the same request the happy path makes, so the
81
+ * recovery costs nothing to maintain.
82
+ */
83
+ async function resolveState(api: NotificationsApiClient): Promise<SetupState> {
84
+ const subscription = await getExistingPushSubscription();
85
+ if (!subscription) return 'idle';
86
+ const registration = await api
87
+ .getPushRegistration({ endpoint: subscription.endpoint })
88
+ .catch(() => null);
89
+ return registration?.registered ? 'on' : 'idle';
90
+ }
91
+
92
+ function statusText(state: SetupState, messages: NotificationMessages): string {
93
+ if (state === 'on') return messages.devicePushOn;
94
+ if (state === 'denied') return messages.devicePushDenied;
95
+ if (state === 'failed') return messages.devicePushFailed;
96
+ return messages.devicePushIdle;
97
+ }
98
+
99
+ export function WebPushDeviceSetup({
100
+ available,
101
+ api,
102
+ messages,
103
+ config,
104
+ }: {
105
+ available: boolean;
106
+ api: NotificationsApiClient;
107
+ messages: NotificationMessages;
108
+ config: WebPushSetupConfig;
109
+ }): JSX.Element | null {
110
+ const [state, setState] = useState<SetupState>('checking');
111
+ // Read once, at mount: neither can change without a new document.
112
+ const [needsInstall] = useState(() => config.needsInstallFirst?.() ?? false);
113
+
114
+ useEffect(() => {
115
+ let cancelled = false;
116
+ void resolveState(api).then((next) => {
117
+ if (!cancelled) setState(next);
118
+ });
119
+ return () => {
120
+ cancelled = true;
121
+ };
122
+ }, [api]);
123
+
124
+ if (!available) return null;
125
+ if (needsInstall && config.installHint) return <InstallFirstHint hint={config.installHint} />;
126
+
127
+ const enable = async (): Promise<void> => {
128
+ setState('busy');
129
+ const result = await enableWebPush(api, config.swPath);
130
+ if (result.ok) setState('on');
131
+ else setState(result.reason === 'permission-denied' ? 'denied' : 'failed');
132
+ };
133
+
134
+ return (
135
+ <Box
136
+ sx={{
137
+ ...cardSx,
138
+ display: 'flex',
139
+ alignItems: 'center',
140
+ justifyContent: 'space-between',
141
+ gap: 2,
142
+ }}
143
+ data-testid="web-push-device-setup"
144
+ >
145
+ <Box sx={{ minWidth: 0 }}>
146
+ <Text variant="body" size="sm" weight="semibold" as="p">
147
+ {messages.devicePushTitle}
148
+ </Text>
149
+ <Text variant="caption" size="xs" color="secondary" as="p">
150
+ {statusText(state, messages)}
151
+ </Text>
152
+ </Box>
153
+ {state !== 'on' ? (
154
+ <Button
155
+ variant="outline"
156
+ color="primary"
157
+ size="sm"
158
+ disabled={state === 'busy' || state === 'checking'}
159
+ onClick={() => void enable()}
160
+ dataTestId="web-push-enable"
161
+ >
162
+ {state === 'busy' ? messages.devicePushEnabling : messages.devicePushEnable}
163
+ </Button>
164
+ ) : null}
165
+ </Box>
166
+ );
167
+ }
@@ -0,0 +1,255 @@
1
+ import type { NotificationEvent, NotificationLogger } from '../types';
2
+
3
+ import type { NotificationRouter } from './router';
4
+
5
+ /**
6
+ * Permission-addressed notifications: "tell whoever can act on this", resolved
7
+ * against the host's REAL authorization engine.
8
+ *
9
+ * Naming an audience by ROLE reads a coarse mirror column, so a tenant who
10
+ * moved a capability onto a custom role — or granted it additively — gets a
11
+ * notification list that disagrees with what the app actually authorizes. The
12
+ * two answers drift silently, and the direction they drift in is "the person
13
+ * who can fix it never hears about it".
14
+ *
15
+ * So this addresses by CAPABILITY: name the permissions the recipient must
16
+ * hold, and the audience is derived from the same evaluation the guards use.
17
+ *
18
+ * ## What the package owns, and what the host answers
19
+ *
20
+ * The FOLD is the package's: the AND, the deduplication, the refusal of an
21
+ * empty permission list, the per-recipient isolation, and the log line that
22
+ * distinguishes "nobody holds it" from "everybody's dispatch failed". Those are
23
+ * the parts that are the same in every host and that are easy to get subtly
24
+ * wrong.
25
+ *
26
+ * The two QUERIES are the host's, through {@link NotificationAudienceDirectory}
27
+ * — because an authorization engine is host machinery. In future-pay this
28
+ * module could not live in a package at all: it needed `notify()` AND the RBAC
29
+ * engine, and neither package could see the other. Inverting the dependency
30
+ * (the host answers, the package asks) is what makes it portable.
31
+ */
32
+
33
+ /**
34
+ * The host's authorization engine, as this fan-out needs it.
35
+ *
36
+ * `listCandidates` must be BOUNDED to people who actually hold a role at the
37
+ * tenant. future-pay's implementation requires a role grant, which is what
38
+ * keeps a store's storefront BUYERS — who all carry a default membership — out
39
+ * of a loop that resolves permissions one user at a time.
40
+ *
41
+ * `getPermissions` must be scoped to `tenantId`. Unioning a user's grants
42
+ * across tenants — the obvious way to "simplify" it — notifies someone about a
43
+ * store whose money they have no authority over, and no `where` clause upstream
44
+ * can save it because that user is already a candidate.
45
+ */
46
+ export interface NotificationAudienceDirectory {
47
+ listCandidates(tenantId: string): Promise<readonly string[]>;
48
+ getPermissions(
49
+ userId: string,
50
+ tenantId: string,
51
+ ): Promise<ReadonlySet<string> | readonly string[]>;
52
+ }
53
+
54
+ /**
55
+ * One candidate that did not receive it, and why.
56
+ *
57
+ * `audience-error` is deliberately its own reason rather than folded into
58
+ * `missing-permission`: "this user does not hold the pair" is a configuration
59
+ * fact, while "we could not find out whether they hold it" is an outage, and
60
+ * they need opposite responses. Collapsing them would report a database timeout
61
+ * as a tenant that simply has nobody to tell.
62
+ */
63
+ export interface PermissionNotificationSkip {
64
+ userId: string;
65
+ reason: 'missing-permission' | 'dispatch-failed' | 'audience-error';
66
+ }
67
+
68
+ /**
69
+ * What one fan-out actually did. Returned rather than logged-and-forgotten so a
70
+ * caller (and a test) can assert on the OUTCOME — who was reached and who was
71
+ * not — without reaching into transport mocks to infer it.
72
+ */
73
+ export interface PermissionNotificationResult {
74
+ /** User ids whose notification committed, in candidate order. */
75
+ notified: string[];
76
+ /** Candidates that did not receive it, with the reason. */
77
+ skipped: PermissionNotificationSkip[];
78
+ }
79
+
80
+ /** What {@link reportOutcome} needs to describe one fan-out. */
81
+ interface OutcomeReport {
82
+ type: string;
83
+ clientId: string;
84
+ permissions: readonly string[];
85
+ candidateCount: number;
86
+ result: PermissionNotificationResult;
87
+ }
88
+
89
+ /**
90
+ * Log what the fan-out amounted to, keyed on WHY nobody was reached rather
91
+ * than on the fact that nobody was.
92
+ *
93
+ * The distinction is the whole point: "this tenant has nobody holding the pair"
94
+ * is a configuration fact and belongs at info, while "three people hold it and
95
+ * every dispatch failed" is an outage in which nobody was told about missing
96
+ * money. Both leave `notified` empty, so keying on that alone would file the
97
+ * second under the first and make a dead transport look like an unconfigured
98
+ * store.
99
+ */
100
+ function reportOutcome(logger: NotificationLogger, report: OutcomeReport): void {
101
+ const { type, clientId, permissions, candidateCount, result } = report;
102
+ // Both non-configuration reasons count as "not reached": a candidate whose
103
+ // authorization query threw is as un-notified as one whose dispatch threw.
104
+ const failed = result.skipped.filter((skip) => skip.reason !== 'missing-permission').length;
105
+ if (failed > 0) {
106
+ logger.error(
107
+ `[notifications] ${type}: ${failed} of ${failed + result.notified.length} matching ` +
108
+ `recipient(s) at client ${clientId} could not be reached; ` +
109
+ `${result.notified.length} delivered`,
110
+ );
111
+ return;
112
+ }
113
+ if (result.notified.length > 0) return;
114
+ // Quiet, and deliberately at info: "nobody at this tenant holds
115
+ // `orders:refund` + `reports:financial:read`" is worth being able to look up
116
+ // when someone asks why they heard nothing, but it is not a fault.
117
+ logger.info(
118
+ `[notifications] ${type}: no recipient at client ${clientId} holds ` +
119
+ `[${permissions.join(', ')}] (${candidateCount} candidate(s) evaluated)`,
120
+ );
121
+ }
122
+
123
+ export type NotifyByPermission = <TPayload>(
124
+ clientId: string,
125
+ permissions: readonly string[],
126
+ event: Omit<NotificationEvent<TPayload>, 'recipient'>,
127
+ ) => Promise<PermissionNotificationResult>;
128
+
129
+ /**
130
+ * Send one notification to every user of `clientId` holding ALL of
131
+ * `permissions` (AND, not OR).
132
+ *
133
+ * AND is the semantics that makes an addressed notification openable: pair the
134
+ * permission that HANDLES the thing with the permission that gates the SURFACE
135
+ * it links to, and every addressee is, by construction, someone the surface's
136
+ * own guard will serve. OR would happily notify someone into a 403.
137
+ *
138
+ * Contract:
139
+ * - `permissions` must be non-empty. An empty list is a PROGRAMMING ERROR and
140
+ * throws: "holds every permission in []" is vacuously true for everyone, so
141
+ * the quiet reading of an empty list is "fan out to the entire tenant" —
142
+ * the one outcome a caller can never have meant. It is the only throw here.
143
+ * - TENANT-SCOPED, unconditionally. Permissions are evaluated at `clientId`
144
+ * and the inbox row is written with that same tenant.
145
+ * - Recipients are DEDUPLICATED: candidates are a set, and holding the
146
+ * permissions through two roles is one notification.
147
+ * - Zero matching users is a NORMAL outcome — logged, never thrown.
148
+ * - Recipients are ISOLATED, in BOTH host queries: the authorization lookup
149
+ * and the dispatch each get their own try/catch, so neither a candidate the
150
+ * engine cannot answer for nor a recipient `notify` throws on can cost the
151
+ * others their notification — or cost the caller the outcome log.
152
+ *
153
+ * Cost: one `getPermissions` per candidate, run sequentially. That is only
154
+ * affordable because the candidate set is bounded (see the directory docs).
155
+ * Callers run this fire-and-forget, off whatever path produced the event.
156
+ */
157
+ /** What the two isolated per-candidate steps need. */
158
+ interface FanOutDeps {
159
+ router: NotificationRouter;
160
+ directory: NotificationAudienceDirectory;
161
+ logger: NotificationLogger;
162
+ }
163
+
164
+ /**
165
+ * One recipient's dispatch, isolated. Resolves `true` when the notification
166
+ * committed and `false` when it did not — the failure is contained here so the
167
+ * loop can carry on, which is the whole point of per-recipient isolation.
168
+ *
169
+ * The log names the USER ID and never an address: an e-mail in a log is PII.
170
+ */
171
+ async function dispatchTo<TPayload>(
172
+ deps: FanOutDeps,
173
+ clientId: string,
174
+ userId: string,
175
+ event: Omit<NotificationEvent<TPayload>, 'recipient'>,
176
+ ): Promise<boolean> {
177
+ try {
178
+ await deps.router.notify<TPayload>({ ...event, recipient: { userId, clientId } });
179
+ return true;
180
+ } catch (error) {
181
+ deps.logger.error(
182
+ `[notifications] ${event.type} dispatch failed for user ${userId} at client ${clientId}:`,
183
+ error,
184
+ );
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * One candidate's permissions, isolated. `null` means the host's engine could
191
+ * not answer.
192
+ *
193
+ * This await used to sit bare in the loop while only `notify` was guarded, which
194
+ * made the documented per-recipient isolation half true: an ordinary
195
+ * connection-pool timeout on candidate #3 of 30 propagated out of
196
+ * `notifyByPermission`, so #4..#30 were never evaluated, `reportOutcome` never
197
+ * ran, and the only trace was a rejection in whatever `.catch` the
198
+ * fire-and-forget caller happened to attach. For a short-payment alert that is
199
+ * nobody being told that money is missing, with nothing in the log saying so.
200
+ */
201
+ async function heldBy(
202
+ deps: FanOutDeps,
203
+ clientId: string,
204
+ userId: string,
205
+ ): Promise<ReadonlySet<string> | null> {
206
+ try {
207
+ const granted = await deps.directory.getPermissions(userId, clientId);
208
+ return granted instanceof Set ? granted : new Set(granted);
209
+ } catch (error) {
210
+ deps.logger.error(
211
+ `[notifications] audience lookup failed for user ${userId} at client ${clientId}:`,
212
+ error,
213
+ );
214
+ return null;
215
+ }
216
+ }
217
+
218
+ export function createNotifyByPermission(deps: FanOutDeps): NotifyByPermission {
219
+ return async function notifyByPermission(clientId, permissions, event) {
220
+ if (permissions.length === 0) {
221
+ throw new Error(
222
+ 'notifyByPermission(): `permissions` must be non-empty — ' +
223
+ 'an empty list matches every user of the tenant.',
224
+ );
225
+ }
226
+ const candidates = [...new Set(await deps.directory.listCandidates(clientId))];
227
+ const result: PermissionNotificationResult = { notified: [], skipped: [] };
228
+
229
+ for (const userId of candidates) {
230
+ const held = await heldBy(deps, clientId, userId);
231
+ if (!held) {
232
+ result.skipped.push({ userId, reason: 'audience-error' });
233
+ continue;
234
+ }
235
+ // THE AND. `held` folds every role the user has at this tenant, so this
236
+ // one line is the whole authorization question — and a single-element
237
+ // list needs no special case.
238
+ if (!permissions.every((permission) => held.has(permission))) {
239
+ result.skipped.push({ userId, reason: 'missing-permission' });
240
+ continue;
241
+ }
242
+ if (await dispatchTo(deps, clientId, userId, event)) result.notified.push(userId);
243
+ else result.skipped.push({ userId, reason: 'dispatch-failed' });
244
+ }
245
+
246
+ reportOutcome(deps.logger, {
247
+ type: event.type,
248
+ clientId,
249
+ permissions,
250
+ candidateCount: candidates.length,
251
+ result,
252
+ });
253
+ return result;
254
+ };
255
+ }
@@ -0,0 +1,269 @@
1
+ import type { NotificationMessages } from '../messages';
2
+ import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
3
+
4
+ /**
5
+ * What every route in this surface shares (12-15): the actor, the request, the
6
+ * response envelope and the body parsing. Mirrors the entity-lifecycle /
7
+ * report-builder shape — framework-neutral descriptors a forty-line adapter
8
+ * mounts.
9
+ */
10
+
11
+ /**
12
+ * What a host must resolve before a request reaches these handlers: WHO is
13
+ * calling. That is the whole seam.
14
+ *
15
+ * There is no tenant here and no permission list, and both absences are the
16
+ * design. Every endpoint in this surface is SELF-scoped — a user reads and
17
+ * writes their own inbox and their own preferences — so the only authorization
18
+ * question is "who is signed in", and the answer is applied by scoping every
19
+ * query to `userId` rather than by a guard that could be forgotten. A
20
+ * permission-gated ADMIN view of someone else's inbox would be a different
21
+ * surface, and would need a different actor.
22
+ */
23
+ export interface NotificationsActor {
24
+ userId: string;
25
+ }
26
+
27
+ /** One request, already authenticated and routed by the host. */
28
+ export interface NotificationsRequest {
29
+ actor: NotificationsActor;
30
+ params: Record<string, string | undefined>;
31
+ query: Record<string, string | undefined>;
32
+ body?: unknown;
33
+ /** Headers the surface reads (`user-agent`, for the device hint). */
34
+ headers?: Record<string, string | undefined>;
35
+ }
36
+
37
+ /** What a handler answers with; the adapter maps it onto its response type. */
38
+ export interface NotificationsResponse {
39
+ status: number;
40
+ /** `undefined` means NO body at all (204) — not the same as `null`. */
41
+ body: unknown;
42
+ }
43
+
44
+ export interface NotificationsRoute {
45
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
46
+ /**
47
+ * Path relative to the host's account mount, in `:param` form. The SHAPE is
48
+ * fixed because the packaged client builds these URLs.
49
+ */
50
+ path: string;
51
+ handle(request: NotificationsRequest): Promise<NotificationsResponse>;
52
+ }
53
+
54
+ /** A user-safe API error carrying the HTTP status the wire promises. */
55
+ export class NotificationsApiError extends Error {
56
+ readonly status: number;
57
+ constructor(status: number, message: string) {
58
+ super(message);
59
+ this.name = 'NotificationsApiError';
60
+ this.status = status;
61
+ Object.setPrototypeOf(this, NotificationsApiError.prototype);
62
+ }
63
+ }
64
+
65
+ /** Success is `{ data }`; a denial is `{ error }`, unwrapped. */
66
+ export const ok = (data: unknown, status = 200): NotificationsResponse => ({
67
+ status,
68
+ body: { data },
69
+ });
70
+
71
+ const fail = (status: number, error: string): NotificationsResponse => ({
72
+ status,
73
+ body: { error },
74
+ });
75
+
76
+ /** Fold a thrown {@link NotificationsApiError} into a response; rethrow the rest. */
77
+ export function foldApiError(error: unknown): NotificationsResponse {
78
+ if (error instanceof NotificationsApiError) return fail(error.status, error.message);
79
+ throw error;
80
+ }
81
+
82
+ /** Wrap a handler so its thrown api errors become the wire's `{ error }`. */
83
+ export function guarded(
84
+ handle: (request: NotificationsRequest) => Promise<NotificationsResponse>,
85
+ ): (request: NotificationsRequest) => Promise<NotificationsResponse> {
86
+ return async (request) => {
87
+ try {
88
+ return await handle(request);
89
+ } catch (error) {
90
+ return foldApiError(error);
91
+ }
92
+ };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Body / query parsing — the request contract, in the package
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function asRecord(body: unknown, messages: NotificationMessages): Record<string, unknown> {
100
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
101
+ throw new NotificationsApiError(400, messages.invalidBody);
102
+ }
103
+ return body as Record<string, unknown>;
104
+ }
105
+
106
+ /** 1..100 non-empty string ids. */
107
+ function parseIds(value: unknown, messages: NotificationMessages): string[] {
108
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
109
+ throw new NotificationsApiError(400, messages.invalidBody);
110
+ }
111
+ return value.map((id) => {
112
+ if (typeof id !== 'string' || id.length === 0) {
113
+ throw new NotificationsApiError(400, messages.invalidBody);
114
+ }
115
+ return id;
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Query strings arrive as strings; the store clamps, but a non-number is a
121
+ * client bug and must not silently read as "the default page".
122
+ */
123
+ function parseLimit(raw: string | undefined, messages: NotificationMessages): number | undefined {
124
+ if (raw === undefined || raw === '') return undefined;
125
+ const limit = Number(raw);
126
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
127
+ throw new NotificationsApiError(400, messages.invalidBody);
128
+ }
129
+ return limit;
130
+ }
131
+
132
+ function parseFilter(
133
+ raw: string | undefined,
134
+ messages: NotificationMessages,
135
+ ): 'all' | 'unread' | undefined {
136
+ if (raw === undefined) return undefined;
137
+ if (raw !== 'all' && raw !== 'unread') {
138
+ throw new NotificationsApiError(400, messages.invalidBody);
139
+ }
140
+ return raw;
141
+ }
142
+
143
+ /** `GET <mount>/notifications` — `filter`, `cursor`, `limit`. */
144
+ export function parseListQuery(
145
+ query: Record<string, string | undefined>,
146
+ messages: NotificationMessages,
147
+ ): { filter?: 'all' | 'unread'; cursor?: string; limit?: number } {
148
+ const filter = parseFilter(query.filter, messages);
149
+ const limit = parseLimit(query.limit, messages);
150
+ return {
151
+ ...(filter !== undefined ? { filter } : {}),
152
+ ...(query.cursor ? { cursor: query.cursor } : {}),
153
+ ...(limit !== undefined ? { limit } : {}),
154
+ };
155
+ }
156
+
157
+ /** `POST <mount>/notifications/mark-read` — explicit ids, or `all: true`. */
158
+ export function parseMarkReadBody(
159
+ body: unknown,
160
+ messages: NotificationMessages,
161
+ ): { all: true } | { ids: string[] } {
162
+ const record = asRecord(body, messages);
163
+ const wantsAll = record.all === true;
164
+ const hasIds = record.ids !== undefined;
165
+ // Exactly one of the two. Both, or neither, is ambiguous in the one direction
166
+ // that matters: "mark everything read" is not a thing to guess at.
167
+ if (wantsAll === hasIds) {
168
+ throw new NotificationsApiError(400, messages.markReadTargetRequired);
169
+ }
170
+ return wantsAll ? { all: true } : { ids: parseIds(record.ids, messages) };
171
+ }
172
+
173
+ /** `POST <mount>/notifications/delete` — 1..100 ids. */
174
+ export function parseDeleteBody(body: unknown, messages: NotificationMessages): string[] {
175
+ return parseIds(asRecord(body, messages).ids, messages);
176
+ }
177
+
178
+ /**
179
+ * `PUT <mount>/notification-preferences` — any subset of categories, each with
180
+ * any subset of channel toggles. Categories left out stay untouched.
181
+ */
182
+ export function parsePreferencesBody(
183
+ body: unknown,
184
+ messages: NotificationMessages,
185
+ ): Record<string, Partial<Record<NotificationChannel, boolean>>> {
186
+ const record = asRecord(body, messages);
187
+ const parsed: Record<string, Partial<Record<NotificationChannel, boolean>>> = {};
188
+ for (const [category, value] of Object.entries(record)) {
189
+ parsed[category] = parseToggles(value, messages);
190
+ }
191
+ return parsed;
192
+ }
193
+
194
+ /** One category's toggles, narrowed onto the closed channel set. */
195
+ function parseToggles(
196
+ value: unknown,
197
+ messages: NotificationMessages,
198
+ ): Partial<Record<NotificationChannel, boolean>> {
199
+ const toggles = asRecord(value, messages);
200
+ const row: Partial<Record<NotificationChannel, boolean>> = {};
201
+ for (const channel of NOTIFICATION_CHANNELS) {
202
+ const flag = toggles[channel];
203
+ if (flag === undefined) continue;
204
+ if (typeof flag !== 'boolean') throw new NotificationsApiError(400, messages.invalidBody);
205
+ row[channel] = flag;
206
+ }
207
+ return row;
208
+ }
209
+
210
+ const MAX_ENDPOINT_CHARS = 2000;
211
+ const MAX_KEY_CHARS = 500;
212
+
213
+ function parseEndpoint(value: unknown, messages: NotificationMessages): string {
214
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ENDPOINT_CHARS) {
215
+ throw new NotificationsApiError(400, messages.invalidBody);
216
+ }
217
+ // A push endpoint is a URL the server will POST to, so an absolute http(s)
218
+ // URL is a hard requirement rather than a formality.
219
+ let url: URL;
220
+ try {
221
+ url = new URL(value);
222
+ } catch {
223
+ throw new NotificationsApiError(400, messages.invalidBody);
224
+ }
225
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
226
+ throw new NotificationsApiError(400, messages.invalidBody);
227
+ }
228
+ return value;
229
+ }
230
+
231
+ /** `POST <mount>/push-subscriptions` — `PushSubscription.toJSON()`. */
232
+ export function parsePushSubscriptionBody(
233
+ body: unknown,
234
+ messages: NotificationMessages,
235
+ ): { endpoint: string; keys: { p256dh: string; auth: string } } {
236
+ const record = asRecord(body, messages);
237
+ const keys = asRecord(record.keys, messages);
238
+ const key = (value: unknown): string => {
239
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_KEY_CHARS) {
240
+ throw new NotificationsApiError(400, messages.invalidBody);
241
+ }
242
+ return value;
243
+ };
244
+ return {
245
+ endpoint: parseEndpoint(record.endpoint, messages),
246
+ keys: { p256dh: key(keys.p256dh), auth: key(keys.auth) },
247
+ };
248
+ }
249
+
250
+ /** `DELETE <mount>/push-subscriptions` — by endpoint. */
251
+ export function parsePushEndpointBody(body: unknown, messages: NotificationMessages): string {
252
+ return parseEndpoint(asRecord(body, messages).endpoint, messages);
253
+ }
254
+
255
+ /**
256
+ * `GET <mount>/push-subscriptions?endpoint=…` — the browser asking "is the
257
+ * subscription I am holding still MINE on the server?".
258
+ *
259
+ * Optional: with no `endpoint` the route answers the key and the count as it
260
+ * always has. Validated by the same rules as the write, so a junk value is a 400
261
+ * rather than a lookup.
262
+ */
263
+ export function parsePushEndpointQuery(
264
+ query: Record<string, string | undefined>,
265
+ messages: NotificationMessages,
266
+ ): string | undefined {
267
+ if (query.endpoint === undefined || query.endpoint === '') return undefined;
268
+ return parseEndpoint(query.endpoint, messages);
269
+ }