@mpgd/game-services 0.25.1 → 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.
- package/dist/client.d.ts +2 -11
- package/dist/client.js +2 -25
- package/dist/notifications/ait-functional-delivery.d.ts +25 -8
- package/dist/notifications/ait-functional-delivery.js +38 -17
- package/dist/notifications/client.d.ts +168 -0
- package/dist/notifications/client.js +432 -0
- package/dist/notifications/fcm.d.ts +17 -3
- package/dist/notifications/fcm.js +109 -7
- package/dist/notifications/service.d.ts +4 -1
- package/dist/notifications/service.js +221 -37
- package/dist/notifications/store-conformance.js +19 -0
- package/dist/notifications/store.d.ts +5 -1
- package/dist/notifications/store.js +1 -1
- package/dist/request-headers.d.ts +16 -0
- package/dist/request-headers.js +30 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +9 -0
- package/package.json +9 -5
|
@@ -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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1212
|
-
* is still making loses its key, so no row of it is
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
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(
|
|
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
|
|
1323
|
-
//
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
|
1733
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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/
|
|
219
|
-
"@mpgd/
|
|
220
|
-
"@mpgd/
|
|
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"
|