@12-apps/notifications 4.12.0 → 4.13.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/dist/{chunk-PNY6S6WH.js → chunk-4DTUD74E.js} +2 -2
- package/dist/{chunk-WZBX7YCE.js → chunk-53SH5ABN.js} +82 -34
- package/dist/chunk-53SH5ABN.js.map +1 -0
- package/dist/{chunk-CPQKKLPS.js → chunk-CSMFJJXY.js} +1 -1
- package/dist/{chunk-CPQKKLPS.js.map → chunk-CSMFJJXY.js.map} +1 -1
- package/dist/{chunk-FBBPS2LT.js → chunk-QPO6NSRR.js} +2 -2
- package/dist/{chunk-GK6GSC2J.js → chunk-XFODKRRB.js} +2 -2
- package/dist/{create-api-notifications-BTudlaSC.d.ts → create-api-notifications-cIEbFqoZ.d.ts} +121 -30
- package/dist/{create-web-notifications-2xxbKnrW.d.ts → create-web-notifications-BODiQRK-.d.ts} +2 -2
- package/dist/{generators-CQYdJfB5.d.ts → generators-GUF-Kml-.d.ts} +1 -1
- package/dist/hono/index.d.ts +5 -5
- package/dist/hono/index.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/{jobs-CaovU4GM.d.ts → jobs-wKoOz91Q.d.ts} +1 -1
- package/dist/manifest/server.d.ts +6 -6
- package/dist/manifest/server.js +4 -4
- package/dist/manifest/web.d.ts +3 -3
- package/dist/manifest/web.js +1 -1
- package/dist/{preferences-screen-S3ZHX5LB.js → preferences-screen-YRPNUZS5.js} +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +1 -1
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +4 -4
- package/dist/{types-H_aFzLA0.d.ts → types-DPiePHJD.d.ts} +19 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-C6U-5JCV.d.ts → web-push-DUn_d_gj.d.ts} +12 -2
- package/dist/{wire-Bn6aA2nL.d.ts → wire-CJka1AvM.d.ts} +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20260914210000_scope_push_subscriptions_per_store/migration.sql +31 -0
- package/prisma/notifications.prisma +12 -0
- package/src/server/context.ts +15 -0
- package/src/server/db.ts +50 -8
- package/src/server/dispatch.ts +9 -2
- package/src/server/inbox.ts +87 -18
- package/src/server/index.ts +2 -0
- package/src/server/push-subscriptions.ts +61 -7
- package/src/server/router.ts +5 -1
- package/src/server/routes.ts +14 -5
- package/src/server/transports/web-push.ts +15 -2
- package/src/types.ts +19 -1
- package/dist/chunk-WZBX7YCE.js.map +0 -1
- /package/dist/{chunk-PNY6S6WH.js.map → chunk-4DTUD74E.js.map} +0 -0
- /package/dist/{chunk-FBBPS2LT.js.map → chunk-QPO6NSRR.js.map} +0 -0
- /package/dist/{chunk-GK6GSC2J.js.map → chunk-XFODKRRB.js.map} +0 -0
- /package/dist/{preferences-screen-S3ZHX5LB.js.map → preferences-screen-YRPNUZS5.js.map} +0 -0
|
@@ -131,11 +131,23 @@ model PushSubscription {
|
|
|
131
131
|
endpoint String @unique
|
|
132
132
|
p256dh String
|
|
133
133
|
auth String
|
|
134
|
+
/// Which ORIGIN this browser registered on.
|
|
135
|
+
///
|
|
136
|
+
/// NULL is the PLATFORM origin — the host itself, or any adopter serving one
|
|
137
|
+
/// origin. A subscription stamped with a store receives that store's
|
|
138
|
+
/// notifications plus platform-wide ones (`clientId IS NULL`) and NEVER
|
|
139
|
+
/// another store's; a platform-origin subscription receives everything, which
|
|
140
|
+
/// is what keeps a marketplace host's behaviour identical to before.
|
|
141
|
+
///
|
|
142
|
+
/// Nullable so every pre-existing row reads as "platform" with no backfill.
|
|
143
|
+
clientId String? @map("client_id")
|
|
134
144
|
// Free-form browser/device hint ("Chrome · Linux") for a device list.
|
|
135
145
|
userAgent String? @map("user_agent")
|
|
136
146
|
createdAt DateTime @default(now()) @map("created_at")
|
|
137
147
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
138
148
|
|
|
139
149
|
@@index([userId])
|
|
150
|
+
// The send path's one query: owner, then the origin rule above.
|
|
151
|
+
@@index([userId, clientId])
|
|
140
152
|
@@map("push_subscriptions")
|
|
141
153
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -22,6 +22,21 @@ import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
|
|
|
22
22
|
*/
|
|
23
23
|
export interface NotificationsActor {
|
|
24
24
|
userId: string;
|
|
25
|
+
/**
|
|
26
|
+
* The store whose ORIGIN this request arrived on, resolved by the
|
|
27
|
+
* HOST from the hostname — never read from the body or the query.
|
|
28
|
+
*
|
|
29
|
+
* The paragraph above still holds: this is not a tenant the surface
|
|
30
|
+
* authorizes against, it is a NARROWING of a read that is already the
|
|
31
|
+
* caller's own, and it can never widen one. A host with a single origin never
|
|
32
|
+
* sets it and every endpoint answers exactly what it answered before.
|
|
33
|
+
*
|
|
34
|
+
* It exists because a multi-tenant host installs one storefront per store as
|
|
35
|
+
* its own PWA, and a PWA's identity is its ORIGIN — so an inbox answering a
|
|
36
|
+
* store's own app with a neighbour's notifications is one store reporting on
|
|
37
|
+
* another.
|
|
38
|
+
*/
|
|
39
|
+
scopeClientId?: string;
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
/** One request, already authenticated and routed by the host. */
|
package/src/server/db.ts
CHANGED
|
@@ -54,14 +54,40 @@ export interface NotificationPageAfter {
|
|
|
54
54
|
id: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* One BRANCH of an inbox filter — a member of `AND`/`OR`, never a whole read.
|
|
59
|
+
*
|
|
60
|
+
* Split out when the store scope arrived, and the split is what keeps `deletedAt: null`
|
|
61
|
+
* REQUIRED on the read itself (see {@link NotificationWhere}). Widening `OR`
|
|
62
|
+
* from the old fixed keyset tuple to a general array needed its members to be
|
|
63
|
+
* valid filters, and the tempting move — relaxing `deletedAt` on the one type —
|
|
64
|
+
* would have removed the only thing that makes a Prisma-backed host exclude
|
|
65
|
+
* soft-deleted rows. Both in-package doubles hard-code that filter regardless,
|
|
66
|
+
* so Prisma is precisely the implementation the requirement was protecting.
|
|
67
|
+
*
|
|
68
|
+
* `createdAt` and `id: { lt }` are here because the keyset boundary is built
|
|
69
|
+
* from them (`../inbox.ts`'s `pageWhere`); without them the branch type cannot
|
|
70
|
+
* express the very thing the widening exists to preserve.
|
|
71
|
+
*/
|
|
72
|
+
export interface NotificationWhereBranch {
|
|
59
73
|
userId?: string;
|
|
60
|
-
id?: string | { in: string[] };
|
|
61
|
-
|
|
74
|
+
id?: string | { in: string[] } | { lt: string };
|
|
75
|
+
createdAt?: Date | { lt: Date };
|
|
76
|
+
deletedAt?: null;
|
|
62
77
|
readAt?: null;
|
|
63
|
-
/**
|
|
64
|
-
|
|
78
|
+
/** `null` matches the platform-wide rows that travel with every store scope. */
|
|
79
|
+
clientId?: string | null;
|
|
80
|
+
OR?: NotificationWhereBranch[];
|
|
81
|
+
AND?: NotificationWhereBranch[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The inbox read filter. `deletedAt: null` is on every read, always — and it
|
|
86
|
+
* stays REQUIRED here, which is the whole reason {@link NotificationWhereBranch}
|
|
87
|
+
* is a separate type.
|
|
88
|
+
*/
|
|
89
|
+
export interface NotificationWhere extends NotificationWhereBranch {
|
|
90
|
+
deletedAt: null;
|
|
65
91
|
}
|
|
66
92
|
|
|
67
93
|
export interface NotificationDelegate {
|
|
@@ -180,11 +206,25 @@ export interface PushSubscriptionRow {
|
|
|
180
206
|
endpoint: string;
|
|
181
207
|
p256dh: string;
|
|
182
208
|
auth: string;
|
|
209
|
+
/** The origin this browser registered on; `null` = the platform origin. */
|
|
210
|
+
clientId: string | null;
|
|
183
211
|
userAgent: string | null;
|
|
184
212
|
}
|
|
185
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Which of a user's subscriptions one send may reach.
|
|
216
|
+
*
|
|
217
|
+
* The disjunction is load-bearing and `clientId: { in: [x, null] }` is NOT a
|
|
218
|
+
* shortcut for it: SQL `IN` never matches NULL, so the platform-wide rows that
|
|
219
|
+
* must travel with every store scope would silently vanish.
|
|
220
|
+
*/
|
|
221
|
+
export interface PushSubscriptionWhere {
|
|
222
|
+
userId: string;
|
|
223
|
+
OR?: [{ clientId: null }, { clientId: string }];
|
|
224
|
+
}
|
|
225
|
+
|
|
186
226
|
export interface PushSubscriptionDelegate {
|
|
187
|
-
count(args: { where:
|
|
227
|
+
count(args: { where: PushSubscriptionWhere }): Promise<number>;
|
|
188
228
|
/**
|
|
189
229
|
* The row holding one endpoint, whoever owns it. Read BEFORE an upsert so a
|
|
190
230
|
* re-own (the same browser profile, a different signed-in user) is a logged
|
|
@@ -192,7 +232,7 @@ export interface PushSubscriptionDelegate {
|
|
|
192
232
|
* whether THIS browser's subscription is still the caller's.
|
|
193
233
|
*/
|
|
194
234
|
findUnique(args: { where: { endpoint: string } }): Promise<PushSubscriptionRow | null>;
|
|
195
|
-
findMany(args: { where:
|
|
235
|
+
findMany(args: { where: PushSubscriptionWhere }): Promise<PushSubscriptionRow[]>;
|
|
196
236
|
upsert(args: {
|
|
197
237
|
where: { endpoint: string };
|
|
198
238
|
create: {
|
|
@@ -200,12 +240,14 @@ export interface PushSubscriptionDelegate {
|
|
|
200
240
|
endpoint: string;
|
|
201
241
|
p256dh: string;
|
|
202
242
|
auth: string;
|
|
243
|
+
clientId: string | null;
|
|
203
244
|
userAgent: string | null;
|
|
204
245
|
};
|
|
205
246
|
update: {
|
|
206
247
|
userId: string;
|
|
207
248
|
p256dh: string;
|
|
208
249
|
auth: string;
|
|
250
|
+
clientId: string | null;
|
|
209
251
|
userAgent: string | null;
|
|
210
252
|
};
|
|
211
253
|
}): Promise<PushSubscriptionRow>;
|
package/src/server/dispatch.ts
CHANGED
|
@@ -92,6 +92,7 @@ export interface NotificationDispatchDeps {
|
|
|
92
92
|
export async function loadRecipient(
|
|
93
93
|
deps: NotificationDispatchDeps,
|
|
94
94
|
userId: string,
|
|
95
|
+
clientId: string | null,
|
|
95
96
|
): Promise<TransportRecipient | null> {
|
|
96
97
|
const contact = await deps.contacts.getContact(userId);
|
|
97
98
|
if (!contact) return null;
|
|
@@ -103,7 +104,11 @@ export async function loadRecipient(
|
|
|
103
104
|
// absent case has to stay distinguishable from a stated language, because
|
|
104
105
|
// that is what lets a generator apply its own default in one place.
|
|
105
106
|
...(contact.locale === undefined ? {} : { locale: contact.locale }),
|
|
106
|
-
|
|
107
|
+
clientId,
|
|
108
|
+
// SCOPED, and this is what keeps `supports()` honest: it gates on this
|
|
109
|
+
// number, so an unscoped count would enqueue a WEB_PUSH delivery for a
|
|
110
|
+
// notification no reachable subscription exists for.
|
|
111
|
+
pushSubscriptionCount: await deps.pushSubscriptions.count(userId, clientId),
|
|
107
112
|
};
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -223,7 +228,9 @@ export async function dispatchOne(
|
|
|
223
228
|
});
|
|
224
229
|
if (queued.length === 0) return 0;
|
|
225
230
|
|
|
226
|
-
|
|
231
|
+
// The STORED column, so the retry sweep scopes identically to the first
|
|
232
|
+
// attempt — anything less would let a retry leak what the first send withheld.
|
|
233
|
+
const recipient = await loadRecipient(deps, notification.userId, notification.clientId);
|
|
227
234
|
if (!recipient) {
|
|
228
235
|
await abandonUnreachable(deps, client, queued, notification.userId);
|
|
229
236
|
return 0;
|
package/src/server/inbox.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
NotificationPageAfter,
|
|
6
6
|
NotificationsDbProvider,
|
|
7
7
|
NotificationWhere,
|
|
8
|
+
NotificationWhereBranch,
|
|
8
9
|
} from './db';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -15,6 +16,29 @@ import type {
|
|
|
15
16
|
* never be resurrected by mark-read.
|
|
16
17
|
*/
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The store whose ORIGIN the caller is reading from, or absent for the platform
|
|
21
|
+
* origin.
|
|
22
|
+
*
|
|
23
|
+
* A host that installs one storefront per store as its own PWA reads this from
|
|
24
|
+
* the request's hostname; a host with one origin never sets it and every read
|
|
25
|
+
* below is exactly what it was. Set, it narrows to that store's rows PLUS the
|
|
26
|
+
* platform-wide ones (`clientId IS NULL`) — a password reset or a security
|
|
27
|
+
* notice is about the person and not about a store, so hiding it inside the
|
|
28
|
+
* only app a customer opens would be a worse failure than the leak this fixes.
|
|
29
|
+
*/
|
|
30
|
+
export type NotificationScope = string | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `clientId IN (<scope>, NULL)`, as a filter branch — or nothing at all.
|
|
34
|
+
*
|
|
35
|
+
* A disjunction rather than an `in`, because SQL `IN` never matches NULL and
|
|
36
|
+
* the NULL rows are precisely the ones that must survive every scope.
|
|
37
|
+
*/
|
|
38
|
+
function scopeBranch(scope: NotificationScope): NotificationWhereBranch[] {
|
|
39
|
+
return scope === undefined ? [] : [{ OR: [{ clientId: scope }, { clientId: null }] }];
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
export interface ListNotificationsInput {
|
|
19
43
|
/** `unread` narrows to unread rows; default lists all non-deleted. */
|
|
20
44
|
filter?: 'all' | 'unread';
|
|
@@ -34,10 +58,27 @@ const DEFAULT_PAGE = 20;
|
|
|
34
58
|
const MAX_PAGE = 100;
|
|
35
59
|
|
|
36
60
|
export interface NotificationInboxStore {
|
|
37
|
-
list(
|
|
38
|
-
|
|
61
|
+
list(
|
|
62
|
+
userId: string,
|
|
63
|
+
input?: ListNotificationsInput,
|
|
64
|
+
scope?: NotificationScope,
|
|
65
|
+
): Promise<ListNotificationsResult>;
|
|
66
|
+
/** Scoped with `list`, or the badge and the list it sits over disagree. */
|
|
67
|
+
unreadCount(userId: string, scope?: NotificationScope): Promise<number>;
|
|
39
68
|
markRead(userId: string, ids: readonly string[]): Promise<number>;
|
|
40
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Scoped too, and this one is a WRITE.
|
|
71
|
+
*
|
|
72
|
+
* Unscoped, "mark all as read" pressed inside store A's app clears store B's
|
|
73
|
+
* unread rows everywhere — a cross-store write from the app that exists to be
|
|
74
|
+
* isolated, and strictly worse than the read leak. `markRead(ids)` and
|
|
75
|
+
* `softDelete(ids)` need nothing: their ids come from the already-scoped list.
|
|
76
|
+
*
|
|
77
|
+
* The platform-wide rows ARE cleared from any origin, and that follows from
|
|
78
|
+
* the scope rule rather than contradicting it — those rows are one person's,
|
|
79
|
+
* not one store's.
|
|
80
|
+
*/
|
|
81
|
+
markAllRead(userId: string, scope?: NotificationScope): Promise<number>;
|
|
41
82
|
softDelete(userId: string, ids: readonly string[]): Promise<number>;
|
|
42
83
|
}
|
|
43
84
|
|
|
@@ -66,27 +107,55 @@ function pageWhere(
|
|
|
66
107
|
userId: string,
|
|
67
108
|
filter: ListNotificationsInput['filter'],
|
|
68
109
|
anchor: NotificationPageAfter | undefined,
|
|
110
|
+
scope: NotificationScope,
|
|
69
111
|
): NotificationWhere {
|
|
112
|
+
// Two DISJUNCTIONS have to hold at once — the page boundary and the store
|
|
113
|
+
// scope — so they are AND-ed rather than merged. A second `OR` key on this
|
|
114
|
+
// object literal would overwrite the first, dropping either the scope or the
|
|
115
|
+
// anchor; the anchor only from page TWO onward, which is the case a cursor
|
|
116
|
+
// exists for and the case a single-page test never reaches.
|
|
117
|
+
const clauses: NotificationWhereBranch[] = [
|
|
118
|
+
// `(createdAt, id) < (anchor.createdAt, anchor.id)`, as a portable `where`.
|
|
119
|
+
...(anchor
|
|
120
|
+
? [
|
|
121
|
+
{
|
|
122
|
+
OR: [
|
|
123
|
+
{ createdAt: { lt: anchor.createdAt } },
|
|
124
|
+
{ createdAt: anchor.createdAt, id: { lt: anchor.id } },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
: []),
|
|
129
|
+
...scopeBranch(scope),
|
|
130
|
+
];
|
|
70
131
|
return {
|
|
71
132
|
userId,
|
|
72
133
|
deletedAt: null,
|
|
73
134
|
...(filter === 'unread' ? { readAt: null } : {}),
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
135
|
+
...(clauses.length > 0 ? { AND: clauses } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The unread rows this scope can see — READ by the badge and WRITTEN by "mark
|
|
141
|
+
* all". One filter for both on purpose: the count and the write have to agree
|
|
142
|
+
* about which rows are in scope, and two copies of the same object literal is
|
|
143
|
+
* how a badge ends up saying 3 over a list of 2.
|
|
144
|
+
*/
|
|
145
|
+
function unreadWhere(userId: string, scope: NotificationScope): NotificationWhere {
|
|
146
|
+
const scoped = scopeBranch(scope);
|
|
147
|
+
return {
|
|
148
|
+
userId,
|
|
149
|
+
deletedAt: null,
|
|
150
|
+
readAt: null,
|
|
151
|
+
...(scoped.length > 0 ? { AND: scoped } : {}),
|
|
83
152
|
};
|
|
84
153
|
}
|
|
85
154
|
|
|
86
155
|
export function createInboxStore(db: NotificationsDbProvider): NotificationInboxStore {
|
|
87
156
|
return {
|
|
88
157
|
/** The owner's inbox, newest first, keyset-paginated, deleted excluded. */
|
|
89
|
-
async list(userId, input = {}) {
|
|
158
|
+
async list(userId, input = {}, scope) {
|
|
90
159
|
const client = await db();
|
|
91
160
|
const limit = Math.min(Math.max(input.limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
|
|
92
161
|
const anchor = input.cursor
|
|
@@ -94,7 +163,7 @@ export function createInboxStore(db: NotificationsDbProvider): NotificationInbox
|
|
|
94
163
|
: undefined;
|
|
95
164
|
if (input.cursor && !anchor) return { items: [], nextCursor: null };
|
|
96
165
|
const rows = await client.notification.findMany({
|
|
97
|
-
where: pageWhere(userId, input.filter, anchor),
|
|
166
|
+
where: pageWhere(userId, input.filter, anchor, scope),
|
|
98
167
|
// `id` tie-breaks equal timestamps so pages never skip/repeat.
|
|
99
168
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
100
169
|
take: limit + 1,
|
|
@@ -107,9 +176,9 @@ export function createInboxStore(db: NotificationsDbProvider): NotificationInbox
|
|
|
107
176
|
},
|
|
108
177
|
|
|
109
178
|
/** Unread badge count (non-deleted, unread). */
|
|
110
|
-
async unreadCount(userId) {
|
|
179
|
+
async unreadCount(userId, scope) {
|
|
111
180
|
const client = await db();
|
|
112
|
-
return client.notification.count({ where:
|
|
181
|
+
return client.notification.count({ where: unreadWhere(userId, scope) });
|
|
113
182
|
},
|
|
114
183
|
|
|
115
184
|
/**
|
|
@@ -128,10 +197,10 @@ export function createInboxStore(db: NotificationsDbProvider): NotificationInbox
|
|
|
128
197
|
},
|
|
129
198
|
|
|
130
199
|
/** Mark every unread notification of the owner read ("mark all"). */
|
|
131
|
-
async markAllRead(userId) {
|
|
200
|
+
async markAllRead(userId, scope) {
|
|
132
201
|
const client = await db();
|
|
133
202
|
const result = await client.notification.updateMany({
|
|
134
|
-
where:
|
|
203
|
+
where: unreadWhere(userId, scope),
|
|
135
204
|
data: { readAt: new Date() },
|
|
136
205
|
});
|
|
137
206
|
return result.count;
|
package/src/server/index.ts
CHANGED
|
@@ -34,11 +34,13 @@ export type {
|
|
|
34
34
|
NotificationPreferenceDelegate,
|
|
35
35
|
NotificationPreferenceRow,
|
|
36
36
|
NotificationWhere,
|
|
37
|
+
NotificationWhereBranch,
|
|
37
38
|
NotificationsDb,
|
|
38
39
|
NotificationsDbClient,
|
|
39
40
|
NotificationsDbProvider,
|
|
40
41
|
PushSubscriptionDelegate,
|
|
41
42
|
PushSubscriptionRow,
|
|
43
|
+
PushSubscriptionWhere,
|
|
42
44
|
} from './db';
|
|
43
45
|
|
|
44
46
|
export type { ListNotificationsInput, NotificationInboxStore } from './inbox';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { NotificationLogger } from '../types';
|
|
2
2
|
|
|
3
|
-
import type { NotificationsDbProvider } from './db';
|
|
3
|
+
import type { NotificationsDbProvider, PushSubscriptionWhere } from './db';
|
|
4
4
|
import type { WebPushSubscriptionSource } from './transports/web-push';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -16,6 +16,41 @@ export interface PushSubscriptionInput {
|
|
|
16
16
|
keys: { p256dh: string; auth: string };
|
|
17
17
|
/** Optional browser/device hint for a device list. */
|
|
18
18
|
userAgent?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Which ORIGIN this browser subscribed on — the HOST's resolved
|
|
21
|
+
* value, never anything the caller sent. Absent/null = the platform origin.
|
|
22
|
+
*/
|
|
23
|
+
clientId?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which of a user's subscriptions one notification may reach.
|
|
28
|
+
*
|
|
29
|
+
* Stated as a rule on the SUBSCRIPTION rather than as a fallback, because the
|
|
30
|
+
* fallback form ("the platform's rows only when the store's are absent")
|
|
31
|
+
* silently changes the platform origin's behaviour, and a marketplace host must
|
|
32
|
+
* be unaffected by this change:
|
|
33
|
+
*
|
|
34
|
+
* | the subscription was registered on | it receives |
|
|
35
|
+
* |---|---|
|
|
36
|
+
* | the PLATFORM origin (`client_id IS NULL`) | every notification — as today |
|
|
37
|
+
* | store X's origin | store X's, and platform-wide ones (`clientId IS NULL`) |
|
|
38
|
+
*
|
|
39
|
+
* Nothing ever reaches a subscription registered on a DIFFERENT store's origin.
|
|
40
|
+
* It follows without a special case that a store-B notification reaches a
|
|
41
|
+
* customer who installed only store A through their PLATFORM subscription, and
|
|
42
|
+
* through neither app.
|
|
43
|
+
*
|
|
44
|
+
* `notificationClientId` is the NOTIFICATION's tenant. `undefined` (nobody
|
|
45
|
+
* asked to narrow) and `null` (a platform-wide notification) both mean every
|
|
46
|
+
* row, which is why they share a branch.
|
|
47
|
+
*/
|
|
48
|
+
function reachableBy(
|
|
49
|
+
userId: string,
|
|
50
|
+
notificationClientId?: string | null,
|
|
51
|
+
): PushSubscriptionWhere {
|
|
52
|
+
if (notificationClientId === undefined || notificationClientId === null) return { userId };
|
|
53
|
+
return { userId, OR: [{ clientId: null }, { clientId: notificationClientId }] };
|
|
19
54
|
}
|
|
20
55
|
|
|
21
56
|
export interface PushSubscriptionStore extends WebPushSubscriptionSource {
|
|
@@ -34,8 +69,19 @@ export interface PushSubscriptionStore extends WebPushSubscriptionSource {
|
|
|
34
69
|
save(userId: string, input: PushSubscriptionInput): Promise<void>;
|
|
35
70
|
/** Remove one browser's subscription (owner-scoped; unknown = no-op). */
|
|
36
71
|
remove(userId: string, endpoint: string): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
|
|
72
|
+
/**
|
|
73
|
+
* How many devices the user has registered.
|
|
74
|
+
*
|
|
75
|
+
* TWO callers with different questions. The settings screen asks unscoped —
|
|
76
|
+
* "you have 2 devices" is a fact about the PERSON — and passes nothing. The
|
|
77
|
+
* dispatch path passes the notification's tenant, because this is what
|
|
78
|
+
* `supports()` gates on: left unscoped there, the router would enqueue a
|
|
79
|
+
* WEB_PUSH delivery for a notification no reachable subscription exists for,
|
|
80
|
+
* `send` would find an empty list and throw, and the sweep would burn every
|
|
81
|
+
* attempt before writing DEAD — a FAILED row for something that was never
|
|
82
|
+
* undeliverable, only out of scope.
|
|
83
|
+
*/
|
|
84
|
+
count(userId: string, notificationClientId?: string | null): Promise<number>;
|
|
39
85
|
/**
|
|
40
86
|
* Whether THIS endpoint is currently registered to THIS user.
|
|
41
87
|
*
|
|
@@ -75,12 +121,18 @@ export function createPushSubscriptionStore(
|
|
|
75
121
|
endpoint: input.endpoint,
|
|
76
122
|
p256dh: input.keys.p256dh,
|
|
77
123
|
auth: input.keys.auth,
|
|
124
|
+
clientId: input.clientId ?? null,
|
|
78
125
|
userAgent: input.userAgent ?? null,
|
|
79
126
|
},
|
|
127
|
+
// Re-stamped on every save, so the same browser moving between a store's
|
|
128
|
+
// app and the platform corrects its own scope rather than keeping the
|
|
129
|
+
// first origin it ever subscribed from. A scope change on the SAME user
|
|
130
|
+
// is not a re-own and must not log one.
|
|
80
131
|
update: {
|
|
81
132
|
userId,
|
|
82
133
|
p256dh: input.keys.p256dh,
|
|
83
134
|
auth: input.keys.auth,
|
|
135
|
+
clientId: input.clientId ?? null,
|
|
84
136
|
userAgent: input.userAgent ?? null,
|
|
85
137
|
},
|
|
86
138
|
});
|
|
@@ -91,9 +143,9 @@ export function createPushSubscriptionStore(
|
|
|
91
143
|
await client.pushSubscription.deleteMany({ where: { userId, endpoint } });
|
|
92
144
|
},
|
|
93
145
|
|
|
94
|
-
async count(userId) {
|
|
146
|
+
async count(userId, notificationClientId) {
|
|
95
147
|
const client = await db();
|
|
96
|
-
return client.pushSubscription.count({ where:
|
|
148
|
+
return client.pushSubscription.count({ where: reachableBy(userId, notificationClientId) });
|
|
97
149
|
},
|
|
98
150
|
|
|
99
151
|
async isRegisteredTo(userId, endpoint) {
|
|
@@ -102,9 +154,11 @@ export function createPushSubscriptionStore(
|
|
|
102
154
|
return row?.userId === userId;
|
|
103
155
|
},
|
|
104
156
|
|
|
105
|
-
async list(userId) {
|
|
157
|
+
async list(userId, notificationClientId) {
|
|
106
158
|
const client = await db();
|
|
107
|
-
const rows = await client.pushSubscription.findMany({
|
|
159
|
+
const rows = await client.pushSubscription.findMany({
|
|
160
|
+
where: reachableBy(userId, notificationClientId),
|
|
161
|
+
});
|
|
108
162
|
return rows.map((row) => ({
|
|
109
163
|
id: row.id,
|
|
110
164
|
endpoint: row.endpoint,
|
package/src/server/router.ts
CHANGED
|
@@ -279,7 +279,11 @@ export function createNotificationRouter(deps: NotificationRouterDeps): Notifica
|
|
|
279
279
|
Loading first also means a notification addressed to nobody now throws
|
|
280
280
|
before any content is built, which is the cheaper order anyway.
|
|
281
281
|
*/
|
|
282
|
-
const recipient = await loadRecipient(
|
|
282
|
+
const recipient = await loadRecipient(
|
|
283
|
+
deps,
|
|
284
|
+
event.recipient.userId,
|
|
285
|
+
event.recipient.clientId ?? null,
|
|
286
|
+
);
|
|
283
287
|
if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
|
|
284
288
|
|
|
285
289
|
// Forwarded exactly as the directory stated it, `undefined` included: the
|
package/src/server/routes.ts
CHANGED
|
@@ -66,12 +66,14 @@ interface NotificationRoutesDeps {
|
|
|
66
66
|
async function channelAvailability(
|
|
67
67
|
deps: NotificationRoutesDeps,
|
|
68
68
|
userId: string,
|
|
69
|
+
scopeClientId: string | null,
|
|
69
70
|
): Promise<Record<NotificationChannel, boolean>> {
|
|
70
71
|
const contact = await deps.contacts.getContact(userId);
|
|
71
72
|
const recipient = {
|
|
72
73
|
userId,
|
|
73
74
|
email: contact?.email ?? null,
|
|
74
75
|
phone: contact?.phone ?? null,
|
|
76
|
+
clientId: scopeClientId,
|
|
75
77
|
pushSubscriptionCount: 1,
|
|
76
78
|
};
|
|
77
79
|
return Object.fromEntries(
|
|
@@ -86,6 +88,7 @@ async function channelAvailability(
|
|
|
86
88
|
async function preferencesPayload(
|
|
87
89
|
deps: NotificationRoutesDeps,
|
|
88
90
|
userId: string,
|
|
91
|
+
scopeClientId: string | null,
|
|
89
92
|
): Promise<{
|
|
90
93
|
preferences: Record<string, Record<string, boolean>>;
|
|
91
94
|
availability: Record<NotificationChannel, boolean>;
|
|
@@ -93,7 +96,7 @@ async function preferencesPayload(
|
|
|
93
96
|
}> {
|
|
94
97
|
const [preferences, availability] = await Promise.all([
|
|
95
98
|
deps.preferences.get(userId),
|
|
96
|
-
channelAvailability(deps, userId),
|
|
99
|
+
channelAvailability(deps, userId, scopeClientId),
|
|
97
100
|
]);
|
|
98
101
|
// `categories` travels with the matrix so the settings screen renders the
|
|
99
102
|
// HOST's taxonomy without being told it twice (once in the api config, once
|
|
@@ -111,6 +114,7 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
111
114
|
await deps.inbox.list(
|
|
112
115
|
actor.userId,
|
|
113
116
|
parseListQuery(query, messagesOf(deps, locale)),
|
|
117
|
+
actor.scopeClientId,
|
|
114
118
|
),
|
|
115
119
|
),
|
|
116
120
|
),
|
|
@@ -120,7 +124,7 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
120
124
|
path: '/notifications/unread-count',
|
|
121
125
|
handle: guarded(async ({ actor }) =>
|
|
122
126
|
// Polled by the SPAs, so it stays a single indexed COUNT.
|
|
123
|
-
ok({ count: await deps.inbox.unreadCount(actor.userId) }),
|
|
127
|
+
ok({ count: await deps.inbox.unreadCount(actor.userId, actor.scopeClientId) }),
|
|
124
128
|
),
|
|
125
129
|
},
|
|
126
130
|
{
|
|
@@ -130,7 +134,7 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
130
134
|
const target = parseMarkReadBody(body, messagesOf(deps, locale));
|
|
131
135
|
const updated =
|
|
132
136
|
'all' in target
|
|
133
|
-
? await deps.inbox.markAllRead(actor.userId)
|
|
137
|
+
? await deps.inbox.markAllRead(actor.userId, actor.scopeClientId)
|
|
134
138
|
: await deps.inbox.markRead(actor.userId, target.ids);
|
|
135
139
|
// Only when something actually flipped. This endpoint is idempotent, so
|
|
136
140
|
// a re-send of an already-read id reports `updated: 0` and has changed
|
|
@@ -163,7 +167,9 @@ function preferenceRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
163
167
|
{
|
|
164
168
|
method: 'GET',
|
|
165
169
|
path: '/notification-preferences',
|
|
166
|
-
handle: guarded(async ({ actor }) =>
|
|
170
|
+
handle: guarded(async ({ actor }) =>
|
|
171
|
+
ok(await preferencesPayload(deps, actor.userId, actor.scopeClientId ?? null)),
|
|
172
|
+
),
|
|
167
173
|
},
|
|
168
174
|
{
|
|
169
175
|
method: 'PUT',
|
|
@@ -175,7 +181,7 @@ function preferenceRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
175
181
|
actor.userId,
|
|
176
182
|
parsePreferencesBody(body, messagesOf(deps, locale)),
|
|
177
183
|
);
|
|
178
|
-
return ok(await preferencesPayload(deps, actor.userId));
|
|
184
|
+
return ok(await preferencesPayload(deps, actor.userId, actor.scopeClientId ?? null));
|
|
179
185
|
}),
|
|
180
186
|
},
|
|
181
187
|
];
|
|
@@ -208,6 +214,9 @@ function pushRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
208
214
|
const input = parsePushSubscriptionBody(body, messagesOf(deps, locale));
|
|
209
215
|
const userAgent = headers?.['user-agent'];
|
|
210
216
|
await deps.pushSubscriptions.save(actor.userId, {
|
|
217
|
+
// The HOST's resolved origin, never the body's — a caller cannot
|
|
218
|
+
// choose which store's app their browser counts as.
|
|
219
|
+
clientId: actor.scopeClientId ?? null,
|
|
211
220
|
...input,
|
|
212
221
|
...(userAgent ? { userAgent } : {}),
|
|
213
222
|
});
|
|
@@ -90,7 +90,20 @@ export interface WebPushDriverDeclaration extends DriverDeclarationBase {
|
|
|
90
90
|
|
|
91
91
|
/** The subscriptions the transport reads and prunes (db-backed by the mount). */
|
|
92
92
|
export interface WebPushSubscriptionSource {
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The subscriptions one notification may reach.
|
|
95
|
+
*
|
|
96
|
+
* `notificationClientId` is the notification's tenant — `null` for a
|
|
97
|
+
* platform-wide one, which every subscription receives. An ADOPTER with a
|
|
98
|
+
* hand-written source must honour it: a function declared with fewer
|
|
99
|
+
* parameters still type-checks, so an un-updated implementation silently
|
|
100
|
+
* ignores the scope and keeps fanning out to every origin, with nothing
|
|
101
|
+
* failing to compile to say so.
|
|
102
|
+
*/
|
|
103
|
+
list(
|
|
104
|
+
userId: string,
|
|
105
|
+
notificationClientId?: string | null,
|
|
106
|
+
): Promise<{ id: string; endpoint: string; p256dh: string; auth: string }[]>;
|
|
94
107
|
prune(id: string): Promise<void>;
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -159,7 +172,7 @@ export function webPushTransport(
|
|
|
159
172
|
supports: (recipient: TransportRecipient) => recipient.pushSubscriptionCount > 0,
|
|
160
173
|
format: formatWebPush,
|
|
161
174
|
async send(message, recipient) {
|
|
162
|
-
const rows = await subscriptions.list(recipient.userId);
|
|
175
|
+
const rows = await subscriptions.list(recipient.userId, recipient.clientId);
|
|
163
176
|
if (rows.length === 0) throw new Error('Recipient no longer has push subscriptions.');
|
|
164
177
|
const payload = JSON.stringify(message);
|
|
165
178
|
let delivered = 0;
|
package/src/types.ts
CHANGED
|
@@ -190,8 +190,26 @@ export interface TransportRecipient {
|
|
|
190
190
|
locale?: string | null;
|
|
191
191
|
/** Phone as the host stores it (transports normalize per provider rules). */
|
|
192
192
|
phone: string | null;
|
|
193
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* How many push subscriptions this NOTIFICATION can actually reach — not how
|
|
195
|
+
* many the user holds. Scoped by {@link TransportRecipient.clientId}.
|
|
196
|
+
*/
|
|
194
197
|
pushSubscriptionCount: number;
|
|
198
|
+
/**
|
|
199
|
+
* The store this notification belongs to, or `null` for a platform-wide one
|
|
200
|
+
*.
|
|
201
|
+
*
|
|
202
|
+
* REQUIRED, and deliberately so where optional is the tempting answer. An
|
|
203
|
+
* optional field fails OPEN: a construction site that forgets it yields
|
|
204
|
+
* `undefined`, which the push scope reads as "platform notification -> every
|
|
205
|
+
* subscription" — precisely the cross-store fan-out this exists to stop.
|
|
206
|
+
* Three construction sites is the argument FOR requiring it, not against; the
|
|
207
|
+
* compiler then names them.
|
|
208
|
+
*
|
|
209
|
+
* Not a breaking change for adopters: every external use CONSUMES a recipient
|
|
210
|
+
* through `supports`/`send` below, and the only constructors are in-package.
|
|
211
|
+
*/
|
|
212
|
+
clientId: string | null;
|
|
195
213
|
}
|
|
196
214
|
|
|
197
215
|
/**
|