@mpgd/game-services 0.24.0 → 0.25.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.
@@ -0,0 +1,2609 @@
1
+ import { assertNotificationConsentRecord, assertNotificationDeployment, assertNotificationEndpointRegistration, assertRecordNotificationNoticeInput, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, latestNotificationConsent, listNotificationNotices, normalizeNotificationTimestamp, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, toSeoulCalendarDate, } from '@mpgd/platform/notifications';
2
+ import { fcmInstallationIdRefusal, notificationExpiredProviderCode, } from './delivery-provider.js';
3
+ import { notificationHistoryRetentionDays, } from './registry.js';
4
+ import { bestNotificationOutcome, formatNotificationInstant, isNotificationRowAbandoned, isNotificationRowDue, notificationAmbiguousReplayHours, notificationSendOutcome, } from './store.js';
5
+ import { describeError as describe } from './text.js';
6
+ /**
7
+ * The notifications v2 server (RFC "Server"): consent records and the notices
8
+ * API, the endpoint registry bound to the game-services session, and sends
9
+ * through the idempotency ledger, the pure policy gate
10
+ * (`evaluateNotificationDelivery` from `@mpgd/platform/notifications`), and
11
+ * the delivery providers.
12
+ *
13
+ * - **Sessions.** Every player call names the authenticated session; the
14
+ * installation id is the session's, never a request field.
15
+ * - **Sends.** A send is validated, bound to its fingerprint, and answered
16
+ * from its key state. A new key resolves its recipients (every usable
17
+ * endpoint of a device provider, or the platform identity), checks the gate
18
+ * once for each, and stores one row per recipient the gate allows or
19
+ * defers. Each allowed row is then a dispatch job: inline, or through the
20
+ * queue, and the gate runs again when the job makes the provider call.
21
+ * - **Answers.** `accepted` delivers, `unregistered` invalidates the
22
+ * endpoint, `rate-limited` defers with backoff for at most 24 hours,
23
+ * `rejected` is not stored (with `agreement` it records a provider denial),
24
+ * and `ambiguous` keeps the claim for the lease.
25
+ * - **Replays.** `dispatchDue` replays rows that came due: deferred rows, and
26
+ * every in-flight row whose lease ended (a queued job that was lost, a
27
+ * holder that failed before the call, an ambiguous answer). Ambiguous
28
+ * replays stop a day after the first attempt. A direct caller may also
29
+ * replay its own platform rows by sending the key again after the lease.
30
+ * Due rows go out at no more than each provider's registry `rate`, through
31
+ * the queue when one is bound and inline otherwise.
32
+ * - **Scheduler.** `runScheduler` turns due server reminders into
33
+ * `reminder:` sends and, under the Korean rules, pushes each consent notice
34
+ * nobody recorded by seven days before its `dueBy` as a `notice:` send of
35
+ * the catalog's notice topic; both then leave as due rows. A refused
36
+ * notice push backs off, doubling from an hour to a day.
37
+ * - **Consent history.** Each write compacts the player's history: records
38
+ * no rule reads any more are deleted a day after they were written.
39
+ */
40
+ const secondMs = 1000;
41
+ const dayMs = 86_400 * secondMs;
42
+ const keyRetentionMs = 30 * dayMs;
43
+ const endpointStaleMs = 60 * dayMs;
44
+ const endpointPurgeDelayMs = 30 * dayMs;
45
+ const historyRetentionMs = notificationHistoryRetentionDays * dayMs;
46
+ const rateLimitGiveUpMs = dayMs;
47
+ const defaultRetryAfterSeconds = 60;
48
+ const defaultClaimLeaseSeconds = 300;
49
+ /** Conditional writes retried before a call answers that the ledger is busy. */
50
+ const maxLedgerAttempts = 8;
51
+ /** Settling an answer the provider already gave is retried harder, so it is not replayed. */
52
+ const maxCompleteAttempts = 24;
53
+ /** How long after the first attempt an ambiguous delivery is replayed. */
54
+ const ambiguousReplayMs = notificationAmbiguousReplayHours * 3_600_000;
55
+ /** The longest wait between ambiguous replays. */
56
+ const maxAmbiguousLeaseMs = 3_600_000;
57
+ /** Longest accepted text for ids, locales, links, and versions. */
58
+ const maxTextLength = 2048;
59
+ /** Longest accepted device address value. */
60
+ const maxAddressLength = 4096;
61
+ /** Longest row key: a send key, a colon, and an installation id that percent-encoding can grow ninefold. */
62
+ const maxRowKeyLength = maxTextLength + 1 + 9 * maxTextLength;
63
+ /** Largest consent evidence envelope, as JSON. */
64
+ const maxEvidenceBytes = 16_384;
65
+ /** How long a scheduler item waits after a failure that may pass. */
66
+ const schedulerRetryMs = 60 * secondMs;
67
+ /** How often a notice check looks again while a push of it waits in the ledger. */
68
+ const noticeRecheckMs = 3_600_000;
69
+ /** The first wait after a refused notice push; it doubles, up to a day. */
70
+ const noticeBackoffMs = 3_600_000;
71
+ /** Records no rule reads are kept this long, for the daily cap, and then deleted. */
72
+ const consentCompactionMs = dayMs;
73
+ /** Lists every notice still owed, now or later: the next re-confirmation of each consent. */
74
+ const noticeHorizon = '9999-12-31T00:00:00Z';
75
+ const noticesEntryId = 'notices';
76
+ /** How much wall time one scheduler run may use by default. */
77
+ const defaultSchedulerBudgetMs = 20_000;
78
+ const reminderEntryPrefix = 'reminder:';
79
+ const reminderEntryId = (key) => `${reminderEntryPrefix}${key}`;
80
+ /** How far through the gate a set-time refusal got: the furthest step is the one answered. */
81
+ const reminderRefusalRank = {
82
+ 'permission-required': 1,
83
+ 'request-missing': 2,
84
+ 'preference-off': 2,
85
+ 'advertising-consent-missing': 3,
86
+ };
87
+ /**
88
+ * Client-written consent records a player may add per rolling day. It is
89
+ * generous: it only stops a client that writes in a loop, since every send
90
+ * reads the player's whole history. It is checked before the write, so
91
+ * concurrent writes may pass it by a few.
92
+ */
93
+ export const maxClientNotificationConsentRecordsPerDay = 200;
94
+ /**
95
+ * Pending server reminders a player may keep, as many as iOS keeps local
96
+ * notifications. Replacing a pending one is always allowed; a new key past
97
+ * it answers `capacity-exceeded`. The count is read before the write, so
98
+ * sets that race may pass it by a few, as the daily consent cap may.
99
+ */
100
+ export const maxPendingNotificationReminders = 64;
101
+ /** How far ahead a server reminder may be set; a later `fireAt` is refused. */
102
+ export const notificationReminderHorizonDays = 90;
103
+ /**
104
+ * A player wrote more consent records than one rolling day allows. It is
105
+ * retryable after `retryAfterSeconds`, when the oldest counted record leaves
106
+ * the window.
107
+ */
108
+ export class NotificationConsentRateLimitError extends NotificationError {
109
+ retryAfterSeconds;
110
+ constructor(retryAfterSeconds) {
111
+ super('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
112
+ message: `Too many consent records today; retry in ${retryAfterSeconds} s.`,
113
+ });
114
+ this.retryAfterSeconds = retryAfterSeconds;
115
+ }
116
+ }
117
+ /** A gate caller error (such as missing template data): the send cannot be made as asked. */
118
+ class NotificationSendInputError extends Error {
119
+ name = 'NotificationSendInputError';
120
+ }
121
+ /**
122
+ * The provider accepted or may have sent, but storing its answer failed. It
123
+ * is retryable for the caller, who answers from the ledger by the same key;
124
+ * the row itself recovers through the due replay once its lease ends, since
125
+ * a queue job never replays an attempted row.
126
+ */
127
+ class NotificationSettleError extends NotificationError {
128
+ constructor(options) {
129
+ super('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
130
+ message: 'The provider answered, but the answer could not be stored; the send key answers from the ledger.',
131
+ cause: options.cause,
132
+ });
133
+ }
134
+ }
135
+ export function createNotificationService(input) {
136
+ const { store, registry, catalog } = input;
137
+ const deployment = input.deployment === undefined
138
+ ? undefined
139
+ : assertNotificationDeployment(input.deployment);
140
+ const rules = deployment?.advertisingRules ?? 'none';
141
+ const leaseSeconds = input.claimLeaseSeconds ?? defaultClaimLeaseSeconds;
142
+ // Checked in seconds: 0.5 is a whole number of milliseconds but no lease.
143
+ if (!Number.isInteger(leaseSeconds) || leaseSeconds <= 0 || leaseSeconds * secondMs > dayMs) {
144
+ throw new TypeError('claimLeaseSeconds must be a whole number of seconds up to one day.');
145
+ }
146
+ const leaseMs = leaseSeconds * secondMs;
147
+ /** The claim lease after `attempts` ambiguous attempts: doubling, up to an hour (or the lease, if longer). */
148
+ const ambiguousLeaseMs = (attempts) => Math.min(leaseMs * 2 ** Math.min(attempts, 16), Math.max(leaseMs, maxAmbiguousLeaseMs));
149
+ const random = input.random ?? Math.random;
150
+ const noticeTopic = catalog.notices?.topic;
151
+ if (catalog.notices !== undefined) {
152
+ const issues = notificationNoticeCopyIssues(catalog.notices.copy, catalog.defaultLocale);
153
+ if (typeof noticeTopic !== 'string' || noticeTopic.length === 0 || issues.length > 0) {
154
+ throw new TypeError(`The catalog's notices block needs its topic and copy by the catalog rules: ${issues
155
+ .map((issue) => `${issue.path}: ${issue.message}`)
156
+ .join('; ')}`);
157
+ }
158
+ }
159
+ const longestWindowMs = Math.max(dayMs, ...registry.providers.flatMap((provider) => (provider.entry.limits ?? []).map((limit) => limit.windowSeconds * secondMs)));
160
+ const nowMs = () => {
161
+ const at = (input.now ?? (() => new Date().toISOString()))();
162
+ return Date.parse(normalizeNotificationTimestamp(at));
163
+ };
164
+ const report = (error, operation, context = {
165
+ sendKey: '',
166
+ }, fromProvider = false) => {
167
+ const where = typeof context === 'string' ? { sendKey: context } : context;
168
+ try {
169
+ input.onError?.(error, {
170
+ operation,
171
+ ...(where.sendKey === '' ? {} : { sendKey: where.sendKey }),
172
+ ...(where.provider === undefined ? {} : { provider: where.provider }),
173
+ ...(where.rowKey === undefined ? {} : { rowKey: where.rowKey }),
174
+ ...(fromProvider ? { fromProvider } : {}),
175
+ });
176
+ }
177
+ catch {
178
+ // A failing error hook must not change the call's answer.
179
+ }
180
+ };
181
+ // Topics.
182
+ /** The topic as declared for the recipient's target, else for the provider's first target that declares it. */
183
+ const topicFor = (provider, target, topicId, origin) => {
184
+ const targets = target !== undefined && provider.targets.includes(target)
185
+ ? [target]
186
+ : provider.targets;
187
+ for (const candidate of targets) {
188
+ const topic = catalog.topicsFor(candidate).find((declared) => declared.id === topicId);
189
+ if (topic !== undefined) {
190
+ // A notice says only its variant of notices.copy.
191
+ return origin.kind === 'notice' && catalog.notices !== undefined
192
+ ? notificationNoticeTopic(topic, catalog.notices.copy, origin.variant)
193
+ : topic;
194
+ }
195
+ }
196
+ return undefined;
197
+ };
198
+ const refuseNoticeTopic = (topic) => {
199
+ if (noticeTopic !== undefined && topic === noticeTopic) {
200
+ throw invalidInput(`Topic ${topic} is reserved for consent notices, which only the scheduler sends.`);
201
+ }
202
+ };
203
+ // Notices.
204
+ const noticesOf = async (player, at) => rules === 'kr'
205
+ ? listNotificationNotices(await store.listConsent(player), await store.listNoticesGiven(player), formatNotificationInstant(at))
206
+ : [];
207
+ const noticeListed = async (player, notice, at) => (await noticesOf(player, at)).some((listed) => isSameNotificationNotice(listed, notice));
208
+ // Recipients (gate step 2).
209
+ const usable = (endpoint, at) => endpoint.status === 'valid'
210
+ && at - Date.parse(endpoint.lastRegisteredAt) < endpointStaleMs
211
+ && (endpoint.sessionExpiresAt === undefined || Date.parse(endpoint.sessionExpiresAt) > at);
212
+ const deviceRecipient = (provider, sendKey, endpoint) => ({
213
+ provider,
214
+ rowKey: `${sendKey}:${encodeURIComponent(endpoint.installationId)}`,
215
+ installationId: endpoint.installationId,
216
+ endpoint,
217
+ resolved: true,
218
+ ...(endpoint.target === undefined ? {} : { target: endpoint.target }),
219
+ ...(endpoint.locale === undefined ? {} : { locale: endpoint.locale }),
220
+ ...(endpoint.timeZone === undefined ? {} : { timeZone: endpoint.timeZone }),
221
+ });
222
+ const platformRecipient = async (provider, request) => {
223
+ let id;
224
+ try {
225
+ id = await input.resolvePlatformRecipient?.({
226
+ provider: provider.entry.provider,
227
+ player: request.player,
228
+ });
229
+ }
230
+ catch (error) {
231
+ // An identity service that cannot answer now is retried, as a host read is.
232
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
233
+ message: `The platform recipient of provider ${provider.entry.provider} could not be resolved.`,
234
+ cause: error,
235
+ });
236
+ }
237
+ const registration = await store.getPlatformRegistration({
238
+ player: request.player,
239
+ provider: provider.entry.provider,
240
+ });
241
+ const platformId = typeof id === 'string' && id.length > 0 ? id : undefined;
242
+ return {
243
+ provider,
244
+ rowKey: request.idempotencyKey,
245
+ ...(platformId === undefined ? {} : { platformId }),
246
+ resolved: platformId !== undefined,
247
+ ...(registration?.locale === undefined ? {} : { locale: registration.locale }),
248
+ ...(registration?.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
249
+ };
250
+ };
251
+ /**
252
+ * Every recipient of a send: one per usable endpoint (installation-id
253
+ * order) of each device provider, the platform identity of each
254
+ * platform-user provider, over the named provider or every provider. When
255
+ * no provider has a recipient, one unresolved recipient per provider, so
256
+ * the gate still answers its earlier steps (`topic-unknown` before
257
+ * `no-endpoint`). A consent notice goes only through providers that can
258
+ * carry it (`notificationProviderCarriesNotices`).
259
+ */
260
+ const recipientsOf = async (request, at, origin) => {
261
+ let scope = registry.providers;
262
+ if (request.provider !== undefined) {
263
+ const named = registry.provider(request.provider);
264
+ scope = named === undefined ? [] : [named];
265
+ }
266
+ if (origin.kind === 'notice') {
267
+ scope = scope.filter((provider) => notificationProviderCarriesNotices(provider.entry));
268
+ }
269
+ // Providers are independent: resolve them together, then keep the order.
270
+ const perProvider = await Promise.all(scope.map(async (provider) => {
271
+ if (provider.entry.addressing !== 'device') {
272
+ return [await platformRecipient(provider, request)];
273
+ }
274
+ const endpoints = (await store.listEndpoints({
275
+ player: request.player,
276
+ provider: provider.entry.provider,
277
+ }))
278
+ .filter((endpoint) => usable(endpoint, at))
279
+ .sort((left, right) => compareText(left.installationId, right.installationId));
280
+ return endpoints.length === 0
281
+ ? [{ provider, rowKey: request.idempotencyKey, resolved: false }]
282
+ : endpoints.map((endpoint) => deviceRecipient(provider, request.idempotencyKey, endpoint));
283
+ }));
284
+ const resolved = perProvider.flat().filter((recipient) => recipient.resolved);
285
+ const unresolved = perProvider.flat().filter((recipient) => !recipient.resolved);
286
+ return resolved.length > 0 ? resolved : unresolved;
287
+ };
288
+ /** A stored row's recipient on replay: its endpoint now, unresolved once it is gone or moved. */
289
+ const recipientOfRow = async (provider, request, row, at) => {
290
+ if (row.installationId === undefined) {
291
+ return platformRecipient(provider, request);
292
+ }
293
+ const endpoint = await store.getEndpoint({
294
+ provider: provider.entry.provider,
295
+ installationId: row.installationId,
296
+ });
297
+ return endpoint !== undefined && endpoint.player === request.player && usable(endpoint, at)
298
+ ? deviceRecipient(provider, request.idempotencyKey, endpoint)
299
+ : { provider, rowKey: row.key, installationId: row.installationId, resolved: false };
300
+ };
301
+ // Gate inputs.
302
+ /**
303
+ * The sends the gate counts for a recipient on `provider`, oldest first,
304
+ * within the longest limit window: delivered sends, and sends another
305
+ * holder is sending or may have sent (an attempted in-flight row). Each
306
+ * send counts once, on `provider` when it reached it there, so one send
307
+ * delivered through two providers is one send for the topic's cap. The
308
+ * send being made is left out, including its earlier delivery to another
309
+ * endpoint, and so is an in-flight send claimed after `self`: of two sends
310
+ * claimed together, the earlier one (then the lower key) is the one the
311
+ * other counts, so a cap of one still lets one through. A claim counts
312
+ * from the moment it is taken, before its own gate has run: a send the
313
+ * gate then holds can defer another for a while, which is the price of
314
+ * never letting two through a cap of one.
315
+ */
316
+ const loadHistory = async (player, excluding, at, self) => {
317
+ const bySend = new Map();
318
+ const add = (sendKey, entry) => {
319
+ bySend.set(sendKey, [...(bySend.get(sendKey) ?? []), entry]);
320
+ };
321
+ for (const sent of await store.listHistory({
322
+ player,
323
+ since: formatNotificationInstant(at - longestWindowMs),
324
+ })) {
325
+ if (sent.sendKey !== excluding) {
326
+ add(sent.sendKey, { provider: sent.provider, topic: sent.topic, at: sent.at });
327
+ }
328
+ }
329
+ for (const send of await store.listInFlightSends(player)) {
330
+ if (send.key === excluding) {
331
+ continue;
332
+ }
333
+ for (const row of send.rows) {
334
+ if (row.state.status !== 'in-flight' || !row.state.attempted) {
335
+ continue;
336
+ }
337
+ const claimedAt = Date.parse(row.state.attemptedAt ?? send.changedAt);
338
+ if (self !== undefined
339
+ && (claimedAt > self.claimedAt || (claimedAt === self.claimedAt && send.key > self.key))) {
340
+ continue;
341
+ }
342
+ add(send.key, {
343
+ provider: row.provider,
344
+ topic: send.request.topic,
345
+ at: formatNotificationInstant(claimedAt),
346
+ });
347
+ }
348
+ }
349
+ return bySend;
350
+ };
351
+ const readServerPermission = async (provider, request) => {
352
+ if (!provider.entry.permission.serverReadable) {
353
+ return undefined;
354
+ }
355
+ try {
356
+ const state = await provider.delivery.readPermission?.({
357
+ player: request.player,
358
+ topic: request.topic,
359
+ });
360
+ if (state === undefined) {
361
+ throw new TypeError(`Provider ${provider.entry.provider} answered no permission.`);
362
+ }
363
+ return state;
364
+ }
365
+ catch (error) {
366
+ // A host that cannot answer now is retried, whatever the error's class.
367
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
368
+ message: `The host could not answer the send-time permission read for provider ${provider.entry.provider}.`,
369
+ cause: error,
370
+ });
371
+ }
372
+ };
373
+ /**
374
+ * For a provider whose permission the server reads, the gate's answer
375
+ * when a step before the permission step refuses (the topic, the channel,
376
+ * the purpose, the recipient, the link): that answer needs no host read,
377
+ * so a host outage cannot turn it into a retry. `evaluate` runs the gate
378
+ * with a stand-in permission, which only the later steps read; its history
379
+ * and time do not matter to the earlier ones. Any other answer, or an
380
+ * error, means the real read decides.
381
+ */
382
+ const earlyRefusal = (provider, evaluate) => {
383
+ if (!provider.entry.permission.serverReadable) {
384
+ return undefined;
385
+ }
386
+ let early;
387
+ try {
388
+ early = evaluate('granted');
389
+ }
390
+ catch {
391
+ return undefined;
392
+ }
393
+ return early.decision.status !== 'allowed' && earlyGateReasons.has(early.decision.reason)
394
+ ? early
395
+ : undefined;
396
+ };
397
+ const decide = (request, origin, recipient, consent, history, serverPermission, at) => {
398
+ const topic = topicFor(recipient.provider, recipient.target, request.topic, origin);
399
+ const locale = request.locale ?? recipient.locale;
400
+ try {
401
+ return {
402
+ topic,
403
+ decision: evaluateNotificationDelivery({
404
+ entry: recipient.provider.entry,
405
+ ...(deployment === undefined ? {} : { deployment }),
406
+ topic,
407
+ recipientResolved: recipient.resolved,
408
+ ...(request.link === undefined ? {} : { link: request.link }),
409
+ consent,
410
+ ...(serverPermission === undefined ? {} : { serverPermission }),
411
+ ...(recipient.endpoint === undefined ? {} : { endpointPermission: recipient.endpoint.permission }),
412
+ history,
413
+ at: formatNotificationInstant(at),
414
+ ...(recipient.timeZone === undefined ? {} : { timeZone: recipient.timeZone }),
415
+ ...(locale === undefined ? {} : { locale }),
416
+ defaultLocale: catalog.defaultLocale,
417
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
418
+ }),
419
+ };
420
+ }
421
+ catch (error) {
422
+ if (error instanceof TypeError) {
423
+ throw new NotificationSendInputError(error.message, { cause: error });
424
+ }
425
+ throw error;
426
+ }
427
+ };
428
+ // Ledger.
429
+ const newToken = () => globalThis.crypto.randomUUID();
430
+ /** A stored send, unless its retention ended (then it is forgotten). */
431
+ const lookup = async (key, at) => {
432
+ const held = await store.getSend(key);
433
+ if (held !== undefined && at - Date.parse(held.send.changedAt) >= keyRetentionMs) {
434
+ await store.updateSend({ key, version: held.version, send: undefined });
435
+ return undefined;
436
+ }
437
+ return held;
438
+ };
439
+ const rowsWith = (send, row, change) => send.rows.flatMap((candidate) => {
440
+ if (candidate.key !== row.key || candidate.provider !== row.provider) {
441
+ return [candidate];
442
+ }
443
+ return change.kind === 'remove' ? [] : [change.row];
444
+ });
445
+ const nextRateLimit = (prior, retryAfterSeconds, at) => {
446
+ const since = prior === undefined ? at : Date.parse(prior.since);
447
+ const deadline = since + rateLimitGiveUpMs;
448
+ if (at >= deadline) {
449
+ return undefined;
450
+ }
451
+ const answered = retryAfterSeconds !== undefined && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
452
+ ? retryAfterSeconds
453
+ : undefined;
454
+ const delaySeconds = prior?.delaySeconds ?? answered ?? defaultRetryAfterSeconds;
455
+ const attempts = prior?.attempts ?? 0;
456
+ // The first deferral waits the provider's delay; later ones back off
457
+ // exponentially from it, with up to 25% jitter so retries spread out, and
458
+ // never wait less than the provider asks this time.
459
+ const draw = random();
460
+ const jitter = attempts === 0 || !Number.isFinite(draw)
461
+ ? 1
462
+ : 1 + Math.min(Math.max(draw, 0), 0.999) * 0.25;
463
+ const delayMs = Math.max(delaySeconds * 2 ** attempts * secondMs * jitter, (answered ?? 0) * secondMs);
464
+ return {
465
+ until: formatNotificationInstant(Math.min(Math.ceil(at + delayMs), deadline)),
466
+ state: { since: formatNotificationInstant(since), delaySeconds, attempts: attempts + 1 },
467
+ };
468
+ };
469
+ const callProvider = async (recipient, request, topic, decision, at) => {
470
+ const mappedLink = topic.mapping?.['link'];
471
+ const link = request.link ?? (typeof mappedLink === 'string' ? mappedLink : undefined);
472
+ const target = recipient.endpoint !== undefined
473
+ ? {
474
+ kind: 'device',
475
+ installationId: recipient.endpoint.installationId,
476
+ address: recipient.endpoint.address,
477
+ }
478
+ : { kind: 'platform-user', id: recipient.platformId ?? '' };
479
+ const locale = request.locale ?? recipient.locale;
480
+ try {
481
+ const answer = await recipient.provider.delivery.send({
482
+ idempotencyKey: recipient.rowKey,
483
+ sendKey: request.idempotencyKey,
484
+ provider: recipient.provider.entry.provider,
485
+ player: request.player,
486
+ topic,
487
+ recipient: target,
488
+ content: decision.content,
489
+ ...(decision.expiresAt === undefined ? {} : { expiresAt: decision.expiresAt }),
490
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
491
+ ...(request.data === undefined ? {} : { data: request.data }),
492
+ ...(link === undefined ? {} : { link }),
493
+ ...(locale === undefined ? {} : { locale }),
494
+ at: formatNotificationInstant(at),
495
+ });
496
+ const known = deliveryAnswerOf(answer);
497
+ if (known === undefined) {
498
+ // A provider that broke the contract may still have sent it.
499
+ report(new TypeError('The provider answered no known delivery answer.'), 'provider.send', request.idempotencyKey);
500
+ return { status: 'ambiguous' };
501
+ }
502
+ return known;
503
+ }
504
+ catch (error) {
505
+ // The provider may have sent it: one rule for every failure it cannot prove.
506
+ report(error, 'provider.send', request.idempotencyKey, true);
507
+ return { status: 'ambiguous' };
508
+ }
509
+ };
510
+ /**
511
+ * The rows a new send stores, after a first gate check per recipient:
512
+ * allowed recipients become queued rows, deferred ones deferred rows, and
513
+ * refusals are answered but not stored. A caller error throws before
514
+ * anything is stored. A queued row's lease ends a claim lease from now, or
515
+ * at `queuedUntil` for a scheduler item, whose rows are due at once and
516
+ * leave through the paced due path.
517
+ */
518
+ const plan = async (request, origin, at, queuedUntil) => {
519
+ const recipients = await recipientsOf(request, at, origin);
520
+ if (recipients.length === 0) {
521
+ return { rows: [], refusals: [noEndpoint()] };
522
+ }
523
+ const consent = await store.listConsent(request.player);
524
+ const history = await loadHistory(request.player, request.idempotencyKey, at);
525
+ const perProvider = new Map();
526
+ const rows = [];
527
+ const refusals = [];
528
+ for (const recipient of recipients) {
529
+ const id = recipient.provider.entry.provider;
530
+ let inputs = perProvider.get(id);
531
+ if (inputs === undefined) {
532
+ inputs = { history: historyOn(history, id) };
533
+ perProvider.set(id, inputs);
534
+ }
535
+ const held = inputs;
536
+ const evaluate = (permission) => decide(request, origin, recipient, consent, held.history, permission, at);
537
+ // One read per provider and send, made only once a recipient needs it.
538
+ const { decision } = earlyRefusal(recipient.provider, evaluate)
539
+ ?? evaluate(await (held.permission ??= readServerPermission(recipient.provider, request)));
540
+ const row = {
541
+ key: recipient.rowKey,
542
+ provider: id,
543
+ ...(recipient.installationId === undefined ? {} : { installationId: recipient.installationId }),
544
+ };
545
+ if (decision.status === 'deferred') {
546
+ rows.push({
547
+ ...row,
548
+ state: { status: 'deferred', reason: decision.reason, until: decision.until },
549
+ });
550
+ }
551
+ else if (decision.status === 'allowed') {
552
+ rows.push({
553
+ ...row,
554
+ state: {
555
+ status: 'in-flight',
556
+ leaseUntil: formatNotificationInstant(queuedUntil ?? at + leaseMs),
557
+ attempted: false,
558
+ },
559
+ });
560
+ }
561
+ else {
562
+ refusals.push(decision);
563
+ }
564
+ }
565
+ return { rows, refusals };
566
+ };
567
+ const eligible = (mode, row, at) => {
568
+ switch (mode) {
569
+ case 'job':
570
+ // A queued delivery runs once: a row no holder claims, either never
571
+ // sent or taken from the due rows for the queue. An attempted row
572
+ // keeps its claim, so it is replayed only after its lease, and never
573
+ // once its replays ended a day after the first attempt.
574
+ return row.state.status === 'in-flight'
575
+ && row.state.claim === undefined
576
+ && !(row.state.attempted
577
+ && row.firstAttemptedAt !== undefined
578
+ && at - Date.parse(row.firstAttemptedAt) >= ambiguousReplayMs);
579
+ case 'retry':
580
+ // A direct caller replays its own platform rows once their lease ended.
581
+ return row.state.status === 'in-flight'
582
+ && row.installationId === undefined
583
+ && isNotificationRowDue(row, at);
584
+ case 'due':
585
+ return isNotificationRowDue(row, at);
586
+ }
587
+ };
588
+ /**
589
+ * Applies a row change while this claim still holds the row; a stale claim
590
+ * changes nothing. Conditional writes that lose to another row of the same
591
+ * send are retried with a jittered backoff, so a fan-out's workers settle
592
+ * one after the other.
593
+ */
594
+ const complete = async (sendKey, claimed, token, change, at) => {
595
+ for (let attempt = 0; attempt < maxCompleteAttempts; attempt += 1) {
596
+ if (attempt > 0) {
597
+ const draw = random();
598
+ await pause((Number.isFinite(draw) ? Math.min(Math.max(draw, 0), 1) : 0.5) * 10 * attempt);
599
+ }
600
+ const held = await store.getSend(sendKey);
601
+ const row = held?.send.rows.find((candidate) => candidate.key === claimed.key && candidate.provider === claimed.provider);
602
+ if (held === undefined
603
+ || row === undefined
604
+ || row.state.status !== 'in-flight'
605
+ || row.state.claim !== token) {
606
+ // Another holder took the row after the lease: its answer stands.
607
+ return;
608
+ }
609
+ const rows = rowsWith(held.send, row, change);
610
+ const wasDelivered = notificationSendOutcome(held.send)?.status === 'delivered';
611
+ const next = rows.length === 0
612
+ ? undefined
613
+ : { ...held.send, rows, changedAt: formatNotificationInstant(at) };
614
+ if (await store.updateSend({ key: sendKey, version: held.version, send: next })) {
615
+ if (!wasDelivered
616
+ && next !== undefined
617
+ && notificationSendOutcome(next)?.status === 'delivered'
618
+ && next.origin.kind === 'notice'
619
+ && await noticeListed(next.request.player, next.origin.notice, at)) {
620
+ // A notice the scheduler sent is recorded once it is delivered.
621
+ await store.addNoticeGiven({
622
+ ...next.origin.notice,
623
+ player: next.request.player,
624
+ channel: 'push',
625
+ at: formatNotificationInstant(at),
626
+ });
627
+ }
628
+ return;
629
+ }
630
+ }
631
+ throw busy(sendKey);
632
+ };
633
+ /**
634
+ * Claims one row, runs the gate for it immediately before the provider
635
+ * call, and stores the answer. Answers the row's refusal, if any.
636
+ */
637
+ const runRow = async (sendKey, provider, rowKey, mode) => {
638
+ let claimed;
639
+ for (let attempt = 0; attempt < maxLedgerAttempts && claimed === undefined; attempt += 1) {
640
+ const at = nowMs();
641
+ const held = await store.getSend(sendKey);
642
+ const row = held?.send.rows.find((candidate) => candidate.key === rowKey && candidate.provider === provider);
643
+ if (held === undefined || row === undefined || !eligible(mode, row, at)) {
644
+ return {};
645
+ }
646
+ if (held.send.origin.kind === 'notice'
647
+ && !await noticeListed(held.send.request.player, held.send.origin.notice, at)) {
648
+ // The game gave the notice while the push waited: it is not sent again.
649
+ const rows = rowsWith(held.send, row, { kind: 'remove' });
650
+ await store.updateSend({
651
+ key: sendKey,
652
+ version: held.version,
653
+ send: rows.length === 0
654
+ ? undefined
655
+ : { ...held.send, rows, changedAt: formatNotificationInstant(at) },
656
+ });
657
+ return {};
658
+ }
659
+ const token = newToken();
660
+ // The replay window opens at the first claim that may reach the
661
+ // provider; paths that prove nothing was sent write the earlier value back.
662
+ // A replay of a row that may have been sent (an ambiguous answer, or a
663
+ // holder that died mid-call) counts as one more ambiguous attempt, so
664
+ // its lease grows with each replay either way.
665
+ const replayed = row.state.status === 'in-flight' && row.state.attempted;
666
+ const ambiguousAttempts = (row.ambiguousAttempts ?? 0) + (replayed ? 1 : 0);
667
+ const claimedRow = {
668
+ ...row,
669
+ firstAttemptedAt: row.firstAttemptedAt ?? formatNotificationInstant(at),
670
+ ...(ambiguousAttempts === 0 ? {} : { ambiguousAttempts }),
671
+ state: {
672
+ status: 'in-flight',
673
+ leaseUntil: formatNotificationInstant(at + ambiguousLeaseMs(ambiguousAttempts)),
674
+ claim: token,
675
+ attempted: true,
676
+ attemptedAt: formatNotificationInstant(at),
677
+ },
678
+ };
679
+ const next = {
680
+ ...held.send,
681
+ // A due replay marks the send as the scheduler's.
682
+ scheduled: held.send.scheduled || mode === 'due',
683
+ rows: rowsWith(held.send, row, { kind: 'set', row: claimedRow }),
684
+ changedAt: formatNotificationInstant(at),
685
+ };
686
+ if (await store.updateSend({ key: sendKey, version: held.version, send: next })) {
687
+ claimed = { send: next, before: row, row: claimedRow, token, at };
688
+ }
689
+ }
690
+ if (claimed === undefined) {
691
+ throw busy(sendKey);
692
+ }
693
+ const { send, before, row, token } = claimed;
694
+ const registered = registry.provider(row.provider);
695
+ if (registered === undefined) {
696
+ // The deployment no longer serves this provider.
697
+ await complete(sendKey, row, token, { kind: 'remove' }, claimed.at);
698
+ return { refusal: noEndpoint() };
699
+ }
700
+ let evaluated;
701
+ let recipient;
702
+ let at;
703
+ try {
704
+ recipient = await recipientOfRow(registered, send.request, row, claimed.at);
705
+ const consent = await store.listConsent(send.request.player);
706
+ const early = earlyRefusal(registered, (standIn) => decide(send.request, send.origin, recipient, consent, [], standIn, nowMs()));
707
+ const serverPermission = early === undefined
708
+ ? await readServerPermission(registered, send.request)
709
+ : undefined;
710
+ at = nowMs();
711
+ evaluated = early ?? decide(send.request, send.origin, recipient, consent, historyOn(await loadHistory(send.request.player, sendKey, at, { key: sendKey, claimedAt: claimed.at }), row.provider), serverPermission, at);
712
+ }
713
+ catch (error) {
714
+ if (error instanceof NotificationSendInputError) {
715
+ // A send that cannot be made as asked fails once, not on every replay.
716
+ await settleQuietly(sendKey, row, token, { kind: 'remove' }, claimed.at);
717
+ throw invalidInput(error.message, error);
718
+ }
719
+ // Nothing was sent (a host read or the store failed): the row goes back
720
+ // as it was, due again after a short backoff so a failing host does not
721
+ // fill every scheduler batch.
722
+ await settleQuietly(sendKey, row, token, { kind: 'set', row: backedOff(before, nowMs()) }, claimed.at);
723
+ throw error;
724
+ }
725
+ const { decision, topic } = evaluated;
726
+ const backoff = row.rateLimit === undefined ? {} : { rateLimit: row.rateLimit };
727
+ const base = {
728
+ key: row.key,
729
+ provider: row.provider,
730
+ ...(row.installationId === undefined ? {} : { installationId: row.installationId }),
731
+ ...(before.firstAttemptedAt === undefined ? {} : { firstAttemptedAt: before.firstAttemptedAt }),
732
+ ...(before.ambiguousAttempts === undefined ? {} : { ambiguousAttempts: before.ambiguousAttempts }),
733
+ };
734
+ if (decision.status === 'deferred') {
735
+ await complete(sendKey, row, token, {
736
+ kind: 'set',
737
+ row: {
738
+ ...base,
739
+ ...backoff,
740
+ state: { status: 'deferred', reason: decision.reason, until: decision.until },
741
+ },
742
+ }, at);
743
+ return {};
744
+ }
745
+ if (decision.status !== 'allowed' || topic === undefined) {
746
+ await complete(sendKey, row, token, { kind: 'remove' }, at);
747
+ return { refusal: decision.status === 'allowed' ? noEndpoint() : decision };
748
+ }
749
+ const answer = await callProvider(recipient, send.request, topic, decision, at);
750
+ const answeredAt = nowMs();
751
+ switch (answer.status) {
752
+ case 'accepted':
753
+ // Counted before the row is settled: the provider took it whatever
754
+ // happens next. A history write that keeps failing is reported, and
755
+ // the row still settles as delivered: an undercounted limit is
756
+ // better than a replay that sends the push twice.
757
+ await withRetries(() => store.addHistory({
758
+ sendKey,
759
+ provider: row.provider,
760
+ player: send.request.player,
761
+ topic: send.request.topic,
762
+ at: formatNotificationInstant(answeredAt),
763
+ }), (error) => report(error, 'history.add', { sendKey, provider: row.provider, rowKey: row.key }));
764
+ await settleAfterCall(sendKey, row, token, { kind: 'set', row: { ...base, state: { status: 'delivered' } } }, answeredAt);
765
+ return {};
766
+ case 'ambiguous': {
767
+ // The provider may have sent it: the claim is kept for the lease.
768
+ const kept = row.state.status === 'in-flight' ? row.state : undefined;
769
+ // The first replay waits the claim lease, and each later ambiguous
770
+ // answer doubles the wait, up to an hour: a provider without an
771
+ // idempotency key may show every replay. The next claim counts the replay.
772
+ const ambiguousAttempts = row.ambiguousAttempts ?? 0;
773
+ const leaseUntil = answeredAt + ambiguousLeaseMs(ambiguousAttempts);
774
+ // The replay window started at the claim that reached the provider (or an earlier one).
775
+ const firstAttemptedAt = row.firstAttemptedAt ?? formatNotificationInstant(at);
776
+ if (isNotificationRowAbandoned({ ...row, firstAttemptedAt }, leaseUntil)) {
777
+ report(new Error('Ambiguous answers for a day: the delivery stays in flight and is not replayed again.'), 'dispatch.abandoned', { sendKey, provider: row.provider, rowKey: row.key });
778
+ }
779
+ await settleAfterCall(sendKey, row, token, {
780
+ kind: 'set',
781
+ row: {
782
+ ...base,
783
+ ...backoff,
784
+ firstAttemptedAt,
785
+ ...(ambiguousAttempts === 0 ? {} : { ambiguousAttempts }),
786
+ state: {
787
+ status: 'in-flight',
788
+ leaseUntil: formatNotificationInstant(leaseUntil),
789
+ claim: token,
790
+ attempted: true,
791
+ ...(kept?.attemptedAt === undefined ? {} : { attemptedAt: kept.attemptedAt }),
792
+ },
793
+ },
794
+ }, answeredAt);
795
+ return {};
796
+ }
797
+ case 'unregistered':
798
+ if (recipient.endpoint !== undefined) {
799
+ await store.invalidateEndpoint({
800
+ provider: row.provider,
801
+ installationId: recipient.endpoint.installationId,
802
+ value: recipient.endpoint.address.value,
803
+ at: formatNotificationInstant(answeredAt),
804
+ });
805
+ }
806
+ await complete(sendKey, row, token, { kind: 'remove' }, answeredAt);
807
+ return { refusal: { status: 'unavailable', reason: 'endpoint-invalid' } };
808
+ case 'rejected':
809
+ // A configuration rejection fails every send the same way: tell the
810
+ // operator. A recipient or agreement refusal is the player's, and a
811
+ // message that would arrive too late is routine; neither is an alert.
812
+ if (answer.reason === 'configuration' && answer.providerCode !== notificationExpiredProviderCode) {
813
+ report(new Error(`The provider rejected the delivery (configuration${answer.providerCode === undefined ? '' : `, ${answer.providerCode}`}).`), 'provider.rejected', { sendKey, provider: row.provider, rowKey: row.key });
814
+ }
815
+ if (answer.reason === 'agreement') {
816
+ await recordProviderDenial(send.request.player, send.request.topic, answeredAt);
817
+ }
818
+ await complete(sendKey, row, token, { kind: 'remove' }, answeredAt);
819
+ return { refusal: { status: 'unavailable', reason: 'provider-rejected' } };
820
+ case 'rate-limited': {
821
+ // Nothing was sent: the claim is released and the row waits.
822
+ const next = nextRateLimit(row.rateLimit, answer.retryAfterSeconds, answeredAt);
823
+ if (next === undefined) {
824
+ await complete(sendKey, row, token, { kind: 'remove' }, answeredAt);
825
+ return { refusal: { status: 'unavailable', reason: 'provider-rate-limited' } };
826
+ }
827
+ await complete(sendKey, row, token, {
828
+ kind: 'set',
829
+ row: {
830
+ ...base,
831
+ rateLimit: next.state,
832
+ state: { status: 'deferred', reason: 'provider-rate-limited', until: next.until },
833
+ },
834
+ }, answeredAt);
835
+ return {};
836
+ }
837
+ }
838
+ };
839
+ /**
840
+ * `complete` after the provider accepted or answered ambiguously: a
841
+ * failure here leaves a delivery that may have happened, which is not the
842
+ * same as nothing sent, so it is marked for `runInline`.
843
+ */
844
+ const settleAfterCall = async (sendKey, row, token, change, at) => {
845
+ try {
846
+ await complete(sendKey, row, token, change, at);
847
+ }
848
+ catch (error) {
849
+ throw new NotificationSettleError({ cause: error });
850
+ }
851
+ };
852
+ /** `complete` on a failure path: its own failure is reported, never thrown over the original. */
853
+ const settleQuietly = async (sendKey, row, token, change, at) => {
854
+ try {
855
+ await complete(sendKey, row, token, change, at);
856
+ }
857
+ catch (error) {
858
+ report(error, 'ledger.release', sendKey);
859
+ }
860
+ };
861
+ /**
862
+ * The host says the recipient has not agreed: a topic that needs an
863
+ * agreement stops until the player agrees again. A denial already latest
864
+ * for the topic is not written again, so repeated refusals of a topic that
865
+ * needs no agreement do not grow the history. Two refusals settled at the
866
+ * same moment may both write one; the latest record is a denial either way.
867
+ */
868
+ const recordProviderDenial = async (player, topic, at) => {
869
+ const latest = latestNotificationConsent(await store.listConsent(player), 'host-agreement', topic);
870
+ if (latest?.state === 'denied') {
871
+ return;
872
+ }
873
+ await store.appendConsent({
874
+ player,
875
+ record: { kind: 'host-agreement', topic, state: 'denied', source: 'provider' },
876
+ at: formatNotificationInstant(at),
877
+ });
878
+ };
879
+ /** The send's answer after its rows ran: the stored outcome, else the best refusal. */
880
+ const settle = async (sendKey, refusals) => {
881
+ const held = await store.getSend(sendKey);
882
+ const stored = held === undefined ? undefined : notificationSendOutcome(held.send);
883
+ return stored ?? bestNotificationOutcome(refusals) ?? noEndpoint();
884
+ };
885
+ /**
886
+ * Runs rows one after the other. One row's failure does not stop the
887
+ * others: its row stays due, and the failure is reported. A caller error
888
+ * is thrown once every row ran, and so is the first failure when no row
889
+ * got anywhere, so a caller never mistakes a send that did nothing for an
890
+ * ambiguous one.
891
+ */
892
+ const runInline = async (sendKey, rows, mode) => {
893
+ const refusals = [];
894
+ let callerError;
895
+ const errors = [];
896
+ for (const row of rows) {
897
+ try {
898
+ const { refusal } = await runRow(sendKey, row.provider, row.key, mode);
899
+ if (refusal !== undefined) {
900
+ refusals.push(refusal);
901
+ }
902
+ }
903
+ catch (error) {
904
+ if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT') {
905
+ callerError ??= error;
906
+ }
907
+ else {
908
+ errors.push({ error, row });
909
+ }
910
+ }
911
+ }
912
+ const failed = errors.length + (callerError === undefined ? 0 : 1);
913
+ // When every row failed before its provider call, the first failure is
914
+ // the call's and the rest are reported. A row whose answer could not be
915
+ // stored may have been delivered, so the call answers from the ledger.
916
+ const nothingSent = errors.every(({ error }) => !(error instanceof NotificationSettleError));
917
+ const thrown = callerError
918
+ ?? (failed === rows.length && nothingSent ? errors[0]?.error : undefined);
919
+ for (const { error, row } of errors) {
920
+ if (error !== thrown) {
921
+ report(error, 'dispatch', { sendKey, provider: row.provider, rowKey: row.key });
922
+ }
923
+ }
924
+ if (thrown !== undefined) {
925
+ throw thrown;
926
+ }
927
+ return { refusals, failed };
928
+ };
929
+ /** Evaluates a send this call created, holding its first-evaluation claim. */
930
+ const sendNew = async (send, version, at) => {
931
+ let planned;
932
+ try {
933
+ planned = await plan(send.request, send.origin, at);
934
+ }
935
+ catch (error) {
936
+ // Nothing was sent: the key is released so a retry evaluates again.
937
+ try {
938
+ await store.updateSend({ key: send.key, version, send: undefined });
939
+ }
940
+ catch (releaseError) {
941
+ report(releaseError, 'ledger.release', send.key);
942
+ }
943
+ if (error instanceof NotificationSendInputError) {
944
+ throw invalidInput(error.message, error);
945
+ }
946
+ throw error;
947
+ }
948
+ const { rows, refusals } = planned;
949
+ if (rows.length === 0) {
950
+ await store.updateSend({ key: send.key, version, send: undefined });
951
+ return bestNotificationOutcome(refusals) ?? noEndpoint();
952
+ }
953
+ const { claim: _claim, ...unclaimed } = send;
954
+ const stored = {
955
+ ...unclaimed,
956
+ rows,
957
+ changedAt: formatNotificationInstant(at),
958
+ };
959
+ if (!await store.updateSend({ key: send.key, version, send: stored })) {
960
+ // The first-evaluation lease ran out and another caller took the key.
961
+ return { status: 'in-flight' };
962
+ }
963
+ const queued = rows.filter((row) => row.state.status === 'in-flight');
964
+ if (queued.length > 0 && input.queue !== undefined) {
965
+ try {
966
+ await input.queue.enqueue(queued.map((row) => ({ sendKey: send.key, provider: row.provider, rowKey: row.key })));
967
+ }
968
+ catch (error) {
969
+ // The rows are stored with a lease: `dispatchDue` runs them once it ends.
970
+ report(error, 'queue.enqueue', send.key);
971
+ }
972
+ return notificationSendOutcome(stored) ?? { status: 'in-flight' };
973
+ }
974
+ const ran = await runInline(send.key, queued, 'job');
975
+ return settle(send.key, [...refusals, ...ran.refusals]);
976
+ };
977
+ // Due rows (the scheduler's dispatch).
978
+ /**
979
+ * Takes due rows for the queue: each becomes a queued row (in flight, no
980
+ * holder) for one claim lease, so the next run does not take it again
981
+ * while its job waits, and a lost job is due again when the lease ends.
982
+ * An attempted row stays attempted (its provider call may have happened),
983
+ * and the take never closes its replay window early. Answers the rows it
984
+ * took; a row that is no longer due is left.
985
+ */
986
+ const takeRows = async (sendKey, wanted) => {
987
+ const names = new Set(wanted.map((row) => JSON.stringify([row.key, row.provider])));
988
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
989
+ const held = await store.getSend(sendKey);
990
+ if (held === undefined) {
991
+ return [];
992
+ }
993
+ const at = nowMs();
994
+ const taken = [];
995
+ const rows = held.send.rows.map((row) => {
996
+ if (!names.has(JSON.stringify([row.key, row.provider])) || !isNotificationRowDue(row, at)) {
997
+ return row;
998
+ }
999
+ const next = takenRow(row, at, leaseMs);
1000
+ taken.push(next);
1001
+ return next;
1002
+ });
1003
+ if (taken.length === 0) {
1004
+ return [];
1005
+ }
1006
+ if (await store.updateSend({
1007
+ key: sendKey,
1008
+ version: held.version,
1009
+ send: { ...held.send, scheduled: true, rows, changedAt: formatNotificationInstant(at) },
1010
+ })) {
1011
+ return taken;
1012
+ }
1013
+ }
1014
+ throw busy(sendKey);
1015
+ };
1016
+ /**
1017
+ * The remaining dispatches each provider allows this run: its registry
1018
+ * `rate` less the deliveries in the rate window (`t − window < s ≤ t`),
1019
+ * read once per run, less every row this run took since. Sends still in
1020
+ * flight from earlier runs are not counted, so they may pass it by a few.
1021
+ */
1022
+ const paceBudgets = () => {
1023
+ const budgets = new Map();
1024
+ return {
1025
+ async take(provider) {
1026
+ let left = budgets.get(provider);
1027
+ if (left === undefined) {
1028
+ const rate = registry.provider(provider)?.entry.rate;
1029
+ const at = nowMs();
1030
+ left = rate === undefined
1031
+ ? Number.POSITIVE_INFINITY
1032
+ : rate.count - await store.countHistory({
1033
+ provider,
1034
+ since: formatNotificationInstant(at - rate.windowSeconds * secondMs + 1),
1035
+ // Deliveries dated after now (the clock went back) are outside the window.
1036
+ until: formatNotificationInstant(at),
1037
+ });
1038
+ }
1039
+ budgets.set(provider, left - 1);
1040
+ return left > 0;
1041
+ },
1042
+ };
1043
+ };
1044
+ /**
1045
+ * One batch of due rows. `pace` carries each provider's remaining budget
1046
+ * across the batches of one scheduler run, so rows taken in an earlier
1047
+ * batch (still in flight, not yet counted as delivered) count too.
1048
+ * Answers how many sends were listed and how many of them the pace held
1049
+ * back entirely.
1050
+ */
1051
+ const dispatchDueRows = async (options = {}, pace = paceBudgets()) => {
1052
+ const limit = batchLimit(options.limit);
1053
+ const keys = await store.listDueSends({ at: formatNotificationInstant(nowMs()), limit });
1054
+ const listed = keys.length;
1055
+ const results = [];
1056
+ let failed = 0;
1057
+ let paced = 0;
1058
+ for (const sendKey of keys) {
1059
+ try {
1060
+ const held = await store.getSend(sendKey);
1061
+ if (held === undefined) {
1062
+ continue;
1063
+ }
1064
+ const at = nowMs();
1065
+ // Oldest first within each provider's pace: a row over it stays due for the next run.
1066
+ const due = [];
1067
+ let heldBack = false;
1068
+ for (const row of held.send.rows) {
1069
+ if (isNotificationRowDue(row, at)) {
1070
+ if (await pace.take(row.provider)) {
1071
+ due.push(row);
1072
+ }
1073
+ else {
1074
+ heldBack = true;
1075
+ }
1076
+ }
1077
+ }
1078
+ if (due.length === 0) {
1079
+ paced += heldBack ? 1 : 0;
1080
+ continue;
1081
+ }
1082
+ if (input.queue !== undefined) {
1083
+ const taken = await takeRows(sendKey, due);
1084
+ if (taken.length === 0) {
1085
+ continue;
1086
+ }
1087
+ try {
1088
+ await input.queue.enqueue(taken.map((row) => ({ sendKey, provider: row.provider, rowKey: row.key })));
1089
+ }
1090
+ catch (error) {
1091
+ // The rows are taken for one lease: the next run takes them again after it.
1092
+ failed += 1;
1093
+ report(error, 'queue.enqueue', sendKey);
1094
+ }
1095
+ results.push({ sendKey, outcome: await settle(sendKey, []) });
1096
+ continue;
1097
+ }
1098
+ const ran = await runInline(sendKey, due, 'due');
1099
+ if (ran.failed > 0) {
1100
+ failed += 1;
1101
+ }
1102
+ results.push({ sendKey, outcome: await settle(sendKey, ran.refusals) });
1103
+ }
1104
+ catch (error) {
1105
+ failed += 1;
1106
+ report(error, 'dispatchDue', sendKey);
1107
+ }
1108
+ }
1109
+ return { results, failed, listed, paced };
1110
+ };
1111
+ // Scheduler items.
1112
+ /**
1113
+ * The send a scheduler item makes, keyed by the item: a new key held by a
1114
+ * first-evaluation claim before any row (`created`), the stored send when
1115
+ * one with rows exists (`existing`), or `busy` while another run holds the
1116
+ * key without rows yet. A key whose holder stopped before storing a row
1117
+ * starts over once its lease ends.
1118
+ */
1119
+ const createItemSend = async (request, origin, at) => {
1120
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
1121
+ const held = await store.getSend(request.idempotencyKey);
1122
+ if (held !== undefined) {
1123
+ const { send } = held;
1124
+ if (send.rows.length > 0) {
1125
+ return { existing: send };
1126
+ }
1127
+ if (send.claim !== undefined && Date.parse(send.claim.leaseUntil) > at) {
1128
+ return 'busy';
1129
+ }
1130
+ await store.updateSend({ key: send.key, version: held.version, send: undefined });
1131
+ continue;
1132
+ }
1133
+ const send = {
1134
+ key: request.idempotencyKey,
1135
+ fingerprint: notificationSendFingerprint(request),
1136
+ request,
1137
+ origin,
1138
+ scheduled: true,
1139
+ rows: [],
1140
+ claim: { token: newToken(), leaseUntil: formatNotificationInstant(at + leaseMs) },
1141
+ changedAt: formatNotificationInstant(at),
1142
+ };
1143
+ const version = await store.createSend(send);
1144
+ if (version !== undefined) {
1145
+ return { created: { send, version } };
1146
+ }
1147
+ }
1148
+ throw busy(request.idempotencyKey);
1149
+ };
1150
+ const releaseQuietly = async (key, version) => {
1151
+ try {
1152
+ await store.updateSend({ key, version, send: undefined });
1153
+ }
1154
+ catch (error) {
1155
+ report(error, 'ledger.release', key);
1156
+ }
1157
+ };
1158
+ /**
1159
+ * Plans the rows of a send `createItemSend` made and stores them, due at
1160
+ * `dueAt`. Answers the send's result and whether rows were stored (a gate
1161
+ * refusal stores none and releases the key); `undefined` when a cancel
1162
+ * removed the key meanwhile, so nothing was stored. A caller error
1163
+ * releases the key and throws `NOTIFICATIONS_INVALID_INPUT`; any other
1164
+ * failure releases it and rethrows.
1165
+ */
1166
+ const storeItemRows = async (created, at, dueAt) => {
1167
+ const { send, version } = created;
1168
+ let planned;
1169
+ try {
1170
+ planned = await plan(send.request, send.origin, at, Math.min(dueAt, at));
1171
+ }
1172
+ catch (error) {
1173
+ await releaseQuietly(send.key, version);
1174
+ if (error instanceof NotificationSendInputError) {
1175
+ throw invalidInput(error.message, error);
1176
+ }
1177
+ throw error;
1178
+ }
1179
+ if (planned.rows.length === 0) {
1180
+ await releaseQuietly(send.key, version);
1181
+ return { outcome: bestNotificationOutcome(planned.refusals) ?? noEndpoint(), stored: false };
1182
+ }
1183
+ const { claim: _claim, ...unclaimed } = send;
1184
+ const stored = {
1185
+ ...unclaimed,
1186
+ rows: planned.rows,
1187
+ changedAt: formatNotificationInstant(at),
1188
+ };
1189
+ // A cancel since the key was made removed it: nothing is stored or sent.
1190
+ if (!await store.updateSend({ key: send.key, version, send: stored })) {
1191
+ return undefined;
1192
+ }
1193
+ return { outcome: notificationSendOutcome(stored) ?? { status: 'in-flight' }, stored: true };
1194
+ };
1195
+ /** The content of a reminder's send: kept on a fired reminder, which drops the rest. */
1196
+ const reminderFingerprint = (player, reminder) => reminder.fingerprint ?? notificationSendFingerprint(reminderRequest(player, reminder));
1197
+ /** The send a reminder makes: its request, whose fingerprint names its content. */
1198
+ const reminderRequest = (player, reminder) => ({
1199
+ idempotencyKey: createNotificationReminderIdempotencyKey({
1200
+ player,
1201
+ key: reminder.key,
1202
+ fireAt: reminder.fireAt,
1203
+ }),
1204
+ player,
1205
+ topic: reminder.topic,
1206
+ ...(reminder.templateData === undefined ? {} : { templateData: reminder.templateData }),
1207
+ ...(reminder.link === undefined ? {} : { link: reminder.link }),
1208
+ });
1209
+ /**
1210
+ * Stops a reminder's send that still waits: the rows not delivered yet
1211
+ * (deferred, queued, or in flight) are dropped, and a send the scheduler
1212
+ * is still making loses its key, so no row of it is ever stored. Only a
1213
+ * send of `fingerprint` (the reminder's own content) is stopped, since the
1214
+ * send key names only the key and time: a send of other content at the key
1215
+ * belongs to the reminder that replaced this one. A send the provider may
1216
+ * already have accepted cannot be recalled. Answers whether anything waited.
1217
+ */
1218
+ const stopReminderSend = async (player, reminder, fingerprint) => {
1219
+ let key;
1220
+ try {
1221
+ key = createNotificationReminderIdempotencyKey({ player, key: reminder.key, fireAt: reminder.fireAt });
1222
+ }
1223
+ catch {
1224
+ return false;
1225
+ }
1226
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
1227
+ const held = await store.getSend(key);
1228
+ if (held === undefined || held.send.origin.kind !== 'reminder' || held.send.fingerprint !== fingerprint) {
1229
+ return false;
1230
+ }
1231
+ const { send } = held;
1232
+ const kept = send.rows.filter((row) => row.state.status === 'delivered');
1233
+ if (send.rows.length > 0 && kept.length === send.rows.length) {
1234
+ return false;
1235
+ }
1236
+ const next = kept.length === 0
1237
+ ? undefined
1238
+ : { ...send, rows: kept, changedAt: formatNotificationInstant(nowMs()) };
1239
+ if (await store.updateSend({ key, version: held.version, send: next })) {
1240
+ return true;
1241
+ }
1242
+ }
1243
+ throw busy(key);
1244
+ };
1245
+ /** Moves an entry's due time later after a failure, so a broken item does not fill every run. */
1246
+ const deferEntry = async (held, at) => {
1247
+ try {
1248
+ await store.updateScheduleEntry({
1249
+ player: held.entry.player,
1250
+ id: held.entry.id,
1251
+ version: held.version,
1252
+ entry: { ...held.entry, dueAt: formatNotificationInstant(at + schedulerRetryMs) },
1253
+ });
1254
+ }
1255
+ catch (error) {
1256
+ report(error, 'scheduler.defer');
1257
+ }
1258
+ };
1259
+ const removeEntry = (held) => store.updateScheduleEntry({
1260
+ player: held.entry.player,
1261
+ id: held.entry.id,
1262
+ version: held.version,
1263
+ entry: undefined,
1264
+ });
1265
+ /**
1266
+ * A due reminder becomes its `reminder:<player>:<key>:<fireAt>` send to
1267
+ * every provider the player has a recipient on, through the gate. The
1268
+ * rows are stored first and the reminder is marked fired after (kept until
1269
+ * its key's retention ends, so a later set or cancel can still stop the
1270
+ * send), so a run that stops between them leaves the reminder pending and
1271
+ * the next run finds its rows. A refused send spends the reminder. A set or
1272
+ * cancel since the read wins: it stops the send, and so does this run.
1273
+ * Answers `busy` while another run is making the send, and `stale` when
1274
+ * this run read a reminder a set has replaced since: neither worked it.
1275
+ */
1276
+ const fireReminder = async (held, reminder) => {
1277
+ const { player } = held.entry;
1278
+ const at = nowMs();
1279
+ let key;
1280
+ try {
1281
+ key = createNotificationReminderIdempotencyKey({ player, key: reminder.key, fireAt: reminder.fireAt });
1282
+ }
1283
+ catch (error) {
1284
+ // A reminder whose send cannot be keyed fails once.
1285
+ await removeEntry(held);
1286
+ throw invalidInput(describe(error), error);
1287
+ }
1288
+ const request = reminderRequest(player, reminder);
1289
+ const fingerprint = notificationSendFingerprint(request);
1290
+ // A fired reminder keeps only what names its send, and the fingerprint
1291
+ // of its own content (the canonical request, which the send also keeps).
1292
+ const fired = {
1293
+ player,
1294
+ id: held.entry.id,
1295
+ dueAt: formatNotificationInstant(at + keyRetentionMs),
1296
+ item: {
1297
+ kind: 'reminder',
1298
+ key: reminder.key,
1299
+ topic: reminder.topic,
1300
+ fireAt: reminder.fireAt,
1301
+ firedAt: formatNotificationInstant(at),
1302
+ fingerprint,
1303
+ },
1304
+ };
1305
+ const markFired = () => store.updateScheduleEntry({ player, id: held.entry.id, version: held.version, entry: fired });
1306
+ let made = await createItemSend(request, { kind: 'reminder', key: reminder.key }, at);
1307
+ if (made !== 'busy' && 'existing' in made && made.existing.fingerprint !== fingerprint) {
1308
+ // The key holds a send of other content. If this reminder is still
1309
+ // the current one (as of the re-read), that send is of a reminder it
1310
+ // replaced at the same time: what still waits of it goes, and this
1311
+ // reminder makes its own. If not, this run read a reminder a set has
1312
+ // replaced since, and the send is the newer reminder's: it is left
1313
+ // alone. A set that lands between the re-read and the stop stops this
1314
+ // reminder's own send and the next run repairs the rest.
1315
+ const current = await store.getScheduleEntry({ player, id: held.entry.id });
1316
+ if (current?.version !== held.version) {
1317
+ return 'stale';
1318
+ }
1319
+ await stopReminderSend(player, reminder, made.existing.fingerprint);
1320
+ made = await createItemSend(request, { kind: 'reminder', key: reminder.key }, at);
1321
+ if (made !== 'busy' && 'existing' in made && made.existing.fingerprint !== fingerprint) {
1322
+ // A row of it was delivered, so the key keeps that send: this
1323
+ // reminder cannot have its own under the same key and time.
1324
+ report(new Error("A delivered send of another reminder holds this reminder's key and time; it is not sent."), 'scheduler.reminder', key);
1325
+ await markFired();
1326
+ return undefined;
1327
+ }
1328
+ }
1329
+ if (made === 'busy') {
1330
+ // Another run is making the send; its lease frees the key if it stops.
1331
+ return 'busy';
1332
+ }
1333
+ if ('existing' in made) {
1334
+ // Its rows were stored by a run that stopped before marking it: they carry it.
1335
+ await markFired();
1336
+ return undefined;
1337
+ }
1338
+ let result;
1339
+ try {
1340
+ // The rows are due when the entry was: a past fireAt counts from its
1341
+ // set time. A pending reminder always has a due time (runScheduler
1342
+ // takes only entries with one); fireAt only satisfies the type.
1343
+ result = await storeItemRows(made.created, at, Date.parse(held.entry.dueAt ?? reminder.fireAt));
1344
+ }
1345
+ catch (error) {
1346
+ if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT') {
1347
+ // A send that cannot be made as asked fails once.
1348
+ await removeEntry(held);
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ if (result === undefined) {
1353
+ return undefined;
1354
+ }
1355
+ if (!result.stored) {
1356
+ // Refused by the gate: the reminder is spent, as a send would be.
1357
+ await removeEntry(held);
1358
+ return { sendKey: key, outcome: result.outcome };
1359
+ }
1360
+ if (!await markFired()) {
1361
+ // The entry changed since the read. The rows serve it when it is the
1362
+ // same reminder (time and content), pending or marked fired by another
1363
+ // run that found them. A cancel, or a set to another time or content,
1364
+ // stops them: theirs runs before their write, and this one covers a
1365
+ // send stored after it. Only this content's send is stopped, so a send
1366
+ // the new reminder made at the same key stays. A read that fails stops
1367
+ // them too, so a cancel is never outlived by its send.
1368
+ let same = false;
1369
+ try {
1370
+ const now = (await store.getScheduleEntry({ player, id: held.entry.id }))?.entry.item;
1371
+ same = now?.kind === 'reminder'
1372
+ && now.fireAt === reminder.fireAt
1373
+ && reminderFingerprint(player, now) === fingerprint;
1374
+ }
1375
+ catch (error) {
1376
+ report(error, 'scheduler.reread');
1377
+ }
1378
+ if (!same) {
1379
+ await stopReminderSend(player, reminder, fingerprint);
1380
+ }
1381
+ return undefined;
1382
+ }
1383
+ return { sendKey: key, outcome: result.outcome };
1384
+ };
1385
+ /**
1386
+ * A fired reminder whose send key's retention ended is forgotten, unless
1387
+ * its send still waits (a long rate-limit or ambiguous replay keeps the
1388
+ * key alive): then it looks again a day later.
1389
+ */
1390
+ const forgetFiredReminder = async (held, reminder) => {
1391
+ let key;
1392
+ try {
1393
+ key = createNotificationReminderIdempotencyKey({
1394
+ player: held.entry.player,
1395
+ key: reminder.key,
1396
+ fireAt: reminder.fireAt,
1397
+ });
1398
+ }
1399
+ catch {
1400
+ key = undefined;
1401
+ }
1402
+ // A read that fails throws, so the entry waits a minute rather than going while its send may wait.
1403
+ const waiting = key !== undefined
1404
+ && ((await store.getSend(key))?.send.rows.some((row) => row.state.status !== 'delivered') ?? false);
1405
+ if (!waiting) {
1406
+ await removeEntry(held);
1407
+ return;
1408
+ }
1409
+ await store.updateScheduleEntry({
1410
+ player: held.entry.player,
1411
+ id: held.entry.id,
1412
+ version: held.version,
1413
+ entry: { ...held.entry, dueAt: formatNotificationInstant(nowMs() + dayMs) },
1414
+ });
1415
+ };
1416
+ /** The instant an Asia/Seoul calendar date starts. */
1417
+ const seoulMidnight = (date) => Date.parse(`${date}T00:00:00+09:00`);
1418
+ const noticeIdentity = (notice) => ({
1419
+ kind: notice.kind,
1420
+ record: { kind: notice.record.kind, at: normalizeNotificationTimestamp(notice.record.at) },
1421
+ dueBy: notice.dueBy,
1422
+ });
1423
+ const carriesNotices = () => rules === 'kr'
1424
+ && noticeTopic !== undefined
1425
+ && registry.providers.some((provider) => notificationProviderCarriesNotices(provider.entry));
1426
+ /**
1427
+ * The player's notice check: each listed notice nobody recorded by the
1428
+ * start of the Asia/Seoul day seven days before its `dueBy` becomes its
1429
+ * `notice:` send through the notice topic, in the variant it is listed
1430
+ * with, filled with `consentDate`. Then the check is due again at the
1431
+ * earliest of: the next notice's first send day (a later result notice or
1432
+ * re-confirmation), an hour while a push waits in the ledger, and the
1433
+ * backoff after a refused push (an hour, doubling to a day). With nothing
1434
+ * owed it is removed.
1435
+ */
1436
+ const checkNotices = async (held, check) => {
1437
+ const { player } = held.entry;
1438
+ if (!carriesNotices() || noticeTopic === undefined) {
1439
+ await removeEntry(held);
1440
+ return [];
1441
+ }
1442
+ const at = nowMs();
1443
+ const now = formatNotificationInstant(at);
1444
+ const today = toSeoulCalendarDate(now);
1445
+ try {
1446
+ // Only the player can make a notice key fail (an id with a lone surrogate): its check fails once.
1447
+ createNotificationNoticeIdempotencyKey({
1448
+ kind: 'consent-result',
1449
+ record: { kind: 'advertising', at: now },
1450
+ dueBy: today,
1451
+ player,
1452
+ });
1453
+ }
1454
+ catch (error) {
1455
+ await removeEntry(held);
1456
+ report(error, 'scheduler.notice');
1457
+ return [];
1458
+ }
1459
+ const records = await store.listConsent(player);
1460
+ const given = await store.listNoticesGiven(player);
1461
+ const owed = listNotificationNotices(records, given, now);
1462
+ const failed = new Set(check.failed ?? []);
1463
+ const previouslyPushed = new Set(check.pushed ?? []);
1464
+ const pushed = [];
1465
+ const results = [];
1466
+ let waiting = false;
1467
+ let refused = false;
1468
+ for (const notice of owed) {
1469
+ const sendFrom = notificationNoticeSendFrom(notice.dueBy);
1470
+ const identity = noticeIdentity(notice);
1471
+ const key = createNotificationNoticeIdempotencyKey({ ...identity, player });
1472
+ if (sendFrom > today || failed.has(key)) {
1473
+ continue;
1474
+ }
1475
+ const existing = await store.getSend(key);
1476
+ if (existing === undefined && previouslyPushed.has(key)) {
1477
+ // The last check pushed it, and the push is gone while the notice is
1478
+ // still owed: the provider refused it when it was sent. It waits out
1479
+ // the backoff like a push the gate refused.
1480
+ refused = true;
1481
+ continue;
1482
+ }
1483
+ const request = {
1484
+ idempotencyKey: key,
1485
+ player,
1486
+ topic: noticeTopic,
1487
+ templateData: { consentDate: toSeoulCalendarDate(identity.record.at) },
1488
+ };
1489
+ const made = await createItemSend(request, { kind: 'notice', notice: identity, variant: notice.variant }, at);
1490
+ if (made === 'busy') {
1491
+ // Any waiting push stays this check's push, so a refusal after it backs off.
1492
+ waiting = true;
1493
+ pushed.push(key);
1494
+ continue;
1495
+ }
1496
+ if ('existing' in made) {
1497
+ if (notificationSendOutcome(made.existing)?.status === 'delivered') {
1498
+ // Delivered, but its record was not written: it is recorded now (while it is listed), not pushed again.
1499
+ if (await noticeListed(player, identity, at)) {
1500
+ await store.addNoticeGiven({ ...identity, player, channel: 'push', at: now });
1501
+ }
1502
+ }
1503
+ else {
1504
+ // Still waiting: it stays this check's push, so a later refusal still backs off.
1505
+ waiting = true;
1506
+ pushed.push(key);
1507
+ }
1508
+ continue;
1509
+ }
1510
+ try {
1511
+ const result = await storeItemRows(made.created, at, seoulMidnight(sendFrom));
1512
+ if (result === undefined) {
1513
+ // Another run took the key over: its push is looked at again.
1514
+ waiting = true;
1515
+ }
1516
+ else {
1517
+ results.push({ sendKey: key, outcome: result.outcome });
1518
+ if (result.stored) {
1519
+ waiting = true;
1520
+ pushed.push(key);
1521
+ }
1522
+ else {
1523
+ refused = true;
1524
+ }
1525
+ }
1526
+ }
1527
+ catch (error) {
1528
+ if (!(error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
1529
+ throw error;
1530
+ }
1531
+ // A push that cannot render fails once; the game still owes the notice.
1532
+ failed.add(key);
1533
+ report(error, 'scheduler.notice', key);
1534
+ }
1535
+ }
1536
+ // Every notice still owed, now or later (the next re-confirmation of each consent).
1537
+ const ahead = listNotificationNotices(records, given, noticeHorizon);
1538
+ const owedKeys = new Set(ahead.map((notice) => createNotificationNoticeIdempotencyKey({ ...noticeIdentity(notice), player })));
1539
+ const stillFailed = [...failed].filter((key) => owedKeys.has(key));
1540
+ let next;
1541
+ const consider = (time) => {
1542
+ next = next === undefined ? time : Math.min(next, time);
1543
+ };
1544
+ for (const notice of ahead) {
1545
+ const sendFrom = notificationNoticeSendFrom(notice.dueBy);
1546
+ if (sendFrom > today) {
1547
+ consider(seoulMidnight(sendFrom));
1548
+ }
1549
+ }
1550
+ if (waiting) {
1551
+ consider(at + noticeRecheckMs);
1552
+ }
1553
+ // Refusals in a row: a push that is only waiting proves nothing yet, and
1554
+ // nothing left to push ends the series.
1555
+ let attempts = 0;
1556
+ if (refused) {
1557
+ attempts = check.attempts + 1;
1558
+ }
1559
+ else if (waiting) {
1560
+ attempts = check.attempts;
1561
+ }
1562
+ if (refused) {
1563
+ consider(at + Math.min(noticeBackoffMs * 2 ** Math.min(attempts - 1, 16), dayMs));
1564
+ }
1565
+ const item = {
1566
+ kind: 'notices',
1567
+ attempts,
1568
+ ...(stillFailed.length === 0 ? {} : { failed: stillFailed }),
1569
+ ...(pushed.length === 0 ? {} : { pushed }),
1570
+ };
1571
+ // With nothing to look at again, a check that remembers failed pushes
1572
+ // stays without a due time, so they still fail once; a new record makes
1573
+ // it due again.
1574
+ let settled;
1575
+ if (next !== undefined) {
1576
+ settled = { player, id: noticesEntryId, dueAt: formatNotificationInstant(next), item };
1577
+ }
1578
+ else if (stillFailed.length > 0) {
1579
+ settled = { player, id: noticesEntryId, item };
1580
+ }
1581
+ // A record written since the check began made it due again: that stands.
1582
+ await store.updateScheduleEntry({
1583
+ player,
1584
+ id: noticesEntryId,
1585
+ version: held.version,
1586
+ entry: settled,
1587
+ });
1588
+ return results;
1589
+ };
1590
+ /**
1591
+ * Makes the player's notice check due at `at`, and clears its backoff,
1592
+ * always with a new version. With `onlyBackedOff`, it acts only on a check
1593
+ * that is backing off after a refused push (a new registration may let
1594
+ * the push through).
1595
+ */
1596
+ const markNoticesDue = async (player, at, onlyBackedOff = false) => {
1597
+ if (!carriesNotices()) {
1598
+ return;
1599
+ }
1600
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
1601
+ const held = await store.getScheduleEntry({ player, id: noticesEntryId });
1602
+ const check = held?.entry.item.kind === 'notices'
1603
+ ? held.entry.item
1604
+ : { kind: 'notices', attempts: 0 };
1605
+ if (onlyBackedOff && (held === undefined || check.attempts === 0)) {
1606
+ return;
1607
+ }
1608
+ const dueAt = held?.entry.dueAt === undefined
1609
+ ? at
1610
+ : Math.min(Date.parse(held.entry.dueAt), at);
1611
+ // Written even when the check is already due: the new version makes a
1612
+ // run that read the history before this act lose its settle, so the
1613
+ // act's notice is looked at again. A new act or registration starts
1614
+ // over: the backoff ends, and the pushes of the last check are not
1615
+ // read as refused.
1616
+ const { pushed: _pushed, ...kept } = check;
1617
+ const entry = {
1618
+ player,
1619
+ id: noticesEntryId,
1620
+ dueAt: formatNotificationInstant(dueAt),
1621
+ item: { ...kept, attempts: 0 },
1622
+ };
1623
+ if (held === undefined
1624
+ ? (await store.createScheduleEntry(entry)) !== undefined
1625
+ : await store.updateScheduleEntry({ player, id: noticesEntryId, version: held.version, entry })) {
1626
+ return;
1627
+ }
1628
+ }
1629
+ throw busy(noticesEntryId);
1630
+ };
1631
+ /**
1632
+ * Deletes the records no rule reads any more, once they are a day old (the
1633
+ * daily cap still counts younger ones): every record but the latest of its
1634
+ * kind and scope, except, under the Korean rules, an advertising or night
1635
+ * record whose result notice is still owed and the record of its kind
1636
+ * before it, which decides whether it reports a refusal. The latest
1637
+ * consent of a kind is the latest record of it, so re-confirmations keep
1638
+ * their record. A failure is reported; the history only stays longer.
1639
+ */
1640
+ const compactConsent = async (player, records, at) => {
1641
+ try {
1642
+ const keep = new Set();
1643
+ const latest = new Map();
1644
+ for (const record of records) {
1645
+ latest.set(JSON.stringify([record.kind, record.topic ?? null]), record);
1646
+ }
1647
+ for (const record of latest.values()) {
1648
+ keep.add(Date.parse(record.at));
1649
+ }
1650
+ if (rules === 'kr' && records.some((record) => record.kind === 'advertising' || record.kind === 'night')) {
1651
+ const owed = new Set(listNotificationNotices(records, await store.listNoticesGiven(player), formatNotificationInstant(at))
1652
+ .map((notice) => Date.parse(notice.record.at)));
1653
+ for (const kind of ['advertising', 'night']) {
1654
+ const series = records.filter((record) => record.kind === kind);
1655
+ series.forEach((record, index) => {
1656
+ if (owed.has(Date.parse(record.at))) {
1657
+ keep.add(Date.parse(record.at));
1658
+ const before = series[index - 1];
1659
+ if (before !== undefined) {
1660
+ keep.add(Date.parse(before.at));
1661
+ }
1662
+ }
1663
+ });
1664
+ }
1665
+ }
1666
+ // A per-send provider counts every accepted prompt (gate step 3), so
1667
+ // while one is registered the acceptance records are never pruned, and
1668
+ // that part of the history grows with every acceptance.
1669
+ // The registry refuses per-send providers today; this keeps a
1670
+ // registry that serves one from losing acceptances silently.
1671
+ const perSend = registry.providers.some((provider) => provider.entry.permission.allowance === 'per-send');
1672
+ const prunable = records.filter((record) => Date.parse(record.at) <= at - consentCompactionMs
1673
+ && !keep.has(Date.parse(record.at))
1674
+ && !(perSend && (record.kind === 'delivery-permission' || record.kind === 'host-agreement')));
1675
+ if (prunable.length > 0) {
1676
+ await store.pruneConsent({ player, at: prunable.map((record) => record.at) });
1677
+ }
1678
+ }
1679
+ catch (error) {
1680
+ report(error, 'consent.compact');
1681
+ }
1682
+ };
1683
+ /**
1684
+ * Makes the player's notice check due now, best effort: after a
1685
+ * registration only a check backing off after a refused push (the new
1686
+ * device may let it through), after a consent write any.
1687
+ */
1688
+ const retryNoticePushes = async (player, onlyBackedOff = true, phase = 'registration') => {
1689
+ try {
1690
+ await markNoticesDue(player, nowMs(), onlyBackedOff);
1691
+ }
1692
+ catch (error) {
1693
+ report(error, phase === 'registration' ? 'notices.retry' : `notices.retry.${phase}`);
1694
+ }
1695
+ };
1696
+ /**
1697
+ * One round's schedule entries: up to `limit` due ones, each fired,
1698
+ * checked, or forgotten, with each send it touched. A schedule that cannot
1699
+ * be read is counted and reported, and the round's due rows still go out.
1700
+ */
1701
+ const runScheduleEntries = async (limit, touched) => {
1702
+ let failed = 0;
1703
+ let due = [];
1704
+ try {
1705
+ due = await store.listDueScheduleEntries({ at: formatNotificationInstant(nowMs()), limit });
1706
+ }
1707
+ catch (error) {
1708
+ // The due rows still go out: a schedule that cannot be read must not hold them.
1709
+ failed += 1;
1710
+ report(error, 'scheduler.list');
1711
+ }
1712
+ let handled = 0;
1713
+ for (const { player, id } of due) {
1714
+ let held;
1715
+ try {
1716
+ held = await store.getScheduleEntry({ player, id });
1717
+ if (held?.entry.dueAt === undefined || Date.parse(held.entry.dueAt) > nowMs()) {
1718
+ continue;
1719
+ }
1720
+ const { item } = held.entry;
1721
+ if (item.kind === 'notices') {
1722
+ for (const result of await checkNotices(held, item)) {
1723
+ touched.set(result.sendKey, result.outcome);
1724
+ }
1725
+ }
1726
+ else if (item.firedAt !== undefined) {
1727
+ await forgetFiredReminder(held, item);
1728
+ }
1729
+ else {
1730
+ const result = await fireReminder(held, item);
1731
+ if (result === 'busy') {
1732
+ // Another run holds its send: it waits a minute, so the next
1733
+ // round does not list it again, and it is not progress. The run
1734
+ // holding the send then loses its mark to this version, re-reads
1735
+ // the same reminder, and keeps its rows; the next run marks it.
1736
+ await deferEntry(held, nowMs());
1737
+ continue;
1738
+ }
1739
+ if (result === 'stale') {
1740
+ // A set replaced it since the read: the newer entry stands.
1741
+ continue;
1742
+ }
1743
+ if (result !== undefined) {
1744
+ touched.set(result.sendKey, result.outcome);
1745
+ }
1746
+ }
1747
+ // Counted once the entry was worked: a failing one is not progress.
1748
+ handled += 1;
1749
+ }
1750
+ catch (error) {
1751
+ failed += 1;
1752
+ report(error, 'scheduler', `${encodeURIComponent(player)}:${id}`);
1753
+ // A failure that may pass waits a minute; one that already moved or
1754
+ // removed the entry (its version changed) is left as it is.
1755
+ if (held !== undefined && !(error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
1756
+ await deferEntry(held, nowMs());
1757
+ }
1758
+ }
1759
+ }
1760
+ return { listed: due.length, handled, failed };
1761
+ };
1762
+ /**
1763
+ * Each authenticated call carries the session's current end, and the
1764
+ * endpoints the session registered move to it when it is later, so a
1765
+ * session the identity service extended keeps its devices bound without a
1766
+ * new registration. It never fails the call; a failure is reported. It
1767
+ * runs before a call's own validation on purpose: the session is
1768
+ * authenticated, so even a call refused for its input reports a valid end.
1769
+ */
1770
+ const authenticated = async (session) => {
1771
+ const current = sessionOf(session);
1772
+ try {
1773
+ await store.extendSessionEndpoints({
1774
+ sessionId: current.sessionId,
1775
+ ...(current.expiresAt === undefined ? {} : { expiresAt: normalizeNotificationTimestamp(current.expiresAt) }),
1776
+ });
1777
+ }
1778
+ catch (error) {
1779
+ report(error, 'endpoints.extend');
1780
+ }
1781
+ return current;
1782
+ };
1783
+ return {
1784
+ providers: () => registry.snapshot(),
1785
+ async recordConsent(session, record) {
1786
+ const { player } = await authenticated(session);
1787
+ if (!isPlainObject(record)) {
1788
+ throw invalidInput('A consent record is an object.');
1789
+ }
1790
+ const at = formatNotificationInstant(nowMs());
1791
+ // The backend's clock dates the record; a client's `at` is ignored.
1792
+ const { at: _clientAt, ...fields } = record;
1793
+ let checked;
1794
+ try {
1795
+ checked = assertNotificationConsentRecord({ ...fields, at });
1796
+ }
1797
+ catch (error) {
1798
+ throw invalidInput(`Invalid consent record: ${describe(error)}`, error);
1799
+ }
1800
+ if (checked.source === 'provider') {
1801
+ throw invalidInput('Provider records are written only by the backend from a provider answer.');
1802
+ }
1803
+ if ((checked.topic !== undefined && notificationKeyIssue(checked.topic) !== undefined)
1804
+ || (checked.textVersion !== undefined && !isBoundedText(checked.textVersion))
1805
+ || (checked.evidence !== undefined && new TextEncoder().encode(JSON.stringify(checked.evidence)).length > maxEvidenceBytes)) {
1806
+ throw invalidInput('A consent record field is out of bounds.');
1807
+ }
1808
+ const history = await store.listConsent(player);
1809
+ // A repeat of the latest permission, agreement, or preference record
1810
+ // for the kind and topic is not written again: a client that resends
1811
+ // its report changes nothing. Evidence is not compared, so the first
1812
+ // record keeps its own. An `advertising` or `night` record is always
1813
+ // written, since each is a player's act that may owe a notice (a
1814
+ // second refusal, a renewed consent).
1815
+ const latest = checked.kind === 'advertising' || checked.kind === 'night'
1816
+ ? undefined
1817
+ : latestNotificationConsent(history, checked.kind, checked.topic);
1818
+ if (latest !== undefined && latest.state === checked.state && latest.source === checked.source) {
1819
+ return latest;
1820
+ }
1821
+ const windowStart = Date.parse(at) - dayMs;
1822
+ const counted = history.filter((entry) => entry.source !== 'provider' && Date.parse(entry.at) > windowStart);
1823
+ const oldest = counted[counted.length - maxClientNotificationConsentRecordsPerDay];
1824
+ if (oldest !== undefined) {
1825
+ throw new NotificationConsentRateLimitError(Math.max(1, Math.ceil((Date.parse(oldest.at) - windowStart) / secondMs)));
1826
+ }
1827
+ const { at: _at, ...withoutAt } = checked;
1828
+ if (checked.kind === 'advertising' || checked.kind === 'night') {
1829
+ // Before the write, and again after it: a check that finds nothing new
1830
+ // is harmless. Best effort, so the player's consent is recorded even
1831
+ // while the schedule cannot be written; the game still lists the
1832
+ // notice in game.
1833
+ await retryNoticePushes(player, false, 'before-consent');
1834
+ }
1835
+ const stored = await store.appendConsent({ player, record: withoutAt, at });
1836
+ if (stored.kind === 'advertising' || stored.kind === 'night') {
1837
+ // Again after the write: a check that ran between the two found nothing new and settled.
1838
+ await retryNoticePushes(player, false, 'after-consent');
1839
+ }
1840
+ await compactConsent(player, [...history, stored], Date.parse(at));
1841
+ return stored;
1842
+ },
1843
+ async listConsent(session) {
1844
+ return store.listConsent((await authenticated(session)).player);
1845
+ },
1846
+ async registerEndpoint(session, request) {
1847
+ const current = await authenticated(session);
1848
+ let registration;
1849
+ try {
1850
+ registration = assertNotificationEndpointRegistration(request.registration);
1851
+ }
1852
+ catch (error) {
1853
+ throw invalidInput(`Invalid registration: ${describe(error)}`, error);
1854
+ }
1855
+ const provider = registry.provider(registration.provider);
1856
+ if (provider === undefined) {
1857
+ throw invalidInput(`This backend has no notifications provider ${registration.provider}.`);
1858
+ }
1859
+ if ((registration.locale !== undefined && !isBoundedText(registration.locale, 64))
1860
+ || (registration.timeZone !== undefined && !isValidNotificationTimeZone(registration.timeZone))) {
1861
+ throw invalidInput('The registration locale or time zone is not valid.');
1862
+ }
1863
+ const registeredAt = formatNotificationInstant(nowMs());
1864
+ const { address } = registration;
1865
+ if (address.kind === 'platform-user') {
1866
+ if (provider.entry.addressing !== 'platform-user') {
1867
+ throw invalidInput(`Provider ${provider.entry.provider} sends to devices; it needs a device address.`);
1868
+ }
1869
+ await store.putPlatformRegistration({
1870
+ player: current.player,
1871
+ provider: provider.entry.provider,
1872
+ ...(registration.locale === undefined ? {} : { locale: registration.locale }),
1873
+ ...(registration.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
1874
+ registeredAt,
1875
+ });
1876
+ await retryNoticePushes(current.player);
1877
+ return;
1878
+ }
1879
+ if (provider.entry.addressing !== 'device') {
1880
+ throw invalidInput(`Provider ${provider.entry.provider} resolves platform users; it takes no device address.`);
1881
+ }
1882
+ if (!provider.addressKinds.includes(address.kind)) {
1883
+ // No provider sends to a Firebase installation id yet; the kind is
1884
+ // reserved for when FCM does (RFC "Delivery providers").
1885
+ const reserved = address.kind === 'fcm-installation' && provider.addressKinds.includes('fcm-token')
1886
+ ? ` ${fcmInstallationIdRefusal}`
1887
+ : '';
1888
+ throw invalidInput(`Provider ${provider.entry.provider} cannot send to ${address.kind} addresses.${reserved}`);
1889
+ }
1890
+ if (address.value.length > maxAddressLength || hasControlCharacter(address.value)) {
1891
+ throw invalidInput('The device address is out of bounds.');
1892
+ }
1893
+ if (request.target === undefined && provider.targets.length > 1) {
1894
+ throw invalidInput(`Provider ${provider.entry.provider} serves ${provider.targets.join(', ')}; the registration names its target.`);
1895
+ }
1896
+ if (request.target !== undefined && !provider.targets.includes(request.target)) {
1897
+ throw invalidInput(`Provider ${provider.entry.provider} does not serve target ${String(request.target)}.`);
1898
+ }
1899
+ await store.putEndpoint({
1900
+ provider: provider.entry.provider,
1901
+ installationId: current.installationId,
1902
+ player: current.player,
1903
+ address: {
1904
+ kind: address.kind,
1905
+ value: address.value,
1906
+ ...(address.environment === undefined ? {} : { environment: address.environment }),
1907
+ },
1908
+ permission: address.permission,
1909
+ ...(registration.locale === undefined ? {} : { locale: registration.locale }),
1910
+ ...(registration.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
1911
+ ...(request.target === undefined ? {} : { target: request.target }),
1912
+ sessionId: current.sessionId,
1913
+ ...(current.expiresAt === undefined ? {} : { sessionExpiresAt: normalizeNotificationTimestamp(current.expiresAt) }),
1914
+ lastRegisteredAt: registeredAt,
1915
+ });
1916
+ await retryNoticePushes(current.player);
1917
+ },
1918
+ async unregisterEndpoint(session, request = {}) {
1919
+ const current = await authenticated(session);
1920
+ if (request.provider !== undefined && !isBoundedText(request.provider)) {
1921
+ throw invalidInput('provider: expected a non-empty string.');
1922
+ }
1923
+ await store.removeEndpoints({
1924
+ installationId: current.installationId,
1925
+ player: current.player,
1926
+ ...(request.provider === undefined ? {} : { provider: request.provider }),
1927
+ });
1928
+ },
1929
+ async endSession({ sessionId }) {
1930
+ if (!isBoundedText(sessionId)) {
1931
+ throw invalidInput('sessionId: expected a non-empty string.');
1932
+ }
1933
+ await store.removeSessionEndpoints({ sessionId });
1934
+ },
1935
+ async listNotices(session) {
1936
+ return noticesOf((await authenticated(session)).player, nowMs());
1937
+ },
1938
+ async recordNoticeShown(session, notice, options = {}) {
1939
+ const { player } = await authenticated(session);
1940
+ let named;
1941
+ try {
1942
+ const parsed = assertRecordNotificationNoticeInput(notice);
1943
+ named = {
1944
+ kind: parsed.kind,
1945
+ record: { kind: parsed.record.kind, at: normalizeNotificationTimestamp(parsed.record.at) },
1946
+ dueBy: parsed.dueBy,
1947
+ };
1948
+ }
1949
+ catch (error) {
1950
+ throw invalidInput('A notice is a listed notice, or its kind, the kind and time of the record it reports on, '
1951
+ + `and its dueBy: ${describe(error)}`, error);
1952
+ }
1953
+ const channel = options.channel === undefined ? 'in-game' : options.channel;
1954
+ // Only the backend records a push, once the push is delivered.
1955
+ if (channel !== 'in-game' && channel !== 'other') {
1956
+ throw invalidInput('channel: a game records a notice it showed, in-game or other.');
1957
+ }
1958
+ const at = nowMs();
1959
+ // Only a listed notice is recorded: one already recorded, not listed
1960
+ // yet, or unknown changes nothing, so a retried call is safe.
1961
+ if (!await noticeListed(player, named, at)) {
1962
+ return;
1963
+ }
1964
+ await store.addNoticeGiven({ ...named, player, channel, at: formatNotificationInstant(at) });
1965
+ },
1966
+ async deliver(raw) {
1967
+ const request = sendRequestOf(raw);
1968
+ refuseNoticeTopic(request.topic);
1969
+ const fingerprint = notificationSendFingerprint(request);
1970
+ const key = request.idempotencyKey;
1971
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
1972
+ const at = nowMs();
1973
+ const held = await lookup(key, at);
1974
+ if (held !== undefined) {
1975
+ if (held.send.fingerprint !== fingerprint) {
1976
+ throw new NotificationError('NOTIFICATIONS_IDEMPOTENCY_CONFLICT', {
1977
+ message: `Key ${key} is stored for another request.`,
1978
+ });
1979
+ }
1980
+ const { send } = held;
1981
+ if (send.rows.length === 0) {
1982
+ if (send.claim !== undefined && Date.parse(send.claim.leaseUntil) > at) {
1983
+ return { status: 'in-flight' };
1984
+ }
1985
+ // An evaluation that never finished, or an empty row set: start over.
1986
+ await store.updateSend({ key, version: held.version, send: undefined });
1987
+ continue;
1988
+ }
1989
+ const expired = send.rows.filter((row) => eligible('retry', row, at));
1990
+ if (expired.length > 0) {
1991
+ // A platform row's lease expired: this caller is the next claimer and replays it.
1992
+ const wasDelivered = notificationSendOutcome(send)?.status === 'delivered';
1993
+ const { refusals } = await runInline(key, expired, 'retry');
1994
+ const settled = await settle(key, refusals);
1995
+ return wasDelivered && settled.status === 'delivered'
1996
+ ? { status: 'delivered', alreadyProcessed: true }
1997
+ : settled;
1998
+ }
1999
+ const stored = notificationSendOutcome(send) ?? noEndpoint();
2000
+ return stored.status === 'delivered' ? { status: 'delivered', alreadyProcessed: true } : stored;
2001
+ }
2002
+ const token = newToken();
2003
+ const send = {
2004
+ key,
2005
+ fingerprint,
2006
+ request,
2007
+ origin: { kind: 'send' },
2008
+ scheduled: false,
2009
+ rows: [],
2010
+ claim: { token, leaseUntil: formatNotificationInstant(at + leaseMs) },
2011
+ changedAt: formatNotificationInstant(at),
2012
+ };
2013
+ const version = await store.createSend(send);
2014
+ if (version !== undefined) {
2015
+ return sendNew(send, version, at);
2016
+ }
2017
+ // A concurrent send created the key first: answer from its state.
2018
+ }
2019
+ throw busy(key);
2020
+ },
2021
+ async dispatch(job) {
2022
+ if (!isPlainObject(job)
2023
+ || !isBoundedText(job.sendKey)
2024
+ || !isBoundedText(job.provider)
2025
+ || !isBoundedText(job.rowKey, maxRowKeyLength)) {
2026
+ throw invalidInput('A dispatch job names its send key, provider, and row key.');
2027
+ }
2028
+ await runRow(job.sendKey, job.provider, job.rowKey, 'job');
2029
+ },
2030
+ async dispatchDue(options = {}) {
2031
+ const { results, failed } = await dispatchDueRows(options);
2032
+ return { results, failed };
2033
+ },
2034
+ async setReminder(session, raw) {
2035
+ const { player } = await authenticated(session);
2036
+ const reminder = reminderOf(raw);
2037
+ refuseNoticeTopic(reminder.topic);
2038
+ if (Date.parse(reminder.fireAt) - nowMs() > notificationReminderHorizonDays * dayMs) {
2039
+ throw invalidInput(`Invalid reminder: fireAt is more than ${notificationReminderHorizonDays} days ahead.`);
2040
+ }
2041
+ let sendKey;
2042
+ try {
2043
+ sendKey = createNotificationReminderIdempotencyKey({ player, key: reminder.key, fireAt: reminder.fireAt });
2044
+ }
2045
+ catch (error) {
2046
+ throw invalidInput(`Invalid reminder: ${describe(error)}`, error);
2047
+ }
2048
+ const origin = { kind: 'reminder', key: reminder.key };
2049
+ // The providers that would deliver the topic remotely, for some target they serve.
2050
+ const declaring = registry.providers.filter((provider) => provider.targets.some((target) => catalog.topicsFor(target).some((topic) => topic.id === reminder.topic && topic.channels.includes('remote'))));
2051
+ if (declaring.length === 0) {
2052
+ throw invalidInput(`Topic ${reminder.topic} is not delivered remotely by this backend.`);
2053
+ }
2054
+ const advertising = declaring.some((provider) => topicFor(provider, undefined, reminder.topic, origin)?.purpose === 'advertising');
2055
+ if (advertising && deployment?.advertisingRules === undefined) {
2056
+ // The gate would refuse every send of it, so it is refused now.
2057
+ throw invalidInput(`Advertising topic ${reminder.topic} needs the deployment's advertisingRules.`);
2058
+ }
2059
+ // The same request the scheduler makes, so their fingerprints compare.
2060
+ const request = reminderRequest(player, reminder);
2061
+ const at = nowMs();
2062
+ // Where the player can be reached now, else every declaring provider unresolved.
2063
+ const reached = (await recipientsOf(request, at, origin)).filter((recipient) => recipient.resolved && declaring.includes(recipient.provider));
2064
+ const candidates = reached.length > 0
2065
+ ? reached
2066
+ : declaring.map((provider) => ({ provider, rowKey: sendKey, resolved: false }));
2067
+ // Any declaring host-template provider that could deliver the topic,
2068
+ // reached now or not: the player may become reachable through it before
2069
+ // the reminder fires, and it would take the reminder's templateData as
2070
+ // its template context. One whose purposes leave the topic out (or an
2071
+ // advertising topic, free-form only) is refused by the gate's first
2072
+ // step; the catalog validator refuses such a mapping, so this matters
2073
+ // only for a hand-built service catalog.
2074
+ const hostTemplate = declaring.some((provider) => {
2075
+ if (provider.entry.content !== 'host-template') {
2076
+ return false;
2077
+ }
2078
+ const purpose = topicFor(provider, undefined, reminder.topic, origin)?.purpose;
2079
+ return purpose !== undefined && purpose !== 'advertising' && provider.entry.purposes.includes(purpose);
2080
+ });
2081
+ if (reminder.templateData !== undefined && hostTemplate) {
2082
+ throw invalidInput('A host-template provider keeps template context on the server; a reminder takes no templateData.');
2083
+ }
2084
+ // Gate steps 3 to 5 at set time: one candidate that passes is enough.
2085
+ const consent = await store.listConsent(player);
2086
+ const permissions = new Map();
2087
+ let refusal;
2088
+ let passed = false;
2089
+ let mapped = false;
2090
+ let linkRefusal;
2091
+ for (const recipient of candidates) {
2092
+ const topic = topicFor(recipient.provider, recipient.target, reminder.topic, origin);
2093
+ if (topic === undefined || !topic.channels.includes('remote')) {
2094
+ continue;
2095
+ }
2096
+ mapped = true;
2097
+ // The gate's link step, at set time: a provider whose sends need a
2098
+ // link takes the reminder's (by its mapping rule) or the mapping's.
2099
+ const linkIssue = reminderLinkIssue(recipient.provider, topic, reminder.link);
2100
+ if (linkIssue !== undefined) {
2101
+ linkRefusal ??= linkIssue;
2102
+ continue;
2103
+ }
2104
+ const id = recipient.provider.entry.provider;
2105
+ let permission = permissions.get(id);
2106
+ if (permission === undefined) {
2107
+ permission = readServerPermission(recipient.provider, request);
2108
+ permissions.set(id, permission);
2109
+ }
2110
+ const serverPermission = await permission;
2111
+ const refused = evaluateNotificationReminderConsent({
2112
+ entry: recipient.provider.entry,
2113
+ topic,
2114
+ consent,
2115
+ ...(serverPermission === undefined ? {} : { serverPermission }),
2116
+ ...(recipient.endpoint === undefined ? {} : { endpointPermission: recipient.endpoint.permission }),
2117
+ history: [],
2118
+ });
2119
+ if (refused === undefined) {
2120
+ passed = true;
2121
+ break;
2122
+ }
2123
+ if (refusal === undefined || reminderRefusalRank[refused] > reminderRefusalRank[refusal]) {
2124
+ refusal = refused;
2125
+ }
2126
+ }
2127
+ if (!passed) {
2128
+ if (refusal !== undefined) {
2129
+ return { status: refusal };
2130
+ }
2131
+ if (linkRefusal !== undefined) {
2132
+ throw invalidInput(`Invalid reminder: ${linkRefusal}`);
2133
+ }
2134
+ // Mapped for none of the player's targets (or none reached): nothing could deliver it.
2135
+ return { status: mapped ? 'permission-required' : 'unavailable' };
2136
+ }
2137
+ // One reminder per key: setting it replaces the previous one and, before
2138
+ // the write, stops the previous one's send that waits; a scheduler run
2139
+ // that fires the previous one meanwhile then fails to mark it and stops
2140
+ // its own send. A previous send of this reminder's content at its key
2141
+ // (an identical re-set) serves it and stays. A stop that fails fails the
2142
+ // set before anything is written, so the set is retried as a whole.
2143
+ const id = reminderEntryId(reminder.key);
2144
+ // A reminder set for a time already past is due now, not at that time,
2145
+ // so a burst of past-dated reminders queues behind those already due.
2146
+ const entry = {
2147
+ player,
2148
+ id,
2149
+ dueAt: formatNotificationInstant(Math.max(Date.parse(reminder.fireAt), at)),
2150
+ item: reminder,
2151
+ };
2152
+ const fingerprint = notificationSendFingerprint(request);
2153
+ let stopped = false;
2154
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
2155
+ const held = await store.getScheduleEntry({ player, id });
2156
+ const previous = held?.entry.item.kind === 'reminder' ? held.entry.item : undefined;
2157
+ // Replacing a pending reminder is always allowed; a new one counts against the cap.
2158
+ if ((previous === undefined || previous.firedAt !== undefined)
2159
+ && await store.countPendingReminders(player) >= maxPendingNotificationReminders) {
2160
+ return { status: 'capacity-exceeded' };
2161
+ }
2162
+ if (previous !== undefined) {
2163
+ const previousFingerprint = reminderFingerprint(player, previous);
2164
+ const identical = previous.fireAt === reminder.fireAt && previousFingerprint === fingerprint;
2165
+ if (!identical) {
2166
+ stopped = (await stopReminderSend(player, previous, previousFingerprint)) || stopped;
2167
+ }
2168
+ }
2169
+ const written = held === undefined
2170
+ ? (await store.createScheduleEntry(entry)) !== undefined
2171
+ : await store.updateScheduleEntry({ player, id, version: held.version, entry });
2172
+ if (written) {
2173
+ const pending = previous !== undefined && previous.firedAt === undefined;
2174
+ return { status: pending || stopped ? 'replaced' : 'scheduled' };
2175
+ }
2176
+ }
2177
+ throw busy(id);
2178
+ },
2179
+ async cancelReminder(session, raw) {
2180
+ const { player } = await authenticated(session);
2181
+ if (!isPlainObject(raw) || Object.keys(raw).some((name) => name !== 'key') || typeof raw.key !== 'string') {
2182
+ throw invalidInput('A reminder cancel names only its key.');
2183
+ }
2184
+ const issue = notificationKeyIssue(raw.key);
2185
+ if (issue !== undefined) {
2186
+ throw invalidInput(`key: ${issue}.`);
2187
+ }
2188
+ const id = reminderEntryId(raw.key);
2189
+ let stopped = false;
2190
+ for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
2191
+ const held = await store.getScheduleEntry({ player, id });
2192
+ if (held === undefined || held.entry.item.kind !== 'reminder') {
2193
+ return { cancelled: stopped };
2194
+ }
2195
+ const reminder = held.entry.item;
2196
+ const fingerprint = reminderFingerprint(player, reminder);
2197
+ // Before the removal, so a failure leaves the cancel to retry; and
2198
+ // after it, best effort, for a scheduler run that stored the send in
2199
+ // between (its own mark then fails and stops it too). Only this
2200
+ // reminder's content is stopped.
2201
+ stopped = (await stopReminderSend(player, reminder, fingerprint)) || stopped;
2202
+ if (await removeEntry(held)) {
2203
+ try {
2204
+ stopped = (await stopReminderSend(player, reminder, fingerprint)) || stopped;
2205
+ }
2206
+ catch (error) {
2207
+ report(error, 'reminders.stop');
2208
+ }
2209
+ return { cancelled: reminder.firedAt === undefined || stopped };
2210
+ }
2211
+ }
2212
+ throw busy(id);
2213
+ },
2214
+ async runScheduler(options = {}) {
2215
+ const limit = batchLimit(options.limit);
2216
+ const budgetMs = typeof options.budgetMs === 'number' && Number.isFinite(options.budgetMs) && options.budgetMs > 0
2217
+ ? options.budgetMs
2218
+ : defaultSchedulerBudgetMs;
2219
+ const elapsed = input.elapsedMs ?? (() => globalThis.performance.now());
2220
+ const startedAt = elapsed();
2221
+ const touched = new Map();
2222
+ // One pace for the whole run: rows taken in an earlier round count.
2223
+ const pace = paceBudgets();
2224
+ let failed = 0;
2225
+ for (;;) {
2226
+ let more;
2227
+ let progressed;
2228
+ try {
2229
+ const entries = await runScheduleEntries(limit, touched);
2230
+ failed += entries.failed;
2231
+ const dispatched = await dispatchDueRows({ limit }, pace);
2232
+ failed += dispatched.failed;
2233
+ for (const result of dispatched.results) {
2234
+ touched.set(result.sendKey, result.outcome);
2235
+ }
2236
+ // Another round only while one may be due (a batch came back
2237
+ // full, and not only with sends the pace holds back) and this
2238
+ // round got somewhere. A schedule that cannot be read lists
2239
+ // nothing, so its batch is never full.
2240
+ more = entries.listed === limit
2241
+ || (dispatched.listed === limit && dispatched.paced < dispatched.listed);
2242
+ progressed = entries.handled > 0 || dispatched.results.length > 0;
2243
+ }
2244
+ catch (error) {
2245
+ // A round that fails keeps what earlier rounds did.
2246
+ failed += 1;
2247
+ report(error, 'scheduler.round');
2248
+ break;
2249
+ }
2250
+ if (!more || !progressed || elapsed() - startedAt >= budgetMs) {
2251
+ break;
2252
+ }
2253
+ }
2254
+ return {
2255
+ results: [...touched].map(([sendKey, outcome]) => ({ sendKey, outcome })),
2256
+ failed,
2257
+ };
2258
+ },
2259
+ async purge() {
2260
+ const at = nowMs();
2261
+ return store.purge({
2262
+ sendsChangedBefore: formatNotificationInstant(at - keyRetentionMs),
2263
+ historyBefore: formatNotificationInstant(at - historyRetentionMs),
2264
+ invalidEndpointsBefore: formatNotificationInstant(at - endpointPurgeDelayMs),
2265
+ staleEndpointsBefore: formatNotificationInstant(at - endpointStaleMs - endpointPurgeDelayMs),
2266
+ sessionEndpointsBefore: formatNotificationInstant(at),
2267
+ });
2268
+ },
2269
+ };
2270
+ }
2271
+ /**
2272
+ * Why a reminder's `link` cannot reach the player through `provider`, or
2273
+ * `undefined`. A provider whose entry needs a link (Devvit) needs the
2274
+ * reminder's or the topic mapping's, as the gate does at send time
2275
+ * (`link-missing`), and a given link must match the provider's mapping rule
2276
+ * for `link`, so a bad one is refused when it is set rather than spent when
2277
+ * it fires.
2278
+ */
2279
+ function reminderLinkIssue(provider, topic, link) {
2280
+ if (link !== undefined) {
2281
+ const pattern = provider.validation.mapping['link']?.pattern;
2282
+ if (pattern !== undefined) {
2283
+ let rule;
2284
+ try {
2285
+ rule = new RegExp(pattern, 'u');
2286
+ }
2287
+ catch {
2288
+ return undefined;
2289
+ }
2290
+ if (!rule.test(link)) {
2291
+ return `link does not match provider ${provider.entry.provider}'s link rule.`;
2292
+ }
2293
+ }
2294
+ return undefined;
2295
+ }
2296
+ if (provider.entry.link && typeof topic.mapping?.['link'] !== 'string') {
2297
+ return `provider ${provider.entry.provider} sends need a link, and neither the reminder nor the topic mapping has one.`;
2298
+ }
2299
+ return undefined;
2300
+ }
2301
+ /** A scheduler batch: clamped to 1 to 1000; anything that is not a number is the default, 100. */
2302
+ function batchLimit(limit) {
2303
+ const asked = typeof limit === 'number' && Number.isFinite(limit) ? Math.floor(limit) : 100;
2304
+ return Math.min(Math.max(asked, 1), 1000);
2305
+ }
2306
+ /**
2307
+ * A due row taken for the queue: queued (in flight, no holder) for one claim
2308
+ * lease. An attempted row stays attempted, and its lease ends before its
2309
+ * replay window does, so the take alone never abandons it.
2310
+ */
2311
+ function takenRow(row, at, leaseMs) {
2312
+ const leaseUntil = at + leaseMs;
2313
+ if (row.state.status !== 'in-flight' || !row.state.attempted) {
2314
+ return {
2315
+ ...row,
2316
+ state: {
2317
+ status: 'in-flight',
2318
+ leaseUntil: formatNotificationInstant(leaseUntil),
2319
+ attempted: false,
2320
+ },
2321
+ };
2322
+ }
2323
+ const windowEnd = row.firstAttemptedAt === undefined
2324
+ ? Number.POSITIVE_INFINITY
2325
+ : Date.parse(row.firstAttemptedAt) + ambiguousReplayMs - 1;
2326
+ return {
2327
+ ...row,
2328
+ state: {
2329
+ status: 'in-flight',
2330
+ leaseUntil: formatNotificationInstant(windowEnd > at ? Math.min(leaseUntil, windowEnd) : leaseUntil),
2331
+ attempted: true,
2332
+ ...(row.state.attemptedAt === undefined ? {} : { attemptedAt: row.state.attemptedAt }),
2333
+ },
2334
+ };
2335
+ }
2336
+ /** A well-formed server reminder, with `fireAt` in the canonical form, or `NOTIFICATIONS_INVALID_INPUT`. */
2337
+ function reminderOf(raw) {
2338
+ if (!isPlainObject(raw)) {
2339
+ throw invalidInput('A reminder is an object.');
2340
+ }
2341
+ const known = new Set(['key', 'topic', 'fireAt', 'templateData', 'link']);
2342
+ const unknown = Object.keys(raw).find((name) => !known.has(name));
2343
+ const keyIssue = typeof raw.key === 'string'
2344
+ ? notificationKeyIssue(raw.key)
2345
+ : 'expected a string';
2346
+ const dataIssue = raw.templateData === undefined
2347
+ ? undefined
2348
+ : notificationTemplateDataIssue(raw.templateData);
2349
+ let issue;
2350
+ if (unknown !== undefined) {
2351
+ issue = `${unknown}: not a reminder field`;
2352
+ }
2353
+ else if (keyIssue !== undefined) {
2354
+ issue = `key: ${keyIssue}`;
2355
+ }
2356
+ else if (!isBoundedText(raw.topic)) {
2357
+ issue = 'topic: expected a non-empty string';
2358
+ }
2359
+ else if (typeof raw.fireAt !== 'string' || raw.fireAt.length > 64 || !isNotificationTimestamp(raw.fireAt)) {
2360
+ issue = 'fireAt: expected RFC 3339 with an explicit offset';
2361
+ }
2362
+ else if (dataIssue !== undefined) {
2363
+ issue = `templateData: ${dataIssue}`;
2364
+ }
2365
+ else if (raw.link !== undefined && !isBoundedText(raw.link)) {
2366
+ issue = 'link: expected a non-empty string';
2367
+ }
2368
+ if (issue !== undefined) {
2369
+ throw invalidInput(`Invalid reminder: ${issue}.`);
2370
+ }
2371
+ const input = raw;
2372
+ return {
2373
+ kind: 'reminder',
2374
+ key: input.key,
2375
+ topic: input.topic,
2376
+ fireAt: normalizeNotificationTimestamp(input.fireAt),
2377
+ ...(input.templateData === undefined ? {} : { templateData: input.templateData }),
2378
+ ...(input.link === undefined ? {} : { link: input.link }),
2379
+ };
2380
+ }
2381
+ function noEndpoint() {
2382
+ return { status: 'unavailable', reason: 'no-endpoint' };
2383
+ }
2384
+ function invalidInput(message, cause) {
2385
+ return new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2386
+ message,
2387
+ ...(cause === undefined ? {} : { cause }),
2388
+ });
2389
+ }
2390
+ function busy(key) {
2391
+ return new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
2392
+ message: `The delivery ledger stayed busy for key ${key}; retry the send.`,
2393
+ });
2394
+ }
2395
+ function sessionOf(session) {
2396
+ if (!isPlainObject(session)
2397
+ || !isBoundedText(session.player)
2398
+ || !isBoundedText(session.sessionId)
2399
+ || !isBoundedText(session.installationId)
2400
+ || (session.expiresAt !== undefined
2401
+ && (typeof session.expiresAt !== 'string' || !isNotificationTimestamp(session.expiresAt)))) {
2402
+ throw new TypeError('A notifications session names its player, session id, installation id, and an optional RFC 3339 expiresAt.');
2403
+ }
2404
+ return session;
2405
+ }
2406
+ /** A well-formed send request, or `NOTIFICATIONS_INVALID_INPUT`. */
2407
+ function sendRequestOf(raw) {
2408
+ if (!isPlainObject(raw)) {
2409
+ throw invalidInput('A send is an object.');
2410
+ }
2411
+ const known = new Set([
2412
+ 'idempotencyKey',
2413
+ 'player',
2414
+ 'topic',
2415
+ 'provider',
2416
+ 'templateData',
2417
+ 'data',
2418
+ 'locale',
2419
+ 'link',
2420
+ ]);
2421
+ const unknown = Object.keys(raw).find((key) => !known.has(key));
2422
+ const issue = unknown !== undefined ? `${unknown}: not a send field` : sendInputIssue(raw);
2423
+ if (issue !== undefined) {
2424
+ throw invalidInput(`Invalid send: ${issue}.`);
2425
+ }
2426
+ const input = raw;
2427
+ return {
2428
+ idempotencyKey: input.idempotencyKey,
2429
+ player: input.player,
2430
+ topic: input.topic,
2431
+ ...(input.provider === undefined ? {} : { provider: input.provider }),
2432
+ ...(input.templateData === undefined ? {} : { templateData: input.templateData }),
2433
+ ...(input.data === undefined ? {} : { data: input.data }),
2434
+ ...(input.locale === undefined ? {} : { locale: input.locale }),
2435
+ ...(input.link === undefined ? {} : { link: input.link }),
2436
+ };
2437
+ }
2438
+ function sendInputIssue(request) {
2439
+ if (typeof request.idempotencyKey !== 'string') {
2440
+ return 'idempotencyKey: expected a string';
2441
+ }
2442
+ const keyIssue = notificationKeyIssue(request.idempotencyKey);
2443
+ if (keyIssue !== undefined) {
2444
+ return `idempotencyKey: ${keyIssue}`;
2445
+ }
2446
+ const reserved = notificationReservedKeyPrefix(request.idempotencyKey);
2447
+ if (reserved !== undefined) {
2448
+ return `idempotencyKey: the ${reserved} prefix is reserved for the scheduler`;
2449
+ }
2450
+ for (const name of ['player', 'topic']) {
2451
+ if (!isBoundedText(request[name])) {
2452
+ return `${name}: expected a non-empty string`;
2453
+ }
2454
+ }
2455
+ if (request.templateData !== undefined) {
2456
+ const issue = notificationTemplateDataIssue(request.templateData);
2457
+ if (issue !== undefined) {
2458
+ return `templateData: ${issue}`;
2459
+ }
2460
+ }
2461
+ if (request.data !== undefined) {
2462
+ const issue = notificationTemplateDataIssue(request.data, { stringsOnly: true });
2463
+ if (issue !== undefined) {
2464
+ return `data: ${issue}`;
2465
+ }
2466
+ // An open carries its topic and route in these keys; routing data never overrides them.
2467
+ const reserved = Object.keys(request.data).find((key) => key === 'topic' || key === 'route');
2468
+ if (reserved !== undefined) {
2469
+ return `data: the key ${reserved} is reserved for the open's own ${reserved}`;
2470
+ }
2471
+ }
2472
+ for (const name of ['provider', 'locale', 'link']) {
2473
+ if (request[name] !== undefined && !isBoundedText(request[name])) {
2474
+ return `${name}: expected a non-empty string`;
2475
+ }
2476
+ }
2477
+ return undefined;
2478
+ }
2479
+ function isBoundedText(value, maxLength = maxTextLength) {
2480
+ return typeof value === 'string'
2481
+ && value.length > 0
2482
+ && value.length <= maxLength
2483
+ && !hasControlCharacter(value);
2484
+ }
2485
+ function hasControlCharacter(value) {
2486
+ return /[\u0000-\u001f\u007f]/u.test(value);
2487
+ }
2488
+ function isPlainObject(value) {
2489
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
2490
+ }
2491
+ function compareText(left, right) {
2492
+ if (left === right) {
2493
+ return 0;
2494
+ }
2495
+ return left < right ? -1 : 1;
2496
+ }
2497
+ /** Seconds a row waits after its holder failed before the provider call. */
2498
+ const releaseBackoffMs = 60 * secondMs;
2499
+ /**
2500
+ * The gate's history for a recipient on `provider`: each send once, on
2501
+ * `provider` when it reached it there, else at its earliest entry, oldest
2502
+ * first.
2503
+ */
2504
+ function historyOn(history, provider) {
2505
+ return [...history.values()]
2506
+ .map((entries) => entries.find((entry) => entry.provider === provider)
2507
+ ?? [...entries].sort((left, right) => Date.parse(left.at) - Date.parse(right.at))[0])
2508
+ .filter((entry) => entry !== undefined)
2509
+ .sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
2510
+ }
2511
+ /**
2512
+ * The row as it was before a claim, due no sooner than a short backoff from
2513
+ * `at`, but never so late that an ambiguous row's replay window closes on
2514
+ * the backoff alone.
2515
+ */
2516
+ function backedOff(row, at) {
2517
+ const notBefore = at + releaseBackoffMs;
2518
+ if (row.state.status === 'deferred') {
2519
+ return {
2520
+ ...row,
2521
+ state: {
2522
+ ...row.state,
2523
+ until: formatNotificationInstant(Math.max(Date.parse(row.state.until), notBefore)),
2524
+ },
2525
+ };
2526
+ }
2527
+ if (row.state.status === 'in-flight') {
2528
+ // An attempted row's replays end a day after its first attempt; the
2529
+ // backoff does not close that window early, unless it already closed.
2530
+ const windowEnd = row.state.attempted && row.firstAttemptedAt !== undefined
2531
+ ? Date.parse(row.firstAttemptedAt) + ambiguousReplayMs - 1
2532
+ : Number.POSITIVE_INFINITY;
2533
+ const until = windowEnd > at ? Math.min(notBefore, windowEnd) : notBefore;
2534
+ return {
2535
+ ...row,
2536
+ state: {
2537
+ ...row.state,
2538
+ leaseUntil: formatNotificationInstant(Math.max(Date.parse(row.state.leaseUntil), until)),
2539
+ },
2540
+ };
2541
+ }
2542
+ return row;
2543
+ }
2544
+ /**
2545
+ * The gate's refusals from the steps before the permission step
2546
+ * (`evaluateNotificationDelivery`: topic, channel, purpose, recipient,
2547
+ * link), which a send-time permission read cannot change. Typed by the
2548
+ * gate's reason unions, so a misspelt reason does not compile; a gate that
2549
+ * moved the permission step earlier must update this list (the
2550
+ * permission-order test in service.test.ts covers both sides).
2551
+ */
2552
+ const earlyGateReasons = new Set(['topic-unknown', 'channel-unsupported', 'purpose-unsupported', 'no-endpoint', 'link-missing']);
2553
+ /** Writes that must not fail a settled delivery are tried this often. */
2554
+ const maxSideWriteAttempts = 3;
2555
+ /** Runs `write` up to `maxSideWriteAttempts` times with a short pause; reports only the last failure and never throws. */
2556
+ async function withRetries(write, onFailure) {
2557
+ for (let attempt = 0; attempt < maxSideWriteAttempts; attempt += 1) {
2558
+ try {
2559
+ await write();
2560
+ return;
2561
+ }
2562
+ catch (error) {
2563
+ if (attempt === maxSideWriteAttempts - 1) {
2564
+ onFailure(error);
2565
+ }
2566
+ else {
2567
+ await pause(25 * (attempt + 1));
2568
+ }
2569
+ }
2570
+ }
2571
+ }
2572
+ function pause(ms) {
2573
+ return new Promise((resolve) => {
2574
+ setTimeout(resolve, ms);
2575
+ });
2576
+ }
2577
+ /** A provider's answer when it is one of the five kinds, else `undefined`. */
2578
+ function deliveryAnswerOf(answer) {
2579
+ if (!isPlainObject(answer)) {
2580
+ return undefined;
2581
+ }
2582
+ switch (answer.status) {
2583
+ case 'accepted':
2584
+ return typeof answer.providerMessageId === 'string'
2585
+ ? { status: 'accepted', providerMessageId: answer.providerMessageId }
2586
+ : { status: 'accepted' };
2587
+ case 'unregistered':
2588
+ return { status: 'unregistered' };
2589
+ case 'rate-limited':
2590
+ return typeof answer.retryAfterSeconds === 'number'
2591
+ ? { status: 'rate-limited', retryAfterSeconds: answer.retryAfterSeconds }
2592
+ : { status: 'rate-limited' };
2593
+ case 'rejected':
2594
+ if (answer.reason !== 'configuration' && answer.reason !== 'recipient' && answer.reason !== 'agreement') {
2595
+ return undefined;
2596
+ }
2597
+ return typeof answer.providerCode === 'string'
2598
+ ? {
2599
+ status: 'rejected',
2600
+ reason: answer.reason,
2601
+ providerCode: answer.providerCode.slice(0, 64),
2602
+ }
2603
+ : { status: 'rejected', reason: answer.reason };
2604
+ case 'ambiguous':
2605
+ return { status: 'ambiguous' };
2606
+ default:
2607
+ return undefined;
2608
+ }
2609
+ }