@mpgd/game-services 0.26.0 → 0.27.1

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.
@@ -16,7 +16,10 @@ import { fcmInstallationIdRefusal, } from './delivery-provider.js';
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
17
  * registers; an Android FCM notification message cannot carry actions, and
18
18
  * the Capacitor shell does not render advertising natively yet. An expiry
19
- * becomes `android.ttl` and the `apns-expiration` header.
19
+ * becomes `android.ttl` and the `apns-expiration` header. A replay of the
20
+ * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
+ * crash after HTTP 200, before the ledger write, does not show a second
22
+ * notification.
20
23
  */
21
24
  export const fcmMessagingScope = 'https://www.googleapis.com/auth/firebase.messaging';
22
25
  export const fcmTokenEndpoint = 'https://oauth2.googleapis.com/token';
@@ -37,6 +40,11 @@ export const fcmAdvertisingCategory = 'mpgd-advertising';
37
40
  * not refused for its size.
38
41
  */
39
42
  const maxPayloadBytes = 3840;
43
+ /**
44
+ * `android.collapse_key` and `apns-collapse-id` are at most 64 bytes. The
45
+ * id is the SHA-256 of the send key, which is 64 hex characters.
46
+ */
47
+ export const fcmCollapseIdBytes = 64;
40
48
  /** The longest `Retry-After` this provider passes on; the service gives up after a day anyway. */
41
49
  const maxRetryAfterSeconds = 86_400;
42
50
  /** How long a refused grant or an unusable key answers without asking Google again. */
@@ -307,22 +315,28 @@ export function buildFcmMessage(request) {
307
315
  code: 'MESSAGE_EXPIRED',
308
316
  };
309
317
  }
318
+ // Same id on both transports: a replay of this send replaces the first
319
+ // instead of showing beside it. Collapse is per token, so two devices of
320
+ // one send do not hide each other.
321
+ const collapseId = fcmCollapseId(request.sendKey);
310
322
  const android = {
323
+ collapse_key: collapseId,
311
324
  ...(expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` }),
312
325
  notification: { channel_id: channelId },
313
326
  };
314
327
  const category = request.topic.purpose === 'advertising' ? fcmAdvertisingCategory : undefined;
315
328
  const apns = {
316
- ...(expiresAt === undefined
317
- ? {}
318
- : { headers: { 'apns-expiration': String(Math.floor(expiresAt / 1000)) } }),
329
+ headers: {
330
+ 'apns-collapse-id': collapseId,
331
+ ...(expiresAt === undefined ? {} : { 'apns-expiration': String(Math.floor(expiresAt / 1000)) }),
332
+ },
319
333
  ...(category === undefined ? {} : { payload: { aps: { category } } }),
320
334
  };
321
335
  const payload = {
322
336
  notification,
323
337
  data: { ...data },
324
338
  android,
325
- ...(Object.keys(apns).length === 0 ? {} : { apns }),
339
+ apns,
326
340
  };
327
341
  // Everything but the token counts toward the size FCM and APNs carry.
328
342
  const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)).length;
@@ -600,3 +614,91 @@ function retryAfterOf(response, now) {
600
614
  function isRecord(value) {
601
615
  return typeof value === 'object' && value !== null && !Array.isArray(value);
602
616
  }
617
+ /**
618
+ * Collapse id for one send key: SHA-256 hex, exactly 64 bytes, so it fits
619
+ * `android.collapse_key` and `apns-collapse-id`. Sync because
620
+ * `buildFcmMessage` is sync; Workers have no sync WebCrypto digest.
621
+ */
622
+ export function fcmCollapseId(sendKey) {
623
+ const hex = sha256Hex(new TextEncoder().encode(sendKey));
624
+ return hex.length === fcmCollapseIdBytes ? hex : hex.slice(0, fcmCollapseIdBytes);
625
+ }
626
+ const sha256K = new Uint32Array([
627
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
628
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
629
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
630
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
631
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
632
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
633
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
634
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
635
+ ]);
636
+ function rotr(value, bits) {
637
+ return (value >>> bits) | (value << (32 - bits));
638
+ }
639
+ function sha256Hex(data) {
640
+ const bitLength = data.length * 8;
641
+ const paddedLength = (Math.floor((data.length + 8) / 64) + 1) * 64;
642
+ const padded = new Uint8Array(paddedLength);
643
+ padded.set(data);
644
+ padded[data.length] = 0x80;
645
+ const view = new DataView(padded.buffer);
646
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000));
647
+ view.setUint32(paddedLength - 4, bitLength >>> 0);
648
+ let h0 = 0x6a09e667;
649
+ let h1 = 0xbb67ae85;
650
+ let h2 = 0x3c6ef372;
651
+ let h3 = 0xa54ff53a;
652
+ let h4 = 0x510e527f;
653
+ let h5 = 0x9b05688c;
654
+ let h6 = 0x1f83d9ab;
655
+ let h7 = 0x5be0cd19;
656
+ const words = new Uint32Array(64);
657
+ for (let offset = 0; offset < paddedLength; offset += 64) {
658
+ for (let index = 0; index < 16; index += 1) {
659
+ words[index] = view.getUint32(offset + index * 4);
660
+ }
661
+ for (let index = 16; index < 64; index += 1) {
662
+ const earlier = words[index - 15] ?? 0;
663
+ const recent = words[index - 2] ?? 0;
664
+ const s0 = rotr(earlier, 7) ^ rotr(earlier, 18) ^ (earlier >>> 3);
665
+ const s1 = rotr(recent, 17) ^ rotr(recent, 19) ^ (recent >>> 10);
666
+ words[index] = ((words[index - 16] ?? 0) + s0 + (words[index - 7] ?? 0) + s1) >>> 0;
667
+ }
668
+ let a = h0;
669
+ let b = h1;
670
+ let c = h2;
671
+ let d = h3;
672
+ let e = h4;
673
+ let f = h5;
674
+ let g = h6;
675
+ let h = h7;
676
+ for (let index = 0; index < 64; index += 1) {
677
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
678
+ const ch = (e & f) ^ (~e & g);
679
+ const temp1 = (h + s1 + ch + (sha256K[index] ?? 0) + (words[index] ?? 0)) >>> 0;
680
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
681
+ const maj = (a & b) ^ (a & c) ^ (b & c);
682
+ const temp2 = (s0 + maj) >>> 0;
683
+ h = g;
684
+ g = f;
685
+ f = e;
686
+ e = (d + temp1) >>> 0;
687
+ d = c;
688
+ c = b;
689
+ b = a;
690
+ a = (temp1 + temp2) >>> 0;
691
+ }
692
+ h0 = (h0 + a) >>> 0;
693
+ h1 = (h1 + b) >>> 0;
694
+ h2 = (h2 + c) >>> 0;
695
+ h3 = (h3 + d) >>> 0;
696
+ h4 = (h4 + e) >>> 0;
697
+ h5 = (h5 + f) >>> 0;
698
+ h6 = (h6 + g) >>> 0;
699
+ h7 = (h7 + h) >>> 0;
700
+ }
701
+ return [h0, h1, h2, h3, h4, h5, h6, h7]
702
+ .map((word) => word.toString(16).padStart(8, '0'))
703
+ .join('');
704
+ }
@@ -179,7 +179,10 @@ export interface NotificationService {
179
179
  * each endpoint reported, or the host read, for any provider the player
180
180
  * can be reached on; a refusal stores nothing. Otherwise the reminder with
181
181
  * this key is stored or replaced, and a send of the previous one that
182
- * still waits (deferred or in flight) is stopped.
182
+ * still waits (deferred, or in flight with no live claim) is stopped. A
183
+ * row a holder has claimed is left so an acceptance the provider already
184
+ * took can be recorded; that holder does not call the provider if the
185
+ * reminder was cancelled or replaced before the call.
183
186
  */
184
187
  setReminder(session: NotificationSession, input: unknown): Promise<{
185
188
  readonly status: NotificationReminderSetStatus;
@@ -1,4 +1,4 @@
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';
1
+ import { assertNotificationConsentRecord, assertNotificationDeployment, assertNotificationEndpointRegistration, assertRecordNotificationNoticeInput, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, fillNotificationTemplate, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, latestNotificationConsent, listNotificationNotices, normalizeNotificationTimestamp, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, toSeoulCalendarDate, } from '@mpgd/platform/notifications';
2
2
  import { fcmInstallationIdRefusal, notificationExpiredProviderCode, } from './delivery-provider.js';
3
3
  import { notificationHistoryRetentionDays, } from './registry.js';
4
4
  import { bestNotificationOutcome, formatNotificationInstant, isNotificationRowAbandoned, isNotificationRowDue, notificationAmbiguousReplayHours, notificationSendOutcome, } from './store.js';
@@ -525,6 +525,8 @@ export function createNotificationService(input) {
525
525
  const perProvider = new Map();
526
526
  const rows = [];
527
527
  const refusals = [];
528
+ let checked = 0;
529
+ let hostFailure;
528
530
  for (const recipient of recipients) {
529
531
  const id = recipient.provider.entry.provider;
530
532
  let inputs = perProvider.get(id);
@@ -535,8 +537,23 @@ export function createNotificationService(input) {
535
537
  const held = inputs;
536
538
  const evaluate = (permission) => decide(request, origin, recipient, consent, held.history, permission, at);
537
539
  // 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
+ // A host that cannot answer is skipped; the send fails only when no
541
+ // provider could be checked and none passed.
542
+ let decision;
543
+ try {
544
+ const early = earlyRefusal(recipient.provider, evaluate);
545
+ decision = early === undefined
546
+ ? evaluate(await (held.permission ??= readServerPermission(recipient.provider, request))).decision
547
+ : early.decision;
548
+ checked += 1;
549
+ }
550
+ catch (error) {
551
+ if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE') {
552
+ hostFailure ??= error;
553
+ continue;
554
+ }
555
+ throw error;
556
+ }
540
557
  const row = {
541
558
  key: recipient.rowKey,
542
559
  provider: id,
@@ -562,6 +579,9 @@ export function createNotificationService(input) {
562
579
  refusals.push(decision);
563
580
  }
564
581
  }
582
+ if (checked === 0 && rows.length === 0 && hostFailure !== undefined) {
583
+ throw hostFailure;
584
+ }
565
585
  return { rows, refusals };
566
586
  };
567
587
  const eligible = (mode, row, at) => {
@@ -630,9 +650,19 @@ export function createNotificationService(input) {
630
650
  }
631
651
  throw busy(sendKey);
632
652
  };
653
+ /**
654
+ * True when a reminder send must not reach the provider: its schedule entry
655
+ * is gone, or names another time or content. Assigned once the reminder
656
+ * helpers below exist; `runRow` only calls it after this factory returns.
657
+ * A schedule read that fails counts as stopped, so a cancel is never
658
+ * outlived by a replay.
659
+ */
660
+ let sendWasStopped = async () => false;
633
661
  /**
634
662
  * Claims one row, runs the gate for it immediately before the provider
635
663
  * call, and stores the answer. Answers the row's refusal, if any.
664
+ * A notice the game recorded since the claim, or a reminder cancelled or
665
+ * replaced since the claim, is dropped instead of sent.
636
666
  */
637
667
  const runRow = async (sendKey, provider, rowKey, mode) => {
638
668
  let claimed;
@@ -646,15 +676,20 @@ export function createNotificationService(input) {
646
676
  if (held.send.origin.kind === 'notice'
647
677
  && !await noticeListed(held.send.request.player, held.send.origin.notice, at)) {
648
678
  // The game gave the notice while the push waited: it is not sent again.
679
+ // A lost compare-and-set is retried; a row another holder claimed is
680
+ // left for that holder, which checks again immediately before the call.
649
681
  const rows = rowsWith(held.send, row, { kind: 'remove' });
650
- await store.updateSend({
682
+ const dropped = await store.updateSend({
651
683
  key: sendKey,
652
684
  version: held.version,
653
685
  send: rows.length === 0
654
686
  ? undefined
655
687
  : { ...held.send, rows, changedAt: formatNotificationInstant(at) },
656
688
  });
657
- return {};
689
+ if (dropped) {
690
+ return {};
691
+ }
692
+ continue;
658
693
  }
659
694
  const token = newToken();
660
695
  // The replay window opens at the first claim that may reach the
@@ -746,6 +781,15 @@ export function createNotificationService(input) {
746
781
  await complete(sendKey, row, token, { kind: 'remove' }, at);
747
782
  return { refusal: decision.status === 'allowed' ? noEndpoint() : decision };
748
783
  }
784
+ if ((send.origin.kind === 'notice'
785
+ && !await noticeListed(send.request.player, send.origin.notice, nowMs()))
786
+ || (send.origin.kind === 'reminder' && await sendWasStopped(send))) {
787
+ // Recorded, cancelled, or replaced while this claim resolved the
788
+ // recipient. Drop while the claim still holds; a lost compare-and-set
789
+ // is retried until it wins or the claim is gone.
790
+ await complete(sendKey, row, token, { kind: 'remove' }, nowMs());
791
+ return {};
792
+ }
749
793
  const answer = await callProvider(recipient, send.request, topic, decision, at);
750
794
  const answeredAt = nowMs();
751
795
  switch (answer.status) {
@@ -774,7 +818,9 @@ export function createNotificationService(input) {
774
818
  // The replay window started at the claim that reached the provider (or an earlier one).
775
819
  const firstAttemptedAt = row.firstAttemptedAt ?? formatNotificationInstant(at);
776
820
  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 });
821
+ report(new Error(answer.providerCode === undefined
822
+ ? 'Ambiguous answers for a day: the delivery stays in flight and is not replayed again.'
823
+ : `Ambiguous answers for a day (${answer.providerCode}): the delivery stays in flight and is not replayed again.`), 'dispatch.abandoned', { sendKey, provider: row.provider, rowKey: row.key });
778
824
  }
779
825
  await settleAfterCall(sendKey, row, token, {
780
826
  kind: 'set',
@@ -1208,12 +1254,17 @@ export function createNotificationService(input) {
1208
1254
  });
1209
1255
  /**
1210
1256
  * 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.
1257
+ * (deferred, queued, or in flight with no live claim) are dropped, and a
1258
+ * send the scheduler is still making loses its key, so no row of it is
1259
+ * ever stored. A row whose claim lease is still live is left: its holder
1260
+ * may already have called the provider, and deleting the row makes
1261
+ * `complete` a no-op, which drops the idempotency key and lets the same
1262
+ * key and time send again. The holder drops the row itself if the reminder
1263
+ * was cancelled or replaced before the call. Only a send of `fingerprint`
1264
+ * (the reminder's own content) is stopped, since the send key names only
1265
+ * the key and time: a send of other content at the key belongs to the
1266
+ * reminder that replaced this one. A send the provider may already have
1267
+ * accepted cannot be recalled. Answers whether anything waited.
1217
1268
  */
1218
1269
  const stopReminderSend = async (player, reminder, fingerprint) => {
1219
1270
  let key;
@@ -1224,24 +1275,50 @@ export function createNotificationService(input) {
1224
1275
  return false;
1225
1276
  }
1226
1277
  for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
1278
+ const at = nowMs();
1227
1279
  const held = await store.getSend(key);
1228
1280
  if (held === undefined || held.send.origin.kind !== 'reminder' || held.send.fingerprint !== fingerprint) {
1229
1281
  return false;
1230
1282
  }
1231
1283
  const { send } = held;
1232
- const kept = send.rows.filter((row) => row.state.status === 'delivered');
1284
+ const kept = send.rows.filter((row) => row.state.status === 'delivered' || liveClaim(row, at));
1233
1285
  if (send.rows.length > 0 && kept.length === send.rows.length) {
1234
1286
  return false;
1235
1287
  }
1236
1288
  const next = kept.length === 0
1237
1289
  ? undefined
1238
- : { ...send, rows: kept, changedAt: formatNotificationInstant(nowMs()) };
1290
+ : { ...send, rows: kept, changedAt: formatNotificationInstant(at) };
1239
1291
  if (await store.updateSend({ key, version: held.version, send: next })) {
1240
1292
  return true;
1241
1293
  }
1242
1294
  }
1243
1295
  throw busy(key);
1244
1296
  };
1297
+ sendWasStopped = async (send) => {
1298
+ if (send.origin.kind !== 'reminder') {
1299
+ return false;
1300
+ }
1301
+ try {
1302
+ const held = await store.getScheduleEntry({
1303
+ player: send.request.player,
1304
+ id: reminderEntryId(send.origin.key),
1305
+ });
1306
+ const item = held?.entry.item;
1307
+ if (item?.kind !== 'reminder') {
1308
+ return true;
1309
+ }
1310
+ const sameTime = createNotificationReminderIdempotencyKey({
1311
+ player: send.request.player,
1312
+ key: send.origin.key,
1313
+ fireAt: item.fireAt,
1314
+ }) === send.key;
1315
+ return !sameTime || reminderFingerprint(send.request.player, item) !== send.fingerprint;
1316
+ }
1317
+ catch (error) {
1318
+ report(error, 'scheduler.reread', send.key);
1319
+ return true;
1320
+ }
1321
+ };
1245
1322
  /** Moves an entry's due time later after a failure, so a broken item does not fill every run. */
1246
1323
  const deferEntry = async (held, at) => {
1247
1324
  try {
@@ -1319,11 +1396,17 @@ export function createNotificationService(input) {
1319
1396
  await stopReminderSend(player, reminder, made.existing.fingerprint);
1320
1397
  made = await createItemSend(request, { kind: 'reminder', key: reminder.key }, at);
1321
1398
  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;
1399
+ // A delivered row keeps the key: this reminder cannot have its own
1400
+ // under the same key and time. A merely live claim does not. The
1401
+ // holder drops that row if the reminder was replaced before the
1402
+ // provider call, and marking this one fired would spend it with no
1403
+ // delivery. It stays pending until the claim settles or is dropped.
1404
+ if (made.existing.rows.some((row) => row.state.status === 'delivered')) {
1405
+ report(new Error("A delivered send of another reminder holds this reminder's key and time; it is not sent."), 'scheduler.reminder', key);
1406
+ await markFired();
1407
+ return undefined;
1408
+ }
1409
+ return 'busy';
1327
1410
  }
1328
1411
  }
1329
1412
  if (made === 'busy') {
@@ -1729,8 +1812,9 @@ export function createNotificationService(input) {
1729
1812
  else {
1730
1813
  const result = await fireReminder(held, item);
1731
1814
  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
1815
+ // Another run holds its send, or a live claim of other content
1816
+ // still occupies the key: it waits a minute, so the next round
1817
+ // does not list it again, and it is not progress. The run
1734
1818
  // holding the send then loses its mark to this version, re-reads
1735
1819
  // the same reminder, and keeps its rows; the next run marks it.
1736
1820
  await deferEntry(held, nowMs());
@@ -2081,12 +2165,26 @@ export function createNotificationService(input) {
2081
2165
  if (reminder.templateData !== undefined && hostTemplate) {
2082
2166
  throw invalidInput('A host-template provider keeps template context on the server; a reminder takes no templateData.');
2083
2167
  }
2168
+ // An uncompilable link rule is the deployment's. Every provider that
2169
+ // would deliver the topic is checked, not only until the first
2170
+ // candidate passes: registry order, or a player not reached on that
2171
+ // provider yet, must not schedule a reminder the deployment cannot take.
2172
+ if (reminder.link !== undefined) {
2173
+ for (const provider of declaring) {
2174
+ const configuration = reminderLinkRuleConfigurationIssue(provider);
2175
+ if (configuration !== undefined) {
2176
+ throw invalidInput(`Invalid reminder: ${configuration}`);
2177
+ }
2178
+ }
2179
+ }
2084
2180
  // Gate steps 3 to 5 at set time: one candidate that passes is enough.
2085
2181
  const consent = await store.listConsent(player);
2086
2182
  const permissions = new Map();
2087
2183
  let refusal;
2088
2184
  let passed = false;
2089
2185
  let mapped = false;
2186
+ let evaluated = false;
2187
+ let hostFailure;
2090
2188
  let linkRefusal;
2091
2189
  for (const recipient of candidates) {
2092
2190
  const topic = topicFor(recipient.provider, recipient.target, reminder.topic, origin);
@@ -2096,9 +2194,14 @@ export function createNotificationService(input) {
2096
2194
  mapped = true;
2097
2195
  // The gate's link step, at set time: a provider whose sends need a
2098
2196
  // link takes the reminder's (by its mapping rule) or the mapping's.
2197
+ // A link rule that does not compile is the deployment's, and fails
2198
+ // the set even when another provider would pass.
2099
2199
  const linkIssue = reminderLinkIssue(recipient.provider, topic, reminder.link);
2100
2200
  if (linkIssue !== undefined) {
2101
- linkRefusal ??= linkIssue;
2201
+ if (linkIssue.configuration) {
2202
+ throw invalidInput(`Invalid reminder: ${linkIssue.message}`);
2203
+ }
2204
+ linkRefusal ??= linkIssue.message;
2102
2205
  continue;
2103
2206
  }
2104
2207
  const id = recipient.provider.entry.provider;
@@ -2107,7 +2210,18 @@ export function createNotificationService(input) {
2107
2210
  permission = readServerPermission(recipient.provider, request);
2108
2211
  permissions.set(id, permission);
2109
2212
  }
2110
- const serverPermission = await permission;
2213
+ let serverPermission;
2214
+ try {
2215
+ serverPermission = await permission;
2216
+ evaluated = true;
2217
+ }
2218
+ catch (error) {
2219
+ if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE') {
2220
+ hostFailure ??= error;
2221
+ continue;
2222
+ }
2223
+ throw error;
2224
+ }
2111
2225
  const refused = evaluateNotificationReminderConsent({
2112
2226
  entry: recipient.provider.entry,
2113
2227
  topic,
@@ -2131,9 +2245,15 @@ export function createNotificationService(input) {
2131
2245
  if (linkRefusal !== undefined) {
2132
2246
  throw invalidInput(`Invalid reminder: ${linkRefusal}`);
2133
2247
  }
2248
+ if (!evaluated && hostFailure !== undefined) {
2249
+ throw hostFailure;
2250
+ }
2134
2251
  // Mapped for none of the player's targets (or none reached): nothing could deliver it.
2135
2252
  return { status: mapped ? 'permission-required' : 'unavailable' };
2136
2253
  }
2254
+ // A placeholder the fire would throw on is refused now, before the
2255
+ // previous reminder's send is stopped or this one is written.
2256
+ assertReminderCopyFills(declaring, reminder.topic, reminder.templateData, catalog);
2137
2257
  // One reminder per key: setting it replaces the previous one and, before
2138
2258
  // the write, stops the previous one's send that waits; a scheduler run
2139
2259
  // that fires the previous one meanwhile then fails to mark it and stops
@@ -2268,36 +2388,98 @@ export function createNotificationService(input) {
2268
2388
  },
2269
2389
  };
2270
2390
  }
2391
+ /**
2392
+ * Fills every locale copy a free-form provider would send, and a target
2393
+ * mapping's own title and body, with the reminder's template data. A
2394
+ * host-template provider skips catalog copy (the host renders that) but not
2395
+ * a mapping that supplies its own title and body: that text is what the
2396
+ * send renders, ahead of the host template. A placeholder without a value
2397
+ * is `NOTIFICATIONS_INVALID_INPUT` now, not a reminder that is stored and
2398
+ * then dropped when it fires.
2399
+ */
2400
+ function assertReminderCopyFills(providers, topicId, templateData, catalog) {
2401
+ const data = templateData ?? {};
2402
+ for (const provider of providers) {
2403
+ const hostTemplate = provider.entry.content === 'host-template';
2404
+ for (const target of provider.targets) {
2405
+ const topic = catalog.topicsFor(target).find((declared) => declared.id === topicId && declared.channels.includes('remote'));
2406
+ if (topic === undefined) {
2407
+ continue;
2408
+ }
2409
+ const mappedTitle = topic.mapping?.['title'];
2410
+ const mappedBody = topic.mapping?.['body'];
2411
+ const mapped = typeof mappedTitle === 'string' && typeof mappedBody === 'string';
2412
+ if (hostTemplate && !mapped) {
2413
+ continue;
2414
+ }
2415
+ const texts = mapped
2416
+ ? [mappedTitle, mappedBody]
2417
+ : topic.copy === undefined
2418
+ ? []
2419
+ : Object.values(topic.copy).flatMap((localeCopy) => [localeCopy.title, localeCopy.body]);
2420
+ for (const text of texts) {
2421
+ try {
2422
+ fillNotificationTemplate(text, data);
2423
+ }
2424
+ catch (error) {
2425
+ throw invalidInput(`Invalid reminder: ${describe(error)}`, error);
2426
+ }
2427
+ }
2428
+ }
2429
+ }
2430
+ }
2431
+ /** A holder still owns the row: its claim lease has not ended. */
2432
+ function liveClaim(row, at) {
2433
+ return row.state.status === 'in-flight'
2434
+ && row.state.claim !== undefined
2435
+ && Date.parse(row.state.leaseUntil) > at;
2436
+ }
2271
2437
  /**
2272
2438
  * Why a reminder's `link` cannot reach the player through `provider`, or
2273
2439
  * `undefined`. A provider whose entry needs a link (Devvit) needs the
2274
2440
  * reminder's or the topic mapping's, as the gate does at send time
2275
2441
  * (`link-missing`), and a given link must match the provider's mapping rule
2276
2442
  * for `link`, so a bad one is refused when it is set rather than spent when
2277
- * it fires.
2443
+ * it fires. A rule that does not compile is a configuration error, not a
2444
+ * pass: an uncompilable pattern must not fail open.
2278
2445
  */
2279
2446
  function reminderLinkIssue(provider, topic, link) {
2280
2447
  if (link !== undefined) {
2448
+ const configuration = reminderLinkRuleConfigurationIssue(provider);
2449
+ if (configuration !== undefined) {
2450
+ return { configuration: true, message: configuration };
2451
+ }
2281
2452
  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
- }
2453
+ if (pattern !== undefined && !new RegExp(pattern, 'u').test(link)) {
2454
+ return {
2455
+ configuration: false,
2456
+ message: `link does not match provider ${provider.entry.provider}'s link rule.`,
2457
+ };
2293
2458
  }
2294
2459
  return undefined;
2295
2460
  }
2296
2461
  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.`;
2462
+ return {
2463
+ configuration: false,
2464
+ message: `provider ${provider.entry.provider} sends need a link, and neither the reminder nor the topic mapping has one.`,
2465
+ };
2298
2466
  }
2299
2467
  return undefined;
2300
2468
  }
2469
+ /** An uncompilable link rule, or `undefined`. The pattern is the deployment's. */
2470
+ function reminderLinkRuleConfigurationIssue(provider) {
2471
+ const pattern = provider.validation.mapping['link']?.pattern;
2472
+ if (pattern === undefined) {
2473
+ return undefined;
2474
+ }
2475
+ try {
2476
+ new RegExp(pattern, 'u');
2477
+ return undefined;
2478
+ }
2479
+ catch (error) {
2480
+ return `provider ${provider.entry.provider}'s link rule does not compile (${describe(error)}).`;
2481
+ }
2482
+ }
2301
2483
  /** A scheduler batch: clamped to 1 to 1000; anything that is not a number is the default, 100. */
2302
2484
  function batchLimit(limit) {
2303
2485
  const asked = typeof limit === 'number' && Number.isFinite(limit) ? Math.floor(limit) : 100;
@@ -2602,7 +2784,9 @@ function deliveryAnswerOf(answer) {
2602
2784
  }
2603
2785
  : { status: 'rejected', reason: answer.reason };
2604
2786
  case 'ambiguous':
2605
- return { status: 'ambiguous' };
2787
+ return typeof answer.providerCode === 'string'
2788
+ ? { status: 'ambiguous', providerCode: answer.providerCode.slice(0, 64) }
2789
+ : { status: 'ambiguous' };
2606
2790
  default:
2607
2791
  return undefined;
2608
2792
  }
@@ -110,6 +110,8 @@ export async function runNotificationStoreConformance(createStore) {
110
110
  equal(Date.parse((await store.getEndpoint({ provider: 'fcm', installationId: 'i-7' }))?.sessionExpiresAt ?? ''), Date.parse(at(60_000)), 'endpoints: only the named session moves');
111
111
  equal(await store.extendSessionEndpoints({ sessionId: 'extended' }), 1, 'endpoints: a session that no longer ends clears the end');
112
112
  equal((await store.getEndpoint({ provider: 'fcm', installationId: 'i-5' }))?.sessionExpiresAt, undefined, 'endpoints: the cleared end reads back');
113
+ equal(await store.extendSessionEndpoints({ sessionId: 'extended', expiresAt: at(180_000) }), 0, 'endpoints: a cleared end stays cleared when a later end is reported');
114
+ equal((await store.getEndpoint({ provider: 'fcm', installationId: 'i-5' }))?.sessionExpiresAt, undefined, 'endpoints: extending a cleared session does not restore an end');
113
115
  await store.removeSessionEndpoints({ sessionId: 'extended' });
114
116
  await store.removeSessionEndpoints({ sessionId: 'other' });
115
117
  equal(await store.removeSessionEndpoints({ sessionId: 'shared' }), 2, 'endpoints: the session end unbinds what it registered');
@@ -203,6 +205,23 @@ export async function runNotificationStoreConformance(createStore) {
203
205
  });
204
206
  }
205
207
  deepEqual(await store.listDueSends({ at: at(100), limit: 2 }), ['k-2', 'k-4'], 'sends: due earliest first, limited');
208
+ for (const key of ['k-tie-b', 'k-tie-a']) {
209
+ await store.createSend({
210
+ ...send,
211
+ key,
212
+ request: { ...send.request, idempotencyKey: key },
213
+ rows: [{
214
+ ...send.rows[0],
215
+ key: `${key}:i-1`,
216
+ state: { status: 'deferred', reason: 'quiet-hours', until: at(5) },
217
+ }],
218
+ });
219
+ }
220
+ deepEqual(await store.listDueSends({ at: at(5), limit: 10 }), ['k-tie-a', 'k-tie-b'], 'sends: the same due time is ordered by key');
221
+ for (const key of ['k-tie-a', 'k-tie-b']) {
222
+ const held = await store.getSend(key);
223
+ ok(await store.updateSend({ key, version: held?.version ?? '', send: undefined }), 'sends: the tie-break fixtures are removed before purge');
224
+ }
206
225
  // History: one entry per send and provider, read from a time on.
207
226
  await store.addHistory({
208
227
  sendKey: 'h-1',
@@ -306,7 +306,11 @@ export interface NotificationStore {
306
306
  }): Promise<boolean>;
307
307
  /** The player's sends with at least one `in-flight` row. */
308
308
  listInFlightSends(player: string): Promise<readonly NotificationStoredSend[]>;
309
- /** Up to `limit` send keys with a row due at `at` (`notificationSendDueAt`), earliest first. */
309
+ /**
310
+ * Up to `limit` send keys with a row due at `at` (`notificationSendDueAt`),
311
+ * earliest first, then by key. Equal due times use the same order as the
312
+ * D1 store (`due_at_ms, send_key`) and a Redis sorted set's score ties.
313
+ */
310
314
  listDueSends(input: {
311
315
  readonly at: string;
312
316
  readonly limit: number;
@@ -340,7 +340,7 @@ export function createInMemoryNotificationStore() {
340
340
  const due = notificationSendDueAt(send);
341
341
  return due !== undefined && Date.parse(due) <= now ? [{ key: send.key, due: Date.parse(due) }] : [];
342
342
  })
343
- .sort((left, right) => left.due - right.due)
343
+ .sort((left, right) => left.due - right.due || compareCodePoints(left.key, right.key))
344
344
  .slice(0, Math.max(0, limit))
345
345
  .map((entry) => entry.key);
346
346
  },