@mpgd/game-services 0.26.0 → 0.27.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.
@@ -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
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Request headers for game-services calls: static headers, then a resolver
3
+ * for rotating credentials, then per-request headers. Shared by the commerce
4
+ * client and the notifications client so a game gives both one resolver.
5
+ */
6
+ export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
7
+ /** Credentials could not be resolved; no backend request was dispatched. */
8
+ export declare class GameServicesHeaderResolutionError extends Error {
9
+ constructor();
10
+ }
11
+ /** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
12
+ export declare function resolveGameServicesRequestHeaders(input: {
13
+ readonly headers?: Readonly<Record<string, string>> | undefined;
14
+ readonly getHeaders?: GameServicesHeaderResolver | undefined;
15
+ readonly requestHeaders?: Readonly<Record<string, string>> | undefined;
16
+ }): Promise<Readonly<Record<string, string>>>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Request headers for game-services calls: static headers, then a resolver
3
+ * for rotating credentials, then per-request headers. Shared by the commerce
4
+ * client and the notifications client so a game gives both one resolver.
5
+ */
6
+ /** Credentials could not be resolved; no backend request was dispatched. */
7
+ export class GameServicesHeaderResolutionError extends Error {
8
+ constructor() {
9
+ super('Game Services request headers could not be resolved before dispatch.');
10
+ this.name = 'GameServicesHeaderResolutionError';
11
+ }
12
+ }
13
+ /** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
14
+ export async function resolveGameServicesRequestHeaders(input) {
15
+ let dynamicHeaders;
16
+ try {
17
+ dynamicHeaders = await input.getHeaders?.();
18
+ }
19
+ catch {
20
+ // Resolver exceptions can contain credentials; expose a stable outcome.
21
+ throw new GameServicesHeaderResolutionError();
22
+ }
23
+ const merged = new Map();
24
+ for (const layer of [input.headers, dynamicHeaders, input.requestHeaders]) {
25
+ for (const [name, value] of Object.entries(layer ?? {})) {
26
+ merged.set(name.toLowerCase(), value);
27
+ }
28
+ }
29
+ return Object.fromEntries(merged);
30
+ }
package/dist/runtime.d.ts CHANGED
@@ -54,4 +54,11 @@ export declare function createGameServicesRuntime(input: CreateGameServicesRunti
54
54
  export declare function resolveGameServicesLedgerTarget(target: PlatformTarget | string): GameServicesLedgerTarget | null;
55
55
  export declare function resolveGameServicesAuthorityMode(profile: string): GameServicesAuthorityMode;
56
56
  export declare function resolveGameServicesTransport(transport: string | undefined): 'http' | 'orpc';
57
+ /**
58
+ * Whether a game-services URL may carry a session's credentials in a
59
+ * production build: an absolute https URL with no credentials and a public
60
+ * host, the rule the authoritative commerce backend follows. The
61
+ * notifications client's starter wiring uses it too.
62
+ */
63
+ export declare function isPublicGameServicesUrl(baseUrl: string): boolean;
57
64
  export {};
package/dist/runtime.js CHANGED
@@ -149,6 +149,15 @@ function normalizeBaseUrl(baseUrl) {
149
149
  const normalized = baseUrl?.trim();
150
150
  return normalized === undefined || normalized.length === 0 ? undefined : normalized;
151
151
  }
152
+ /**
153
+ * Whether a game-services URL may carry a session's credentials in a
154
+ * production build: an absolute https URL with no credentials and a public
155
+ * host, the rule the authoritative commerce backend follows. The
156
+ * notifications client's starter wiring uses it too.
157
+ */
158
+ export function isPublicGameServicesUrl(baseUrl) {
159
+ return isPublicHttpsUrl(parseAbsoluteUrl(baseUrl.trim()));
160
+ }
152
161
  function isPublicHttpsUrl(url) {
153
162
  return url !== undefined
154
163
  && url.protocol === 'https:'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -133,6 +133,10 @@
133
133
  "types": "./dist/notifications/index.d.ts",
134
134
  "default": "./dist/notifications/index.js"
135
135
  },
136
+ "./notifications/client": {
137
+ "types": "./dist/notifications/client.d.ts",
138
+ "default": "./dist/notifications/client.js"
139
+ },
136
140
  "./notifications/store-conformance": {
137
141
  "types": "./dist/notifications/store-conformance.d.ts",
138
142
  "default": "./dist/notifications/store-conformance.js"
@@ -215,9 +219,9 @@
215
219
  "@orpc/client": "2.0.0-beta.14",
216
220
  "@orpc/contract": "2.0.0-beta.14",
217
221
  "@orpc/server": "2.0.0-beta.14",
218
- "@mpgd/analytics": "0.4.13",
219
- "@mpgd/catalog": "0.10.4",
220
- "@mpgd/platform": "0.26.0"
222
+ "@mpgd/analytics": "0.4.14",
223
+ "@mpgd/catalog": "0.10.5",
224
+ "@mpgd/platform": "0.27.0"
221
225
  },
222
226
  "peerDependencies": {
223
227
  "@apple/app-store-server-library": "^3.1.0"
@@ -246,7 +250,7 @@
246
250
  "fix": "ttsc fix",
247
251
  "build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
248
252
  "test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
249
- "test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/scheduler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/fcm.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/store-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/ait-functional-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
253
+ "test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/client-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/scheduler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/fcm.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/store-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/ait-functional-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
250
254
  "test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler-conformance.test.ts",
251
255
  "test": "pnpm test:unit && pnpm test:app-store-server && pnpm test:recovery && pnpm test:packed-transport",
252
256
  "test:app-store-server": "vitest run"