@serve.zone/dcrouter 17.7.0 → 17.8.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/deno.json +1 -1
- package/dist_serve/bundle.js +410 -410
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +23 -1
- package/dist_ts/config/classes.gateway-client-manager.js +3 -1
- package/dist_ts/db/documents/classes.cached.email.d.ts +16 -1
- package/dist_ts/db/documents/classes.cached.email.js +67 -4
- package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
- package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
- package/dist_ts/db/documents/index.d.ts +3 -0
- package/dist_ts/db/documents/index.js +5 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
- package/dist_ts/email/classes.accepted-email-spool.js +31 -3
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
- package/dist_ts/opsserver/classes.opsserver.js +5 -1
- package/dist_ts/opsserver/handlers/email-ops.handler.js +5 -3
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +11 -5
- package/dist_ts/opsserver/handlers/index.d.ts +2 -0
- package/dist_ts/opsserver/handlers/index.js +3 -1
- package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
- package/dist_ts/plugins.d.ts +5 -2
- package/dist_ts/plugins.js +6 -3
- package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
- package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
- package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
- package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
- package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
- package/dist_ts/webpush/classes.webpush-transport.js +161 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
- package/dist_ts/webpush/index.d.ts +4 -0
- package/dist_ts/webpush/index.js +5 -0
- package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
- package/dist_ts/webpush/webpush-indexes.js +123 -0
- package/dist_ts_interfaces/data/route-management.d.ts +2 -0
- package/dist_ts_interfaces/data/route-management.js +1 -1
- package/dist_ts_migrations/index.js +221 -11
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +13 -9
- package/readme.md +27 -0
- package/third-party-notices.md +37 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +29 -0
- package/ts/config/classes.gateway-client-manager.ts +2 -0
- package/ts/db/documents/classes.cached.email.ts +58 -1
- package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
- package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
- package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
- package/ts/db/documents/index.ts +5 -0
- package/ts/email/classes.accepted-email-spool.ts +44 -2
- package/ts/email/classes.workapp-mail-manager.ts +97 -10
- package/ts/index.ts +3 -0
- package/ts/opsserver/classes.opsserver.ts +4 -0
- package/ts/opsserver/handlers/email-ops.handler.ts +4 -2
- package/ts/opsserver/handlers/gatewayclient.handler.ts +10 -3
- package/ts/opsserver/handlers/index.ts +2 -0
- package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
- package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
- package/ts/plugins.ts +6 -0
- package/ts/webpush/classes.webpush-crypto.ts +306 -0
- package/ts/webpush/classes.webpush-manager.ts +3173 -0
- package/ts/webpush/classes.webpush-transport.ts +241 -0
- package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
- package/ts/webpush/index.ts +4 -0
- package/ts/webpush/webpush-indexes.ts +141 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/readme.plan.md +0 -22
|
@@ -0,0 +1,3173 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { logger } from '../logger.js';
|
|
3
|
+
import {
|
|
4
|
+
DcRouterDb,
|
|
5
|
+
WebPushAdmissionDoc,
|
|
6
|
+
WebPushBindingDoc,
|
|
7
|
+
WebPushSpoolDoc,
|
|
8
|
+
type IWebPushCredentialRotationRecovery,
|
|
9
|
+
type IWebPushStoredControllerFence,
|
|
10
|
+
type IWebPushStoredCredential,
|
|
11
|
+
type IWebPushStoredVapidKey,
|
|
12
|
+
type TWebPushControllerIntent,
|
|
13
|
+
} from '../db/index.js';
|
|
14
|
+
import {
|
|
15
|
+
WebPushCrypto,
|
|
16
|
+
canonicalJson,
|
|
17
|
+
parseWebPushKeyRing,
|
|
18
|
+
type IWebPushAadContext,
|
|
19
|
+
} from './classes.webpush-crypto.js';
|
|
20
|
+
import {
|
|
21
|
+
WebPushHttpTransport,
|
|
22
|
+
WebPushTransportError,
|
|
23
|
+
type IWebPushTransport,
|
|
24
|
+
} from './classes.webpush-transport.js';
|
|
25
|
+
import { WebPushEndpointPolicyError } from './helpers.webpush-endpoint.js';
|
|
26
|
+
import {
|
|
27
|
+
getWebPushIndexSpecifications,
|
|
28
|
+
type IWebPushIndexSpecification,
|
|
29
|
+
type TWebPushIndexCollection,
|
|
30
|
+
} from './webpush-indexes.js';
|
|
31
|
+
|
|
32
|
+
type IWebPushBinding = plugins.servezoneInterfaces.data.IWebPushBinding;
|
|
33
|
+
type IWebPushCredentialOneTimeSecret =
|
|
34
|
+
plugins.servezoneInterfaces.data.IWebPushCredentialOneTimeSecret;
|
|
35
|
+
type IWebPushCredentialPublic =
|
|
36
|
+
plugins.servezoneInterfaces.data.IWebPushCredentialPublic;
|
|
37
|
+
type IWebPushDeliveryStatus = plugins.servezoneInterfaces.data.IWebPushDeliveryStatus;
|
|
38
|
+
type IWebPushNotificationPayload =
|
|
39
|
+
plugins.servezoneInterfaces.data.IWebPushNotificationPayload;
|
|
40
|
+
type IWebPushResourceOwner = plugins.servezoneInterfaces.data.IWebPushResourceOwner;
|
|
41
|
+
type IWebPushServiceStatus = plugins.servezoneInterfaces.data.IWebPushServiceStatus;
|
|
42
|
+
type IWebPushSubscription = plugins.servezoneInterfaces.data.IWebPushSubscription;
|
|
43
|
+
type TWebPushBindingSync =
|
|
44
|
+
plugins.servezoneInterfaces.requests.webpush.TWebPushBindingSync;
|
|
45
|
+
type IWebPushAppCredentialAuth =
|
|
46
|
+
plugins.servezoneInterfaces.requests.webpush.IWebPushAppCredentialAuth;
|
|
47
|
+
type TWebPushCancellationTarget =
|
|
48
|
+
plugins.servezoneInterfaces.data.TWebPushCancellationTarget;
|
|
49
|
+
type TWebPushDeliveryState = plugins.servezoneInterfaces.data.TWebPushDeliveryState;
|
|
50
|
+
type TWebPushUrgency = plugins.servezoneInterfaces.data.TWebPushUrgency;
|
|
51
|
+
|
|
52
|
+
interface IWebPushQenv {
|
|
53
|
+
getEnvVarOnDemand(nameArg: string): Promise<string | undefined>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IWebPushManagerOptions {
|
|
57
|
+
qenv?: IWebPushQenv;
|
|
58
|
+
transport?: IWebPushTransport;
|
|
59
|
+
startWorker?: boolean;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
admissionPolicy?: Partial<IWebPushAdmissionPolicy>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface IWebPushAdmissionPolicy {
|
|
65
|
+
shortWindowMs: number;
|
|
66
|
+
shortLimit: number;
|
|
67
|
+
longWindowMs: number;
|
|
68
|
+
longLimit: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface IWebPushAuthenticatedBinding {
|
|
72
|
+
binding: WebPushBindingDoc;
|
|
73
|
+
credential: IWebPushStoredCredential;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface IWebPushBindingSyncResult {
|
|
77
|
+
binding: IWebPushBinding;
|
|
78
|
+
credential?: IWebPushCredentialOneTimeSecret;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Durable controller ordering token. The controller id and epoch remain stable
|
|
83
|
+
* for an owner, while generation increases for every desired-state mutation.
|
|
84
|
+
*/
|
|
85
|
+
export interface IWebPushControllerMutation {
|
|
86
|
+
id: string;
|
|
87
|
+
epoch: string;
|
|
88
|
+
generation: number;
|
|
89
|
+
operationId: string;
|
|
90
|
+
intent: TWebPushControllerIntent;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface IWebPushBindingDelete {
|
|
94
|
+
id?: string;
|
|
95
|
+
owner: IWebPushResourceOwner;
|
|
96
|
+
controller: IWebPushControllerMutation;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface IWebPushEnqueueResult {
|
|
100
|
+
accepted: boolean;
|
|
101
|
+
spoolItemId: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface IWebPushCancelResult {
|
|
105
|
+
cancelledCount: number;
|
|
106
|
+
alreadyTerminalCount: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const MAX_PAYLOAD_BYTES = 3500;
|
|
110
|
+
const MAX_TTL_SECONDS = 86_400;
|
|
111
|
+
const DEFAULT_TTL_SECONDS = 86_400;
|
|
112
|
+
const MAX_ID_LENGTH = 256;
|
|
113
|
+
const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
|
|
114
|
+
const MAX_COLLAPSE_KEY_LENGTH = 256;
|
|
115
|
+
const MAX_ROUTE_LENGTH = 2048;
|
|
116
|
+
const MAX_EVENT_ID_LENGTH = 256;
|
|
117
|
+
const MAX_NONTERMINAL_ITEMS_PER_BINDING = 10_000;
|
|
118
|
+
const DEFAULT_ADMISSION_POLICY: IWebPushAdmissionPolicy = {
|
|
119
|
+
shortWindowMs: 60_000,
|
|
120
|
+
shortLimit: 120,
|
|
121
|
+
longWindowMs: 60 * 60_000,
|
|
122
|
+
longLimit: 5_000,
|
|
123
|
+
};
|
|
124
|
+
const MAX_ADMISSION_CAS_ATTEMPTS = 512;
|
|
125
|
+
const DELIVERY_BATCH_SIZE = 25;
|
|
126
|
+
const DELIVERY_LEASE_MS = 60_000;
|
|
127
|
+
const DELIVERY_WORKER_INTERVAL_MS = 1_000;
|
|
128
|
+
const DELIVERY_MAX_ATTEMPTS = 8;
|
|
129
|
+
const TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
130
|
+
const CREDENTIAL_OVERLAP_MS = 15 * 60 * 1000;
|
|
131
|
+
const VAPID_RETIREMENT_MS = 90 * 24 * 60 * 60 * 1000;
|
|
132
|
+
const VAPID_KEY_RING_LIMIT = 4;
|
|
133
|
+
const LIFECYCLE_DRAIN_TIMEOUT_MS = DELIVERY_LEASE_MS + 5_000;
|
|
134
|
+
const LIFECYCLE_DRAIN_POLL_MS = 250;
|
|
135
|
+
const MAINTENANCE_BATCH_SIZE = 100;
|
|
136
|
+
const OPAQUE_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
137
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
138
|
+
const TERMINAL_STATES: TWebPushDeliveryState[] = [
|
|
139
|
+
'pushServiceAccepted',
|
|
140
|
+
'invalidSubscription',
|
|
141
|
+
'failed',
|
|
142
|
+
'expired',
|
|
143
|
+
'cancelled',
|
|
144
|
+
];
|
|
145
|
+
const NONTERMINAL_STATES: TWebPushDeliveryState[] = [
|
|
146
|
+
'accepted',
|
|
147
|
+
'queued',
|
|
148
|
+
'sending',
|
|
149
|
+
'deferred',
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
function requireNonEmptyString(
|
|
153
|
+
valueArg: unknown,
|
|
154
|
+
labelArg: string,
|
|
155
|
+
maxLengthArg = MAX_ID_LENGTH,
|
|
156
|
+
): string {
|
|
157
|
+
if (
|
|
158
|
+
typeof valueArg !== 'string'
|
|
159
|
+
|| valueArg.trim().length === 0
|
|
160
|
+
|| valueArg.length > maxLengthArg
|
|
161
|
+
) {
|
|
162
|
+
throw new plugins.typedrequest.TypedResponseError(`${labelArg} is missing or malformed`);
|
|
163
|
+
}
|
|
164
|
+
return valueArg.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function requireOpaqueId(valueArg: unknown, labelArg: string): string {
|
|
168
|
+
const value = requireNonEmptyString(valueArg, labelArg);
|
|
169
|
+
if (!OPAQUE_ID_PATTERN.test(value) || value.includes('://')) {
|
|
170
|
+
throw new plugins.typedrequest.TypedResponseError(`${labelArg} is malformed`);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requireBase64Url(
|
|
176
|
+
valueArg: unknown,
|
|
177
|
+
labelArg: string,
|
|
178
|
+
expectedLengthArg: number,
|
|
179
|
+
): string {
|
|
180
|
+
const value = requireNonEmptyString(valueArg, labelArg, 512);
|
|
181
|
+
if (!BASE64URL_PATTERN.test(value)) {
|
|
182
|
+
throw new plugins.typedrequest.TypedResponseError(`${labelArg} is malformed`);
|
|
183
|
+
}
|
|
184
|
+
const decoded = Buffer.from(value, 'base64url');
|
|
185
|
+
if (
|
|
186
|
+
decoded.length !== expectedLengthArg
|
|
187
|
+
|| decoded.toString('base64url') !== value
|
|
188
|
+
) {
|
|
189
|
+
throw new plugins.typedrequest.TypedResponseError(`${labelArg} is malformed`);
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeOwner(ownerArg: IWebPushResourceOwner): IWebPushResourceOwner {
|
|
195
|
+
if (!['onebox', 'cloudly', 'custom'].includes(ownerArg?.gatewayClientType)) {
|
|
196
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push owner gatewayClientType is malformed');
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
gatewayClientType: ownerArg.gatewayClientType,
|
|
200
|
+
gatewayClientId: requireOpaqueId(ownerArg.gatewayClientId, 'Web Push owner gatewayClientId'),
|
|
201
|
+
appInstanceId: requireOpaqueId(ownerArg.appInstanceId, 'Web Push owner appInstanceId'),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeSubscription(subscriptionArg: IWebPushSubscription): IWebPushSubscription {
|
|
206
|
+
if (!subscriptionArg || typeof subscriptionArg !== 'object') {
|
|
207
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push subscription is malformed');
|
|
208
|
+
}
|
|
209
|
+
const endpoint = requireNonEmptyString(subscriptionArg.endpoint, 'Web Push subscription endpoint', 4096);
|
|
210
|
+
const expirationTime = subscriptionArg.expirationTime;
|
|
211
|
+
if (
|
|
212
|
+
expirationTime !== null
|
|
213
|
+
&& (!Number.isSafeInteger(expirationTime) || expirationTime < 0)
|
|
214
|
+
) {
|
|
215
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push subscription expirationTime is malformed');
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
endpoint,
|
|
219
|
+
expirationTime,
|
|
220
|
+
keys: {
|
|
221
|
+
p256dh: requireBase64Url(
|
|
222
|
+
subscriptionArg.keys?.p256dh,
|
|
223
|
+
'Web Push subscription p256dh key',
|
|
224
|
+
65,
|
|
225
|
+
),
|
|
226
|
+
auth: requireBase64Url(
|
|
227
|
+
subscriptionArg.keys?.auth,
|
|
228
|
+
'Web Push subscription auth key',
|
|
229
|
+
16,
|
|
230
|
+
),
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizePayload(payloadArg: IWebPushNotificationPayload): IWebPushNotificationPayload {
|
|
236
|
+
if (!payloadArg || typeof payloadArg !== 'object' || Array.isArray(payloadArg)) {
|
|
237
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload is malformed');
|
|
238
|
+
}
|
|
239
|
+
const keys = Object.keys(payloadArg).sort();
|
|
240
|
+
if (keys.join(',') !== 'event,eventId,route,schemaVersion') {
|
|
241
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload contains unsupported fields');
|
|
242
|
+
}
|
|
243
|
+
if (payloadArg.schemaVersion !== 1 || payloadArg.event !== 'notificationAvailable') {
|
|
244
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload schema is unsupported');
|
|
245
|
+
}
|
|
246
|
+
const eventId = requireOpaqueId(payloadArg.eventId, 'Web Push payload eventId');
|
|
247
|
+
if (eventId.length > MAX_EVENT_ID_LENGTH) {
|
|
248
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload eventId is too long');
|
|
249
|
+
}
|
|
250
|
+
const route = requireNonEmptyString(payloadArg.route, 'Web Push payload route', MAX_ROUTE_LENGTH);
|
|
251
|
+
if (!route.startsWith('/') || route.startsWith('//') || route.includes('\\')) {
|
|
252
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload route must be same-origin');
|
|
253
|
+
}
|
|
254
|
+
let parsedRoute: URL;
|
|
255
|
+
try {
|
|
256
|
+
parsedRoute = new URL(route, 'https://webpush.invalid');
|
|
257
|
+
} catch {
|
|
258
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload route is malformed');
|
|
259
|
+
}
|
|
260
|
+
if (parsedRoute.origin !== 'https://webpush.invalid') {
|
|
261
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload route must be same-origin');
|
|
262
|
+
}
|
|
263
|
+
const normalized = {
|
|
264
|
+
schemaVersion: 1 as const,
|
|
265
|
+
event: 'notificationAvailable' as const,
|
|
266
|
+
eventId,
|
|
267
|
+
route,
|
|
268
|
+
};
|
|
269
|
+
if (Buffer.byteLength(canonicalJson(normalized), 'utf8') > MAX_PAYLOAD_BYTES) {
|
|
270
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push payload exceeds the maximum size');
|
|
271
|
+
}
|
|
272
|
+
return normalized;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizeTtl(ttlArg: number | undefined): number {
|
|
276
|
+
const ttl = ttlArg ?? DEFAULT_TTL_SECONDS;
|
|
277
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {
|
|
278
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push ttlSeconds is malformed');
|
|
279
|
+
}
|
|
280
|
+
return ttl;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeUrgency(urgencyArg: TWebPushUrgency | undefined): TWebPushUrgency {
|
|
284
|
+
const urgency = urgencyArg ?? 'normal';
|
|
285
|
+
if (!['very-low', 'low', 'normal', 'high'].includes(urgency)) {
|
|
286
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push urgency is malformed');
|
|
287
|
+
}
|
|
288
|
+
return urgency;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizeFeatureFlag(valueArg: string | undefined): boolean {
|
|
292
|
+
if (valueArg === undefined || !valueArg.trim()) return false;
|
|
293
|
+
const value = valueArg.trim().toLowerCase();
|
|
294
|
+
if (['false', '0', 'off', 'no'].includes(value)) return false;
|
|
295
|
+
if (['true', '1', 'on', 'yes'].includes(value)) return true;
|
|
296
|
+
throw new Error('DCROUTER_WEB_PUSH_ENABLED must be an explicit boolean');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeAdmissionPolicy(
|
|
300
|
+
policyArg: Partial<IWebPushAdmissionPolicy> | undefined,
|
|
301
|
+
): IWebPushAdmissionPolicy {
|
|
302
|
+
const policy = {
|
|
303
|
+
...DEFAULT_ADMISSION_POLICY,
|
|
304
|
+
...(policyArg || {}),
|
|
305
|
+
};
|
|
306
|
+
for (const [name, value] of Object.entries(policy)) {
|
|
307
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
308
|
+
throw new Error(`Web Push admission policy ${name} must be a positive integer`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
policy.shortWindowMs > policy.longWindowMs
|
|
313
|
+
|| policy.shortLimit > policy.longLimit
|
|
314
|
+
) {
|
|
315
|
+
throw new Error('Web Push admission policy short window must fit inside the long window');
|
|
316
|
+
}
|
|
317
|
+
return policy;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function requireLifecycleGeneration(valueArg: unknown): number {
|
|
321
|
+
if (!Number.isSafeInteger(valueArg) || (valueArg as number) < 1) {
|
|
322
|
+
throw new Error('Web Push binding lifecycle generation is malformed');
|
|
323
|
+
}
|
|
324
|
+
return valueArg as number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeControllerMutation(
|
|
328
|
+
controllerArg: IWebPushControllerMutation,
|
|
329
|
+
expectedIntentArg: TWebPushControllerIntent,
|
|
330
|
+
): IWebPushControllerMutation {
|
|
331
|
+
if (!controllerArg || typeof controllerArg !== 'object') {
|
|
332
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
333
|
+
'Web Push controller mutation is missing or malformed',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (
|
|
337
|
+
!Number.isSafeInteger(controllerArg.generation)
|
|
338
|
+
|| controllerArg.generation < 1
|
|
339
|
+
) {
|
|
340
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
341
|
+
'Web Push controller generation is malformed',
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (controllerArg.intent !== expectedIntentArg) {
|
|
345
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
346
|
+
'Web Push controller intent does not match the requested mutation',
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
id: requireOpaqueId(controllerArg.id, 'Web Push controller id'),
|
|
351
|
+
epoch: requireOpaqueId(controllerArg.epoch, 'Web Push controller epoch'),
|
|
352
|
+
generation: controllerArg.generation,
|
|
353
|
+
operationId: requireOpaqueId(
|
|
354
|
+
controllerArg.operationId,
|
|
355
|
+
'Web Push controller operation id',
|
|
356
|
+
),
|
|
357
|
+
intent: expectedIntentArg,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function normalizeDeleteControllerMutation(
|
|
362
|
+
controllerArg: IWebPushControllerMutation,
|
|
363
|
+
): IWebPushControllerMutation {
|
|
364
|
+
if (!controllerArg || !['disabled', 'deleted'].includes(controllerArg.intent)) {
|
|
365
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
366
|
+
'Web Push delete controller intent must be disabled or deleted',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return normalizeControllerMutation(controllerArg, controllerArg.intent);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function controllerRequestDigest(valueArg: unknown): string {
|
|
373
|
+
return plugins.crypto
|
|
374
|
+
.createHash('sha256')
|
|
375
|
+
.update('dcrouter:webpush:controller:v1\0', 'utf8')
|
|
376
|
+
.update(canonicalJson(valueArg), 'utf8')
|
|
377
|
+
.digest('base64url');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function assertStoredControllerFence(
|
|
381
|
+
fenceArg: IWebPushStoredControllerFence,
|
|
382
|
+
): void {
|
|
383
|
+
const hasValidOpaqueId = (valueArg: unknown): valueArg is string => (
|
|
384
|
+
typeof valueArg === 'string'
|
|
385
|
+
&& valueArg.length > 0
|
|
386
|
+
&& valueArg.length <= MAX_ID_LENGTH
|
|
387
|
+
&& valueArg === valueArg.trim()
|
|
388
|
+
&& OPAQUE_ID_PATTERN.test(valueArg)
|
|
389
|
+
&& !valueArg.includes('://')
|
|
390
|
+
);
|
|
391
|
+
const digest = typeof fenceArg?.requestDigest === 'string'
|
|
392
|
+
? fenceArg.requestDigest
|
|
393
|
+
: '';
|
|
394
|
+
const decodedDigest = BASE64URL_PATTERN.test(digest)
|
|
395
|
+
? Buffer.from(digest, 'base64url')
|
|
396
|
+
: Buffer.alloc(0);
|
|
397
|
+
if (
|
|
398
|
+
!fenceArg
|
|
399
|
+
|| typeof fenceArg !== 'object'
|
|
400
|
+
|| !hasValidOpaqueId(fenceArg.id)
|
|
401
|
+
|| !hasValidOpaqueId(fenceArg.epoch)
|
|
402
|
+
|| !Number.isSafeInteger(fenceArg.generation)
|
|
403
|
+
|| fenceArg.generation < 1
|
|
404
|
+
|| !hasValidOpaqueId(fenceArg.operationId)
|
|
405
|
+
|| !['enabled', 'disabled', 'deleted'].includes(fenceArg.intent)
|
|
406
|
+
|| decodedDigest.length !== 32
|
|
407
|
+
|| decodedDigest.toString('base64url') !== digest
|
|
408
|
+
|| !Number.isSafeInteger(fenceArg.appliedAt)
|
|
409
|
+
|| fenceArg.appliedAt < 0
|
|
410
|
+
) {
|
|
411
|
+
throw new Error('Stored Web Push controller fence is malformed');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function controllerFenceDecision(
|
|
416
|
+
fenceArg: IWebPushStoredControllerFence | undefined,
|
|
417
|
+
controllerArg: IWebPushControllerMutation,
|
|
418
|
+
requestDigestArg: string,
|
|
419
|
+
): 'apply' | 'replay' {
|
|
420
|
+
if (fenceArg === undefined) return 'apply';
|
|
421
|
+
assertStoredControllerFence(fenceArg);
|
|
422
|
+
if (fenceArg.id !== controllerArg.id || fenceArg.epoch !== controllerArg.epoch) {
|
|
423
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
424
|
+
'Web Push binding is owned by a different controller epoch',
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (controllerArg.generation < fenceArg.generation) {
|
|
428
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
429
|
+
'Web Push controller generation is stale',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if (controllerArg.generation > fenceArg.generation) return 'apply';
|
|
433
|
+
if (
|
|
434
|
+
fenceArg.operationId !== controllerArg.operationId
|
|
435
|
+
|| fenceArg.intent !== controllerArg.intent
|
|
436
|
+
|| fenceArg.requestDigest !== requestDigestArg
|
|
437
|
+
) {
|
|
438
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
439
|
+
'Web Push controller generation conflicts with an applied mutation',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return 'replay';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function createControllerFence(
|
|
446
|
+
controllerArg: IWebPushControllerMutation,
|
|
447
|
+
requestDigestArg: string,
|
|
448
|
+
nowArg: number,
|
|
449
|
+
): IWebPushStoredControllerFence {
|
|
450
|
+
return {
|
|
451
|
+
...controllerArg,
|
|
452
|
+
requestDigest: requestDigestArg,
|
|
453
|
+
appliedAt: nowArg,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function validateVapidSubject(subjectArg: string): string {
|
|
458
|
+
const subject = requireNonEmptyString(subjectArg, 'Web Push VAPID subject', 2048);
|
|
459
|
+
let parsed: URL;
|
|
460
|
+
try {
|
|
461
|
+
parsed = new URL(subject);
|
|
462
|
+
} catch {
|
|
463
|
+
throw new Error('DCROUTER_WEB_PUSH_VAPID_SUBJECT must be an https: or mailto: URI');
|
|
464
|
+
}
|
|
465
|
+
if (!['https:', 'mailto:'].includes(parsed.protocol)) {
|
|
466
|
+
throw new Error('DCROUTER_WEB_PUSH_VAPID_SUBJECT must be an https: or mailto: URI');
|
|
467
|
+
}
|
|
468
|
+
if (parsed.protocol === 'https:' && parsed.hostname.toLowerCase() === 'localhost') {
|
|
469
|
+
throw new Error('DCROUTER_WEB_PUSH_VAPID_SUBJECT must not use localhost');
|
|
470
|
+
}
|
|
471
|
+
return subject;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function constantTimeDigestEqual(leftArg: string, rightArg: string): boolean {
|
|
475
|
+
if (!BASE64URL_PATTERN.test(leftArg) || !BASE64URL_PATTERN.test(rightArg)) return false;
|
|
476
|
+
const left = Buffer.from(leftArg, 'base64url');
|
|
477
|
+
const right = Buffer.from(rightArg, 'base64url');
|
|
478
|
+
return left.length === right.length && plugins.crypto.timingSafeEqual(left, right);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isDuplicateKeyError(errorArg: unknown): boolean {
|
|
482
|
+
return (errorArg as { code?: unknown })?.code === 11000;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function asBindingDoc(rawArg: unknown): WebPushBindingDoc {
|
|
486
|
+
return rawArg as WebPushBindingDoc;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function asAdmissionDoc(rawArg: unknown): WebPushAdmissionDoc {
|
|
490
|
+
return rawArg as WebPushAdmissionDoc;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function asSpoolDoc(rawArg: unknown): WebPushSpoolDoc {
|
|
494
|
+
return rawArg as WebPushSpoolDoc;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function toCredentialPublic(
|
|
498
|
+
credentialArg: IWebPushStoredCredential,
|
|
499
|
+
): IWebPushCredentialPublic {
|
|
500
|
+
return {
|
|
501
|
+
id: credentialArg.id,
|
|
502
|
+
status: credentialArg.status,
|
|
503
|
+
createdAt: credentialArg.createdAt,
|
|
504
|
+
updatedAt: credentialArg.updatedAt,
|
|
505
|
+
...(credentialArg.lastRotatedAt !== null
|
|
506
|
+
? { lastRotatedAt: credentialArg.lastRotatedAt }
|
|
507
|
+
: {}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function toVapidKeyPublic(
|
|
512
|
+
keyArg: IWebPushStoredVapidKey,
|
|
513
|
+
): plugins.servezoneInterfaces.data.IWebPushVapidKeyPublic {
|
|
514
|
+
return {
|
|
515
|
+
id: keyArg.id,
|
|
516
|
+
publicKey: keyArg.publicKey,
|
|
517
|
+
status: keyArg.status,
|
|
518
|
+
createdAt: keyArg.createdAt,
|
|
519
|
+
...(keyArg.activatedAt !== null ? { activatedAt: keyArg.activatedAt } : {}),
|
|
520
|
+
...(keyArg.retireAfter !== null ? { retireAfter: keyArg.retireAfter } : {}),
|
|
521
|
+
...(keyArg.retiredAt !== null ? { retiredAt: keyArg.retiredAt } : {}),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function toPublicWebPushBinding(bindingArg: WebPushBindingDoc): IWebPushBinding {
|
|
526
|
+
return {
|
|
527
|
+
id: bindingArg.id,
|
|
528
|
+
owner: {
|
|
529
|
+
gatewayClientType: bindingArg.ownerGatewayClientType,
|
|
530
|
+
gatewayClientId: bindingArg.ownerGatewayClientId,
|
|
531
|
+
appInstanceId: bindingArg.ownerAppInstanceId,
|
|
532
|
+
},
|
|
533
|
+
enabled: bindingArg.enabled,
|
|
534
|
+
status: bindingArg.status,
|
|
535
|
+
credential: toCredentialPublic(bindingArg.credential),
|
|
536
|
+
vapidKeys: (bindingArg.vapidKeys || []).map((keyArg) => toVapidKeyPublic(keyArg)),
|
|
537
|
+
createdAt: bindingArg.createdAt,
|
|
538
|
+
updatedAt: bindingArg.updatedAt,
|
|
539
|
+
createdBy: bindingArg.createdBy,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function toPublicWebPushDelivery(spoolArg: WebPushSpoolDoc): IWebPushDeliveryStatus {
|
|
544
|
+
return {
|
|
545
|
+
spoolItemId: spoolArg.id,
|
|
546
|
+
state: spoolArg.state,
|
|
547
|
+
attempts: spoolArg.attempts,
|
|
548
|
+
acceptedAt: spoolArg.acceptedAt,
|
|
549
|
+
updatedAt: spoolArg.updatedAt,
|
|
550
|
+
...(!TERMINAL_STATES.includes(spoolArg.state)
|
|
551
|
+
? { nextAttemptAt: spoolArg.nextAttemptAt }
|
|
552
|
+
: {}),
|
|
553
|
+
...(spoolArg.terminalAt !== undefined ? { terminalAt: spoolArg.terminalAt } : {}),
|
|
554
|
+
...(spoolArg.pushServiceStatusCode !== null
|
|
555
|
+
? { pushServiceStatusCode: spoolArg.pushServiceStatusCode }
|
|
556
|
+
: {}),
|
|
557
|
+
...(spoolArg.errorCode ? { errorCode: spoolArg.errorCode } : {}),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function buildWebPushTerminalUpdate(optionsArg: {
|
|
562
|
+
state: Extract<
|
|
563
|
+
TWebPushDeliveryState,
|
|
564
|
+
'pushServiceAccepted' | 'invalidSubscription' | 'failed' | 'expired' | 'cancelled'
|
|
565
|
+
>;
|
|
566
|
+
now: number;
|
|
567
|
+
pushServiceStatusCode?: number;
|
|
568
|
+
errorCode?: string;
|
|
569
|
+
}): Record<string, unknown> {
|
|
570
|
+
return {
|
|
571
|
+
$set: {
|
|
572
|
+
state: optionsArg.state,
|
|
573
|
+
updatedAt: optionsArg.now,
|
|
574
|
+
terminalAt: optionsArg.now,
|
|
575
|
+
purgeAt: new Date(optionsArg.now + TERMINAL_RETENTION_MS),
|
|
576
|
+
pushServiceStatusCode: optionsArg.pushServiceStatusCode ?? null,
|
|
577
|
+
errorCode: optionsArg.errorCode ?? null,
|
|
578
|
+
_updatedAt: new Date(optionsArg.now).toISOString(),
|
|
579
|
+
},
|
|
580
|
+
$unset: {
|
|
581
|
+
subscriptionEnvelope: '',
|
|
582
|
+
payloadEnvelope: '',
|
|
583
|
+
leaseToken: '',
|
|
584
|
+
leaseExpiresAt: '',
|
|
585
|
+
cancelRequestedAt: '',
|
|
586
|
+
cancelErrorCode: '',
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
interface IWebPushNativeIndexDescription {
|
|
592
|
+
name?: string;
|
|
593
|
+
key?: Record<string, unknown>;
|
|
594
|
+
unique?: boolean;
|
|
595
|
+
expireAfterSeconds?: number;
|
|
596
|
+
partialFilterExpression?: Record<string, unknown>;
|
|
597
|
+
sparse?: boolean;
|
|
598
|
+
collation?: Record<string, unknown>;
|
|
599
|
+
hidden?: boolean;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function canonicalIndexValue(valueArg: unknown): string {
|
|
603
|
+
if (valueArg === undefined) return 'undefined';
|
|
604
|
+
return canonicalJson(valueArg);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function assertWebPushIndexMatches(
|
|
608
|
+
collectionArg: TWebPushIndexCollection,
|
|
609
|
+
actualArg: IWebPushNativeIndexDescription | undefined,
|
|
610
|
+
expectedArg: IWebPushIndexSpecification,
|
|
611
|
+
): void {
|
|
612
|
+
const malformed = (): never => {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`Web Push persistence index ${collectionArg}.${expectedArg.name} is missing or malformed`,
|
|
615
|
+
);
|
|
616
|
+
};
|
|
617
|
+
if (!actualArg) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`Web Push persistence index ${collectionArg}.${expectedArg.name} is missing or malformed`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
if (actualArg.name !== expectedArg.name) malformed();
|
|
623
|
+
if (JSON.stringify(actualArg.key) !== JSON.stringify(expectedArg.key)) malformed();
|
|
624
|
+
if (Boolean(actualArg.unique) !== Boolean(expectedArg.unique)) malformed();
|
|
625
|
+
if (actualArg.expireAfterSeconds !== expectedArg.expireAfterSeconds) malformed();
|
|
626
|
+
if (
|
|
627
|
+
canonicalIndexValue(actualArg.partialFilterExpression)
|
|
628
|
+
!== canonicalIndexValue(expectedArg.partialFilterExpression)
|
|
629
|
+
) {
|
|
630
|
+
malformed();
|
|
631
|
+
}
|
|
632
|
+
if (
|
|
633
|
+
actualArg.sparse === true
|
|
634
|
+
|| actualArg.collation !== undefined
|
|
635
|
+
|| actualArg.hidden === true
|
|
636
|
+
) {
|
|
637
|
+
malformed();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function assertWebPushIndexes(
|
|
642
|
+
collectionArg: TWebPushIndexCollection,
|
|
643
|
+
actualIndexesArg: IWebPushNativeIndexDescription[],
|
|
644
|
+
): void {
|
|
645
|
+
const byName = new Map(
|
|
646
|
+
actualIndexesArg.map((indexArg) => [indexArg.name, indexArg]),
|
|
647
|
+
);
|
|
648
|
+
for (const expected of getWebPushIndexSpecifications(collectionArg)) {
|
|
649
|
+
assertWebPushIndexMatches(collectionArg, byName.get(expected.name), expected);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export class WebPushManager {
|
|
654
|
+
private readonly qenv: IWebPushQenv;
|
|
655
|
+
private readonly transport: IWebPushTransport;
|
|
656
|
+
private readonly startWorker: boolean;
|
|
657
|
+
private readonly now: () => number;
|
|
658
|
+
private readonly admissionPolicy: IWebPushAdmissionPolicy;
|
|
659
|
+
private enabled = false;
|
|
660
|
+
private started = false;
|
|
661
|
+
private ready = false;
|
|
662
|
+
private crypto?: WebPushCrypto;
|
|
663
|
+
private vapidSubject?: string;
|
|
664
|
+
private workerTimer?: NodeJS.Timeout;
|
|
665
|
+
private activeWorkerCycle?: Promise<void>;
|
|
666
|
+
private expiredCredentialOverlapCursor?: string;
|
|
667
|
+
private expiredCredentialRecoveryCursor?: string;
|
|
668
|
+
private retiredVapidCursor?: string;
|
|
669
|
+
private inactiveLifecycleCursor?: string;
|
|
670
|
+
|
|
671
|
+
public constructor(optionsArg: IWebPushManagerOptions = {}) {
|
|
672
|
+
this.qenv = optionsArg.qenv ?? new plugins.qenv.Qenv('./', '.nogit/', false);
|
|
673
|
+
this.transport = optionsArg.transport ?? new WebPushHttpTransport();
|
|
674
|
+
this.startWorker = optionsArg.startWorker !== false;
|
|
675
|
+
this.now = optionsArg.now ?? (() => Date.now());
|
|
676
|
+
this.admissionPolicy = normalizeAdmissionPolicy(optionsArg.admissionPolicy);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
public get isEnabled(): boolean {
|
|
680
|
+
return this.enabled;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
public get isReady(): boolean {
|
|
684
|
+
return this.ready;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
public async start(): Promise<void> {
|
|
688
|
+
if (this.started) return;
|
|
689
|
+
const enabledValue = await this.qenv.getEnvVarOnDemand('DCROUTER_WEB_PUSH_ENABLED');
|
|
690
|
+
this.enabled = normalizeFeatureFlag(enabledValue);
|
|
691
|
+
if (!this.enabled) {
|
|
692
|
+
this.started = true;
|
|
693
|
+
this.ready = false;
|
|
694
|
+
logger.log('info', 'Web Push provider is disabled');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (!DcRouterDb.getInstance().isReady()) {
|
|
698
|
+
throw new Error('Web Push persistence is unavailable');
|
|
699
|
+
}
|
|
700
|
+
const encryptionKeyRingRaw = await this.qenv.getEnvVarOnDemand(
|
|
701
|
+
'DCROUTER_WEB_PUSH_MASTER_KEY_RING',
|
|
702
|
+
);
|
|
703
|
+
const hmacKeyRingRaw = await this.qenv.getEnvVarOnDemand(
|
|
704
|
+
'DCROUTER_WEB_PUSH_HMAC_KEY_RING',
|
|
705
|
+
);
|
|
706
|
+
const vapidSubjectRaw = await this.qenv.getEnvVarOnDemand(
|
|
707
|
+
'DCROUTER_WEB_PUSH_VAPID_SUBJECT',
|
|
708
|
+
);
|
|
709
|
+
if (!encryptionKeyRingRaw || !hmacKeyRingRaw || !vapidSubjectRaw) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
'Enabled Web Push requires master key ring, HMAC key ring, and VAPID subject',
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
this.crypto = new WebPushCrypto(
|
|
715
|
+
parseWebPushKeyRing(encryptionKeyRingRaw, 'DCROUTER_WEB_PUSH_MASTER_KEY_RING'),
|
|
716
|
+
parseWebPushKeyRing(hmacKeyRingRaw, 'DCROUTER_WEB_PUSH_HMAC_KEY_RING'),
|
|
717
|
+
);
|
|
718
|
+
this.vapidSubject = validateVapidSubject(vapidSubjectRaw);
|
|
719
|
+
const bindingCollection = WebPushBindingDoc.getNativeCollection();
|
|
720
|
+
const admissionCollection = WebPushAdmissionDoc.getNativeCollection();
|
|
721
|
+
const spoolCollection = WebPushSpoolDoc.getNativeCollection();
|
|
722
|
+
await bindingCollection.findOne({}, { projection: { _id: 1 } });
|
|
723
|
+
await admissionCollection.findOne({}, { projection: { _id: 1 } });
|
|
724
|
+
await spoolCollection.findOne({}, { projection: { _id: 1 } });
|
|
725
|
+
assertWebPushIndexes(
|
|
726
|
+
'WebPushBindingDoc',
|
|
727
|
+
await bindingCollection.listIndexes().toArray(),
|
|
728
|
+
);
|
|
729
|
+
assertWebPushIndexes(
|
|
730
|
+
'WebPushAdmissionDoc',
|
|
731
|
+
await admissionCollection.listIndexes().toArray(),
|
|
732
|
+
);
|
|
733
|
+
assertWebPushIndexes(
|
|
734
|
+
'WebPushSpoolDoc',
|
|
735
|
+
await spoolCollection.listIndexes().toArray(),
|
|
736
|
+
);
|
|
737
|
+
await this.runMaintenanceOnce();
|
|
738
|
+
this.started = true;
|
|
739
|
+
this.ready = true;
|
|
740
|
+
logger.log('info', 'Web Push provider is ready');
|
|
741
|
+
if (this.startWorker) this.scheduleWorker(0);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
public async stop(): Promise<void> {
|
|
745
|
+
this.ready = false;
|
|
746
|
+
if (this.workerTimer) {
|
|
747
|
+
clearTimeout(this.workerTimer);
|
|
748
|
+
this.workerTimer = undefined;
|
|
749
|
+
}
|
|
750
|
+
await this.activeWorkerCycle;
|
|
751
|
+
this.started = false;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
public getUnavailableServiceStatus(): IWebPushServiceStatus {
|
|
755
|
+
return {
|
|
756
|
+
ready: false,
|
|
757
|
+
bindingId: 'unavailable',
|
|
758
|
+
bindingStatus: 'disabled',
|
|
759
|
+
retiringVapidKeys: [],
|
|
760
|
+
maxPayloadBytes: MAX_PAYLOAD_BYTES,
|
|
761
|
+
maxTtlSeconds: MAX_TTL_SECONDS,
|
|
762
|
+
contentEncoding: 'aes128gcm',
|
|
763
|
+
message: this.enabled
|
|
764
|
+
? 'Web Push provider is not ready'
|
|
765
|
+
: 'Web Push provider is disabled',
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
public async listBindings(
|
|
770
|
+
ownerFilterArg: Partial<IWebPushResourceOwner> = {},
|
|
771
|
+
): Promise<IWebPushBinding[]> {
|
|
772
|
+
this.requirePersistenceReady();
|
|
773
|
+
const selector: Record<string, unknown> = {};
|
|
774
|
+
if (ownerFilterArg.gatewayClientType !== undefined) {
|
|
775
|
+
if (!['onebox', 'cloudly', 'custom'].includes(ownerFilterArg.gatewayClientType)) {
|
|
776
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push owner filter is malformed');
|
|
777
|
+
}
|
|
778
|
+
selector.ownerGatewayClientType = ownerFilterArg.gatewayClientType;
|
|
779
|
+
}
|
|
780
|
+
if (ownerFilterArg.gatewayClientId !== undefined) {
|
|
781
|
+
selector.ownerGatewayClientId = requireOpaqueId(
|
|
782
|
+
ownerFilterArg.gatewayClientId,
|
|
783
|
+
'Web Push owner gatewayClientId',
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (ownerFilterArg.appInstanceId !== undefined) {
|
|
787
|
+
selector.ownerAppInstanceId = requireOpaqueId(
|
|
788
|
+
ownerFilterArg.appInstanceId,
|
|
789
|
+
'Web Push owner appInstanceId',
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
const rows = await WebPushBindingDoc.getNativeCollection()
|
|
793
|
+
.find(selector)
|
|
794
|
+
.sort({ createdAt: 1, id: 1 })
|
|
795
|
+
.limit(1000)
|
|
796
|
+
.toArray();
|
|
797
|
+
return rows.map((rowArg) => toPublicWebPushBinding(asBindingDoc(rowArg)));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
public async getBindingById(idArg: string): Promise<WebPushBindingDoc | null> {
|
|
801
|
+
this.requirePersistenceReady();
|
|
802
|
+
const id = requireOpaqueId(idArg, 'Web Push binding id');
|
|
803
|
+
const row = await WebPushBindingDoc.getNativeCollection().findOne({ id });
|
|
804
|
+
return row ? asBindingDoc(row) : null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
public async getBindingByCredentialId(
|
|
808
|
+
credentialIdArg: string,
|
|
809
|
+
): Promise<WebPushBindingDoc | null> {
|
|
810
|
+
this.requirePersistenceReady();
|
|
811
|
+
const credentialId = requireOpaqueId(credentialIdArg, 'Web Push credential id');
|
|
812
|
+
const row = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
813
|
+
$or: [
|
|
814
|
+
{ 'credential.id': credentialId },
|
|
815
|
+
{ 'previousCredentials.id': credentialId },
|
|
816
|
+
],
|
|
817
|
+
});
|
|
818
|
+
return row ? asBindingDoc(row) : null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
public async syncBinding(
|
|
822
|
+
bindingArg: TWebPushBindingSync,
|
|
823
|
+
controllerArg: IWebPushControllerMutation,
|
|
824
|
+
createdByArg: string,
|
|
825
|
+
): Promise<IWebPushBindingSyncResult> {
|
|
826
|
+
this.requireReady();
|
|
827
|
+
const owner = normalizeOwner(bindingArg.owner);
|
|
828
|
+
const createdBy = requireNonEmptyString(createdByArg, 'Web Push binding creator');
|
|
829
|
+
if (bindingArg.enabled !== true) {
|
|
830
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
831
|
+
'Web Push binding synchronization only accepts enabled bindings',
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
const controller = normalizeControllerMutation(
|
|
835
|
+
controllerArg,
|
|
836
|
+
'enabled',
|
|
837
|
+
);
|
|
838
|
+
const requestedId = bindingArg.id !== undefined
|
|
839
|
+
? requireOpaqueId(bindingArg.id, 'Web Push binding id')
|
|
840
|
+
: undefined;
|
|
841
|
+
const requestDigest = controllerRequestDigest({
|
|
842
|
+
action: 'sync',
|
|
843
|
+
id: requestedId ?? null,
|
|
844
|
+
owner,
|
|
845
|
+
enabled: bindingArg.enabled,
|
|
846
|
+
});
|
|
847
|
+
const collection = WebPushBindingDoc.getNativeCollection();
|
|
848
|
+
const byId = requestedId ? await collection.findOne({ id: requestedId }) : null;
|
|
849
|
+
const byOwner = await collection.findOne({
|
|
850
|
+
ownerGatewayClientType: owner.gatewayClientType,
|
|
851
|
+
ownerGatewayClientId: owner.gatewayClientId,
|
|
852
|
+
ownerAppInstanceId: owner.appInstanceId,
|
|
853
|
+
});
|
|
854
|
+
if (byId && byOwner && String(byId.id) !== String(byOwner.id)) {
|
|
855
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push binding identity conflicts with owner');
|
|
856
|
+
}
|
|
857
|
+
if (requestedId && byOwner && String(byOwner.id) !== requestedId) {
|
|
858
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push binding identity conflicts with owner');
|
|
859
|
+
}
|
|
860
|
+
const existingRaw = byId || byOwner;
|
|
861
|
+
if (existingRaw) {
|
|
862
|
+
const existing = asBindingDoc(existingRaw);
|
|
863
|
+
this.assertBindingOwner(existing, owner);
|
|
864
|
+
if (
|
|
865
|
+
controllerFenceDecision(existing.controllerFence, controller, requestDigest)
|
|
866
|
+
=== 'replay'
|
|
867
|
+
) {
|
|
868
|
+
return await this.resumeControllerSyncReplay(existing, controller, requestDigest);
|
|
869
|
+
}
|
|
870
|
+
const lifecycleGeneration = requireLifecycleGeneration(existing.lifecycleGeneration);
|
|
871
|
+
if (typeof existing.deletedAt === 'number') {
|
|
872
|
+
await this.scrubDeletedBindingLifecycle(existing);
|
|
873
|
+
const scrubbedRaw = await collection.findOne({
|
|
874
|
+
id: existing.id,
|
|
875
|
+
lifecycleGeneration,
|
|
876
|
+
deletedAt: existing.deletedAt,
|
|
877
|
+
});
|
|
878
|
+
if (!scrubbedRaw) {
|
|
879
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
880
|
+
'Web Push binding changed concurrently; retry synchronization',
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
const scrubbed = asBindingDoc(scrubbedRaw);
|
|
884
|
+
if (
|
|
885
|
+
controllerFenceDecision(scrubbed.controllerFence, controller, requestDigest)
|
|
886
|
+
=== 'replay'
|
|
887
|
+
) {
|
|
888
|
+
return await this.resumeControllerSyncReplay(
|
|
889
|
+
scrubbed,
|
|
890
|
+
controller,
|
|
891
|
+
requestDigest,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
const now = this.now();
|
|
895
|
+
const createdCredential = this.createCredential(scrubbed.id, now);
|
|
896
|
+
const nextLifecycleGeneration = lifecycleGeneration + 1;
|
|
897
|
+
const reactivated = await collection.updateOne(
|
|
898
|
+
{
|
|
899
|
+
id: scrubbed.id,
|
|
900
|
+
mutationRevision: scrubbed.mutationRevision,
|
|
901
|
+
deletedAt: scrubbed.deletedAt,
|
|
902
|
+
lifecycleGeneration,
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
$set: {
|
|
906
|
+
enabled: true,
|
|
907
|
+
status: 'active',
|
|
908
|
+
credential: createdCredential.stored,
|
|
909
|
+
previousCredentials: [],
|
|
910
|
+
vapidKeys: [this.createVapidKey(owner, scrubbed.id, now)],
|
|
911
|
+
updatedAt: now,
|
|
912
|
+
lifecycleGeneration: nextLifecycleGeneration,
|
|
913
|
+
controllerFence: createControllerFence(controller, requestDigest, now),
|
|
914
|
+
mutationRevision: scrubbed.mutationRevision + 1,
|
|
915
|
+
_updatedAt: new Date(now).toISOString(),
|
|
916
|
+
},
|
|
917
|
+
$unset: {
|
|
918
|
+
deletedAt: '',
|
|
919
|
+
credentialRotationRecovery: '',
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
);
|
|
923
|
+
if (reactivated.matchedCount !== 1) {
|
|
924
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
925
|
+
'Web Push binding changed concurrently; retry synchronization',
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
await WebPushAdmissionDoc.getNativeCollection().deleteMany({
|
|
929
|
+
bindingId: scrubbed.id,
|
|
930
|
+
lifecycleGeneration: { $lt: nextLifecycleGeneration },
|
|
931
|
+
});
|
|
932
|
+
const refreshed = await collection.findOne({
|
|
933
|
+
id: scrubbed.id,
|
|
934
|
+
lifecycleGeneration: nextLifecycleGeneration,
|
|
935
|
+
deletedAt: { $exists: false },
|
|
936
|
+
});
|
|
937
|
+
if (!refreshed) throw new Error('Reactivated Web Push binding disappeared');
|
|
938
|
+
return {
|
|
939
|
+
binding: toPublicWebPushBinding(asBindingDoc(refreshed)),
|
|
940
|
+
credential: createdCredential.oneTime,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
if (bindingArg.enabled && !existing.enabled) {
|
|
944
|
+
await this.drainBindingLifecyclesBefore(
|
|
945
|
+
existing.id,
|
|
946
|
+
lifecycleGeneration,
|
|
947
|
+
'BINDING_DISABLED',
|
|
948
|
+
);
|
|
949
|
+
const stillDisabled = await collection.findOne({
|
|
950
|
+
id: existing.id,
|
|
951
|
+
mutationRevision: existing.mutationRevision,
|
|
952
|
+
lifecycleGeneration,
|
|
953
|
+
enabled: false,
|
|
954
|
+
deletedAt: { $exists: false },
|
|
955
|
+
}, { projection: { _id: 1 } });
|
|
956
|
+
if (!stillDisabled) {
|
|
957
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
958
|
+
'Web Push binding changed concurrently; retry synchronization',
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
let credential: IWebPushCredentialOneTimeSecret | undefined;
|
|
963
|
+
const now = this.now();
|
|
964
|
+
const lifecycleChanged = existing.enabled !== bindingArg.enabled;
|
|
965
|
+
const nextLifecycleGeneration = lifecycleChanged
|
|
966
|
+
? lifecycleGeneration + 1
|
|
967
|
+
: lifecycleGeneration;
|
|
968
|
+
const nextCredential = { ...existing.credential };
|
|
969
|
+
if (bindingArg.enabled && nextCredential.status === 'revoked') {
|
|
970
|
+
const created = this.createCredential(existing.id, now);
|
|
971
|
+
Object.assign(nextCredential, created.stored);
|
|
972
|
+
credential = created.oneTime;
|
|
973
|
+
} else {
|
|
974
|
+
nextCredential.status = bindingArg.enabled ? 'active' : 'disabled';
|
|
975
|
+
nextCredential.updatedAt = now;
|
|
976
|
+
}
|
|
977
|
+
const update: {
|
|
978
|
+
$set: Record<string, unknown>;
|
|
979
|
+
$unset?: Record<string, ''>;
|
|
980
|
+
} = {
|
|
981
|
+
$set: {
|
|
982
|
+
enabled: bindingArg.enabled,
|
|
983
|
+
status: bindingArg.enabled ? 'active' : 'disabled',
|
|
984
|
+
credential: nextCredential,
|
|
985
|
+
updatedAt: now,
|
|
986
|
+
lifecycleGeneration: nextLifecycleGeneration,
|
|
987
|
+
controllerFence: createControllerFence(controller, requestDigest, now),
|
|
988
|
+
mutationRevision: existing.mutationRevision + 1,
|
|
989
|
+
_updatedAt: new Date(now).toISOString(),
|
|
990
|
+
...(lifecycleChanged ? { previousCredentials: [] } : {}),
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
if (lifecycleChanged) {
|
|
994
|
+
update.$unset = {
|
|
995
|
+
credentialRotationRecovery: '',
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const updated = await collection.updateOne(
|
|
999
|
+
{
|
|
1000
|
+
id: existing.id,
|
|
1001
|
+
mutationRevision: existing.mutationRevision,
|
|
1002
|
+
lifecycleGeneration,
|
|
1003
|
+
enabled: existing.enabled,
|
|
1004
|
+
deletedAt: { $exists: false },
|
|
1005
|
+
},
|
|
1006
|
+
update,
|
|
1007
|
+
);
|
|
1008
|
+
if (updated.matchedCount !== 1) {
|
|
1009
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1010
|
+
'Web Push binding changed concurrently; retry synchronization',
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
if (lifecycleChanged) {
|
|
1014
|
+
await WebPushAdmissionDoc.getNativeCollection().deleteMany({
|
|
1015
|
+
bindingId: existing.id,
|
|
1016
|
+
lifecycleGeneration: { $lt: nextLifecycleGeneration },
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
if (!bindingArg.enabled || lifecycleChanged) {
|
|
1020
|
+
await this.drainBindingLifecyclesBefore(
|
|
1021
|
+
existing.id,
|
|
1022
|
+
nextLifecycleGeneration,
|
|
1023
|
+
bindingArg.enabled ? 'BINDING_LIFECYCLE_CHANGED' : 'BINDING_DISABLED',
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
const refreshed = await collection.findOne({ id: existing.id });
|
|
1027
|
+
if (!refreshed) throw new Error('Synchronized Web Push binding disappeared');
|
|
1028
|
+
return {
|
|
1029
|
+
binding: toPublicWebPushBinding(asBindingDoc(refreshed)),
|
|
1030
|
+
credential,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const now = this.now();
|
|
1035
|
+
const id = requestedId ?? plugins.uuid.v4();
|
|
1036
|
+
const createdCredential = this.createCredential(id, now);
|
|
1037
|
+
const vapidKey = this.createVapidKey(owner, id, now);
|
|
1038
|
+
const doc = new WebPushBindingDoc();
|
|
1039
|
+
doc.id = id;
|
|
1040
|
+
doc.ownerGatewayClientType = owner.gatewayClientType;
|
|
1041
|
+
doc.ownerGatewayClientId = owner.gatewayClientId;
|
|
1042
|
+
doc.ownerAppInstanceId = owner.appInstanceId;
|
|
1043
|
+
doc.enabled = bindingArg.enabled;
|
|
1044
|
+
doc.status = bindingArg.enabled ? 'active' : 'disabled';
|
|
1045
|
+
doc.credential = {
|
|
1046
|
+
...createdCredential.stored,
|
|
1047
|
+
status: bindingArg.enabled ? 'active' : 'disabled',
|
|
1048
|
+
};
|
|
1049
|
+
doc.previousCredentials = [];
|
|
1050
|
+
doc.vapidKeys = [vapidKey];
|
|
1051
|
+
doc.createdAt = now;
|
|
1052
|
+
doc.updatedAt = now;
|
|
1053
|
+
doc.createdBy = createdBy;
|
|
1054
|
+
doc.mutationRevision = 1;
|
|
1055
|
+
doc.lifecycleGeneration = 1;
|
|
1056
|
+
doc.controllerFence = createControllerFence(controller, requestDigest, now);
|
|
1057
|
+
try {
|
|
1058
|
+
await doc.save();
|
|
1059
|
+
} catch (error: unknown) {
|
|
1060
|
+
if (!isDuplicateKeyError(error)) throw error;
|
|
1061
|
+
const raced = await collection.findOne({
|
|
1062
|
+
ownerGatewayClientType: owner.gatewayClientType,
|
|
1063
|
+
ownerGatewayClientId: owner.gatewayClientId,
|
|
1064
|
+
ownerAppInstanceId: owner.appInstanceId,
|
|
1065
|
+
});
|
|
1066
|
+
if (!raced) throw error;
|
|
1067
|
+
const racedBinding = asBindingDoc(raced);
|
|
1068
|
+
this.assertBindingOwner(racedBinding, owner);
|
|
1069
|
+
if (
|
|
1070
|
+
controllerFenceDecision(racedBinding.controllerFence, controller, requestDigest)
|
|
1071
|
+
=== 'replay'
|
|
1072
|
+
) {
|
|
1073
|
+
return await this.resumeControllerSyncReplay(racedBinding, controller, requestDigest);
|
|
1074
|
+
}
|
|
1075
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1076
|
+
'Web Push binding changed concurrently; retry synchronization',
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
binding: toPublicWebPushBinding(doc),
|
|
1081
|
+
credential: createdCredential.oneTime,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private async resumeControllerSyncReplay(
|
|
1086
|
+
bindingArg: WebPushBindingDoc,
|
|
1087
|
+
controllerArg: IWebPushControllerMutation,
|
|
1088
|
+
requestDigestArg: string,
|
|
1089
|
+
): Promise<IWebPushBindingSyncResult> {
|
|
1090
|
+
const lifecycleGeneration = requireLifecycleGeneration(bindingArg.lifecycleGeneration);
|
|
1091
|
+
if (controllerArg.intent === 'enabled') {
|
|
1092
|
+
if (
|
|
1093
|
+
!bindingArg.enabled
|
|
1094
|
+
|| bindingArg.status !== 'active'
|
|
1095
|
+
|| typeof bindingArg.deletedAt === 'number'
|
|
1096
|
+
) {
|
|
1097
|
+
throw new Error('Applied Web Push enable controller mutation is inconsistent');
|
|
1098
|
+
}
|
|
1099
|
+
await this.drainBindingLifecyclesBefore(
|
|
1100
|
+
bindingArg.id,
|
|
1101
|
+
lifecycleGeneration,
|
|
1102
|
+
'BINDING_LIFECYCLE_CHANGED',
|
|
1103
|
+
);
|
|
1104
|
+
} else if (controllerArg.intent === 'disabled') {
|
|
1105
|
+
if (bindingArg.enabled) {
|
|
1106
|
+
throw new Error('Applied Web Push disable controller mutation is inconsistent');
|
|
1107
|
+
}
|
|
1108
|
+
if (typeof bindingArg.deletedAt === 'number') {
|
|
1109
|
+
await this.scrubDeletedBindingLifecycle(bindingArg);
|
|
1110
|
+
} else {
|
|
1111
|
+
await this.drainBindingLifecyclesBefore(
|
|
1112
|
+
bindingArg.id,
|
|
1113
|
+
lifecycleGeneration,
|
|
1114
|
+
'BINDING_DISABLED',
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
} else {
|
|
1118
|
+
throw new Error('Delete controller mutation cannot be replayed as synchronization');
|
|
1119
|
+
}
|
|
1120
|
+
const refreshedRaw = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
1121
|
+
id: bindingArg.id,
|
|
1122
|
+
});
|
|
1123
|
+
if (!refreshedRaw) throw new Error('Synchronized Web Push binding disappeared');
|
|
1124
|
+
const refreshed = asBindingDoc(refreshedRaw);
|
|
1125
|
+
controllerFenceDecision(refreshed.controllerFence, controllerArg, requestDigestArg);
|
|
1126
|
+
return {
|
|
1127
|
+
binding: toPublicWebPushBinding(refreshed),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
public async deleteBinding(
|
|
1132
|
+
deleteArg: IWebPushBindingDelete,
|
|
1133
|
+
deletedByArg: string,
|
|
1134
|
+
): Promise<boolean> {
|
|
1135
|
+
this.requireReady();
|
|
1136
|
+
const owner = normalizeOwner(deleteArg?.owner);
|
|
1137
|
+
const deletedBy = requireNonEmptyString(deletedByArg, 'Web Push binding deletion actor');
|
|
1138
|
+
const requestedId = deleteArg.id !== undefined
|
|
1139
|
+
? requireOpaqueId(deleteArg.id, 'Web Push binding id')
|
|
1140
|
+
: undefined;
|
|
1141
|
+
const controller = normalizeDeleteControllerMutation(deleteArg.controller);
|
|
1142
|
+
const requestDigest = controllerRequestDigest({
|
|
1143
|
+
action: 'delete',
|
|
1144
|
+
id: requestedId ?? null,
|
|
1145
|
+
owner,
|
|
1146
|
+
});
|
|
1147
|
+
const collection = WebPushBindingDoc.getNativeCollection();
|
|
1148
|
+
const byId = requestedId ? await collection.findOne({ id: requestedId }) : null;
|
|
1149
|
+
const byOwner = await collection.findOne({
|
|
1150
|
+
ownerGatewayClientType: owner.gatewayClientType,
|
|
1151
|
+
ownerGatewayClientId: owner.gatewayClientId,
|
|
1152
|
+
ownerAppInstanceId: owner.appInstanceId,
|
|
1153
|
+
});
|
|
1154
|
+
if (byId && byOwner && String(byId.id) !== String(byOwner.id)) {
|
|
1155
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1156
|
+
'Web Push binding identity conflicts with owner',
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
if (requestedId && byOwner && String(byOwner.id) !== requestedId) {
|
|
1160
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1161
|
+
'Web Push binding identity conflicts with owner',
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
const existingRaw = byId || byOwner;
|
|
1165
|
+
if (!existingRaw) {
|
|
1166
|
+
const now = this.now();
|
|
1167
|
+
const id = requestedId ?? plugins.uuid.v4();
|
|
1168
|
+
const tombstone = new WebPushBindingDoc();
|
|
1169
|
+
tombstone.id = id;
|
|
1170
|
+
tombstone.ownerGatewayClientType = owner.gatewayClientType;
|
|
1171
|
+
tombstone.ownerGatewayClientId = owner.gatewayClientId;
|
|
1172
|
+
tombstone.ownerAppInstanceId = owner.appInstanceId;
|
|
1173
|
+
tombstone.enabled = false;
|
|
1174
|
+
tombstone.status = 'disabled';
|
|
1175
|
+
tombstone.credential = {
|
|
1176
|
+
id: plugins.uuid.v4(),
|
|
1177
|
+
status: 'revoked',
|
|
1178
|
+
hmacKeyId: this.crypto!.currentHmacKeyId,
|
|
1179
|
+
createdAt: now,
|
|
1180
|
+
updatedAt: now,
|
|
1181
|
+
lastRotatedAt: null,
|
|
1182
|
+
validUntil: null,
|
|
1183
|
+
};
|
|
1184
|
+
tombstone.previousCredentials = [];
|
|
1185
|
+
tombstone.vapidKeys = [];
|
|
1186
|
+
tombstone.createdAt = now;
|
|
1187
|
+
tombstone.updatedAt = now;
|
|
1188
|
+
tombstone.createdBy = deletedBy;
|
|
1189
|
+
tombstone.mutationRevision = 1;
|
|
1190
|
+
tombstone.lifecycleGeneration = 1;
|
|
1191
|
+
tombstone.controllerFence = createControllerFence(controller, requestDigest, now);
|
|
1192
|
+
tombstone.deletedAt = now;
|
|
1193
|
+
try {
|
|
1194
|
+
await tombstone.save();
|
|
1195
|
+
} catch (error: unknown) {
|
|
1196
|
+
if (!isDuplicateKeyError(error)) throw error;
|
|
1197
|
+
return await this.deleteBinding(deleteArg, deletedBy);
|
|
1198
|
+
}
|
|
1199
|
+
await this.scrubDeletedBindingLifecycle(tombstone);
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
const binding = asBindingDoc(existingRaw);
|
|
1203
|
+
this.assertBindingOwner(binding, owner);
|
|
1204
|
+
if (
|
|
1205
|
+
controllerFenceDecision(binding.controllerFence, controller, requestDigest)
|
|
1206
|
+
=== 'replay'
|
|
1207
|
+
) {
|
|
1208
|
+
return await this.resumeControllerDeleteReplay(binding, controller, requestDigest);
|
|
1209
|
+
}
|
|
1210
|
+
const lifecycleGeneration = requireLifecycleGeneration(binding.lifecycleGeneration);
|
|
1211
|
+
if (typeof binding.deletedAt === 'number') {
|
|
1212
|
+
await this.scrubDeletedBindingLifecycle(binding);
|
|
1213
|
+
const scrubbedRaw = await collection.findOne({
|
|
1214
|
+
id: binding.id,
|
|
1215
|
+
lifecycleGeneration,
|
|
1216
|
+
deletedAt: binding.deletedAt,
|
|
1217
|
+
enabled: false,
|
|
1218
|
+
});
|
|
1219
|
+
if (!scrubbedRaw) {
|
|
1220
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1221
|
+
'Web Push binding changed concurrently; retry deletion',
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
const scrubbed = asBindingDoc(scrubbedRaw);
|
|
1225
|
+
if (
|
|
1226
|
+
controllerFenceDecision(scrubbed.controllerFence, controller, requestDigest)
|
|
1227
|
+
=== 'replay'
|
|
1228
|
+
) {
|
|
1229
|
+
return await this.resumeControllerDeleteReplay(
|
|
1230
|
+
scrubbed,
|
|
1231
|
+
controller,
|
|
1232
|
+
requestDigest,
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
const now = this.now();
|
|
1236
|
+
const recorded = await collection.updateOne(
|
|
1237
|
+
{
|
|
1238
|
+
id: scrubbed.id,
|
|
1239
|
+
mutationRevision: scrubbed.mutationRevision,
|
|
1240
|
+
lifecycleGeneration,
|
|
1241
|
+
deletedAt: scrubbed.deletedAt,
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
$set: {
|
|
1245
|
+
controllerFence: createControllerFence(controller, requestDigest, now),
|
|
1246
|
+
updatedAt: now,
|
|
1247
|
+
mutationRevision: scrubbed.mutationRevision + 1,
|
|
1248
|
+
_updatedAt: new Date(now).toISOString(),
|
|
1249
|
+
},
|
|
1250
|
+
},
|
|
1251
|
+
);
|
|
1252
|
+
if (recorded.matchedCount !== 1) {
|
|
1253
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1254
|
+
'Web Push binding changed concurrently; retry deletion',
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
const refreshedRaw = await collection.findOne({ id: scrubbed.id });
|
|
1258
|
+
if (!refreshedRaw) throw new Error('Deleted Web Push binding tombstone disappeared');
|
|
1259
|
+
return await this.resumeControllerDeleteReplay(
|
|
1260
|
+
asBindingDoc(refreshedRaw),
|
|
1261
|
+
controller,
|
|
1262
|
+
requestDigest,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const now = this.now();
|
|
1266
|
+
const revokedCredential: IWebPushStoredCredential = {
|
|
1267
|
+
...binding.credential,
|
|
1268
|
+
status: 'revoked',
|
|
1269
|
+
updatedAt: now,
|
|
1270
|
+
validUntil: null,
|
|
1271
|
+
};
|
|
1272
|
+
delete revokedCredential.secretHash;
|
|
1273
|
+
const scrubbedVapidKeys = (binding.vapidKeys || []).map((keyArg) => {
|
|
1274
|
+
const scrubbed: IWebPushStoredVapidKey = {
|
|
1275
|
+
...keyArg,
|
|
1276
|
+
status: 'retired',
|
|
1277
|
+
retireAfter: null,
|
|
1278
|
+
retiredAt: now,
|
|
1279
|
+
};
|
|
1280
|
+
delete scrubbed.privateKeyEnvelope;
|
|
1281
|
+
return scrubbed;
|
|
1282
|
+
});
|
|
1283
|
+
const deletedLifecycleGeneration = lifecycleGeneration + 1;
|
|
1284
|
+
const result = await WebPushBindingDoc.getNativeCollection().updateOne(
|
|
1285
|
+
{
|
|
1286
|
+
id: binding.id,
|
|
1287
|
+
mutationRevision: binding.mutationRevision,
|
|
1288
|
+
lifecycleGeneration,
|
|
1289
|
+
deletedAt: { $exists: false },
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
$set: {
|
|
1293
|
+
enabled: false,
|
|
1294
|
+
status: 'disabled',
|
|
1295
|
+
credential: revokedCredential,
|
|
1296
|
+
previousCredentials: [],
|
|
1297
|
+
vapidKeys: scrubbedVapidKeys,
|
|
1298
|
+
deletedAt: now,
|
|
1299
|
+
lifecycleGeneration: deletedLifecycleGeneration,
|
|
1300
|
+
controllerFence: createControllerFence(controller, requestDigest, now),
|
|
1301
|
+
updatedAt: now,
|
|
1302
|
+
mutationRevision: binding.mutationRevision + 1,
|
|
1303
|
+
_updatedAt: new Date(now).toISOString(),
|
|
1304
|
+
},
|
|
1305
|
+
$unset: {
|
|
1306
|
+
credentialRotationRecovery: '',
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
);
|
|
1310
|
+
if (result.matchedCount !== 1) {
|
|
1311
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1312
|
+
'Web Push binding changed concurrently; retry deletion',
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
const tombstone = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
1316
|
+
id: binding.id,
|
|
1317
|
+
lifecycleGeneration: deletedLifecycleGeneration,
|
|
1318
|
+
deletedAt: now,
|
|
1319
|
+
});
|
|
1320
|
+
if (!tombstone) {
|
|
1321
|
+
throw new Error('Deleted Web Push binding tombstone disappeared');
|
|
1322
|
+
}
|
|
1323
|
+
await this.scrubDeletedBindingLifecycle(asBindingDoc(tombstone));
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
private async resumeControllerDeleteReplay(
|
|
1328
|
+
bindingArg: WebPushBindingDoc,
|
|
1329
|
+
controllerArg: IWebPushControllerMutation,
|
|
1330
|
+
requestDigestArg: string,
|
|
1331
|
+
): Promise<boolean> {
|
|
1332
|
+
if (bindingArg.enabled || typeof bindingArg.deletedAt !== 'number') {
|
|
1333
|
+
throw new Error('Applied Web Push delete controller mutation is inconsistent');
|
|
1334
|
+
}
|
|
1335
|
+
await this.scrubDeletedBindingLifecycle(bindingArg);
|
|
1336
|
+
const refreshedRaw = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
1337
|
+
id: bindingArg.id,
|
|
1338
|
+
});
|
|
1339
|
+
if (!refreshedRaw) throw new Error('Deleted Web Push binding tombstone disappeared');
|
|
1340
|
+
const refreshed = asBindingDoc(refreshedRaw);
|
|
1341
|
+
controllerFenceDecision(refreshed.controllerFence, controllerArg, requestDigestArg);
|
|
1342
|
+
if (refreshed.enabled || typeof refreshed.deletedAt !== 'number') {
|
|
1343
|
+
throw new Error('Applied Web Push delete controller mutation is inconsistent');
|
|
1344
|
+
}
|
|
1345
|
+
return true;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
public async rotateCredential(
|
|
1349
|
+
credentialIdArg: string,
|
|
1350
|
+
): Promise<IWebPushCredentialOneTimeSecret> {
|
|
1351
|
+
this.requireReady();
|
|
1352
|
+
const requestedCredentialId = requireOpaqueId(
|
|
1353
|
+
credentialIdArg,
|
|
1354
|
+
'Web Push credential id',
|
|
1355
|
+
);
|
|
1356
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1357
|
+
const binding = await this.getBindingByCredentialId(requestedCredentialId);
|
|
1358
|
+
if (
|
|
1359
|
+
!binding
|
|
1360
|
+
|| !binding.enabled
|
|
1361
|
+
|| binding.status !== 'active'
|
|
1362
|
+
|| typeof binding.deletedAt === 'number'
|
|
1363
|
+
) {
|
|
1364
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1365
|
+
'Active Web Push credential not found',
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
const now = this.now();
|
|
1369
|
+
const replay = this.replayCredentialRotation(
|
|
1370
|
+
binding,
|
|
1371
|
+
requestedCredentialId,
|
|
1372
|
+
now,
|
|
1373
|
+
);
|
|
1374
|
+
if (replay) return replay;
|
|
1375
|
+
if (binding.credential.id !== requestedCredentialId) {
|
|
1376
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1377
|
+
'Active Web Push credential not found',
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
const unexpiredPrevious = (binding.previousCredentials || []).filter(
|
|
1381
|
+
(credentialArg) => (
|
|
1382
|
+
credentialArg.secretHash
|
|
1383
|
+
&& credentialArg.validUntil !== null
|
|
1384
|
+
&& credentialArg.validUntil > now
|
|
1385
|
+
),
|
|
1386
|
+
);
|
|
1387
|
+
if (unexpiredPrevious.length > 0) {
|
|
1388
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1389
|
+
'Web Push credential rotation overlap is still active',
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
const lifecycleGeneration = requireLifecycleGeneration(
|
|
1393
|
+
binding.lifecycleGeneration,
|
|
1394
|
+
);
|
|
1395
|
+
const created = this.createCredential(binding.id, now);
|
|
1396
|
+
const previous: IWebPushStoredCredential = {
|
|
1397
|
+
...binding.credential,
|
|
1398
|
+
status: 'rotated',
|
|
1399
|
+
updatedAt: now,
|
|
1400
|
+
lastRotatedAt: now,
|
|
1401
|
+
validUntil: now + CREDENTIAL_OVERLAP_MS,
|
|
1402
|
+
};
|
|
1403
|
+
const recoveryIdentity = {
|
|
1404
|
+
requestedCredentialId,
|
|
1405
|
+
resultingCredentialId: created.stored.id,
|
|
1406
|
+
lifecycleGeneration,
|
|
1407
|
+
};
|
|
1408
|
+
const recovery: IWebPushCredentialRotationRecovery = {
|
|
1409
|
+
...recoveryIdentity,
|
|
1410
|
+
secretEnvelope: this.crypto!.encryptJson(
|
|
1411
|
+
{ secret: created.oneTime.secret },
|
|
1412
|
+
this.credentialRotationRecoveryAad(binding, recoveryIdentity),
|
|
1413
|
+
),
|
|
1414
|
+
createdAt: now,
|
|
1415
|
+
expiresAt: now + CREDENTIAL_OVERLAP_MS,
|
|
1416
|
+
};
|
|
1417
|
+
const result = await WebPushBindingDoc.getNativeCollection().updateOne(
|
|
1418
|
+
{
|
|
1419
|
+
id: binding.id,
|
|
1420
|
+
mutationRevision: binding.mutationRevision,
|
|
1421
|
+
lifecycleGeneration,
|
|
1422
|
+
enabled: true,
|
|
1423
|
+
status: 'active',
|
|
1424
|
+
'credential.id': requestedCredentialId,
|
|
1425
|
+
deletedAt: { $exists: false },
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
$set: {
|
|
1429
|
+
credential: created.stored,
|
|
1430
|
+
previousCredentials: [previous],
|
|
1431
|
+
credentialRotationRecovery: recovery,
|
|
1432
|
+
updatedAt: now,
|
|
1433
|
+
mutationRevision: binding.mutationRevision + 1,
|
|
1434
|
+
_updatedAt: new Date(now).toISOString(),
|
|
1435
|
+
},
|
|
1436
|
+
},
|
|
1437
|
+
);
|
|
1438
|
+
if (result.matchedCount === 1) return created.oneTime;
|
|
1439
|
+
const raced = await this.getBindingByCredentialId(requestedCredentialId);
|
|
1440
|
+
if (raced) {
|
|
1441
|
+
const racedReplay = this.replayCredentialRotation(
|
|
1442
|
+
raced,
|
|
1443
|
+
requestedCredentialId,
|
|
1444
|
+
this.now(),
|
|
1445
|
+
);
|
|
1446
|
+
if (racedReplay) return racedReplay;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1450
|
+
'Web Push binding changed concurrently; retry credential rotation',
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
public async rotateVapidKey(bindingIdArg: string): Promise<IWebPushBinding> {
|
|
1455
|
+
this.requireReady();
|
|
1456
|
+
await this.cleanupRetiredVapidKeys(bindingIdArg);
|
|
1457
|
+
const binding = await this.getBindingById(bindingIdArg);
|
|
1458
|
+
if (
|
|
1459
|
+
!binding
|
|
1460
|
+
|| !binding.enabled
|
|
1461
|
+
|| binding.status !== 'active'
|
|
1462
|
+
|| typeof binding.deletedAt === 'number'
|
|
1463
|
+
) {
|
|
1464
|
+
throw new plugins.typedrequest.TypedResponseError('Active Web Push binding not found');
|
|
1465
|
+
}
|
|
1466
|
+
if ((binding.vapidKeys || []).length >= VAPID_KEY_RING_LIMIT) {
|
|
1467
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1468
|
+
'Web Push VAPID key ring is full; retiring keys are still in use',
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
const now = this.now();
|
|
1472
|
+
const owner = this.bindingOwner(binding);
|
|
1473
|
+
const nextKeys = (binding.vapidKeys || []).map((keyArg) => (
|
|
1474
|
+
keyArg.status === 'active'
|
|
1475
|
+
? {
|
|
1476
|
+
...keyArg,
|
|
1477
|
+
status: 'retiring' as const,
|
|
1478
|
+
retireAfter: now + VAPID_RETIREMENT_MS,
|
|
1479
|
+
}
|
|
1480
|
+
: keyArg
|
|
1481
|
+
));
|
|
1482
|
+
nextKeys.push(this.createVapidKey(owner, binding.id, now));
|
|
1483
|
+
const result = await WebPushBindingDoc.getNativeCollection().updateOne(
|
|
1484
|
+
{ id: binding.id, mutationRevision: binding.mutationRevision },
|
|
1485
|
+
{
|
|
1486
|
+
$set: {
|
|
1487
|
+
vapidKeys: nextKeys,
|
|
1488
|
+
updatedAt: now,
|
|
1489
|
+
mutationRevision: binding.mutationRevision + 1,
|
|
1490
|
+
_updatedAt: new Date(now).toISOString(),
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1493
|
+
);
|
|
1494
|
+
if (result.matchedCount !== 1) {
|
|
1495
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1496
|
+
'Web Push binding changed concurrently; retry VAPID rotation',
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
const refreshed = await this.getBindingById(binding.id);
|
|
1500
|
+
if (!refreshed) throw new Error('Rotated Web Push binding disappeared');
|
|
1501
|
+
return toPublicWebPushBinding(refreshed);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
public async authenticateAppCredential(
|
|
1505
|
+
authArg: IWebPushAppCredentialAuth,
|
|
1506
|
+
): Promise<IWebPushAuthenticatedBinding> {
|
|
1507
|
+
this.requireReady();
|
|
1508
|
+
const credentialId = requireOpaqueId(authArg?.credentialId, 'Web Push credential id');
|
|
1509
|
+
const credentialSecret = requireNonEmptyString(
|
|
1510
|
+
authArg?.credentialSecret,
|
|
1511
|
+
'Web Push credential secret',
|
|
1512
|
+
512,
|
|
1513
|
+
);
|
|
1514
|
+
const binding = await this.getBindingByCredentialId(credentialId);
|
|
1515
|
+
if (
|
|
1516
|
+
!binding
|
|
1517
|
+
|| !binding.enabled
|
|
1518
|
+
|| binding.status !== 'active'
|
|
1519
|
+
|| typeof binding.deletedAt === 'number'
|
|
1520
|
+
) {
|
|
1521
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push credential is invalid');
|
|
1522
|
+
}
|
|
1523
|
+
requireLifecycleGeneration(binding.lifecycleGeneration);
|
|
1524
|
+
const now = this.now();
|
|
1525
|
+
const credential = binding.credential.id === credentialId
|
|
1526
|
+
? binding.credential
|
|
1527
|
+
: (binding.previousCredentials || []).find((entryArg) => entryArg.id === credentialId);
|
|
1528
|
+
const credentialUsable = credential
|
|
1529
|
+
&& typeof credential.secretHash === 'string'
|
|
1530
|
+
&& (
|
|
1531
|
+
credential.status === 'active'
|
|
1532
|
+
|| (
|
|
1533
|
+
credential.status === 'rotated'
|
|
1534
|
+
&& credential.validUntil !== null
|
|
1535
|
+
&& credential.validUntil > now
|
|
1536
|
+
)
|
|
1537
|
+
);
|
|
1538
|
+
if (
|
|
1539
|
+
!credentialUsable
|
|
1540
|
+
|| !this.crypto!.verifyHmac(
|
|
1541
|
+
'credential',
|
|
1542
|
+
this.credentialHmacValue(binding.id, credentialId, credentialSecret),
|
|
1543
|
+
credential.hmacKeyId,
|
|
1544
|
+
credential.secretHash!,
|
|
1545
|
+
)
|
|
1546
|
+
) {
|
|
1547
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push credential is invalid');
|
|
1548
|
+
}
|
|
1549
|
+
return {
|
|
1550
|
+
binding,
|
|
1551
|
+
credential,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
public async getServiceStatus(
|
|
1556
|
+
authArg: IWebPushAppCredentialAuth,
|
|
1557
|
+
): Promise<IWebPushServiceStatus> {
|
|
1558
|
+
const context = await this.authenticateAppCredential(authArg);
|
|
1559
|
+
const activeVapidKey = context.binding.vapidKeys.find((keyArg) => keyArg.status === 'active');
|
|
1560
|
+
return {
|
|
1561
|
+
ready: Boolean(activeVapidKey?.privateKeyEnvelope),
|
|
1562
|
+
bindingId: context.binding.id,
|
|
1563
|
+
bindingStatus: context.binding.status,
|
|
1564
|
+
...(activeVapidKey ? { activeVapidKey: toVapidKeyPublic(activeVapidKey) } : {}),
|
|
1565
|
+
retiringVapidKeys: context.binding.vapidKeys
|
|
1566
|
+
.filter((keyArg) => keyArg.status === 'retiring')
|
|
1567
|
+
.map((keyArg) => toVapidKeyPublic(keyArg)),
|
|
1568
|
+
maxPayloadBytes: MAX_PAYLOAD_BYTES,
|
|
1569
|
+
maxTtlSeconds: MAX_TTL_SECONDS,
|
|
1570
|
+
contentEncoding: 'aes128gcm',
|
|
1571
|
+
...(!activeVapidKey ? { message: 'Web Push binding has no active VAPID key' } : {}),
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
public async enqueue(
|
|
1576
|
+
authArg: IWebPushAppCredentialAuth,
|
|
1577
|
+
requestArg: {
|
|
1578
|
+
idempotencyKey: string;
|
|
1579
|
+
subscriptionId: string;
|
|
1580
|
+
subscription: IWebPushSubscription;
|
|
1581
|
+
vapidKeyId: string;
|
|
1582
|
+
payload: IWebPushNotificationPayload;
|
|
1583
|
+
ttlSeconds?: number;
|
|
1584
|
+
urgency?: TWebPushUrgency;
|
|
1585
|
+
collapseKey?: string;
|
|
1586
|
+
},
|
|
1587
|
+
): Promise<IWebPushEnqueueResult> {
|
|
1588
|
+
const context = await this.authenticateAppCredential(authArg);
|
|
1589
|
+
const idempotencyKey = requireNonEmptyString(
|
|
1590
|
+
requestArg.idempotencyKey,
|
|
1591
|
+
'Web Push idempotencyKey',
|
|
1592
|
+
MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
1593
|
+
);
|
|
1594
|
+
const subscriptionId = requireOpaqueId(
|
|
1595
|
+
requestArg.subscriptionId,
|
|
1596
|
+
'Web Push subscriptionId',
|
|
1597
|
+
);
|
|
1598
|
+
const subscription = normalizeSubscription(requestArg.subscription);
|
|
1599
|
+
const vapidKeyId = requireOpaqueId(requestArg.vapidKeyId, 'Web Push vapidKeyId');
|
|
1600
|
+
const payload = normalizePayload(requestArg.payload);
|
|
1601
|
+
const ttlSeconds = normalizeTtl(requestArg.ttlSeconds);
|
|
1602
|
+
const urgency = normalizeUrgency(requestArg.urgency);
|
|
1603
|
+
const collapseKey = requestArg.collapseKey === undefined
|
|
1604
|
+
? undefined
|
|
1605
|
+
: requireNonEmptyString(
|
|
1606
|
+
requestArg.collapseKey,
|
|
1607
|
+
'Web Push collapseKey',
|
|
1608
|
+
MAX_COLLAPSE_KEY_LENGTH,
|
|
1609
|
+
);
|
|
1610
|
+
const now = this.now();
|
|
1611
|
+
const lifecycleGeneration = requireLifecycleGeneration(
|
|
1612
|
+
context.binding.lifecycleGeneration,
|
|
1613
|
+
);
|
|
1614
|
+
const vapidKey = context.binding.vapidKeys.find((keyArg) => (
|
|
1615
|
+
keyArg.id === vapidKeyId
|
|
1616
|
+
&& Boolean(keyArg.privateKeyEnvelope)
|
|
1617
|
+
&& (
|
|
1618
|
+
keyArg.status === 'active'
|
|
1619
|
+
|| (
|
|
1620
|
+
keyArg.status === 'retiring'
|
|
1621
|
+
&& keyArg.retireAfter !== null
|
|
1622
|
+
&& keyArg.retireAfter > now
|
|
1623
|
+
)
|
|
1624
|
+
)
|
|
1625
|
+
));
|
|
1626
|
+
if (!vapidKey) {
|
|
1627
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1628
|
+
'Web Push VAPID key is not active for new delivery',
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
const hmacKeyId = context.credential.hmacKeyId;
|
|
1632
|
+
const idempotencyKeyDigest = this.crypto!.hmac(
|
|
1633
|
+
'idempotency-key',
|
|
1634
|
+
`${context.credential.id}\0${idempotencyKey}`,
|
|
1635
|
+
hmacKeyId,
|
|
1636
|
+
).digest;
|
|
1637
|
+
const canonicalRequest = canonicalJson({
|
|
1638
|
+
subscriptionId,
|
|
1639
|
+
subscription,
|
|
1640
|
+
vapidKeyId,
|
|
1641
|
+
payload,
|
|
1642
|
+
ttlSeconds,
|
|
1643
|
+
urgency,
|
|
1644
|
+
collapseKey: collapseKey ?? null,
|
|
1645
|
+
});
|
|
1646
|
+
const requestDigest = this.crypto!.hmac(
|
|
1647
|
+
'request',
|
|
1648
|
+
canonicalRequest,
|
|
1649
|
+
hmacKeyId,
|
|
1650
|
+
).digest;
|
|
1651
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
1652
|
+
const existing = await collection.findOne({
|
|
1653
|
+
bindingId: context.binding.id,
|
|
1654
|
+
lifecycleGeneration,
|
|
1655
|
+
credentialId: context.credential.id,
|
|
1656
|
+
idempotencyKeyDigest,
|
|
1657
|
+
});
|
|
1658
|
+
if (existing) {
|
|
1659
|
+
await this.assertBindingLifecycleActive(context.binding);
|
|
1660
|
+
const spool = asSpoolDoc(existing);
|
|
1661
|
+
if (!constantTimeDigestEqual(spool.requestDigest, requestDigest)) {
|
|
1662
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1663
|
+
'Web Push idempotency key conflicts with a different request',
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
return {
|
|
1667
|
+
accepted: true,
|
|
1668
|
+
spoolItemId: spool.id,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
await this.assertBindingLifecycleActive(context.binding);
|
|
1672
|
+
await this.reserveAdmission(context.binding);
|
|
1673
|
+
const nonterminalCount = await collection.countDocuments({
|
|
1674
|
+
bindingId: context.binding.id,
|
|
1675
|
+
lifecycleGeneration,
|
|
1676
|
+
state: { $in: NONTERMINAL_STATES },
|
|
1677
|
+
});
|
|
1678
|
+
if (nonterminalCount >= MAX_NONTERMINAL_ITEMS_PER_BINDING) {
|
|
1679
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push binding queue quota exceeded');
|
|
1680
|
+
}
|
|
1681
|
+
const spoolId = plugins.uuid.v4();
|
|
1682
|
+
const aadBase = this.spoolAadBase(context.binding, spoolId);
|
|
1683
|
+
const payloadText = canonicalJson(payload);
|
|
1684
|
+
const endpointDigest = this.crypto!.hmac(
|
|
1685
|
+
'endpoint',
|
|
1686
|
+
`${context.binding.id}\0${subscription.endpoint}`,
|
|
1687
|
+
hmacKeyId,
|
|
1688
|
+
).digest;
|
|
1689
|
+
const topic = collapseKey
|
|
1690
|
+
? Buffer.from(
|
|
1691
|
+
this.crypto!.hmac(
|
|
1692
|
+
'topic',
|
|
1693
|
+
`${context.binding.id}\0${collapseKey}`,
|
|
1694
|
+
hmacKeyId,
|
|
1695
|
+
).digest,
|
|
1696
|
+
'base64url',
|
|
1697
|
+
).subarray(0, 24).toString('base64url')
|
|
1698
|
+
: null;
|
|
1699
|
+
const spool = new WebPushSpoolDoc();
|
|
1700
|
+
spool.id = spoolId;
|
|
1701
|
+
spool.bindingId = context.binding.id;
|
|
1702
|
+
spool.lifecycleGeneration = lifecycleGeneration;
|
|
1703
|
+
spool.credentialId = context.credential.id;
|
|
1704
|
+
spool.hmacKeyId = hmacKeyId;
|
|
1705
|
+
spool.idempotencyKeyDigest = idempotencyKeyDigest;
|
|
1706
|
+
spool.requestDigest = requestDigest;
|
|
1707
|
+
spool.subscriptionId = subscriptionId;
|
|
1708
|
+
spool.endpointDigest = endpointDigest;
|
|
1709
|
+
spool.vapidKeyId = vapidKeyId;
|
|
1710
|
+
spool.subscriptionEnvelope = this.crypto!.encryptJson(subscription, {
|
|
1711
|
+
...aadBase,
|
|
1712
|
+
documentType: 'spool-subscription',
|
|
1713
|
+
});
|
|
1714
|
+
spool.payloadEnvelope = this.crypto!.encryptJson(payload, {
|
|
1715
|
+
...aadBase,
|
|
1716
|
+
documentType: 'spool-payload',
|
|
1717
|
+
});
|
|
1718
|
+
spool.payloadBytes = Buffer.byteLength(payloadText, 'utf8');
|
|
1719
|
+
spool.ttlSeconds = ttlSeconds;
|
|
1720
|
+
spool.urgency = urgency;
|
|
1721
|
+
spool.topic = topic;
|
|
1722
|
+
spool.state = 'accepted';
|
|
1723
|
+
spool.attempts = 0;
|
|
1724
|
+
spool.acceptedAt = now;
|
|
1725
|
+
spool.updatedAt = now;
|
|
1726
|
+
spool.nextAttemptAt = now;
|
|
1727
|
+
spool.expiresAt = now + ttlSeconds * 1000;
|
|
1728
|
+
spool.pushServiceStatusCode = null;
|
|
1729
|
+
spool.errorCode = null;
|
|
1730
|
+
try {
|
|
1731
|
+
await spool.save();
|
|
1732
|
+
} catch (error: unknown) {
|
|
1733
|
+
if (!isDuplicateKeyError(error)) throw error;
|
|
1734
|
+
const raced = await collection.findOne({
|
|
1735
|
+
bindingId: context.binding.id,
|
|
1736
|
+
lifecycleGeneration,
|
|
1737
|
+
credentialId: context.credential.id,
|
|
1738
|
+
idempotencyKeyDigest,
|
|
1739
|
+
});
|
|
1740
|
+
if (!raced) throw error;
|
|
1741
|
+
const racedSpool = asSpoolDoc(raced);
|
|
1742
|
+
if (!constantTimeDigestEqual(racedSpool.requestDigest, requestDigest)) {
|
|
1743
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1744
|
+
'Web Push idempotency key conflicts with a different request',
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
await this.cancelSpoolIfLifecycleChanged(
|
|
1748
|
+
racedSpool.id,
|
|
1749
|
+
context.binding.id,
|
|
1750
|
+
lifecycleGeneration,
|
|
1751
|
+
);
|
|
1752
|
+
return {
|
|
1753
|
+
accepted: true,
|
|
1754
|
+
spoolItemId: racedSpool.id,
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
await this.cancelSpoolIfLifecycleChanged(
|
|
1758
|
+
spool.id,
|
|
1759
|
+
context.binding.id,
|
|
1760
|
+
lifecycleGeneration,
|
|
1761
|
+
);
|
|
1762
|
+
return {
|
|
1763
|
+
accepted: true,
|
|
1764
|
+
spoolItemId: spool.id,
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
public async getDeliveryStatus(
|
|
1769
|
+
authArg: IWebPushAppCredentialAuth,
|
|
1770
|
+
spoolItemIdArg: string,
|
|
1771
|
+
): Promise<IWebPushDeliveryStatus | undefined> {
|
|
1772
|
+
const context = await this.authenticateAppCredential(authArg);
|
|
1773
|
+
const spoolItemId = requireOpaqueId(spoolItemIdArg, 'Web Push spoolItemId');
|
|
1774
|
+
const lifecycleGeneration = requireLifecycleGeneration(
|
|
1775
|
+
context.binding.lifecycleGeneration,
|
|
1776
|
+
);
|
|
1777
|
+
const row = await WebPushSpoolDoc.getNativeCollection().findOne({
|
|
1778
|
+
id: spoolItemId,
|
|
1779
|
+
bindingId: context.binding.id,
|
|
1780
|
+
lifecycleGeneration,
|
|
1781
|
+
});
|
|
1782
|
+
return row ? toPublicWebPushDelivery(asSpoolDoc(row)) : undefined;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
public async cancel(
|
|
1786
|
+
authArg: IWebPushAppCredentialAuth,
|
|
1787
|
+
targetArg: TWebPushCancellationTarget,
|
|
1788
|
+
): Promise<IWebPushCancelResult> {
|
|
1789
|
+
const context = await this.authenticateAppCredential(authArg);
|
|
1790
|
+
return await this.cancelWithinBinding(
|
|
1791
|
+
context.binding.id,
|
|
1792
|
+
targetArg,
|
|
1793
|
+
false,
|
|
1794
|
+
context.binding.lifecycleGeneration,
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
public async processDueDeliveriesOnce(limitArg = DELIVERY_BATCH_SIZE): Promise<number> {
|
|
1799
|
+
this.requireReady();
|
|
1800
|
+
const limit = Math.min(Math.max(Math.floor(limitArg), 1), 100);
|
|
1801
|
+
await this.runMaintenanceOnce();
|
|
1802
|
+
await this.expireDueItems(limit);
|
|
1803
|
+
let processed = 0;
|
|
1804
|
+
for (let index = 0; index < limit; index++) {
|
|
1805
|
+
// stop() flips readiness before waiting for the active cycle. Check the
|
|
1806
|
+
// boundary between deliveries so shutdown waits for at most one send.
|
|
1807
|
+
if (!this.ready) break;
|
|
1808
|
+
const claimed = await this.claimNextItem();
|
|
1809
|
+
if (!claimed) break;
|
|
1810
|
+
await this.processClaimedItem(claimed);
|
|
1811
|
+
processed++;
|
|
1812
|
+
}
|
|
1813
|
+
return processed;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
private requirePersistenceReady(): void {
|
|
1817
|
+
if (!this.started || !this.enabled || !this.crypto) {
|
|
1818
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push provider is unavailable');
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
private requireReady(): void {
|
|
1823
|
+
if (!this.ready || !this.crypto || !this.vapidSubject) {
|
|
1824
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push provider is unavailable');
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
private bindingOwner(bindingArg: WebPushBindingDoc): IWebPushResourceOwner {
|
|
1829
|
+
return {
|
|
1830
|
+
gatewayClientType: bindingArg.ownerGatewayClientType,
|
|
1831
|
+
gatewayClientId: bindingArg.ownerGatewayClientId,
|
|
1832
|
+
appInstanceId: bindingArg.ownerAppInstanceId,
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
private assertBindingOwner(
|
|
1837
|
+
bindingArg: WebPushBindingDoc,
|
|
1838
|
+
ownerArg: IWebPushResourceOwner,
|
|
1839
|
+
): void {
|
|
1840
|
+
if (
|
|
1841
|
+
bindingArg.ownerGatewayClientType !== ownerArg.gatewayClientType
|
|
1842
|
+
|| bindingArg.ownerGatewayClientId !== ownerArg.gatewayClientId
|
|
1843
|
+
|| bindingArg.ownerAppInstanceId !== ownerArg.appInstanceId
|
|
1844
|
+
) {
|
|
1845
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
1846
|
+
'Web Push binding owner cannot be changed',
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
private credentialHmacValue(
|
|
1852
|
+
bindingIdArg: string,
|
|
1853
|
+
credentialIdArg: string,
|
|
1854
|
+
secretArg: string,
|
|
1855
|
+
): string {
|
|
1856
|
+
return `${bindingIdArg}\0${credentialIdArg}\0${secretArg}`;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
private createCredential(bindingIdArg: string, nowArg: number): {
|
|
1860
|
+
stored: IWebPushStoredCredential;
|
|
1861
|
+
oneTime: IWebPushCredentialOneTimeSecret;
|
|
1862
|
+
} {
|
|
1863
|
+
const id = plugins.uuid.v4();
|
|
1864
|
+
const secret = plugins.crypto.randomBytes(32).toString('base64url');
|
|
1865
|
+
const hmac = this.crypto!.hmac(
|
|
1866
|
+
'credential',
|
|
1867
|
+
this.credentialHmacValue(bindingIdArg, id, secret),
|
|
1868
|
+
);
|
|
1869
|
+
const stored: IWebPushStoredCredential = {
|
|
1870
|
+
id,
|
|
1871
|
+
status: 'active',
|
|
1872
|
+
hmacKeyId: hmac.keyId,
|
|
1873
|
+
secretHash: hmac.digest,
|
|
1874
|
+
createdAt: nowArg,
|
|
1875
|
+
updatedAt: nowArg,
|
|
1876
|
+
lastRotatedAt: null,
|
|
1877
|
+
validUntil: null,
|
|
1878
|
+
};
|
|
1879
|
+
return {
|
|
1880
|
+
stored,
|
|
1881
|
+
oneTime: {
|
|
1882
|
+
credential: toCredentialPublic(stored),
|
|
1883
|
+
secret,
|
|
1884
|
+
secretShownOnce: true,
|
|
1885
|
+
},
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
private credentialRotationRecoveryAad(
|
|
1890
|
+
bindingArg: WebPushBindingDoc,
|
|
1891
|
+
identityArg: Pick<
|
|
1892
|
+
IWebPushCredentialRotationRecovery,
|
|
1893
|
+
'requestedCredentialId' | 'resultingCredentialId' | 'lifecycleGeneration'
|
|
1894
|
+
>,
|
|
1895
|
+
): IWebPushAadContext {
|
|
1896
|
+
return {
|
|
1897
|
+
gatewayClientId: bindingArg.ownerGatewayClientId,
|
|
1898
|
+
appInstanceId: bindingArg.ownerAppInstanceId,
|
|
1899
|
+
bindingId: bindingArg.id,
|
|
1900
|
+
documentType: 'credential-rotation-recovery',
|
|
1901
|
+
documentId: canonicalJson({
|
|
1902
|
+
lifecycleGeneration: identityArg.lifecycleGeneration,
|
|
1903
|
+
requestedCredentialId: identityArg.requestedCredentialId,
|
|
1904
|
+
resultingCredentialId: identityArg.resultingCredentialId,
|
|
1905
|
+
}),
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
private replayCredentialRotation(
|
|
1910
|
+
bindingArg: WebPushBindingDoc,
|
|
1911
|
+
requestedCredentialIdArg: string,
|
|
1912
|
+
nowArg: number,
|
|
1913
|
+
): IWebPushCredentialOneTimeSecret | undefined {
|
|
1914
|
+
const recovery = bindingArg.credentialRotationRecovery;
|
|
1915
|
+
if (
|
|
1916
|
+
!recovery
|
|
1917
|
+
|| recovery.expiresAt <= nowArg
|
|
1918
|
+
|| (
|
|
1919
|
+
recovery.requestedCredentialId !== requestedCredentialIdArg
|
|
1920
|
+
&& recovery.resultingCredentialId !== requestedCredentialIdArg
|
|
1921
|
+
)
|
|
1922
|
+
) {
|
|
1923
|
+
return undefined;
|
|
1924
|
+
}
|
|
1925
|
+
const lifecycleGeneration = requireLifecycleGeneration(
|
|
1926
|
+
bindingArg.lifecycleGeneration,
|
|
1927
|
+
);
|
|
1928
|
+
if (
|
|
1929
|
+
!bindingArg.enabled
|
|
1930
|
+
|| bindingArg.status !== 'active'
|
|
1931
|
+
|| typeof bindingArg.deletedAt === 'number'
|
|
1932
|
+
|| recovery.lifecycleGeneration !== lifecycleGeneration
|
|
1933
|
+
|| recovery.resultingCredentialId !== bindingArg.credential.id
|
|
1934
|
+
|| bindingArg.credential.status !== 'active'
|
|
1935
|
+
|| typeof bindingArg.credential.secretHash !== 'string'
|
|
1936
|
+
) {
|
|
1937
|
+
throw new Error('Web Push credential rotation recovery state is inconsistent');
|
|
1938
|
+
}
|
|
1939
|
+
let secret: string;
|
|
1940
|
+
try {
|
|
1941
|
+
secret = this.crypto!.decryptJson<{ secret: string }>(
|
|
1942
|
+
recovery.secretEnvelope,
|
|
1943
|
+
this.credentialRotationRecoveryAad(bindingArg, recovery),
|
|
1944
|
+
).secret;
|
|
1945
|
+
secret = requireNonEmptyString(
|
|
1946
|
+
secret,
|
|
1947
|
+
'Web Push credential rotation recovery secret',
|
|
1948
|
+
512,
|
|
1949
|
+
);
|
|
1950
|
+
} catch (error: unknown) {
|
|
1951
|
+
throw new Error('Web Push credential rotation recovery is unreadable', {
|
|
1952
|
+
cause: error,
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
if (
|
|
1956
|
+
!this.crypto!.verifyHmac(
|
|
1957
|
+
'credential',
|
|
1958
|
+
this.credentialHmacValue(
|
|
1959
|
+
bindingArg.id,
|
|
1960
|
+
bindingArg.credential.id,
|
|
1961
|
+
secret,
|
|
1962
|
+
),
|
|
1963
|
+
bindingArg.credential.hmacKeyId,
|
|
1964
|
+
bindingArg.credential.secretHash,
|
|
1965
|
+
)
|
|
1966
|
+
) {
|
|
1967
|
+
throw new Error('Web Push credential rotation recovery failed verification');
|
|
1968
|
+
}
|
|
1969
|
+
return {
|
|
1970
|
+
credential: toCredentialPublic(bindingArg.credential),
|
|
1971
|
+
secret,
|
|
1972
|
+
secretShownOnce: true,
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
private createVapidKey(
|
|
1977
|
+
ownerArg: IWebPushResourceOwner,
|
|
1978
|
+
bindingIdArg: string,
|
|
1979
|
+
nowArg: number,
|
|
1980
|
+
): IWebPushStoredVapidKey {
|
|
1981
|
+
const id = plugins.uuid.v4();
|
|
1982
|
+
const keyPair = plugins.webpush.generateVAPIDKeys();
|
|
1983
|
+
const aad: IWebPushAadContext = {
|
|
1984
|
+
gatewayClientId: ownerArg.gatewayClientId,
|
|
1985
|
+
appInstanceId: ownerArg.appInstanceId,
|
|
1986
|
+
bindingId: bindingIdArg,
|
|
1987
|
+
documentType: 'vapid-private-key',
|
|
1988
|
+
documentId: id,
|
|
1989
|
+
};
|
|
1990
|
+
return {
|
|
1991
|
+
id,
|
|
1992
|
+
publicKey: keyPair.publicKey,
|
|
1993
|
+
status: 'active',
|
|
1994
|
+
createdAt: nowArg,
|
|
1995
|
+
activatedAt: nowArg,
|
|
1996
|
+
retireAfter: null,
|
|
1997
|
+
retiredAt: null,
|
|
1998
|
+
privateKeyEnvelope: this.crypto!.encryptJson(
|
|
1999
|
+
{ privateKey: keyPair.privateKey },
|
|
2000
|
+
aad,
|
|
2001
|
+
),
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
private spoolAadBase(
|
|
2006
|
+
bindingArg: WebPushBindingDoc,
|
|
2007
|
+
spoolIdArg: string,
|
|
2008
|
+
): Omit<IWebPushAadContext, 'documentType'> {
|
|
2009
|
+
return {
|
|
2010
|
+
gatewayClientId: bindingArg.ownerGatewayClientId,
|
|
2011
|
+
appInstanceId: bindingArg.ownerAppInstanceId,
|
|
2012
|
+
bindingId: bindingArg.id,
|
|
2013
|
+
documentId: spoolIdArg,
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
private async assertBindingLifecycleActive(bindingArg: WebPushBindingDoc): Promise<void> {
|
|
2018
|
+
const lifecycleGeneration = requireLifecycleGeneration(bindingArg.lifecycleGeneration);
|
|
2019
|
+
const active = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
2020
|
+
id: bindingArg.id,
|
|
2021
|
+
lifecycleGeneration,
|
|
2022
|
+
enabled: true,
|
|
2023
|
+
status: 'active',
|
|
2024
|
+
deletedAt: { $exists: false },
|
|
2025
|
+
}, { projection: { _id: 1 } });
|
|
2026
|
+
if (!active) {
|
|
2027
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
2028
|
+
'Web Push binding lifecycle changed; retry with current credentials',
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
private async reserveAdmission(bindingArg: WebPushBindingDoc): Promise<void> {
|
|
2034
|
+
const bindingId = requireOpaqueId(bindingArg.id, 'Web Push binding id');
|
|
2035
|
+
const lifecycleGeneration = requireLifecycleGeneration(bindingArg.lifecycleGeneration);
|
|
2036
|
+
await this.assertBindingLifecycleActive(bindingArg);
|
|
2037
|
+
const collection = WebPushAdmissionDoc.getNativeCollection();
|
|
2038
|
+
const policy = this.admissionPolicy;
|
|
2039
|
+
|
|
2040
|
+
for (let attempt = 0; attempt < MAX_ADMISSION_CAS_ATTEMPTS; attempt++) {
|
|
2041
|
+
const now = this.now();
|
|
2042
|
+
const purgeAt = new Date(now + policy.longWindowMs * 2);
|
|
2043
|
+
const raw = await collection.findOne({
|
|
2044
|
+
bindingId,
|
|
2045
|
+
lifecycleGeneration,
|
|
2046
|
+
});
|
|
2047
|
+
if (!raw) {
|
|
2048
|
+
const admission = new WebPushAdmissionDoc();
|
|
2049
|
+
admission.bindingId = bindingId;
|
|
2050
|
+
admission.lifecycleGeneration = lifecycleGeneration;
|
|
2051
|
+
admission.shortWindowStartedAt = now;
|
|
2052
|
+
admission.shortWindowCount = 1;
|
|
2053
|
+
admission.longWindowStartedAt = now;
|
|
2054
|
+
admission.longWindowCount = 1;
|
|
2055
|
+
admission.revision = 1;
|
|
2056
|
+
admission.updatedAt = now;
|
|
2057
|
+
admission.purgeAt = purgeAt;
|
|
2058
|
+
try {
|
|
2059
|
+
await admission.save();
|
|
2060
|
+
return;
|
|
2061
|
+
} catch (error: unknown) {
|
|
2062
|
+
if (isDuplicateKeyError(error)) continue;
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
const admission = asAdmissionDoc(raw);
|
|
2068
|
+
const admissionGeneration = requireLifecycleGeneration(admission.lifecycleGeneration);
|
|
2069
|
+
if (admissionGeneration !== lifecycleGeneration) {
|
|
2070
|
+
throw new Error('Web Push admission lifecycle generation is inconsistent');
|
|
2071
|
+
}
|
|
2072
|
+
for (const [name, value] of Object.entries({
|
|
2073
|
+
shortWindowStartedAt: admission.shortWindowStartedAt,
|
|
2074
|
+
shortWindowCount: admission.shortWindowCount,
|
|
2075
|
+
longWindowStartedAt: admission.longWindowStartedAt,
|
|
2076
|
+
longWindowCount: admission.longWindowCount,
|
|
2077
|
+
revision: admission.revision,
|
|
2078
|
+
})) {
|
|
2079
|
+
if (!Number.isSafeInteger(value) || value < 0 || (name === 'revision' && value < 1)) {
|
|
2080
|
+
throw new Error(`Web Push admission document ${name} is malformed`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const shortWindowExpired = now - admission.shortWindowStartedAt >= policy.shortWindowMs;
|
|
2085
|
+
const longWindowExpired = now - admission.longWindowStartedAt >= policy.longWindowMs;
|
|
2086
|
+
const shortWindowStartedAt = shortWindowExpired
|
|
2087
|
+
? now
|
|
2088
|
+
: admission.shortWindowStartedAt;
|
|
2089
|
+
const longWindowStartedAt = longWindowExpired
|
|
2090
|
+
? now
|
|
2091
|
+
: admission.longWindowStartedAt;
|
|
2092
|
+
const shortWindowCount = shortWindowExpired ? 0 : admission.shortWindowCount;
|
|
2093
|
+
const longWindowCount = longWindowExpired ? 0 : admission.longWindowCount;
|
|
2094
|
+
if (shortWindowCount >= policy.shortLimit || longWindowCount >= policy.longLimit) {
|
|
2095
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
2096
|
+
'Web Push request rate limit exceeded',
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
const updated = await collection.updateOne(
|
|
2101
|
+
{
|
|
2102
|
+
bindingId,
|
|
2103
|
+
lifecycleGeneration,
|
|
2104
|
+
revision: admission.revision,
|
|
2105
|
+
},
|
|
2106
|
+
{
|
|
2107
|
+
$set: {
|
|
2108
|
+
shortWindowStartedAt,
|
|
2109
|
+
shortWindowCount: shortWindowCount + 1,
|
|
2110
|
+
longWindowStartedAt,
|
|
2111
|
+
longWindowCount: longWindowCount + 1,
|
|
2112
|
+
revision: admission.revision + 1,
|
|
2113
|
+
updatedAt: now,
|
|
2114
|
+
purgeAt,
|
|
2115
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2116
|
+
},
|
|
2117
|
+
},
|
|
2118
|
+
);
|
|
2119
|
+
if (updated.matchedCount === 1) return;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
2123
|
+
'Web Push admission changed concurrently; retry request',
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
private async cancelSpoolIfLifecycleChanged(
|
|
2128
|
+
spoolItemIdArg: string,
|
|
2129
|
+
bindingIdArg: string,
|
|
2130
|
+
lifecycleGenerationArg: number,
|
|
2131
|
+
): Promise<void> {
|
|
2132
|
+
const lifecycleGeneration = requireLifecycleGeneration(lifecycleGenerationArg);
|
|
2133
|
+
const active = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
2134
|
+
id: bindingIdArg,
|
|
2135
|
+
lifecycleGeneration,
|
|
2136
|
+
enabled: true,
|
|
2137
|
+
status: 'active',
|
|
2138
|
+
deletedAt: { $exists: false },
|
|
2139
|
+
}, { projection: { _id: 1 } });
|
|
2140
|
+
if (active) return;
|
|
2141
|
+
await this.cancelWithinBinding(
|
|
2142
|
+
bindingIdArg,
|
|
2143
|
+
{
|
|
2144
|
+
type: 'spoolItem',
|
|
2145
|
+
spoolItemId: spoolItemIdArg,
|
|
2146
|
+
},
|
|
2147
|
+
false,
|
|
2148
|
+
lifecycleGeneration,
|
|
2149
|
+
'BINDING_LIFECYCLE_CHANGED',
|
|
2150
|
+
);
|
|
2151
|
+
await WebPushAdmissionDoc.getNativeCollection().deleteOne({
|
|
2152
|
+
bindingId: bindingIdArg,
|
|
2153
|
+
lifecycleGeneration,
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
private async scrubDeletedBindingLifecycle(
|
|
2158
|
+
bindingArg: WebPushBindingDoc,
|
|
2159
|
+
waitForDrainArg = true,
|
|
2160
|
+
): Promise<void> {
|
|
2161
|
+
const tombstoneGeneration = requireLifecycleGeneration(bindingArg.lifecycleGeneration);
|
|
2162
|
+
if (typeof bindingArg.deletedAt !== 'number') return;
|
|
2163
|
+
const cancellationErrorCode = bindingArg.controllerFence?.intent === 'disabled'
|
|
2164
|
+
? 'BINDING_DISABLED'
|
|
2165
|
+
: 'BINDING_DELETED';
|
|
2166
|
+
await this.requestBindingLifecyclesBeforeCancellation(
|
|
2167
|
+
bindingArg.id,
|
|
2168
|
+
tombstoneGeneration,
|
|
2169
|
+
cancellationErrorCode,
|
|
2170
|
+
);
|
|
2171
|
+
const collection = WebPushBindingDoc.getNativeCollection();
|
|
2172
|
+
let scrubbed = false;
|
|
2173
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
2174
|
+
const currentRaw = await collection.findOne({
|
|
2175
|
+
id: bindingArg.id,
|
|
2176
|
+
lifecycleGeneration: tombstoneGeneration,
|
|
2177
|
+
deletedAt: bindingArg.deletedAt,
|
|
2178
|
+
enabled: false,
|
|
2179
|
+
});
|
|
2180
|
+
if (!currentRaw) return;
|
|
2181
|
+
const current = asBindingDoc(currentRaw);
|
|
2182
|
+
const needsScrub = (
|
|
2183
|
+
typeof current.credential?.secretHash === 'string'
|
|
2184
|
+
|| (current.previousCredentials || []).length > 0
|
|
2185
|
+
|| current.credentialRotationRecovery !== undefined
|
|
2186
|
+
|| (current.vapidKeys || []).some((keyArg) => (
|
|
2187
|
+
keyArg.privateKeyEnvelope !== undefined
|
|
2188
|
+
|| keyArg.status !== 'retired'
|
|
2189
|
+
|| keyArg.retireAfter !== null
|
|
2190
|
+
))
|
|
2191
|
+
);
|
|
2192
|
+
if (!needsScrub) {
|
|
2193
|
+
scrubbed = true;
|
|
2194
|
+
break;
|
|
2195
|
+
}
|
|
2196
|
+
const now = this.now();
|
|
2197
|
+
const credential: IWebPushStoredCredential = {
|
|
2198
|
+
...current.credential,
|
|
2199
|
+
status: 'revoked',
|
|
2200
|
+
updatedAt: now,
|
|
2201
|
+
validUntil: null,
|
|
2202
|
+
};
|
|
2203
|
+
delete credential.secretHash;
|
|
2204
|
+
const vapidKeys = (current.vapidKeys || []).map((keyArg) => {
|
|
2205
|
+
const scrubbedKey: IWebPushStoredVapidKey = {
|
|
2206
|
+
...keyArg,
|
|
2207
|
+
status: 'retired',
|
|
2208
|
+
retireAfter: null,
|
|
2209
|
+
retiredAt: keyArg.retiredAt ?? now,
|
|
2210
|
+
};
|
|
2211
|
+
delete scrubbedKey.privateKeyEnvelope;
|
|
2212
|
+
return scrubbedKey;
|
|
2213
|
+
});
|
|
2214
|
+
const result = await collection.updateOne(
|
|
2215
|
+
{
|
|
2216
|
+
id: current.id,
|
|
2217
|
+
mutationRevision: current.mutationRevision,
|
|
2218
|
+
lifecycleGeneration: tombstoneGeneration,
|
|
2219
|
+
deletedAt: bindingArg.deletedAt,
|
|
2220
|
+
enabled: false,
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
$set: {
|
|
2224
|
+
credential,
|
|
2225
|
+
previousCredentials: [],
|
|
2226
|
+
vapidKeys,
|
|
2227
|
+
updatedAt: now,
|
|
2228
|
+
mutationRevision: current.mutationRevision + 1,
|
|
2229
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2230
|
+
},
|
|
2231
|
+
$unset: {
|
|
2232
|
+
credentialRotationRecovery: '',
|
|
2233
|
+
},
|
|
2234
|
+
},
|
|
2235
|
+
);
|
|
2236
|
+
if (result.matchedCount === 1) {
|
|
2237
|
+
scrubbed = true;
|
|
2238
|
+
break;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
if (!scrubbed) {
|
|
2242
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
2243
|
+
'Web Push binding cleanup changed concurrently; retry deletion',
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
await WebPushAdmissionDoc.getNativeCollection().deleteMany({
|
|
2247
|
+
bindingId: bindingArg.id,
|
|
2248
|
+
lifecycleGeneration: { $lte: tombstoneGeneration },
|
|
2249
|
+
});
|
|
2250
|
+
await this.finishStaleCancelledSendingBefore(
|
|
2251
|
+
bindingArg.id,
|
|
2252
|
+
tombstoneGeneration,
|
|
2253
|
+
cancellationErrorCode,
|
|
2254
|
+
);
|
|
2255
|
+
if (waitForDrainArg) {
|
|
2256
|
+
await this.waitForBindingLifecyclesBeforeDrain(
|
|
2257
|
+
bindingArg.id,
|
|
2258
|
+
tombstoneGeneration,
|
|
2259
|
+
cancellationErrorCode,
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
private async cleanupExpiredCredentialOverlaps(
|
|
2265
|
+
limitArg = MAINTENANCE_BATCH_SIZE,
|
|
2266
|
+
): Promise<void> {
|
|
2267
|
+
const now = this.now();
|
|
2268
|
+
const collection = WebPushBindingDoc.getNativeCollection();
|
|
2269
|
+
const batch = await this.selectBindingMaintenanceBatch(
|
|
2270
|
+
{ 'previousCredentials.validUntil': { $lte: now } },
|
|
2271
|
+
this.expiredCredentialOverlapCursor,
|
|
2272
|
+
limitArg,
|
|
2273
|
+
);
|
|
2274
|
+
this.expiredCredentialOverlapCursor = batch.nextCursor;
|
|
2275
|
+
for (const binding of batch.rows) {
|
|
2276
|
+
const remaining = (binding.previousCredentials || []).filter(
|
|
2277
|
+
(credentialArg) => (
|
|
2278
|
+
typeof credentialArg.secretHash === 'string'
|
|
2279
|
+
&& credentialArg.validUntil !== null
|
|
2280
|
+
&& credentialArg.validUntil > now
|
|
2281
|
+
),
|
|
2282
|
+
);
|
|
2283
|
+
if (remaining.length === (binding.previousCredentials || []).length) continue;
|
|
2284
|
+
await collection.updateOne(
|
|
2285
|
+
{
|
|
2286
|
+
id: binding.id,
|
|
2287
|
+
mutationRevision: binding.mutationRevision,
|
|
2288
|
+
lifecycleGeneration: binding.lifecycleGeneration,
|
|
2289
|
+
},
|
|
2290
|
+
{
|
|
2291
|
+
$set: {
|
|
2292
|
+
previousCredentials: remaining,
|
|
2293
|
+
updatedAt: now,
|
|
2294
|
+
mutationRevision: binding.mutationRevision + 1,
|
|
2295
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2296
|
+
},
|
|
2297
|
+
},
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
private async cleanupExpiredCredentialRotationRecoveries(
|
|
2303
|
+
limitArg = MAINTENANCE_BATCH_SIZE,
|
|
2304
|
+
): Promise<void> {
|
|
2305
|
+
const now = this.now();
|
|
2306
|
+
const collection = WebPushBindingDoc.getNativeCollection();
|
|
2307
|
+
const batch = await this.selectBindingMaintenanceBatch(
|
|
2308
|
+
{ 'credentialRotationRecovery.expiresAt': { $lte: now } },
|
|
2309
|
+
this.expiredCredentialRecoveryCursor,
|
|
2310
|
+
limitArg,
|
|
2311
|
+
);
|
|
2312
|
+
this.expiredCredentialRecoveryCursor = batch.nextCursor;
|
|
2313
|
+
for (const binding of batch.rows) {
|
|
2314
|
+
const recovery = binding.credentialRotationRecovery;
|
|
2315
|
+
if (!recovery || recovery.expiresAt > now) continue;
|
|
2316
|
+
await collection.updateOne(
|
|
2317
|
+
{
|
|
2318
|
+
id: binding.id,
|
|
2319
|
+
mutationRevision: binding.mutationRevision,
|
|
2320
|
+
lifecycleGeneration: binding.lifecycleGeneration,
|
|
2321
|
+
'credentialRotationRecovery.requestedCredentialId':
|
|
2322
|
+
recovery.requestedCredentialId,
|
|
2323
|
+
'credentialRotationRecovery.resultingCredentialId':
|
|
2324
|
+
recovery.resultingCredentialId,
|
|
2325
|
+
'credentialRotationRecovery.expiresAt': recovery.expiresAt,
|
|
2326
|
+
},
|
|
2327
|
+
{
|
|
2328
|
+
$set: {
|
|
2329
|
+
updatedAt: now,
|
|
2330
|
+
mutationRevision: binding.mutationRevision + 1,
|
|
2331
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2332
|
+
},
|
|
2333
|
+
$unset: {
|
|
2334
|
+
credentialRotationRecovery: '',
|
|
2335
|
+
},
|
|
2336
|
+
},
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
private async cleanupRetiredVapidKeys(
|
|
2342
|
+
bindingIdArg?: string,
|
|
2343
|
+
limitArg = MAINTENANCE_BATCH_SIZE,
|
|
2344
|
+
): Promise<void> {
|
|
2345
|
+
const now = this.now();
|
|
2346
|
+
const bindingCollection = WebPushBindingDoc.getNativeCollection();
|
|
2347
|
+
const spoolCollection = WebPushSpoolDoc.getNativeCollection();
|
|
2348
|
+
const selector: Record<string, unknown> = bindingIdArg
|
|
2349
|
+
? { id: requireOpaqueId(bindingIdArg, 'Web Push binding id') }
|
|
2350
|
+
: {
|
|
2351
|
+
$or: [
|
|
2352
|
+
{ 'vapidKeys.status': 'retired' },
|
|
2353
|
+
{ 'vapidKeys.retireAfter': { $lte: now } },
|
|
2354
|
+
],
|
|
2355
|
+
};
|
|
2356
|
+
const rows = bindingIdArg
|
|
2357
|
+
? (await bindingCollection
|
|
2358
|
+
.find(selector)
|
|
2359
|
+
.limit(1)
|
|
2360
|
+
.toArray())
|
|
2361
|
+
.map((rawArg) => asBindingDoc(rawArg))
|
|
2362
|
+
: await (async () => {
|
|
2363
|
+
const batch = await this.selectBindingMaintenanceBatch(
|
|
2364
|
+
selector,
|
|
2365
|
+
this.retiredVapidCursor,
|
|
2366
|
+
limitArg,
|
|
2367
|
+
);
|
|
2368
|
+
this.retiredVapidCursor = batch.nextCursor;
|
|
2369
|
+
return batch.rows;
|
|
2370
|
+
})();
|
|
2371
|
+
for (const binding of rows) {
|
|
2372
|
+
const retained: IWebPushStoredVapidKey[] = [];
|
|
2373
|
+
for (const key of binding.vapidKeys || []) {
|
|
2374
|
+
if (key.status === 'active') {
|
|
2375
|
+
retained.push(key);
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
if (
|
|
2379
|
+
key.status === 'retiring'
|
|
2380
|
+
&& (key.retireAfter === null || key.retireAfter > now)
|
|
2381
|
+
) {
|
|
2382
|
+
retained.push(key);
|
|
2383
|
+
continue;
|
|
2384
|
+
}
|
|
2385
|
+
if (key.status === 'retired') continue;
|
|
2386
|
+
const pending = await spoolCollection.countDocuments({
|
|
2387
|
+
bindingId: binding.id,
|
|
2388
|
+
lifecycleGeneration: requireLifecycleGeneration(binding.lifecycleGeneration),
|
|
2389
|
+
vapidKeyId: key.id,
|
|
2390
|
+
state: { $in: NONTERMINAL_STATES },
|
|
2391
|
+
}, { limit: 1 });
|
|
2392
|
+
if (pending > 0) {
|
|
2393
|
+
retained.push(key);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
if (retained.length === binding.vapidKeys.length) continue;
|
|
2397
|
+
await bindingCollection.updateOne(
|
|
2398
|
+
{
|
|
2399
|
+
id: binding.id,
|
|
2400
|
+
mutationRevision: binding.mutationRevision,
|
|
2401
|
+
lifecycleGeneration: binding.lifecycleGeneration,
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
$set: {
|
|
2405
|
+
vapidKeys: retained,
|
|
2406
|
+
updatedAt: now,
|
|
2407
|
+
mutationRevision: binding.mutationRevision + 1,
|
|
2408
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2409
|
+
},
|
|
2410
|
+
},
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
private async runMaintenanceOnce(): Promise<void> {
|
|
2416
|
+
await this.cleanupExpiredCredentialOverlaps();
|
|
2417
|
+
await this.cleanupExpiredCredentialRotationRecoveries();
|
|
2418
|
+
await this.cleanupRetiredVapidKeys();
|
|
2419
|
+
await this.finishStaleCancelledSending();
|
|
2420
|
+
await this.cleanupInactiveBindingLifecycles();
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
private async cleanupInactiveBindingLifecycles(
|
|
2424
|
+
limitArg = Math.min(MAINTENANCE_BATCH_SIZE, 20),
|
|
2425
|
+
): Promise<void> {
|
|
2426
|
+
const batch = await this.selectBindingMaintenanceBatch(
|
|
2427
|
+
{
|
|
2428
|
+
$or: [
|
|
2429
|
+
{ deletedAt: { $type: 'number' } },
|
|
2430
|
+
{ enabled: false },
|
|
2431
|
+
],
|
|
2432
|
+
},
|
|
2433
|
+
this.inactiveLifecycleCursor,
|
|
2434
|
+
limitArg,
|
|
2435
|
+
);
|
|
2436
|
+
this.inactiveLifecycleCursor = batch.nextCursor;
|
|
2437
|
+
for (const binding of batch.rows) {
|
|
2438
|
+
try {
|
|
2439
|
+
const lifecycleGeneration = requireLifecycleGeneration(
|
|
2440
|
+
binding.lifecycleGeneration,
|
|
2441
|
+
);
|
|
2442
|
+
if (typeof binding.deletedAt === 'number') {
|
|
2443
|
+
await this.scrubDeletedBindingLifecycle(binding, false);
|
|
2444
|
+
} else {
|
|
2445
|
+
await this.requestBindingLifecyclesBeforeCancellation(
|
|
2446
|
+
binding.id,
|
|
2447
|
+
lifecycleGeneration,
|
|
2448
|
+
'BINDING_DISABLED',
|
|
2449
|
+
);
|
|
2450
|
+
await WebPushAdmissionDoc.getNativeCollection().deleteMany({
|
|
2451
|
+
bindingId: binding.id,
|
|
2452
|
+
lifecycleGeneration: { $lt: lifecycleGeneration },
|
|
2453
|
+
});
|
|
2454
|
+
await this.finishStaleCancelledSendingBefore(
|
|
2455
|
+
binding.id,
|
|
2456
|
+
lifecycleGeneration,
|
|
2457
|
+
'BINDING_DISABLED',
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
} catch (error: unknown) {
|
|
2461
|
+
logger.log(
|
|
2462
|
+
'warn',
|
|
2463
|
+
`Web Push lifecycle maintenance will retry binding ${binding.id}: ${(error as Error).name}`,
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
private async selectBindingMaintenanceBatch(
|
|
2470
|
+
selectorArg: Record<string, unknown>,
|
|
2471
|
+
cursorArg: string | undefined,
|
|
2472
|
+
limitArg: number,
|
|
2473
|
+
): Promise<{
|
|
2474
|
+
rows: WebPushBindingDoc[];
|
|
2475
|
+
nextCursor?: string;
|
|
2476
|
+
}> {
|
|
2477
|
+
const selector = cursorArg === undefined
|
|
2478
|
+
? selectorArg
|
|
2479
|
+
: {
|
|
2480
|
+
$and: [
|
|
2481
|
+
selectorArg,
|
|
2482
|
+
{ id: { $gt: cursorArg } },
|
|
2483
|
+
],
|
|
2484
|
+
};
|
|
2485
|
+
const rows = (await WebPushBindingDoc.getNativeCollection()
|
|
2486
|
+
.find(selector)
|
|
2487
|
+
.sort({ id: 1 })
|
|
2488
|
+
.limit(Math.min(Math.max(Math.floor(limitArg), 1), MAINTENANCE_BATCH_SIZE))
|
|
2489
|
+
.toArray())
|
|
2490
|
+
.map((rawArg) => asBindingDoc(rawArg));
|
|
2491
|
+
return {
|
|
2492
|
+
rows,
|
|
2493
|
+
...(rows.length > 0 ? { nextCursor: rows[rows.length - 1].id } : {}),
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
private async requestBindingLifecyclesBeforeCancellation(
|
|
2498
|
+
bindingIdArg: string,
|
|
2499
|
+
beforeLifecycleGenerationArg: number,
|
|
2500
|
+
errorCodeArg: string,
|
|
2501
|
+
): Promise<void> {
|
|
2502
|
+
const beforeLifecycleGeneration = requireLifecycleGeneration(
|
|
2503
|
+
beforeLifecycleGenerationArg,
|
|
2504
|
+
);
|
|
2505
|
+
await this.cancelWithinBinding(
|
|
2506
|
+
bindingIdArg,
|
|
2507
|
+
{
|
|
2508
|
+
type: 'subscription',
|
|
2509
|
+
subscriptionId: '*',
|
|
2510
|
+
},
|
|
2511
|
+
true,
|
|
2512
|
+
{ $lt: beforeLifecycleGeneration },
|
|
2513
|
+
errorCodeArg,
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
private async drainBindingLifecyclesBefore(
|
|
2518
|
+
bindingIdArg: string,
|
|
2519
|
+
beforeLifecycleGenerationArg: number,
|
|
2520
|
+
errorCodeArg: string,
|
|
2521
|
+
): Promise<void> {
|
|
2522
|
+
const beforeLifecycleGeneration = requireLifecycleGeneration(
|
|
2523
|
+
beforeLifecycleGenerationArg,
|
|
2524
|
+
);
|
|
2525
|
+
await this.requestBindingLifecyclesBeforeCancellation(
|
|
2526
|
+
bindingIdArg,
|
|
2527
|
+
beforeLifecycleGeneration,
|
|
2528
|
+
errorCodeArg,
|
|
2529
|
+
);
|
|
2530
|
+
await WebPushAdmissionDoc.getNativeCollection().deleteMany({
|
|
2531
|
+
bindingId: bindingIdArg,
|
|
2532
|
+
lifecycleGeneration: { $lt: beforeLifecycleGeneration },
|
|
2533
|
+
});
|
|
2534
|
+
await this.waitForBindingLifecyclesBeforeDrain(
|
|
2535
|
+
bindingIdArg,
|
|
2536
|
+
beforeLifecycleGeneration,
|
|
2537
|
+
errorCodeArg,
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
private async waitForBindingLifecyclesBeforeDrain(
|
|
2542
|
+
bindingIdArg: string,
|
|
2543
|
+
beforeLifecycleGenerationArg: number,
|
|
2544
|
+
errorCodeArg: string,
|
|
2545
|
+
): Promise<void> {
|
|
2546
|
+
const bindingId = requireOpaqueId(bindingIdArg, 'Web Push binding id');
|
|
2547
|
+
const beforeLifecycleGeneration = requireLifecycleGeneration(
|
|
2548
|
+
beforeLifecycleGenerationArg,
|
|
2549
|
+
);
|
|
2550
|
+
const deadline = Date.now() + LIFECYCLE_DRAIN_TIMEOUT_MS;
|
|
2551
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
2552
|
+
while (true) {
|
|
2553
|
+
await this.requestBindingLifecyclesBeforeCancellation(
|
|
2554
|
+
bindingId,
|
|
2555
|
+
beforeLifecycleGeneration,
|
|
2556
|
+
errorCodeArg,
|
|
2557
|
+
);
|
|
2558
|
+
while (
|
|
2559
|
+
await this.finishStaleCancelledSendingBefore(
|
|
2560
|
+
bindingId,
|
|
2561
|
+
beforeLifecycleGeneration,
|
|
2562
|
+
errorCodeArg,
|
|
2563
|
+
) > 0
|
|
2564
|
+
) {
|
|
2565
|
+
// Drain every bounded stale-lease batch before checking completion.
|
|
2566
|
+
}
|
|
2567
|
+
const remaining = await collection.countDocuments({
|
|
2568
|
+
bindingId,
|
|
2569
|
+
lifecycleGeneration: { $lt: beforeLifecycleGeneration },
|
|
2570
|
+
state: { $in: NONTERMINAL_STATES },
|
|
2571
|
+
}, { limit: 1 });
|
|
2572
|
+
if (remaining === 0) return;
|
|
2573
|
+
if (Date.now() >= deadline) {
|
|
2574
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
2575
|
+
'Web Push binding cleanup is still draining in-flight delivery; retry',
|
|
2576
|
+
);
|
|
2577
|
+
}
|
|
2578
|
+
await new Promise<void>((resolveArg) => {
|
|
2579
|
+
setTimeout(resolveArg, LIFECYCLE_DRAIN_POLL_MS);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
private async finishStaleCancelledSending(
|
|
2585
|
+
limitArg = MAINTENANCE_BATCH_SIZE,
|
|
2586
|
+
): Promise<number> {
|
|
2587
|
+
const now = this.now();
|
|
2588
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
2589
|
+
const rows = await collection.find({
|
|
2590
|
+
state: 'sending',
|
|
2591
|
+
cancelRequestedAt: { $exists: true },
|
|
2592
|
+
$or: [
|
|
2593
|
+
{ leaseToken: { $exists: false } },
|
|
2594
|
+
{ leaseExpiresAt: { $exists: false } },
|
|
2595
|
+
{ leaseExpiresAt: { $lte: now } },
|
|
2596
|
+
],
|
|
2597
|
+
}, {
|
|
2598
|
+
projection: {
|
|
2599
|
+
id: 1,
|
|
2600
|
+
leaseToken: 1,
|
|
2601
|
+
leaseExpiresAt: 1,
|
|
2602
|
+
cancelRequestedAt: 1,
|
|
2603
|
+
},
|
|
2604
|
+
})
|
|
2605
|
+
.sort({ cancelRequestedAt: 1, id: 1 })
|
|
2606
|
+
.limit(Math.min(Math.max(Math.floor(limitArg), 1), MAINTENANCE_BATCH_SIZE))
|
|
2607
|
+
.toArray();
|
|
2608
|
+
let finished = 0;
|
|
2609
|
+
for (const raw of rows) {
|
|
2610
|
+
const spool = asSpoolDoc(raw);
|
|
2611
|
+
const result = await collection.updateOne(
|
|
2612
|
+
{
|
|
2613
|
+
id: spool.id,
|
|
2614
|
+
state: 'sending',
|
|
2615
|
+
cancelRequestedAt: spool.cancelRequestedAt,
|
|
2616
|
+
...(spool.leaseToken === undefined
|
|
2617
|
+
? { leaseToken: { $exists: false } }
|
|
2618
|
+
: { leaseToken: spool.leaseToken }),
|
|
2619
|
+
...(spool.leaseExpiresAt === undefined
|
|
2620
|
+
? { leaseExpiresAt: { $exists: false } }
|
|
2621
|
+
: { leaseExpiresAt: spool.leaseExpiresAt }),
|
|
2622
|
+
},
|
|
2623
|
+
buildWebPushTerminalUpdate({
|
|
2624
|
+
state: 'failed',
|
|
2625
|
+
now,
|
|
2626
|
+
errorCode: 'DELIVERY_OUTCOME_UNKNOWN_AFTER_CANCELLATION',
|
|
2627
|
+
}),
|
|
2628
|
+
);
|
|
2629
|
+
finished += result.modifiedCount;
|
|
2630
|
+
}
|
|
2631
|
+
return finished;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
private async finishStaleCancelledSendingBefore(
|
|
2635
|
+
bindingIdArg: string,
|
|
2636
|
+
beforeLifecycleGenerationArg: number,
|
|
2637
|
+
_fallbackErrorCodeArg: string,
|
|
2638
|
+
): Promise<number> {
|
|
2639
|
+
const bindingId = requireOpaqueId(bindingIdArg, 'Web Push binding id');
|
|
2640
|
+
const beforeLifecycleGeneration = requireLifecycleGeneration(
|
|
2641
|
+
beforeLifecycleGenerationArg,
|
|
2642
|
+
);
|
|
2643
|
+
const now = this.now();
|
|
2644
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
2645
|
+
const rows = await collection.find({
|
|
2646
|
+
bindingId,
|
|
2647
|
+
lifecycleGeneration: { $lt: beforeLifecycleGeneration },
|
|
2648
|
+
state: 'sending',
|
|
2649
|
+
cancelRequestedAt: { $exists: true },
|
|
2650
|
+
$or: [
|
|
2651
|
+
{ leaseToken: { $exists: false } },
|
|
2652
|
+
{ leaseExpiresAt: { $exists: false } },
|
|
2653
|
+
{ leaseExpiresAt: { $lte: now } },
|
|
2654
|
+
],
|
|
2655
|
+
}, {
|
|
2656
|
+
projection: {
|
|
2657
|
+
id: 1,
|
|
2658
|
+
leaseToken: 1,
|
|
2659
|
+
leaseExpiresAt: 1,
|
|
2660
|
+
cancelRequestedAt: 1,
|
|
2661
|
+
cancelErrorCode: 1,
|
|
2662
|
+
},
|
|
2663
|
+
}).limit(MAINTENANCE_BATCH_SIZE).toArray();
|
|
2664
|
+
let finished = 0;
|
|
2665
|
+
for (const raw of rows) {
|
|
2666
|
+
const spool = asSpoolDoc(raw);
|
|
2667
|
+
const result = await collection.updateOne(
|
|
2668
|
+
{
|
|
2669
|
+
id: spool.id,
|
|
2670
|
+
bindingId,
|
|
2671
|
+
state: 'sending',
|
|
2672
|
+
cancelRequestedAt: spool.cancelRequestedAt,
|
|
2673
|
+
...(spool.leaseToken === undefined
|
|
2674
|
+
? { leaseToken: { $exists: false } }
|
|
2675
|
+
: { leaseToken: spool.leaseToken }),
|
|
2676
|
+
...(spool.leaseExpiresAt === undefined
|
|
2677
|
+
? { leaseExpiresAt: { $exists: false } }
|
|
2678
|
+
: { leaseExpiresAt: spool.leaseExpiresAt }),
|
|
2679
|
+
},
|
|
2680
|
+
buildWebPushTerminalUpdate({
|
|
2681
|
+
state: 'failed',
|
|
2682
|
+
now,
|
|
2683
|
+
errorCode: 'DELIVERY_OUTCOME_UNKNOWN_AFTER_CANCELLATION',
|
|
2684
|
+
}),
|
|
2685
|
+
);
|
|
2686
|
+
finished += result.modifiedCount;
|
|
2687
|
+
}
|
|
2688
|
+
return finished;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
private async cancelWithinBinding(
|
|
2692
|
+
bindingIdArg: string,
|
|
2693
|
+
targetArg: TWebPushCancellationTarget,
|
|
2694
|
+
allSubscriptionsArg = false,
|
|
2695
|
+
lifecycleGenerationArg?: number | { $lt: number },
|
|
2696
|
+
errorCodeArg = 'CANCELLED_BY_APPLICATION',
|
|
2697
|
+
): Promise<IWebPushCancelResult> {
|
|
2698
|
+
const bindingId = requireOpaqueId(bindingIdArg, 'Web Push binding id');
|
|
2699
|
+
const targetSelector: Record<string, unknown> = {};
|
|
2700
|
+
if (targetArg.type === 'spoolItem') {
|
|
2701
|
+
targetSelector.id = requireOpaqueId(targetArg.spoolItemId, 'Web Push spoolItemId');
|
|
2702
|
+
} else if (allSubscriptionsArg) {
|
|
2703
|
+
targetSelector.subscriptionId = { $type: 'string' };
|
|
2704
|
+
} else {
|
|
2705
|
+
targetSelector.subscriptionId = requireOpaqueId(
|
|
2706
|
+
targetArg.subscriptionId,
|
|
2707
|
+
'Web Push subscriptionId',
|
|
2708
|
+
);
|
|
2709
|
+
}
|
|
2710
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
2711
|
+
const lifecycleGenerationSelector = typeof lifecycleGenerationArg === 'number'
|
|
2712
|
+
? requireLifecycleGeneration(lifecycleGenerationArg)
|
|
2713
|
+
: lifecycleGenerationArg === undefined
|
|
2714
|
+
? undefined
|
|
2715
|
+
: { $lt: requireLifecycleGeneration(lifecycleGenerationArg.$lt) };
|
|
2716
|
+
const selector = {
|
|
2717
|
+
bindingId,
|
|
2718
|
+
...(lifecycleGenerationSelector === undefined
|
|
2719
|
+
? {}
|
|
2720
|
+
: { lifecycleGeneration: lifecycleGenerationSelector }),
|
|
2721
|
+
...targetSelector,
|
|
2722
|
+
};
|
|
2723
|
+
const alreadyTerminalCount = await collection.countDocuments({
|
|
2724
|
+
...selector,
|
|
2725
|
+
state: { $in: TERMINAL_STATES },
|
|
2726
|
+
});
|
|
2727
|
+
let cancelledCount = 0;
|
|
2728
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
2729
|
+
const now = this.now();
|
|
2730
|
+
const terminalized = await collection.updateMany(
|
|
2731
|
+
{
|
|
2732
|
+
...selector,
|
|
2733
|
+
state: { $in: ['accepted', 'queued', 'deferred'] },
|
|
2734
|
+
},
|
|
2735
|
+
buildWebPushTerminalUpdate({
|
|
2736
|
+
state: 'cancelled',
|
|
2737
|
+
now,
|
|
2738
|
+
errorCode: errorCodeArg,
|
|
2739
|
+
}),
|
|
2740
|
+
);
|
|
2741
|
+
const markedSending = await collection.updateMany(
|
|
2742
|
+
{
|
|
2743
|
+
...selector,
|
|
2744
|
+
state: 'sending',
|
|
2745
|
+
cancelRequestedAt: { $exists: false },
|
|
2746
|
+
},
|
|
2747
|
+
{
|
|
2748
|
+
$set: {
|
|
2749
|
+
cancelRequestedAt: now,
|
|
2750
|
+
cancelErrorCode: errorCodeArg,
|
|
2751
|
+
updatedAt: now,
|
|
2752
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2753
|
+
},
|
|
2754
|
+
},
|
|
2755
|
+
);
|
|
2756
|
+
const modified = terminalized.modifiedCount + markedSending.modifiedCount;
|
|
2757
|
+
cancelledCount += modified;
|
|
2758
|
+
if (modified === 0) break;
|
|
2759
|
+
}
|
|
2760
|
+
return {
|
|
2761
|
+
cancelledCount,
|
|
2762
|
+
alreadyTerminalCount,
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
private async expireDueItems(limitArg: number): Promise<void> {
|
|
2767
|
+
const now = this.now();
|
|
2768
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
2769
|
+
const rows = await collection
|
|
2770
|
+
.find({
|
|
2771
|
+
state: { $in: NONTERMINAL_STATES },
|
|
2772
|
+
expiresAt: { $lte: now },
|
|
2773
|
+
}, {
|
|
2774
|
+
projection: { id: 1, state: 1, leaseToken: 1, leaseExpiresAt: 1 },
|
|
2775
|
+
})
|
|
2776
|
+
.limit(limitArg)
|
|
2777
|
+
.toArray();
|
|
2778
|
+
for (const raw of rows) {
|
|
2779
|
+
const spool = asSpoolDoc(raw);
|
|
2780
|
+
if (
|
|
2781
|
+
spool.state === 'sending'
|
|
2782
|
+
&& typeof spool.leaseExpiresAt === 'number'
|
|
2783
|
+
&& spool.leaseExpiresAt > now
|
|
2784
|
+
) {
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
const leaseSelector = spool.state === 'sending'
|
|
2788
|
+
? {
|
|
2789
|
+
...(spool.leaseToken === undefined
|
|
2790
|
+
? { leaseToken: { $exists: false } }
|
|
2791
|
+
: { leaseToken: spool.leaseToken }),
|
|
2792
|
+
...(spool.leaseExpiresAt === undefined
|
|
2793
|
+
? { leaseExpiresAt: { $exists: false } }
|
|
2794
|
+
: { leaseExpiresAt: spool.leaseExpiresAt }),
|
|
2795
|
+
}
|
|
2796
|
+
: {};
|
|
2797
|
+
await collection.updateOne(
|
|
2798
|
+
{
|
|
2799
|
+
id: spool.id,
|
|
2800
|
+
state: spool.state,
|
|
2801
|
+
...leaseSelector,
|
|
2802
|
+
},
|
|
2803
|
+
buildWebPushTerminalUpdate({
|
|
2804
|
+
state: 'expired',
|
|
2805
|
+
now,
|
|
2806
|
+
errorCode: 'DELIVERY_TTL_EXPIRED',
|
|
2807
|
+
}),
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
private async claimNextItem(): Promise<WebPushSpoolDoc | null> {
|
|
2813
|
+
const now = this.now();
|
|
2814
|
+
const leaseToken = plugins.uuid.v4();
|
|
2815
|
+
const row = await WebPushSpoolDoc.getNativeCollection().findOneAndUpdate(
|
|
2816
|
+
{
|
|
2817
|
+
expiresAt: { $gt: now },
|
|
2818
|
+
$or: [
|
|
2819
|
+
{
|
|
2820
|
+
state: { $in: ['accepted', 'queued', 'deferred'] },
|
|
2821
|
+
nextAttemptAt: { $lte: now },
|
|
2822
|
+
cancelRequestedAt: { $exists: false },
|
|
2823
|
+
$or: [
|
|
2824
|
+
{ leaseToken: { $exists: false } },
|
|
2825
|
+
{ leaseExpiresAt: { $lte: now } },
|
|
2826
|
+
],
|
|
2827
|
+
},
|
|
2828
|
+
{
|
|
2829
|
+
state: 'sending',
|
|
2830
|
+
leaseExpiresAt: { $lte: now },
|
|
2831
|
+
cancelRequestedAt: { $exists: false },
|
|
2832
|
+
},
|
|
2833
|
+
],
|
|
2834
|
+
},
|
|
2835
|
+
{
|
|
2836
|
+
$set: {
|
|
2837
|
+
state: 'sending',
|
|
2838
|
+
leaseToken,
|
|
2839
|
+
leaseExpiresAt: now + DELIVERY_LEASE_MS,
|
|
2840
|
+
updatedAt: now,
|
|
2841
|
+
_updatedAt: new Date(now).toISOString(),
|
|
2842
|
+
},
|
|
2843
|
+
$inc: { attempts: 1 },
|
|
2844
|
+
},
|
|
2845
|
+
{
|
|
2846
|
+
sort: { nextAttemptAt: 1, acceptedAt: 1, id: 1 },
|
|
2847
|
+
returnDocument: 'after',
|
|
2848
|
+
},
|
|
2849
|
+
);
|
|
2850
|
+
return row ? asSpoolDoc(row) : null;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
private async processClaimedItem(spoolArg: WebPushSpoolDoc): Promise<void> {
|
|
2854
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
2855
|
+
const leaseToken = spoolArg.leaseToken;
|
|
2856
|
+
if (!leaseToken) return;
|
|
2857
|
+
const now = this.now();
|
|
2858
|
+
const current = await collection.findOne({
|
|
2859
|
+
id: spoolArg.id,
|
|
2860
|
+
state: 'sending',
|
|
2861
|
+
leaseToken,
|
|
2862
|
+
});
|
|
2863
|
+
if (!current) return;
|
|
2864
|
+
const currentSpool = asSpoolDoc(current);
|
|
2865
|
+
if (currentSpool.cancelRequestedAt !== undefined) {
|
|
2866
|
+
await this.finishTerminal(currentSpool, 'cancelled', {
|
|
2867
|
+
errorCode: currentSpool.cancelErrorCode || 'CANCELLED_BY_APPLICATION',
|
|
2868
|
+
});
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
if (currentSpool.expiresAt <= now) {
|
|
2872
|
+
await this.finishTerminal(currentSpool, 'expired', {
|
|
2873
|
+
errorCode: 'DELIVERY_TTL_EXPIRED',
|
|
2874
|
+
});
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
let lifecycleGeneration: number;
|
|
2878
|
+
try {
|
|
2879
|
+
lifecycleGeneration = requireLifecycleGeneration(currentSpool.lifecycleGeneration);
|
|
2880
|
+
} catch {
|
|
2881
|
+
await this.finishTerminal(currentSpool, 'failed', {
|
|
2882
|
+
errorCode: 'DELIVERY_CONFIGURATION_UNAVAILABLE',
|
|
2883
|
+
});
|
|
2884
|
+
return;
|
|
2885
|
+
}
|
|
2886
|
+
const bindingRaw = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
2887
|
+
id: currentSpool.bindingId,
|
|
2888
|
+
lifecycleGeneration,
|
|
2889
|
+
enabled: true,
|
|
2890
|
+
status: 'active',
|
|
2891
|
+
deletedAt: { $exists: false },
|
|
2892
|
+
});
|
|
2893
|
+
if (!bindingRaw) {
|
|
2894
|
+
await this.finishTerminal(currentSpool, 'cancelled', {
|
|
2895
|
+
errorCode: 'BINDING_LIFECYCLE_CHANGED',
|
|
2896
|
+
});
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
const binding = asBindingDoc(bindingRaw);
|
|
2900
|
+
const vapidKey = binding.vapidKeys.find((keyArg) => (
|
|
2901
|
+
keyArg.id === currentSpool.vapidKeyId
|
|
2902
|
+
&& Boolean(keyArg.privateKeyEnvelope)
|
|
2903
|
+
&& (keyArg.status === 'active' || keyArg.status === 'retiring')
|
|
2904
|
+
));
|
|
2905
|
+
if (
|
|
2906
|
+
!vapidKey
|
|
2907
|
+
|| !vapidKey.privateKeyEnvelope
|
|
2908
|
+
|| !currentSpool.subscriptionEnvelope
|
|
2909
|
+
|| !currentSpool.payloadEnvelope
|
|
2910
|
+
) {
|
|
2911
|
+
await this.finishTerminal(currentSpool, 'failed', {
|
|
2912
|
+
errorCode: 'DELIVERY_CONFIGURATION_UNAVAILABLE',
|
|
2913
|
+
});
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
const aadBase = this.spoolAadBase(binding, currentSpool.id);
|
|
2917
|
+
let subscription: IWebPushSubscription;
|
|
2918
|
+
let payload: IWebPushNotificationPayload;
|
|
2919
|
+
let privateKey: string;
|
|
2920
|
+
try {
|
|
2921
|
+
subscription = this.crypto!.decryptJson<IWebPushSubscription>(
|
|
2922
|
+
currentSpool.subscriptionEnvelope,
|
|
2923
|
+
{ ...aadBase, documentType: 'spool-subscription' },
|
|
2924
|
+
);
|
|
2925
|
+
payload = this.crypto!.decryptJson<IWebPushNotificationPayload>(
|
|
2926
|
+
currentSpool.payloadEnvelope,
|
|
2927
|
+
{ ...aadBase, documentType: 'spool-payload' },
|
|
2928
|
+
);
|
|
2929
|
+
privateKey = this.crypto!.decryptJson<{ privateKey: string }>(
|
|
2930
|
+
vapidKey.privateKeyEnvelope,
|
|
2931
|
+
{
|
|
2932
|
+
gatewayClientId: binding.ownerGatewayClientId,
|
|
2933
|
+
appInstanceId: binding.ownerAppInstanceId,
|
|
2934
|
+
bindingId: binding.id,
|
|
2935
|
+
documentType: 'vapid-private-key',
|
|
2936
|
+
documentId: vapidKey.id,
|
|
2937
|
+
},
|
|
2938
|
+
).privateKey;
|
|
2939
|
+
subscription = normalizeSubscription(subscription);
|
|
2940
|
+
payload = normalizePayload(payload);
|
|
2941
|
+
requireBase64Url(privateKey, 'Web Push VAPID private key', 32);
|
|
2942
|
+
} catch {
|
|
2943
|
+
await this.finishTerminal(currentSpool, 'failed', {
|
|
2944
|
+
errorCode: 'ENCRYPTED_DELIVERY_DATA_INVALID',
|
|
2945
|
+
});
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const immediatelyBeforeSend = await collection.findOne({
|
|
2949
|
+
id: currentSpool.id,
|
|
2950
|
+
state: 'sending',
|
|
2951
|
+
leaseToken,
|
|
2952
|
+
});
|
|
2953
|
+
if (!immediatelyBeforeSend) return;
|
|
2954
|
+
const immediatelyBeforeSendSpool = asSpoolDoc(immediatelyBeforeSend);
|
|
2955
|
+
if (immediatelyBeforeSendSpool.cancelRequestedAt !== undefined) {
|
|
2956
|
+
await this.finishTerminal(immediatelyBeforeSendSpool, 'cancelled', {
|
|
2957
|
+
errorCode: immediatelyBeforeSendSpool.cancelErrorCode || 'CANCELLED_BY_APPLICATION',
|
|
2958
|
+
});
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
const bindingImmediatelyBeforeSend = await WebPushBindingDoc.getNativeCollection().findOne({
|
|
2962
|
+
id: currentSpool.bindingId,
|
|
2963
|
+
lifecycleGeneration,
|
|
2964
|
+
enabled: true,
|
|
2965
|
+
status: 'active',
|
|
2966
|
+
deletedAt: { $exists: false },
|
|
2967
|
+
}, { projection: { _id: 1 } });
|
|
2968
|
+
if (!bindingImmediatelyBeforeSend) {
|
|
2969
|
+
await this.finishTerminal(currentSpool, 'cancelled', {
|
|
2970
|
+
errorCode: 'BINDING_LIFECYCLE_CHANGED',
|
|
2971
|
+
});
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
const sendStartedAt = this.now();
|
|
2975
|
+
if (currentSpool.expiresAt <= sendStartedAt) {
|
|
2976
|
+
await this.finishTerminal(currentSpool, 'expired', {
|
|
2977
|
+
errorCode: 'DELIVERY_TTL_EXPIRED',
|
|
2978
|
+
});
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
const remainingTtlSeconds = Math.max(
|
|
2982
|
+
0,
|
|
2983
|
+
Math.floor((currentSpool.expiresAt - sendStartedAt) / 1000),
|
|
2984
|
+
);
|
|
2985
|
+
try {
|
|
2986
|
+
const response = await this.transport.send({
|
|
2987
|
+
subscription,
|
|
2988
|
+
payload: canonicalJson(payload),
|
|
2989
|
+
ttlSeconds: remainingTtlSeconds,
|
|
2990
|
+
urgency: currentSpool.urgency,
|
|
2991
|
+
...(currentSpool.topic ? { topic: currentSpool.topic } : {}),
|
|
2992
|
+
vapid: {
|
|
2993
|
+
subject: this.vapidSubject!,
|
|
2994
|
+
publicKey: vapidKey.publicKey,
|
|
2995
|
+
privateKey,
|
|
2996
|
+
},
|
|
2997
|
+
});
|
|
2998
|
+
if (response.statusCode >= 200 && response.statusCode <= 299) {
|
|
2999
|
+
await this.finishTerminal(currentSpool, 'pushServiceAccepted', {
|
|
3000
|
+
pushServiceStatusCode: response.statusCode,
|
|
3001
|
+
});
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
if (response.statusCode === 404 || response.statusCode === 410) {
|
|
3005
|
+
await this.finishTerminal(currentSpool, 'invalidSubscription', {
|
|
3006
|
+
pushServiceStatusCode: response.statusCode,
|
|
3007
|
+
errorCode: 'PUSH_SUBSCRIPTION_INVALID',
|
|
3008
|
+
});
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
if (response.statusCode === 429) {
|
|
3012
|
+
await this.deferOrFinish(currentSpool, {
|
|
3013
|
+
pushServiceStatusCode: response.statusCode,
|
|
3014
|
+
errorCode: 'PUSH_SERVICE_RATE_LIMITED',
|
|
3015
|
+
retryAfter: response.retryAfter,
|
|
3016
|
+
});
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (response.statusCode >= 500 && response.statusCode <= 599) {
|
|
3020
|
+
await this.deferOrFinish(currentSpool, {
|
|
3021
|
+
pushServiceStatusCode: response.statusCode,
|
|
3022
|
+
errorCode: 'PUSH_SERVICE_UNAVAILABLE',
|
|
3023
|
+
});
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
await this.finishTerminal(currentSpool, 'failed', {
|
|
3027
|
+
pushServiceStatusCode: response.statusCode,
|
|
3028
|
+
errorCode: 'PUSH_SERVICE_REJECTED',
|
|
3029
|
+
});
|
|
3030
|
+
} catch (error: unknown) {
|
|
3031
|
+
if (error instanceof WebPushEndpointPolicyError) {
|
|
3032
|
+
await this.finishTerminal(currentSpool, 'invalidSubscription', {
|
|
3033
|
+
errorCode: error.code,
|
|
3034
|
+
});
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
if (error instanceof WebPushTransportError) {
|
|
3038
|
+
await this.deferOrFinish(currentSpool, {
|
|
3039
|
+
errorCode: error.code,
|
|
3040
|
+
});
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
await this.finishTerminal(currentSpool, 'failed', {
|
|
3044
|
+
errorCode: 'VAPID_OR_PAYLOAD_CONFIGURATION_ERROR',
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
private async finishTerminal(
|
|
3050
|
+
spoolArg: WebPushSpoolDoc,
|
|
3051
|
+
stateArg: Extract<
|
|
3052
|
+
TWebPushDeliveryState,
|
|
3053
|
+
'pushServiceAccepted' | 'invalidSubscription' | 'failed' | 'expired' | 'cancelled'
|
|
3054
|
+
>,
|
|
3055
|
+
metadataArg: {
|
|
3056
|
+
pushServiceStatusCode?: number;
|
|
3057
|
+
errorCode?: string;
|
|
3058
|
+
} = {},
|
|
3059
|
+
): Promise<void> {
|
|
3060
|
+
if (!spoolArg.leaseToken) return;
|
|
3061
|
+
const now = this.now();
|
|
3062
|
+
await WebPushSpoolDoc.getNativeCollection().updateOne(
|
|
3063
|
+
{
|
|
3064
|
+
id: spoolArg.id,
|
|
3065
|
+
state: 'sending',
|
|
3066
|
+
leaseToken: spoolArg.leaseToken,
|
|
3067
|
+
},
|
|
3068
|
+
buildWebPushTerminalUpdate({
|
|
3069
|
+
state: stateArg,
|
|
3070
|
+
now,
|
|
3071
|
+
...metadataArg,
|
|
3072
|
+
}),
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
private async deferOrFinish(
|
|
3077
|
+
spoolArg: WebPushSpoolDoc,
|
|
3078
|
+
metadataArg: {
|
|
3079
|
+
pushServiceStatusCode?: number;
|
|
3080
|
+
errorCode: string;
|
|
3081
|
+
retryAfter?: string;
|
|
3082
|
+
},
|
|
3083
|
+
): Promise<void> {
|
|
3084
|
+
if (!spoolArg.leaseToken) return;
|
|
3085
|
+
const collection = WebPushSpoolDoc.getNativeCollection();
|
|
3086
|
+
const currentRaw = await collection.findOne({
|
|
3087
|
+
id: spoolArg.id,
|
|
3088
|
+
state: 'sending',
|
|
3089
|
+
leaseToken: spoolArg.leaseToken,
|
|
3090
|
+
});
|
|
3091
|
+
if (!currentRaw) return;
|
|
3092
|
+
const current = asSpoolDoc(currentRaw);
|
|
3093
|
+
if (current.cancelRequestedAt !== undefined) {
|
|
3094
|
+
await this.finishTerminal(current, 'cancelled', {
|
|
3095
|
+
errorCode: current.cancelErrorCode || 'CANCELLED_BY_APPLICATION',
|
|
3096
|
+
});
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
const now = this.now();
|
|
3100
|
+
const retryDelay = this.retryDelayMs(current.attempts, metadataArg.retryAfter, now);
|
|
3101
|
+
const nextAttemptAt = now + retryDelay;
|
|
3102
|
+
if (current.attempts >= DELIVERY_MAX_ATTEMPTS || nextAttemptAt >= current.expiresAt) {
|
|
3103
|
+
await this.finishTerminal(
|
|
3104
|
+
current,
|
|
3105
|
+
nextAttemptAt >= current.expiresAt ? 'expired' : 'failed',
|
|
3106
|
+
{
|
|
3107
|
+
pushServiceStatusCode: metadataArg.pushServiceStatusCode,
|
|
3108
|
+
errorCode: nextAttemptAt >= current.expiresAt
|
|
3109
|
+
? 'DELIVERY_TTL_EXPIRED'
|
|
3110
|
+
: 'DELIVERY_ATTEMPTS_EXHAUSTED',
|
|
3111
|
+
},
|
|
3112
|
+
);
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
await collection.updateOne(
|
|
3116
|
+
{
|
|
3117
|
+
id: current.id,
|
|
3118
|
+
state: 'sending',
|
|
3119
|
+
leaseToken: current.leaseToken,
|
|
3120
|
+
cancelRequestedAt: { $exists: false },
|
|
3121
|
+
},
|
|
3122
|
+
{
|
|
3123
|
+
$set: {
|
|
3124
|
+
state: 'deferred',
|
|
3125
|
+
nextAttemptAt,
|
|
3126
|
+
updatedAt: now,
|
|
3127
|
+
pushServiceStatusCode: metadataArg.pushServiceStatusCode ?? null,
|
|
3128
|
+
errorCode: metadataArg.errorCode,
|
|
3129
|
+
_updatedAt: new Date(now).toISOString(),
|
|
3130
|
+
},
|
|
3131
|
+
$unset: {
|
|
3132
|
+
leaseToken: '',
|
|
3133
|
+
leaseExpiresAt: '',
|
|
3134
|
+
},
|
|
3135
|
+
},
|
|
3136
|
+
);
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
private retryDelayMs(
|
|
3140
|
+
attemptsArg: number,
|
|
3141
|
+
retryAfterArg: string | undefined,
|
|
3142
|
+
nowArg: number,
|
|
3143
|
+
): number {
|
|
3144
|
+
if (retryAfterArg) {
|
|
3145
|
+
const seconds = Number.parseInt(retryAfterArg, 10);
|
|
3146
|
+
if (Number.isSafeInteger(seconds) && seconds >= 0) {
|
|
3147
|
+
return Math.min(Math.max(seconds * 1000, 1000), 60 * 60 * 1000);
|
|
3148
|
+
}
|
|
3149
|
+
const date = Date.parse(retryAfterArg);
|
|
3150
|
+
if (Number.isFinite(date) && date > nowArg) {
|
|
3151
|
+
return Math.min(Math.max(date - nowArg, 1000), 60 * 60 * 1000);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
return Math.min(30_000 * 2 ** Math.max(0, attemptsArg - 1), 60 * 60 * 1000);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
private scheduleWorker(delayArg: number): void {
|
|
3158
|
+
if (!this.ready || !this.startWorker) return;
|
|
3159
|
+
this.workerTimer = setTimeout(() => {
|
|
3160
|
+
this.workerTimer = undefined;
|
|
3161
|
+
this.activeWorkerCycle = this.processDueDeliveriesOnce()
|
|
3162
|
+
.then(() => undefined)
|
|
3163
|
+
.catch((error: unknown) => {
|
|
3164
|
+
logger.log('error', `Web Push delivery cycle failed: ${(error as Error).name}`);
|
|
3165
|
+
})
|
|
3166
|
+
.finally(() => {
|
|
3167
|
+
this.activeWorkerCycle = undefined;
|
|
3168
|
+
this.scheduleWorker(DELIVERY_WORKER_INTERVAL_MS);
|
|
3169
|
+
});
|
|
3170
|
+
}, delayArg);
|
|
3171
|
+
this.workerTimer.unref();
|
|
3172
|
+
}
|
|
3173
|
+
}
|