@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.
- package/ADOPTING.md +316 -0
- package/README.md +153 -0
- package/package.json +94 -0
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
- package/prisma/notifications.prisma +141 -0
- package/scripts/sync-notifications-schema.mjs +60 -0
- package/src/errors.ts +21 -0
- package/src/generators.ts +44 -0
- package/src/hono/index.ts +121 -0
- package/src/index.ts +73 -0
- package/src/messages.ts +156 -0
- package/src/phone.ts +57 -0
- package/src/preferences-core.ts +89 -0
- package/src/react/api.ts +111 -0
- package/src/react/bell-button.tsx +78 -0
- package/src/react/bell-icon.tsx +33 -0
- package/src/react/create-web-notifications.tsx +127 -0
- package/src/react/hooks.ts +74 -0
- package/src/react/inbox-state.ts +216 -0
- package/src/react/index.ts +61 -0
- package/src/react/panel.tsx +181 -0
- package/src/react/preferences-screen.tsx +242 -0
- package/src/react/relative-time.ts +18 -0
- package/src/react/row.tsx +98 -0
- package/src/react/transport.ts +72 -0
- package/src/react/web-push-client.ts +113 -0
- package/src/react/web-push-setup.tsx +167 -0
- package/src/server/by-permission.ts +255 -0
- package/src/server/context.ts +269 -0
- package/src/server/create-api-notifications.ts +215 -0
- package/src/server/db.ts +252 -0
- package/src/server/dispatch.ts +298 -0
- package/src/server/inbox.ts +155 -0
- package/src/server/index.ts +115 -0
- package/src/server/preferences.ts +103 -0
- package/src/server/push-subscriptions.ts +121 -0
- package/src/server/router.ts +275 -0
- package/src/server/routes.ts +218 -0
- package/src/server/transports/drivers.ts +148 -0
- package/src/server/transports/email.ts +141 -0
- package/src/server/transports/registry.ts +106 -0
- package/src/server/transports/sms.ts +120 -0
- package/src/server/transports/web-push.ts +168 -0
- package/src/server/transports/whatsapp.ts +183 -0
- package/src/types.ts +158 -0
- package/src/web-push/index.ts +70 -0
- 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
|
+
}
|