@mpgd/platform 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3537 @@
1
+ import { assertNotificationConsentRecord, assertNotificationDeployment, assertRecordNotificationNoticeInput, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateLocalNotificationReminder, evaluateNotificationDelivery, evaluateNotificationReminderConsent, findNotificationCopy, isNotificationTimestamp, isRecordableNotificationPermissionState, isSameNotificationNotice, isValidNotificationTimeZone, latestNotificationConsent, listNotificationNotices, normalizeNotificationTimestamp, notificationDefaultDeploymentTimeZone, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationPermissionConsentKind, notificationPermissionStateOf, notificationProviderCarriesNotices, notificationRegistryMismatch, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, notificationTopicNeedsNoAgreement, resolveNotificationContent, routeNotificationReminder, toSeoulCalendarDate, } from './notifications.js';
2
+ import { notificationConformanceAppliesToReason, notificationConformanceDefaultLocale, } from './notifications-vectors.js';
3
+ /**
4
+ * Notifications v2 conformance runner (RFC "Conformance vectors") with the
5
+ * in-memory reference pieces every vector needs: a fake clock; a fake host
6
+ * with its fixture `NotificationProvider`; a fake backend that keeps sessions,
7
+ * consent records, endpoints, server reminders, notices, and an idempotency
8
+ * ledger, and applies the pure policy gate before every provider call; and the
9
+ * reference notifications runtime the runner drives as the `game` actor. Pure
10
+ * in-process code: no Phaser, DOM, network, or SDK imports. Nothing here sends
11
+ * a real notification or grants anything.
12
+ *
13
+ * A P-phase provider runs the same vectors by supplying `createProvider`; the
14
+ * runner reads the provider's capabilities and skips, with a reason, every
15
+ * vector whose `appliesTo` they do not satisfy.
16
+ */
17
+ export { notificationConformanceDefaultLocale };
18
+ /** The evidence schema the fake host attaches to a host-rendered agreement. */
19
+ export const notificationConformanceAgreementSchema = 'fixture.host-agreement.v1';
20
+ /** The fake session's installation id. */
21
+ export const notificationConformanceInstallationId = 'installation-1';
22
+ /** Seconds a first `rate-limited` provider answer defers the send by when it names no delay. */
23
+ export const notificationConformanceRetryAfterSeconds = 60;
24
+ /** Seconds the fake ledger's claim lease lasts; an `ambiguous` answer keeps the claim this long. */
25
+ export const notificationConformanceLeaseSeconds = 300;
26
+ const secondMs = 1000;
27
+ const dayMs = 24 * 60 * 60 * secondMs;
28
+ /** A registration identical to the last acknowledged one is skipped for this long. */
29
+ const registrationRefreshMs = 7 * dayMs;
30
+ /** An endpoint not refreshed for this long counts as missing at gate step 2. */
31
+ const endpointStaleMs = 60 * dayMs;
32
+ /** A send still rate-limited this long after its first deferral ends `unavailable`. */
33
+ const rateLimitGiveUpMs = dayMs;
34
+ /** Ledger keys are kept this long after their last change. */
35
+ const keyRetentionMs = 30 * dayMs;
36
+ /** An operation the vector did not script; it fails the vector with its message. */
37
+ export class NotificationConformanceScriptError extends Error {
38
+ name = 'NotificationConformanceScriptError';
39
+ }
40
+ /**
41
+ * A send the gate cannot evaluate as asked, such as one without the template
42
+ * data or copy it needs. A direct send answers `NOTIFICATIONS_INVALID_INPUT`,
43
+ * and the scheduler drops the item after one failure; any other failure is
44
+ * retried.
45
+ */
46
+ class NotificationSendInputError extends Error {
47
+ name = 'NotificationSendInputError';
48
+ }
49
+ export function createNotificationConformanceClock(initial) {
50
+ let current = normalizeNotificationTimestamp(initial);
51
+ return {
52
+ now: () => current,
53
+ set(at) {
54
+ current = normalizeNotificationTimestamp(at);
55
+ },
56
+ advance(seconds) {
57
+ if (!Number.isFinite(seconds) || seconds < 0) {
58
+ throw new RangeError('The conformance clock only advances by a non-negative number of seconds.');
59
+ }
60
+ current = normalizeNotificationTimestamp(new Date(Date.parse(current) + seconds * secondMs).toISOString());
61
+ },
62
+ };
63
+ }
64
+ const eventBufferLimit = 64;
65
+ const appKey = '\u0000app';
66
+ /**
67
+ * The fake host and its fixture provider (RFC "Vector format", fake defaults).
68
+ *
69
+ * - States start at `prompt` for `os` and `game` prompts, unreadable `host`
70
+ * prompts read `unknown`, an unsupported profile answers `unsupported`, and a
71
+ * topic whose mapping needs no agreement reads `granted` and never prompts. A
72
+ * game-rendered consent is one boolean, as Devvit's `isOptedIn`: opted in
73
+ * reads `granted`, otherwise `prompt`.
74
+ * - `actionRequired` holds the provider in `availability: 'action-required'`
75
+ * with `requiredAction: 'login'` until `completeAction`.
76
+ * - A prompt consumes the next `queuePrompt` result and fails the vector when
77
+ * none is queued. A `denied` answer reports `prompt-with-rationale` while
78
+ * denials remain under `permission.promptLimit`, `blocked` once they are used
79
+ * up, and `denied` without a limit; `dismissed` leaves the state and counts
80
+ * no denial; `host-terms-declined` reaches the prompt and answers
81
+ * `action-required` with `requiredAction: 'host-settings'`. An unreadable
82
+ * host still reports the state its prompt just decided.
83
+ * - Registrations carry the device permission and zone, never an installation
84
+ * id; `issueEndpoint` after the provider's first subscriber emits
85
+ * `endpoint-changed`. Events before the first subscriber are buffered for
86
+ * it, and a tap is `cold` before that subscriber and `warm` after. A
87
+ * listener's error fails the host call that delivered the event, after every
88
+ * listener ran.
89
+ */
90
+ export function createNotificationConformanceHost(input) {
91
+ const { profile } = input;
92
+ const capabilities = profile.capabilities;
93
+ const unsupported = capabilities.availability === 'unsupported';
94
+ const { scope, readable, promptLimit } = capabilities.permission;
95
+ const gamePrompt = capabilities.permission.prompt === 'game';
96
+ const initialState = unsupported ? 'unsupported' : 'prompt';
97
+ const topics = new Map(input.catalog.map((topic) => [topic.id, topic]));
98
+ let appState = initialState;
99
+ let optedIn = false;
100
+ const topicStates = new Map();
101
+ const denials = new Map();
102
+ let actionRequired = input.host?.actionRequired === true;
103
+ const promptQueue = [];
104
+ let prompts = 0;
105
+ let endpoint;
106
+ const pending = new Map();
107
+ const localOperations = [];
108
+ const localRequests = [];
109
+ const listeners = new Set();
110
+ let buffer = [];
111
+ let subscribed = false;
112
+ const needsNoAgreement = (topic) => {
113
+ const declared = topics.get(topic);
114
+ return scope === 'topic' && declared !== undefined && notificationTopicNeedsNoAgreement(declared);
115
+ };
116
+ const keyOf = (topic) => (scope === 'app' ? appKey : topic);
117
+ /** The state the host holds, whether or not the game can read it. */
118
+ const trueState = (key) => {
119
+ if (unsupported) {
120
+ return 'unsupported';
121
+ }
122
+ if (gamePrompt) {
123
+ return optedIn ? 'granted' : 'prompt';
124
+ }
125
+ if (key !== appKey && needsNoAgreement(key)) {
126
+ return 'granted';
127
+ }
128
+ return key === appKey ? appState : (topicStates.get(key) ?? initialState);
129
+ };
130
+ /** What a read reports. */
131
+ const readState = (key) => {
132
+ if (unsupported) {
133
+ return 'unsupported';
134
+ }
135
+ if (actionRequired) {
136
+ return 'action-required';
137
+ }
138
+ if (key !== appKey && needsNoAgreement(key)) {
139
+ return 'granted';
140
+ }
141
+ return readable ? trueState(key) : 'unknown';
142
+ };
143
+ const snapshotOf = (requested, decided = new Map()) => {
144
+ const stateOf = (key) => decided.get(key) ?? readState(key);
145
+ return {
146
+ ...(scope === 'app' ? { app: stateOf(appKey) } : {}),
147
+ topics: Object.fromEntries(requested.map((topic) => [topic, scope === 'app' ? stateOf(appKey) : stateOf(topic)])),
148
+ ...(actionRequired && !unsupported ? { requiredAction: 'login' } : {}),
149
+ };
150
+ };
151
+ const dispatch = (event) => {
152
+ if (!subscribed) {
153
+ bufferEvent(buffer, event);
154
+ return;
155
+ }
156
+ throwFirst(deliverEach([...listeners], [event]));
157
+ };
158
+ const registrationOf = (address) => ({
159
+ provider: profile.provider,
160
+ address: {
161
+ kind: address.kind,
162
+ value: address.value,
163
+ ...(address.environment === undefined ? {} : { environment: address.environment }),
164
+ permission: readState(appKey),
165
+ },
166
+ ...(input.timeZone === undefined ? {} : { timeZone: input.timeZone }),
167
+ });
168
+ /** Applies one prompt answer to a scope key: the new state and the outcome. */
169
+ const applyPrompt = (key, result) => {
170
+ const current = trueState(key);
171
+ switch (result) {
172
+ case 'granted':
173
+ return { state: 'granted', outcome: 'granted' };
174
+ case 'already-granted':
175
+ return { state: 'granted', outcome: 'already-granted' };
176
+ case 'dismissed':
177
+ return { state: current, outcome: 'dismissed' };
178
+ case 'host-terms-declined':
179
+ return { state: current, outcome: 'action-required', requiredAction: 'host-settings' };
180
+ case 'denied': {
181
+ if (gamePrompt) {
182
+ return { state: 'prompt', outcome: 'denied' };
183
+ }
184
+ const count = (denials.get(key) ?? 0) + 1;
185
+ denials.set(key, count);
186
+ if (promptLimit === undefined) {
187
+ return { state: 'denied', outcome: 'denied' };
188
+ }
189
+ return {
190
+ state: count < promptLimit ? 'prompt-with-rationale' : 'blocked',
191
+ outcome: 'denied',
192
+ };
193
+ }
194
+ }
195
+ };
196
+ const setTrueState = (key, state) => {
197
+ if (gamePrompt) {
198
+ optedIn = state === 'granted';
199
+ }
200
+ else if (key === appKey) {
201
+ appState = state;
202
+ }
203
+ else {
204
+ topicStates.set(key, state);
205
+ }
206
+ };
207
+ const provider = {
208
+ provider: profile.provider,
209
+ async getCapabilities() {
210
+ const clone = cloneCapabilities(capabilities);
211
+ return actionRequired && !unsupported ? { ...clone, availability: 'action-required' } : clone;
212
+ },
213
+ async getPermission(request) {
214
+ return snapshotOf(request.topics);
215
+ },
216
+ async requestPermission(request) {
217
+ const outcomes = {};
218
+ if (unsupported || actionRequired) {
219
+ // Unsupported is an answer, and a required player action is a state:
220
+ // neither reaches a prompt.
221
+ for (const topic of request.topics) {
222
+ outcomes[topic] = unsupported ? 'unavailable' : 'action-required';
223
+ }
224
+ return {
225
+ snapshot: snapshotOf(request.topics),
226
+ outcomes,
227
+ ...(unsupported ? {} : { requiredAction: 'login' }),
228
+ };
229
+ }
230
+ const askable = [];
231
+ for (const topic of request.topics) {
232
+ if (topics.get(topic)?.mapping === undefined) {
233
+ outcomes[topic] = 'unavailable';
234
+ }
235
+ else if (needsNoAgreement(topic)) {
236
+ outcomes[topic] = 'already-granted';
237
+ }
238
+ else {
239
+ askable.push(topic);
240
+ }
241
+ }
242
+ if (askable.length === 0) {
243
+ return { snapshot: snapshotOf(request.topics), outcomes };
244
+ }
245
+ prompts += 1;
246
+ const result = promptQueue.shift();
247
+ if (result === undefined) {
248
+ throw new NotificationConformanceScriptError(`Unscripted prompt for ${askable.join(', ')}: queue a host.queuePrompt result first.`);
249
+ }
250
+ const decided = new Map();
251
+ let requiredAction;
252
+ let decidedAnswer = false;
253
+ for (const key of scope === 'app' ? [appKey] : askable) {
254
+ const applied = applyPrompt(key, result);
255
+ setTrueState(key, applied.state);
256
+ requiredAction = applied.requiredAction ?? requiredAction;
257
+ decidedAnswer = isDecidedAnswer(applied.outcome);
258
+ if (!readable && decidedAnswer) {
259
+ // An unreadable host still tells the game the answer its prompt just gave.
260
+ decided.set(key, applied.state);
261
+ }
262
+ for (const topic of key === appKey ? askable : [key]) {
263
+ outcomes[topic] = applied.outcome;
264
+ }
265
+ }
266
+ return {
267
+ snapshot: snapshotOf(request.topics, decided),
268
+ outcomes,
269
+ ...(requiredAction === undefined ? {} : { requiredAction }),
270
+ ...(capabilities.permission.prompt === 'host' && decidedAnswer
271
+ ? {
272
+ evidence: {
273
+ schema: notificationConformanceAgreementSchema,
274
+ payload: { provider: profile.provider, topics: askable.join(','), result },
275
+ },
276
+ }
277
+ : {}),
278
+ };
279
+ },
280
+ ...(!gamePrompt
281
+ ? {}
282
+ : {
283
+ async revokePermission(request) {
284
+ optedIn = false;
285
+ return {
286
+ snapshot: snapshotOf(request.topics),
287
+ outcomes: Object.fromEntries(request.topics.map((topic) => [topic, 'denied'])),
288
+ };
289
+ },
290
+ }),
291
+ ...(capabilities.remote.addressing === 'none'
292
+ ? {}
293
+ : {
294
+ async register() {
295
+ if (capabilities.remote.addressing === 'platform-user') {
296
+ return {
297
+ provider: profile.provider,
298
+ address: { kind: 'platform-user' },
299
+ ...(input.timeZone === undefined ? {} : { timeZone: input.timeZone }),
300
+ };
301
+ }
302
+ return endpoint === undefined ? null : registrationOf(endpoint);
303
+ },
304
+ }),
305
+ ...(!capabilities.local.supported
306
+ ? {}
307
+ : {
308
+ async scheduleLocal(request) {
309
+ localRequests.push(request);
310
+ if (actionRequired || trueState(keyOf(request.topic)) !== 'granted') {
311
+ return { status: 'permission-required' };
312
+ }
313
+ const entry = {
314
+ key: request.key,
315
+ topic: request.topic,
316
+ fireAt: normalizeNotificationTimestamp(request.fireAt),
317
+ };
318
+ if (pending.has(entry.key)) {
319
+ pending.set(entry.key, entry);
320
+ localOperations.push({ op: 'replace', ...entry, exact: false });
321
+ return { status: 'replaced' };
322
+ }
323
+ const maxPending = capabilities.local.maxPending;
324
+ if (maxPending !== undefined && pending.size >= maxPending) {
325
+ // Keep the nearest reminders: the farthest one gives way, or the new one is refused.
326
+ const farthest = sortLocalEntries([...pending.values()]).at(-1);
327
+ if (farthest === undefined || Date.parse(entry.fireAt) >= Date.parse(farthest.fireAt)) {
328
+ return { status: 'capacity-exceeded' };
329
+ }
330
+ pending.delete(farthest.key);
331
+ localOperations.push({ op: 'cancel', key: farthest.key, topic: farthest.topic });
332
+ }
333
+ pending.set(entry.key, entry);
334
+ localOperations.push({ op: 'schedule', ...entry, exact: false });
335
+ return { status: 'scheduled' };
336
+ },
337
+ async cancelLocal(request) {
338
+ for (const key of request.keys) {
339
+ const entry = pending.get(key);
340
+ if (entry !== undefined) {
341
+ pending.delete(key);
342
+ localOperations.push({ op: 'cancel', key, topic: entry.topic });
343
+ }
344
+ }
345
+ },
346
+ async listLocal() {
347
+ return sortLocalEntries([...pending.values()]);
348
+ },
349
+ }),
350
+ async openSettings() {
351
+ return !unsupported && !actionRequired && capabilities.permission.settings
352
+ ? 'opened'
353
+ : 'unavailable';
354
+ },
355
+ subscribe(listener) {
356
+ listeners.add(listener);
357
+ if (!subscribed) {
358
+ subscribed = true;
359
+ const drained = buffer;
360
+ buffer = [];
361
+ // Every buffered event reaches the first listener once, even if it throws on one.
362
+ throwFirst(deliverEach([listener], drained));
363
+ }
364
+ return () => {
365
+ listeners.delete(listener);
366
+ };
367
+ },
368
+ };
369
+ return {
370
+ provider,
371
+ get prompts() {
372
+ return prompts;
373
+ },
374
+ get localOperations() {
375
+ return localOperations;
376
+ },
377
+ get localPending() {
378
+ return sortLocalEntries([...pending.values()]);
379
+ },
380
+ get localRequests() {
381
+ return localRequests;
382
+ },
383
+ queuePrompt(result) {
384
+ promptQueue.push(result);
385
+ },
386
+ setPermission(state) {
387
+ if (gamePrompt || scope === 'app') {
388
+ setTrueState(appKey, state);
389
+ return;
390
+ }
391
+ for (const topic of new Set([...topics.keys(), ...topicStates.keys()])) {
392
+ topicStates.set(topic, state);
393
+ }
394
+ },
395
+ issueEndpoint(next) {
396
+ endpoint = next;
397
+ // An address issued while the app runs is announced, as an FCM token refresh is.
398
+ if (subscribed) {
399
+ dispatch({ type: 'endpoint-changed', registration: registrationOf(next) });
400
+ }
401
+ },
402
+ completeAction() {
403
+ actionRequired = false;
404
+ },
405
+ tap(tap) {
406
+ dispatch({
407
+ type: 'opened',
408
+ channel: tap.channel,
409
+ source: subscribed ? 'warm' : 'cold',
410
+ ...(tap.topic === undefined ? {} : { topic: tap.topic }),
411
+ ...(tap.route === undefined ? {} : { route: tap.route }),
412
+ ...(tap.data === undefined ? {} : { data: { ...tap.data } }),
413
+ });
414
+ },
415
+ restart() {
416
+ listeners.clear();
417
+ buffer = [];
418
+ subscribed = false;
419
+ },
420
+ readServerPermission(request) {
421
+ if (actionRequired && !unsupported) {
422
+ return 'action-required';
423
+ }
424
+ return trueState(keyOf(request.topic));
425
+ },
426
+ };
427
+ }
428
+ /**
429
+ * Buffers an event for the first listener, keeping at most
430
+ * `eventBufferLimit`: past it the oldest event that is not an open goes first,
431
+ * so a cold-start open is not lost to later events.
432
+ */
433
+ function bufferEvent(buffer, event) {
434
+ buffer.push(event);
435
+ if (buffer.length > eventBufferLimit) {
436
+ const evicted = buffer.findIndex((buffered) => buffered.type !== 'opened');
437
+ buffer.splice(evicted === -1 ? 0 : evicted, 1);
438
+ }
439
+ }
440
+ function isDecidedAnswer(outcome) {
441
+ return outcome === 'granted' || outcome === 'already-granted' || outcome === 'denied';
442
+ }
443
+ /** Calls every listener with every event, in order; returns the errors they threw. */
444
+ function deliverEach(listeners, events) {
445
+ const errors = [];
446
+ for (const event of events) {
447
+ for (const listener of listeners) {
448
+ try {
449
+ listener(event);
450
+ }
451
+ catch (error) {
452
+ errors.push(error);
453
+ }
454
+ }
455
+ }
456
+ return errors;
457
+ }
458
+ function throwFirst(errors) {
459
+ if (errors.length > 0) {
460
+ throw errors[0];
461
+ }
462
+ }
463
+ function sortLocalEntries(entries) {
464
+ return [...entries].sort((left, right) => Date.parse(left.fireAt) - Date.parse(right.fireAt) || left.key.localeCompare(right.key));
465
+ }
466
+ function cloneCapabilities(capabilities) {
467
+ return JSON.parse(JSON.stringify(capabilities));
468
+ }
469
+ // Registry entry.
470
+ /**
471
+ * The registry entry the fake backend derives for a provider (RFC "Vector
472
+ * format"): purposes, addressing, content, and permission fields from its
473
+ * capabilities; `expiry` and `actions` for a free-form device provider; `link`
474
+ * for `host-link` opens; per-recipient limits from `perUserPerDay` and
475
+ * `perUserPerMinute`, the daily one first; and no `rate`. `undefined` for a
476
+ * provider without remote delivery.
477
+ */
478
+ export function notificationConformanceRegistryEntry(input) {
479
+ const { remote, permission } = input.capabilities;
480
+ if (!remote.supported || remote.addressing === 'none') {
481
+ return undefined;
482
+ }
483
+ const limits = [];
484
+ if (input.limits?.perUserPerDay !== undefined) {
485
+ limits.push({ count: input.limits.perUserPerDay, windowSeconds: 86_400 });
486
+ }
487
+ if (input.limits?.perUserPerMinute !== undefined) {
488
+ limits.push({ count: input.limits.perUserPerMinute, windowSeconds: 60 });
489
+ }
490
+ const freeFormDevice = remote.addressing === 'device' && remote.content === 'free-form';
491
+ return {
492
+ provider: input.provider,
493
+ purposes: [...remote.purposes],
494
+ addressing: remote.addressing,
495
+ content: remote.content,
496
+ permission: {
497
+ scope: permission.scope,
498
+ serverReadable: permission.serverReadable,
499
+ allowance: permission.allowance,
500
+ },
501
+ ...(limits.length === 0 ? {} : { limits }),
502
+ expiry: freeFormDevice,
503
+ actions: freeFormDevice,
504
+ link: input.capabilities.open === 'host-link',
505
+ mappingFields: [],
506
+ };
507
+ }
508
+ /** A fresh `unavailable: no-endpoint` answer: callers may keep or change what they get. */
509
+ function noEndpoint() {
510
+ return { status: 'unavailable', reason: 'no-endpoint' };
511
+ }
512
+ /**
513
+ * The fake backend (RFC "Server").
514
+ *
515
+ * - Sessions: each `authenticate` starts a new session on the fake
516
+ * installation, and `revokeSession` ends it and unbinds the endpoints it
517
+ * registered. Consent records, registrations, reminders, and notices belong
518
+ * to the session's player. The backend stamps each record's `at` with its
519
+ * own clock, strictly after the player's previous record, so a record's
520
+ * kind and time name it, and so does each notice that reports on it. It
521
+ * records a notice only while that notice is listed, so a retried or early
522
+ * call changes nothing, and a pushed notice the game gave while the push
523
+ * waited is not sent again. Notices go out through `notices.topic` only on
524
+ * a target that declares it; a target that delivers advertising under the
525
+ * Korean rules must declare it, as the catalog validator requires, and
526
+ * `notices.copy` must follow the catalog's rules.
527
+ * - Endpoints: a device endpoint is keyed by provider and installation, keeps
528
+ * the latest address, permission, and `lastRegisteredAt`, and moves with the
529
+ * installation to a newly authenticated player. An address is unique per
530
+ * provider, a dead address stays dead until the installation reports a new
531
+ * one, and an endpoint not refreshed for 60 days counts as missing. A
532
+ * platform-user provider resolves the authenticated identity and keeps the
533
+ * player's locale and zone.
534
+ * - Sends: a send is validated (input bounds, reserved prefixes), bound to
535
+ * its fingerprint (`NOTIFICATIONS_IDEMPOTENCY_CONFLICT` for another request),
536
+ * and answered from its key state: `delivered` with `alreadyProcessed`, the
537
+ * stored `deferred` result, or `in-flight`. A new key runs the gate for each
538
+ * recipient (every valid endpoint in installation-id order, each under
539
+ * `<key>:<installation id>`, or the platform identity) and calls the
540
+ * provider for the allowed ones. The send is `delivered` when any recipient
541
+ * accepted, else `in-flight`, `deferred` (the earliest), a provider's
542
+ * refusal, a policy refusal, and last an unresolved recipient. Only
543
+ * delivered, deferred, and in-flight rows are stored.
544
+ * - Provider answers come from `queueProviderResult`, `accepted` when none is
545
+ * queued: `unregistered` invalidates the endpoint; `rate-limited` defers by
546
+ * `retryAfterSeconds` (60 when absent), then by doubling delays without
547
+ * jitter, never past 24 hours after the first, and then gives up;
548
+ * `rejected` is not stored, and with
549
+ * `agreement` appends a `host-agreement` record of `denied` from the
550
+ * provider; `ambiguous` keeps the claim for the 300-second lease.
551
+ * - The scheduler processes due server reminders (as `reminder:` sends), due
552
+ * stored rows (deferred ones, and in-flight ones of scheduled sends or
553
+ * device endpoints after the lease, replayed through the gate without the
554
+ * key lookup), and, under the Korean rules, every unrecorded notice from the
555
+ * Asia/Seoul date seven days before it is due (as a `notice:` send of the
556
+ * notice topic in the variant of `notices.copy` it is listed with, filled
557
+ * with `{{consentDate}}`, and recorded with channel `push` once delivered),
558
+ * earliest first and within the registry entry's `rate`. Only a provider
559
+ * that can carry a notice (`notificationProviderCarriesNotices`) sends one,
560
+ * and never with its mapping's own text; a notice that cannot render is
561
+ * not tried again. A direct caller replays its own in-flight platform send
562
+ * after the lease. Keys are kept for 30 days after their last change.
563
+ * - The notice topic is the scheduler's: `deliver` and `setReminder` through
564
+ * it throw `NOTIFICATIONS_INVALID_INPUT`.
565
+ * - Setting or cancelling a reminder drops its sends' rows that still wait
566
+ * (deferred) or are in flight, so neither is replayed; a send the provider
567
+ * may already have accepted cannot be recalled. A send's limits count the
568
+ * player's other sends, never an earlier delivery of the same send to
569
+ * another endpoint. A send the gate cannot evaluate as asked (such as missing
570
+ * template data) answers `NOTIFICATIONS_INVALID_INPUT` directly and fails
571
+ * once when scheduled; any other failure, such as a host read that throws,
572
+ * is retried on the next run, and one failing item never stops the others.
573
+ */
574
+ export function createNotificationConformanceBackend(input) {
575
+ const { entry, clock } = input;
576
+ const rules = input.deployment?.advertisingRules ?? 'none';
577
+ const topicOf = (id) => input.catalog.find((topic) => topic.id === id);
578
+ /**
579
+ * The topic a send is rendered from. A consent notice goes out through the
580
+ * notice topic as `notificationNoticeTopic` gives it: its variant's
581
+ * `notices.copy` in place of a copy of its own, so the gate resolves the
582
+ * locale as for any topic, and no `title` or `body` from its mapping.
583
+ * Notices are sent only through `notices.topic`, so the copy is there.
584
+ */
585
+ const topicFor = (request, origin) => {
586
+ const topic = topicOf(request.topic);
587
+ return topic === undefined || origin.kind !== 'notice'
588
+ ? topic
589
+ : notificationNoticeTopic(topic, input.notices?.copy ?? {}, origin.variant);
590
+ };
591
+ if (input.deployment !== undefined) {
592
+ assertNotificationDeployment(input.deployment);
593
+ }
594
+ // A JavaScript caller can pass anything: the block needs its topic.
595
+ const notices = input.notices;
596
+ if (notices !== undefined
597
+ && (typeof notices !== 'object'
598
+ || notices === null
599
+ || typeof notices.topic !== 'string'
600
+ || notices.topic.length === 0)) {
601
+ throw new TypeError("The catalog's notices block needs its topic and its copy: { topic, copy }.");
602
+ }
603
+ const noticeTopic = input.notices?.topic;
604
+ // Required only where the catalog validator requires it: declared, it is a
605
+ // remote service topic; a target that delivers advertising under the Korean
606
+ // rules must declare it. Another target sends no notice pushes.
607
+ const noticeTopicHere = noticeTopic === undefined ? undefined : topicOf(noticeTopic);
608
+ if (noticeTopicHere !== undefined
609
+ && (noticeTopicHere.purpose !== 'service' || !noticeTopicHere.channels.includes('remote'))) {
610
+ throw new TypeError(`The notice topic ${noticeTopic} is not a declared service topic.`);
611
+ }
612
+ if (noticeTopic !== undefined
613
+ && noticeTopicHere === undefined
614
+ && rules === 'kr'
615
+ && input.catalog.some((topic) => topic.purpose === 'advertising')) {
616
+ throw new TypeError(`The notice topic ${noticeTopic} is not declared for a target that delivers advertising.`);
617
+ }
618
+ // A notice says only its variant of `notices.copy`, so the copy follows the
619
+ // catalog's rules: every variant in every locale, and the consent date.
620
+ if (input.notices !== undefined) {
621
+ const issues = notificationNoticeCopyIssues(input.notices.copy, input.defaultLocale);
622
+ if (issues.length > 0) {
623
+ throw new TypeError(`The catalog's notices.copy breaks the catalog rules: ${issues
624
+ .map((issue) => `${issue.path}: ${issue.message}`)
625
+ .join('; ')}`);
626
+ }
627
+ }
628
+ /** The notice topic is the scheduler's: a game send or reminder through it is refused. */
629
+ const refuseNoticeTopic = (topic) => {
630
+ if (noticeTopic !== undefined && topic === noticeTopic) {
631
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
632
+ message: `Topic ${topic} is reserved for consent notices, which only the scheduler sends.`,
633
+ });
634
+ }
635
+ };
636
+ let current;
637
+ let sessionCount = 0;
638
+ const sessionListeners = new Set();
639
+ const identities = new Set();
640
+ const platformRegistrations = new Map();
641
+ const consentRows = [];
642
+ const endpoints = [];
643
+ const deliveries = [];
644
+ const providerCalls = [];
645
+ const reminders = [];
646
+ const noticesGiven = [];
647
+ /** Delivered sends, oldest first, with the send key each delivery belongs to. */
648
+ const history = [];
649
+ const ledger = new Map();
650
+ /** Send keys being evaluated, with their fingerprint: a concurrent send of the key answers `in-flight`. */
651
+ const claims = new Map();
652
+ /** Notice sends that could not be made: they fail once, not on every run. */
653
+ const failedNotices = new Set();
654
+ const providerResults = [];
655
+ const now = () => Date.parse(clock.now());
656
+ const requireSession = (operation) => {
657
+ if (current === undefined) {
658
+ throw new NotificationConformanceScriptError(`${operation} needs an authenticated player: script backend.authenticate first.`);
659
+ }
660
+ return current;
661
+ };
662
+ const requireEntry = (operation) => {
663
+ if (entry === undefined) {
664
+ throw new NotificationConformanceScriptError(`${operation} needs a provider with remote delivery.`);
665
+ }
666
+ return entry;
667
+ };
668
+ const recordsOf = (player) => consentRows.filter((row) => row.player === player).map((row) => row.record);
669
+ const appendConsent = (player, record) => {
670
+ // The backend's clock dates the record, strictly after the player's
671
+ // previous one: a notice names its record by kind and time, so two records
672
+ // of one kind written while the clock stands still keep their notices apart.
673
+ let at = now();
674
+ for (let index = consentRows.length - 1; index >= 0; index -= 1) {
675
+ const row = consentRows[index];
676
+ if (row !== undefined && row.player === player) {
677
+ at = Math.max(at, Date.parse(row.record.at) + 1);
678
+ break;
679
+ }
680
+ }
681
+ consentRows.push({
682
+ player,
683
+ record: assertNotificationConsentRecord({ ...record, at: formatInstant(at) }),
684
+ });
685
+ };
686
+ const noticesOf = (player) => rules === 'kr'
687
+ ? listNotificationNotices(recordsOf(player), noticesGiven.filter((given) => given.player === player), clock.now())
688
+ : [];
689
+ /** Whether `notice` is listed for `player` now; only a listed notice is recorded or sent. */
690
+ const noticeListed = (player, notice) => noticesOf(player).some((listed) => isSameNotificationNotice(listed, notice));
691
+ const isFresh = (endpoint) => now() - Date.parse(endpoint.lastRegisteredAt) < endpointStaleMs;
692
+ const validEndpointsOf = (player) => endpoints
693
+ .filter((endpoint) => endpoint.provider === entry?.provider
694
+ && endpoint.player === player
695
+ && endpoint.status === 'valid'
696
+ && isFresh(endpoint))
697
+ .sort((left, right) => compareText(left.installationId, right.installationId));
698
+ const bindEndpoint = (next) => {
699
+ // An address belongs to one installation per provider.
700
+ for (let index = endpoints.length - 1; index >= 0; index -= 1) {
701
+ const other = endpoints[index];
702
+ if (other !== undefined
703
+ && other.provider === next.provider
704
+ && other.value === next.value
705
+ && other.installationId !== next.installationId) {
706
+ endpoints.splice(index, 1);
707
+ }
708
+ }
709
+ const index = endpoints.findIndex((endpoint) => endpoint.provider === next.provider && endpoint.installationId === next.installationId);
710
+ const existing = index === -1 ? undefined : endpoints[index];
711
+ const stored = {
712
+ ...next,
713
+ // A dead address stays dead until the installation reports a new one.
714
+ status: existing?.status === 'invalid' && existing.value === next.value ? 'invalid' : 'valid',
715
+ lastRegisteredAt: clock.now(),
716
+ };
717
+ if (index === -1) {
718
+ endpoints.push(stored);
719
+ }
720
+ else {
721
+ endpoints[index] = stored;
722
+ }
723
+ };
724
+ const invalidate = (endpoint) => {
725
+ const index = endpoint === undefined
726
+ ? -1
727
+ : endpoints.findIndex((candidate) => candidate.provider === endpoint.provider
728
+ && candidate.installationId === endpoint.installationId
729
+ && candidate.value === endpoint.value);
730
+ const stored = endpoints[index];
731
+ if (stored !== undefined) {
732
+ endpoints[index] = { ...stored, status: 'invalid' };
733
+ }
734
+ };
735
+ // Recipients (gate step 2).
736
+ const deviceRecipient = (request, endpoint) => ({
737
+ key: `${request.idempotencyKey}:${encodeURIComponent(endpoint.installationId)}`,
738
+ installationId: endpoint.installationId,
739
+ endpoint,
740
+ resolved: true,
741
+ ...(endpoint.locale === undefined ? {} : { locale: endpoint.locale }),
742
+ ...(endpoint.timeZone === undefined ? {} : { timeZone: endpoint.timeZone }),
743
+ });
744
+ const platformRecipient = (request) => {
745
+ const registration = platformRegistrations.get(request.player);
746
+ return {
747
+ key: request.idempotencyKey,
748
+ resolved: identities.has(request.player),
749
+ ...(registration?.locale === undefined ? {} : { locale: registration.locale }),
750
+ ...(registration?.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
751
+ };
752
+ };
753
+ const recipientsOf = (request) => {
754
+ if (entry?.addressing !== 'device') {
755
+ return [platformRecipient(request)];
756
+ }
757
+ const valid = validEndpointsOf(request.player);
758
+ return valid.length === 0
759
+ ? [{ key: request.idempotencyKey, resolved: false }]
760
+ : valid.map((endpoint) => deviceRecipient(request, endpoint));
761
+ };
762
+ /** A stored row's recipient on replay: its endpoint now, unresolved once it is gone. */
763
+ const recipientOfRow = (request, row) => {
764
+ if (row.installationId === undefined) {
765
+ return platformRecipient(request);
766
+ }
767
+ const endpoint = validEndpointsOf(request.player)
768
+ .find((candidate) => candidate.installationId === row.installationId);
769
+ return endpoint === undefined
770
+ ? { key: row.key, installationId: row.installationId, resolved: false }
771
+ : deviceRecipient(request, endpoint);
772
+ };
773
+ /**
774
+ * Delivered sends, and in-flight ones holding their reservation, oldest
775
+ * first: the gate reads them from the newest. The send being made is left
776
+ * out, including its earlier delivery to another endpoint.
777
+ */
778
+ const historyFor = (player, excluding) => [
779
+ ...history.filter((sent) => sent.player === player && sent.sendKey !== excluding),
780
+ ...[...ledger]
781
+ .filter(([key, held]) => key !== excluding && held.request.player === player && held.outcome.status === 'in-flight')
782
+ .map(([, held]) => ({
783
+ provider: entry?.provider ?? '',
784
+ topic: held.request.topic,
785
+ at: formatInstant(held.changedAt),
786
+ })),
787
+ ].sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
788
+ const readServerPermission = async (request) => {
789
+ if (entry?.permission.serverReadable !== true) {
790
+ return undefined;
791
+ }
792
+ if (input.readServerPermission === undefined) {
793
+ throw new NotificationConformanceScriptError(`Provider ${entry.provider} is server-readable but the harness has no readServerPermission.`);
794
+ }
795
+ try {
796
+ return await input.readServerPermission({ player: request.player, topic: request.topic });
797
+ }
798
+ catch (error) {
799
+ // A host that cannot answer now is retried, whatever the error's class.
800
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
801
+ message: `The host could not answer the send-time permission read for ${request.player}.`,
802
+ cause: error,
803
+ });
804
+ }
805
+ };
806
+ /**
807
+ * The first `rate-limited` answer defers by `retryAfterSeconds` (60 when
808
+ * absent or not positive); later ones double that delay, without jitter so vectors stay
809
+ * deterministic. No deferral runs past 24 hours after the first, and an
810
+ * answer at or after that point gives the send up.
811
+ */
812
+ const nextRateLimit = (prior, retryAfterSeconds) => {
813
+ const at = now();
814
+ const since = prior?.since ?? at;
815
+ const deadline = since + rateLimitGiveUpMs;
816
+ if (at >= deadline) {
817
+ return undefined;
818
+ }
819
+ const delaySeconds = prior?.delaySeconds
820
+ ?? (retryAfterSeconds !== undefined && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
821
+ ? retryAfterSeconds
822
+ : notificationConformanceRetryAfterSeconds);
823
+ const attempts = prior?.attempts ?? 0;
824
+ return {
825
+ until: formatInstant(Math.min(at + delaySeconds * 2 ** attempts * secondMs, deadline)),
826
+ state: { since, delaySeconds, attempts: attempts + 1 },
827
+ };
828
+ };
829
+ /**
830
+ * The gate and the provider for each recipient. Every decision is made
831
+ * before the first provider call, so a caller error throws before any state
832
+ * changes, and nothing here waits, so it sees one consistent state.
833
+ */
834
+ const processRecipients = (request, origin, recipients, serverPermission, previous) => {
835
+ const provider = requireEntry('A send');
836
+ const topic = topicFor(request, origin);
837
+ const at = clock.now();
838
+ const consent = recordsOf(request.player);
839
+ const sent = historyFor(request.player, request.idempotencyKey);
840
+ const evaluated = recipients.map((recipient) => {
841
+ const locale = request.locale ?? recipient.locale;
842
+ const decision = decide({
843
+ entry: provider,
844
+ ...(input.deployment === undefined ? {} : { deployment: input.deployment }),
845
+ topic,
846
+ recipientResolved: recipient.resolved,
847
+ ...(request.link === undefined ? {} : { link: request.link }),
848
+ consent,
849
+ ...(serverPermission === undefined ? {} : { serverPermission }),
850
+ ...(recipient.endpoint === undefined
851
+ ? {}
852
+ : { endpointPermission: recipient.endpoint.permission }),
853
+ history: sent,
854
+ at,
855
+ ...(recipient.timeZone === undefined ? {} : { timeZone: recipient.timeZone }),
856
+ ...(locale === undefined ? {} : { locale }),
857
+ defaultLocale: input.defaultLocale,
858
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
859
+ });
860
+ return { recipient, decision };
861
+ });
862
+ // The gate allows only a declared topic, so an allowed send reads its mapping.
863
+ const mapping = topic?.mapping;
864
+ const mappedLink = mapping?.['link'];
865
+ const link = request.link ?? (typeof mappedLink === 'string' ? mappedLink : undefined);
866
+ const rows = [];
867
+ const refusals = [];
868
+ for (const { recipient, decision } of evaluated) {
869
+ const prior = previous.get(recipient.key);
870
+ const row = {
871
+ key: recipient.key,
872
+ ...(recipient.installationId === undefined ? {} : { installationId: recipient.installationId }),
873
+ };
874
+ const backoff = prior?.rateLimit === undefined ? {} : { rateLimit: prior.rateLimit };
875
+ if (decision.status === 'deferred') {
876
+ rows.push({
877
+ ...row,
878
+ ...backoff,
879
+ state: { status: 'deferred', reason: decision.reason, until: decision.until },
880
+ });
881
+ continue;
882
+ }
883
+ if (decision.status !== 'allowed') {
884
+ refusals.push(decision);
885
+ continue;
886
+ }
887
+ providerCalls.push({
888
+ idempotencyKey: recipient.key,
889
+ sendKey: request.idempotencyKey,
890
+ provider: provider.provider,
891
+ topic: request.topic,
892
+ player: request.player,
893
+ ...(recipient.endpoint === undefined ? {} : { address: recipient.endpoint.value }),
894
+ ...(mapping === undefined ? {} : { mapping }),
895
+ ...(link === undefined ? {} : { link }),
896
+ content: decision.content,
897
+ ...(decision.expiresAt === undefined ? {} : { expiresAt: decision.expiresAt }),
898
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
899
+ ...(request.data === undefined ? {} : { data: request.data }),
900
+ at,
901
+ });
902
+ const answer = providerResults.shift() ?? { status: 'accepted' };
903
+ switch (answer.status) {
904
+ case 'accepted':
905
+ rows.push({ ...row, state: { status: 'delivered' } });
906
+ break;
907
+ case 'ambiguous':
908
+ // The provider may have sent it: the claim is kept for the lease.
909
+ rows.push({
910
+ ...row,
911
+ ...backoff,
912
+ state: {
913
+ status: 'in-flight',
914
+ leaseUntil: now() + notificationConformanceLeaseSeconds * secondMs,
915
+ },
916
+ });
917
+ break;
918
+ case 'unregistered':
919
+ invalidate(recipient.endpoint);
920
+ refusals.push({ status: 'unavailable', reason: 'endpoint-invalid' });
921
+ break;
922
+ case 'rejected':
923
+ if (answer.reason === 'agreement') {
924
+ // The host says the recipient has not agreed: the gate stops until they agree again.
925
+ appendConsent(request.player, {
926
+ kind: 'host-agreement',
927
+ topic: request.topic,
928
+ state: 'denied',
929
+ source: 'provider',
930
+ at,
931
+ });
932
+ }
933
+ refusals.push({ status: 'unavailable', reason: 'provider-rejected' });
934
+ break;
935
+ case 'rate-limited': {
936
+ // Nothing was sent: the claim, and any reservation, is released.
937
+ const next = nextRateLimit(prior?.rateLimit, answer.retryAfterSeconds);
938
+ if (next === undefined) {
939
+ refusals.push({ status: 'unavailable', reason: 'provider-rate-limited' });
940
+ }
941
+ else {
942
+ rows.push({
943
+ ...row,
944
+ rateLimit: next.state,
945
+ state: { status: 'deferred', reason: 'provider-rate-limited', until: next.until },
946
+ });
947
+ }
948
+ }
949
+ }
950
+ }
951
+ return { rows, refusals };
952
+ };
953
+ const record = (outcome, request) => {
954
+ deliveries.push({
955
+ ...outcome,
956
+ idempotencyKey: request.idempotencyKey,
957
+ topic: request.topic,
958
+ player: request.player,
959
+ at: clock.now(),
960
+ });
961
+ };
962
+ const onDelivered = (request, origin) => {
963
+ history.push({
964
+ player: request.player,
965
+ provider: entry?.provider ?? '',
966
+ topic: request.topic,
967
+ at: clock.now(),
968
+ sendKey: request.idempotencyKey,
969
+ });
970
+ if (origin.kind === 'notice' && noticeListed(request.player, origin.notice)) {
971
+ // A notice the scheduler sent is recorded once it is delivered.
972
+ noticesGiven.push({
973
+ ...origin.notice,
974
+ player: request.player,
975
+ channel: 'push',
976
+ at: clock.now(),
977
+ });
978
+ }
979
+ };
980
+ /**
981
+ * Evaluates a send with no stored entry and records its outcome. Answers
982
+ * `undefined`, recording nothing, when `stillWanted` turned false during the
983
+ * send's only wait (the server-readable permission read).
984
+ */
985
+ const sendNew = async (request, fingerprint, origin, scheduled, stillWanted = () => true) => {
986
+ const key = request.idempotencyKey;
987
+ // The claim is taken before the first wait, so a concurrent send of the key sees it.
988
+ claims.set(key, fingerprint);
989
+ try {
990
+ const serverPermission = await readServerPermission(request);
991
+ if (!stillWanted()) {
992
+ return undefined;
993
+ }
994
+ // No recipient on a provider the backend does not have.
995
+ const reaches = entry !== undefined
996
+ && (request.provider === undefined || request.provider === entry.provider);
997
+ const { rows, refusals } = reaches
998
+ ? processRecipients(request, origin, recipientsOf(request), serverPermission, new Map())
999
+ : { rows: [], refusals: [noEndpoint()] };
1000
+ const stored = bestOutcome(rows.map(rowOutcome));
1001
+ if (stored !== undefined) {
1002
+ ledger.set(key, {
1003
+ request,
1004
+ fingerprint,
1005
+ origin,
1006
+ scheduled,
1007
+ rows,
1008
+ outcome: stored,
1009
+ changedAt: now(),
1010
+ });
1011
+ if (stored.status === 'delivered') {
1012
+ onDelivered(request, origin);
1013
+ }
1014
+ }
1015
+ const outcome = stored ?? bestOutcome(refusals) ?? noEndpoint();
1016
+ record(outcome, request);
1017
+ return outcome;
1018
+ }
1019
+ finally {
1020
+ claims.delete(key);
1021
+ }
1022
+ };
1023
+ /**
1024
+ * Replays `due` rows of a stored send through the gate and the provider,
1025
+ * without the key lookup, and replaces them with the new answers; a replay
1026
+ * that leaves no row deletes the entry. Answers the send's result, or
1027
+ * `undefined` when `stillWanted` turned false during the wait.
1028
+ */
1029
+ const replay = async (key, held, due, stillWanted) => {
1030
+ claims.set(key, held.fingerprint);
1031
+ try {
1032
+ const serverPermission = await readServerPermission(held.request);
1033
+ if (!stillWanted()) {
1034
+ return undefined;
1035
+ }
1036
+ const { rows, refusals } = processRecipients(held.request, held.origin, due.map((row) => recipientOfRow(held.request, row)), serverPermission, new Map(due.map((row) => [row.key, row])));
1037
+ const wasDelivered = held.outcome.status === 'delivered';
1038
+ held.rows = [...held.rows.filter((row) => !due.includes(row)), ...rows];
1039
+ const stored = bestOutcome(held.rows.map(rowOutcome));
1040
+ if (stored === undefined) {
1041
+ // A replay that ends suppressed or unavailable leaves no entry.
1042
+ ledger.delete(key);
1043
+ return bestOutcome(refusals) ?? noEndpoint();
1044
+ }
1045
+ held.outcome = stored;
1046
+ held.changedAt = now();
1047
+ if (!wasDelivered && stored.status === 'delivered') {
1048
+ onDelivered(held.request, held.origin);
1049
+ }
1050
+ return stored;
1051
+ }
1052
+ finally {
1053
+ claims.delete(key);
1054
+ }
1055
+ };
1056
+ /** Removes rows from a stored send; an entry without rows is deleted. */
1057
+ const dropRows = (key, held, drop) => {
1058
+ const kept = held.rows.filter((row) => !drop(row));
1059
+ if (kept.length === held.rows.length) {
1060
+ return false;
1061
+ }
1062
+ held.rows = kept;
1063
+ const stored = bestOutcome(kept.map(rowOutcome));
1064
+ if (stored === undefined) {
1065
+ ledger.delete(key);
1066
+ }
1067
+ else {
1068
+ held.outcome = stored;
1069
+ held.changedAt = now();
1070
+ }
1071
+ return true;
1072
+ };
1073
+ /** Rows the scheduler takes: due deferred ones, and in-flight ones it owns once the lease expired. */
1074
+ const dueRows = (held, at) => held.rows.filter((row) => (row.state.status === 'deferred' && Date.parse(row.state.until) <= at)
1075
+ || (row.state.status === 'in-flight'
1076
+ && row.state.leaseUntil <= at
1077
+ && (held.scheduled || row.installationId !== undefined)));
1078
+ /** A stored entry, unless its retention ended. */
1079
+ const lookup = (key) => {
1080
+ const held = ledger.get(key);
1081
+ if (held !== undefined && now() - held.changedAt >= keyRetentionMs) {
1082
+ ledger.delete(key);
1083
+ return undefined;
1084
+ }
1085
+ return held;
1086
+ };
1087
+ /**
1088
+ * Drops the rows of a reminder's sends that wait (deferred) or are in
1089
+ * flight, so the scheduler replays none of them: whether there were any. A
1090
+ * send the provider may already have accepted cannot be recalled.
1091
+ */
1092
+ const dropReminderSends = (player, key) => {
1093
+ let dropped = false;
1094
+ for (const [sendKey, held] of [...ledger]) {
1095
+ if (held.origin.kind === 'reminder' && held.origin.key === key && held.request.player === player) {
1096
+ dropped = dropRows(sendKey, held, (row) => row.state.status !== 'delivered') || dropped;
1097
+ }
1098
+ }
1099
+ return dropped;
1100
+ };
1101
+ const removeReminder = (reminder) => {
1102
+ const index = reminders.indexOf(reminder);
1103
+ if (index !== -1) {
1104
+ reminders.splice(index, 1);
1105
+ }
1106
+ };
1107
+ const rateReached = () => {
1108
+ const rate = entry?.rate;
1109
+ if (rate === undefined) {
1110
+ return false;
1111
+ }
1112
+ const since = now() - rate.windowSeconds * secondMs;
1113
+ return providerCalls.filter((call) => Date.parse(call.at) > since).length >= rate.count;
1114
+ };
1115
+ // Scheduler items.
1116
+ const sendReminder = async (reminder) => {
1117
+ // A cancel or replace since the snapshot wins.
1118
+ if (!reminders.includes(reminder)) {
1119
+ return;
1120
+ }
1121
+ let idempotencyKey;
1122
+ try {
1123
+ idempotencyKey = createNotificationReminderIdempotencyKey(reminder);
1124
+ }
1125
+ catch (error) {
1126
+ removeReminder(reminder);
1127
+ throw new NotificationSendInputError(error instanceof Error ? error.message : String(error), {
1128
+ cause: error,
1129
+ });
1130
+ }
1131
+ const request = {
1132
+ idempotencyKey,
1133
+ player: reminder.player,
1134
+ topic: reminder.topic,
1135
+ ...(reminder.templateData === undefined ? {} : { templateData: reminder.templateData }),
1136
+ ...(reminder.link === undefined ? {} : { link: reminder.link }),
1137
+ };
1138
+ if (claims.has(request.idempotencyKey)) {
1139
+ // Another worker holds the send and settles the reminder.
1140
+ return;
1141
+ }
1142
+ if (lookup(request.idempotencyKey) !== undefined) {
1143
+ // Its send was already made; the stored rows carry it from here.
1144
+ removeReminder(reminder);
1145
+ return;
1146
+ }
1147
+ let settled = true;
1148
+ try {
1149
+ await sendNew(request, notificationSendFingerprint(request), { kind: 'reminder', key: reminder.key }, true, () => reminders.includes(reminder));
1150
+ }
1151
+ catch (error) {
1152
+ // A send that cannot be made (a caller error) fails once; any other failure is retried.
1153
+ settled = error instanceof NotificationSendInputError;
1154
+ throw error;
1155
+ }
1156
+ finally {
1157
+ if (settled) {
1158
+ removeReminder(reminder);
1159
+ }
1160
+ }
1161
+ };
1162
+ const replayStored = async (key, held) => {
1163
+ // The lease: only the stored entry, while no other worker holds its key.
1164
+ if (ledger.get(key) !== held || claims.has(key)) {
1165
+ return;
1166
+ }
1167
+ const due = dueRows(held, now());
1168
+ if (due.length === 0) {
1169
+ return;
1170
+ }
1171
+ if (held.origin.kind === 'notice' && !noticeListed(held.request.player, held.origin.notice)) {
1172
+ // The game gave the notice while the push waited: it is not sent again.
1173
+ dropRows(key, held, (row) => due.includes(row));
1174
+ return;
1175
+ }
1176
+ const previous = held.outcome;
1177
+ // From here the scheduler owns the send: an ambiguous answer waits out the lease.
1178
+ held.scheduled = true;
1179
+ let outcome;
1180
+ try {
1181
+ outcome = await replay(key, held, due, () => ledger.get(key) === held && due.every((row) => held.rows.includes(row)));
1182
+ }
1183
+ catch (error) {
1184
+ if (error instanceof NotificationSendInputError && ledger.get(key) === held) {
1185
+ // A replay that cannot be made fails once instead of on every run. A
1186
+ // notice that cannot render is not sent again: the game still owes it.
1187
+ dropRows(key, held, (row) => due.includes(row));
1188
+ if (held.origin.kind === 'notice') {
1189
+ failedNotices.add(key);
1190
+ }
1191
+ }
1192
+ throw error;
1193
+ }
1194
+ if (outcome !== undefined && !sameOutcome(previous, outcome)) {
1195
+ record(outcome, held.request);
1196
+ }
1197
+ };
1198
+ const sendNotice = async (player, listed, topic) => {
1199
+ const notice = noticeIdentityOf(listed);
1200
+ // The variant the notice was listed with, so the push says what the game shows.
1201
+ const { variant } = listed;
1202
+ const request = {
1203
+ idempotencyKey: createNotificationNoticeIdempotencyKey({ ...notice, player }),
1204
+ player,
1205
+ topic,
1206
+ templateData: { consentDate: toSeoulCalendarDate(notice.record.at) },
1207
+ };
1208
+ if (!noticeListed(player, notice)
1209
+ || failedNotices.has(request.idempotencyKey)
1210
+ || lookup(request.idempotencyKey) !== undefined
1211
+ || claims.has(request.idempotencyKey)) {
1212
+ return;
1213
+ }
1214
+ try {
1215
+ await sendNew(request, notificationSendFingerprint(request), { kind: 'notice', notice, variant }, true);
1216
+ }
1217
+ catch (error) {
1218
+ if (error instanceof NotificationSendInputError) {
1219
+ // The notice topic cannot render it (a catalog error): the game still owes it in the game.
1220
+ failedNotices.add(request.idempotencyKey);
1221
+ }
1222
+ throw error;
1223
+ }
1224
+ };
1225
+ return {
1226
+ entry,
1227
+ get consent() {
1228
+ return consentRows.map((row) => ({ ...row.record, player: row.player }));
1229
+ },
1230
+ get endpoints() {
1231
+ return endpoints;
1232
+ },
1233
+ get deliveries() {
1234
+ return deliveries;
1235
+ },
1236
+ get providerCalls() {
1237
+ return providerCalls;
1238
+ },
1239
+ get reminders() {
1240
+ return reminders;
1241
+ },
1242
+ get notices() {
1243
+ return [...new Set(consentRows.map((row) => row.player))]
1244
+ .flatMap((player) => noticesOf(player).map((notice) => ({ ...notice, player })))
1245
+ .sort((left, right) => compareText(left.dueBy, right.dueBy) || compareText(left.kind, right.kind));
1246
+ },
1247
+ get noticesGiven() {
1248
+ return noticesGiven;
1249
+ },
1250
+ session() {
1251
+ return current;
1252
+ },
1253
+ onAuthenticated(listener) {
1254
+ sessionListeners.add(listener);
1255
+ return () => {
1256
+ sessionListeners.delete(listener);
1257
+ };
1258
+ },
1259
+ authenticate(player) {
1260
+ sessionCount += 1;
1261
+ const started = {
1262
+ id: `session-${sessionCount}`,
1263
+ player,
1264
+ installationId: notificationConformanceInstallationId,
1265
+ };
1266
+ current = started;
1267
+ identities.add(player);
1268
+ for (const listener of [...sessionListeners]) {
1269
+ listener(started);
1270
+ }
1271
+ return started;
1272
+ },
1273
+ revokeSession() {
1274
+ const ended = requireSession('revokeSession');
1275
+ current = undefined;
1276
+ // The end of the session that registered an installation unbinds it.
1277
+ for (let index = endpoints.length - 1; index >= 0; index -= 1) {
1278
+ if (endpoints[index]?.session === ended.id) {
1279
+ endpoints.splice(index, 1);
1280
+ }
1281
+ }
1282
+ },
1283
+ registerInstallation(installation) {
1284
+ const provider = requireEntry('backend.registerEndpoint');
1285
+ if (provider.addressing !== 'device') {
1286
+ throw new NotificationConformanceScriptError('backend.registerEndpoint needs a device-addressed provider.');
1287
+ }
1288
+ if (notificationKeyIssue(installation.installationId) !== undefined) {
1289
+ throw new NotificationConformanceScriptError(`Invalid installation id ${JSON.stringify(installation.installationId)}.`);
1290
+ }
1291
+ bindEndpoint({
1292
+ provider: provider.provider,
1293
+ kind: installation.kind,
1294
+ value: installation.value,
1295
+ installationId: installation.installationId,
1296
+ permission: installation.permission,
1297
+ player: input.defaultPlayer,
1298
+ });
1299
+ },
1300
+ async recordConsent(entryRecord) {
1301
+ appendConsent(requireSession('recordConsent').player, entryRecord);
1302
+ },
1303
+ async listConsent() {
1304
+ return recordsOf(requireSession('listConsent').player);
1305
+ },
1306
+ async registerEndpoint(registration) {
1307
+ const session = requireSession('registerEndpoint');
1308
+ const provider = requireEntry('registerEndpoint');
1309
+ if (registration.provider !== provider.provider) {
1310
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1311
+ message: `The backend has no provider ${registration.provider}.`,
1312
+ });
1313
+ }
1314
+ // An unknown zone is dropped, so quiet hours fall back to the deployment's zone.
1315
+ const timeZone = registration.timeZone !== undefined && isValidNotificationTimeZone(registration.timeZone)
1316
+ ? registration.timeZone
1317
+ : undefined;
1318
+ const { address } = registration;
1319
+ if (address.kind === 'platform-user') {
1320
+ // The recipient resolves from the authenticated identity; only locale and zone are kept.
1321
+ platformRegistrations.set(session.player, {
1322
+ ...(registration.locale === undefined ? {} : { locale: registration.locale }),
1323
+ ...(timeZone === undefined ? {} : { timeZone }),
1324
+ });
1325
+ return;
1326
+ }
1327
+ if (provider.addressing !== 'device') {
1328
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1329
+ message: `Provider ${provider.provider} resolves platform users; it takes no device address.`,
1330
+ });
1331
+ }
1332
+ bindEndpoint({
1333
+ provider: provider.provider,
1334
+ kind: address.kind,
1335
+ value: address.value,
1336
+ ...(address.environment === undefined ? {} : { environment: address.environment }),
1337
+ installationId: session.installationId,
1338
+ permission: address.permission,
1339
+ player: session.player,
1340
+ ...(registration.locale === undefined ? {} : { locale: registration.locale }),
1341
+ ...(timeZone === undefined ? {} : { timeZone }),
1342
+ session: session.id,
1343
+ });
1344
+ },
1345
+ async unregisterEndpoint(request) {
1346
+ const session = current;
1347
+ if (session === undefined) {
1348
+ // The end of the session already unbound what it registered.
1349
+ return;
1350
+ }
1351
+ const index = endpoints.findIndex((endpoint) => endpoint.provider === request.provider
1352
+ && endpoint.installationId === session.installationId
1353
+ && endpoint.player === request.player);
1354
+ if (index !== -1) {
1355
+ endpoints.splice(index, 1);
1356
+ }
1357
+ },
1358
+ async setReminder(reminder) {
1359
+ const session = requireSession('setReminder');
1360
+ const provider = requireEntry('setReminder');
1361
+ refuseNoticeTopic(reminder.topic);
1362
+ const issue = reminderInputIssue(reminder)
1363
+ ?? (provider.content === 'host-template' && reminder.templateData !== undefined
1364
+ ? 'templateData: a host-template provider keeps template context on the server'
1365
+ : undefined);
1366
+ const topic = topicOf(reminder.topic);
1367
+ if (issue !== undefined || topic === undefined || !topic.channels.includes('remote')) {
1368
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1369
+ message: `Invalid server reminder: ${issue ?? `topic ${reminder.topic} has no remote channel`}.`,
1370
+ });
1371
+ }
1372
+ if (topic.purpose === 'advertising' && input.deployment?.advertisingRules === undefined) {
1373
+ // The gate would refuse every send of it, so it is refused now.
1374
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1375
+ message: `Advertising topic ${topic.id} needs the deployment's advertisingRules.`,
1376
+ });
1377
+ }
1378
+ const stored = {
1379
+ player: session.player,
1380
+ key: reminder.key,
1381
+ topic: topic.id,
1382
+ fireAt: normalizeNotificationTimestamp(reminder.fireAt),
1383
+ ...(reminder.templateData === undefined ? {} : { templateData: reminder.templateData }),
1384
+ ...(reminder.link === undefined ? {} : { link: reminder.link }),
1385
+ };
1386
+ try {
1387
+ // A reminder whose send could not be keyed is refused now, not at every scheduler run.
1388
+ createNotificationReminderIdempotencyKey(stored);
1389
+ }
1390
+ catch (error) {
1391
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1392
+ message: error instanceof Error ? error.message : String(error),
1393
+ cause: error,
1394
+ });
1395
+ }
1396
+ const endpointPermissions = provider.addressing === 'device'
1397
+ ? validEndpointsOf(session.player).map((endpoint) => endpoint.permission)
1398
+ : [];
1399
+ const serverPermission = await readServerPermission({
1400
+ idempotencyKey: reminder.key,
1401
+ player: session.player,
1402
+ topic: topic.id,
1403
+ });
1404
+ const refusal = evaluateNotificationReminderConsent({
1405
+ entry: provider,
1406
+ topic,
1407
+ consent: recordsOf(session.player),
1408
+ ...(serverPermission === undefined ? {} : { serverPermission }),
1409
+ ...(endpointPermissions.length === 0
1410
+ ? {}
1411
+ : {
1412
+ endpointPermission: endpointPermissions.includes('granted')
1413
+ ? 'granted'
1414
+ : (endpointPermissions[0] ?? 'unknown'),
1415
+ }),
1416
+ history: historyFor(session.player),
1417
+ });
1418
+ if (refusal !== undefined) {
1419
+ return refusal;
1420
+ }
1421
+ // One reminder per key: setting it replaces the pending one and its sends still waiting.
1422
+ const waited = dropReminderSends(session.player, reminder.key);
1423
+ const index = reminders.findIndex((pending) => pending.player === session.player && pending.key === reminder.key);
1424
+ if (index === -1) {
1425
+ reminders.push(stored);
1426
+ return waited ? 'replaced' : 'scheduled';
1427
+ }
1428
+ reminders[index] = stored;
1429
+ return 'replaced';
1430
+ },
1431
+ async cancelReminder(request) {
1432
+ const session = requireSession('cancelReminder');
1433
+ const issue = notificationKeyIssue(request.key);
1434
+ if (issue !== undefined) {
1435
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', { message: `Invalid key: ${issue}.` });
1436
+ }
1437
+ const waited = dropReminderSends(session.player, request.key);
1438
+ const index = reminders.findIndex((pending) => pending.player === session.player && pending.key === request.key);
1439
+ if (index === -1) {
1440
+ return waited;
1441
+ }
1442
+ reminders.splice(index, 1);
1443
+ return true;
1444
+ },
1445
+ async listNotices() {
1446
+ return noticesOf(requireSession('listNotices').player);
1447
+ },
1448
+ async recordNotice(input) {
1449
+ const session = requireSession('recordNotice');
1450
+ const { channel, ...named } = input;
1451
+ const notice = noticeIdentityOf(recordNoticeInputOf(named));
1452
+ // Only a listed notice is recorded: one already recorded, not listed
1453
+ // yet, or unknown changes nothing, so a retried call is safe.
1454
+ if (!noticeListed(session.player, notice)) {
1455
+ return;
1456
+ }
1457
+ noticesGiven.push({ ...notice, player: session.player, channel, at: clock.now() });
1458
+ },
1459
+ async deliver(request) {
1460
+ const send = sendRequestOf(request, input.defaultPlayer);
1461
+ refuseNoticeTopic(send.topic);
1462
+ const key = send.idempotencyKey;
1463
+ const fingerprint = notificationSendFingerprint(send);
1464
+ const held = lookup(key);
1465
+ if (held !== undefined) {
1466
+ if (held.fingerprint !== fingerprint) {
1467
+ throw conflictError(key);
1468
+ }
1469
+ const expired = held.rows.filter((row) => row.state.status === 'in-flight' && row.state.leaseUntil <= now() && row.installationId === undefined);
1470
+ if (held.outcome.status === 'in-flight' && !held.scheduled && !claims.has(key) && expired.length > 0) {
1471
+ // The claim's lease expired: this caller is the next claimer and replays it.
1472
+ const outcome = (await asInvalidInput(() => replay(key, held, expired, () => ledger.get(key) === held))) ?? { status: 'in-flight' };
1473
+ record(outcome, send);
1474
+ return outcome;
1475
+ }
1476
+ const answer = held.outcome.status === 'delivered'
1477
+ ? { status: 'delivered', alreadyProcessed: true }
1478
+ : held.outcome;
1479
+ record(answer, send);
1480
+ return answer;
1481
+ }
1482
+ const claimed = claims.get(key);
1483
+ if (claimed !== undefined) {
1484
+ if (claimed !== fingerprint) {
1485
+ throw conflictError(key);
1486
+ }
1487
+ const answer = { status: 'in-flight' };
1488
+ record(answer, send);
1489
+ return answer;
1490
+ }
1491
+ // Without a `stillWanted` predicate a send is never abandoned.
1492
+ return (await asInvalidInput(() => sendNew(send, fingerprint, { kind: 'send' }, false)))
1493
+ ?? { status: 'in-flight' };
1494
+ },
1495
+ queueProviderResult(result) {
1496
+ providerResults.push(result);
1497
+ },
1498
+ async runScheduler() {
1499
+ const at = now();
1500
+ for (const [key, held] of [...ledger]) {
1501
+ if (at - held.changedAt >= keyRetentionMs) {
1502
+ ledger.delete(key);
1503
+ }
1504
+ }
1505
+ const due = [];
1506
+ for (const reminder of [...reminders]) {
1507
+ const fireAt = Date.parse(reminder.fireAt);
1508
+ if (fireAt <= at) {
1509
+ due.push({ at: fireAt, run: () => sendReminder(reminder) });
1510
+ }
1511
+ }
1512
+ for (const [key, held] of [...ledger]) {
1513
+ const first = Math.min(...dueRows(held, at).map((row) => row.state.status === 'deferred'
1514
+ ? Date.parse(row.state.until)
1515
+ : row.state.status === 'in-flight' ? row.state.leaseUntil : at));
1516
+ if (Number.isFinite(first)) {
1517
+ due.push({ at: first, run: () => replayStored(key, held) });
1518
+ }
1519
+ }
1520
+ // Only a provider that can carry a notice sends one; through any other
1521
+ // the notice is not sent, so it stays owed in the game.
1522
+ if (rules === 'kr'
1523
+ && noticeTopic !== undefined
1524
+ && noticeTopicHere !== undefined
1525
+ && entry !== undefined
1526
+ && notificationProviderCarriesNotices(entry)) {
1527
+ const today = toSeoulCalendarDate(clock.now());
1528
+ for (const player of [...new Set(consentRows.map((row) => row.player))]) {
1529
+ for (const notice of noticesOf(player)) {
1530
+ const from = notificationNoticeSendFrom(notice.dueBy);
1531
+ if (from <= today) {
1532
+ due.push({
1533
+ at: Date.parse(`${from}T00:00:00+09:00`),
1534
+ run: () => sendNotice(player, notice, noticeTopic),
1535
+ });
1536
+ }
1537
+ }
1538
+ }
1539
+ }
1540
+ due.sort((left, right) => left.at - right.at);
1541
+ const failures = [];
1542
+ for (const item of due) {
1543
+ // Pacing: the rest stays due for the next run.
1544
+ if (rateReached()) {
1545
+ break;
1546
+ }
1547
+ try {
1548
+ await item.run();
1549
+ }
1550
+ catch (error) {
1551
+ failures.push(error);
1552
+ }
1553
+ }
1554
+ if (failures.length > 0) {
1555
+ throw new AggregateError(failures, `The scheduler could not process ${failures.length} item(s); the others ran. First: `
1556
+ + describeError(failures[0]));
1557
+ }
1558
+ },
1559
+ };
1560
+ }
1561
+ /** A well-formed `notices.recordShown` input, or `NOTIFICATIONS_INVALID_INPUT`. */
1562
+ function recordNoticeInputOf(input) {
1563
+ try {
1564
+ return assertRecordNotificationNoticeInput(input);
1565
+ }
1566
+ catch (error) {
1567
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1568
+ message: 'A notice is a listed notice, or its kind, the kind and time of the record it reports '
1569
+ + `on, and its dueBy: ${describeError(error)}`,
1570
+ cause: error,
1571
+ });
1572
+ }
1573
+ }
1574
+ /**
1575
+ * Only the fields that name a notice, with the record time in the canonical
1576
+ * form; a listed notice's `record.state` and `textVersion` are left out.
1577
+ */
1578
+ function noticeIdentityOf(notice) {
1579
+ return {
1580
+ kind: notice.kind,
1581
+ record: { kind: notice.record.kind, at: normalizeNotificationTimestamp(notice.record.at) },
1582
+ dueBy: notice.dueBy,
1583
+ };
1584
+ }
1585
+ /** Why a reminder's input breaks the RFC bounds, or `undefined`. */
1586
+ function reminderInputIssue(input) {
1587
+ const keyIssue = notificationKeyIssue(input.key);
1588
+ if (keyIssue !== undefined) {
1589
+ return `key: ${keyIssue}`;
1590
+ }
1591
+ if (timestampOrUndefined(input.fireAt) === undefined) {
1592
+ return 'fireAt: expected RFC 3339 with an explicit offset';
1593
+ }
1594
+ if (input.templateData !== undefined) {
1595
+ const issue = notificationTemplateDataIssue(input.templateData);
1596
+ if (issue !== undefined) {
1597
+ return `templateData: ${issue}`;
1598
+ }
1599
+ }
1600
+ if (input.link !== undefined && !isBoundedText(input.link)) {
1601
+ return 'link: expected a non-empty string';
1602
+ }
1603
+ return undefined;
1604
+ }
1605
+ function sendRequestOf(request, defaultPlayer) {
1606
+ const issue = sendInputIssue(request);
1607
+ if (issue !== undefined) {
1608
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1609
+ message: `Invalid send: ${issue}.`,
1610
+ });
1611
+ }
1612
+ return {
1613
+ idempotencyKey: request.idempotencyKey,
1614
+ player: request.player ?? defaultPlayer,
1615
+ topic: request.topic,
1616
+ ...(request.provider === undefined ? {} : { provider: request.provider }),
1617
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
1618
+ ...(request.data === undefined ? {} : { data: request.data }),
1619
+ ...(request.locale === undefined ? {} : { locale: request.locale }),
1620
+ ...(request.link === undefined ? {} : { link: request.link }),
1621
+ };
1622
+ }
1623
+ function sendInputIssue(request) {
1624
+ const keyIssue = notificationKeyIssue(request.idempotencyKey);
1625
+ if (keyIssue !== undefined) {
1626
+ return `idempotencyKey: ${keyIssue}`;
1627
+ }
1628
+ const reserved = notificationReservedKeyPrefix(request.idempotencyKey);
1629
+ if (reserved !== undefined) {
1630
+ return `idempotencyKey: the ${reserved} prefix is reserved for the scheduler`;
1631
+ }
1632
+ if (request.templateData !== undefined) {
1633
+ const issue = notificationTemplateDataIssue(request.templateData);
1634
+ if (issue !== undefined) {
1635
+ return `templateData: ${issue}`;
1636
+ }
1637
+ }
1638
+ if (request.data !== undefined) {
1639
+ const issue = notificationTemplateDataIssue(request.data, { stringsOnly: true });
1640
+ if (issue !== undefined) {
1641
+ return `data: ${issue}`;
1642
+ }
1643
+ }
1644
+ for (const [name, value] of [
1645
+ ['topic', request.topic],
1646
+ ['player', request.player],
1647
+ ['provider', request.provider],
1648
+ ['locale', request.locale],
1649
+ ['link', request.link],
1650
+ ]) {
1651
+ if (value !== undefined && !isBoundedText(value)) {
1652
+ return `${name}: expected a non-empty string`;
1653
+ }
1654
+ }
1655
+ return undefined;
1656
+ }
1657
+ function isBoundedText(value) {
1658
+ return typeof value === 'string' && value.length > 0 && value.length <= 16_384;
1659
+ }
1660
+ function timestampOrUndefined(value) {
1661
+ if (typeof value !== 'string') {
1662
+ return undefined;
1663
+ }
1664
+ try {
1665
+ return normalizeNotificationTimestamp(value);
1666
+ }
1667
+ catch {
1668
+ return undefined;
1669
+ }
1670
+ }
1671
+ function conflictError(key) {
1672
+ return new NotificationError('NOTIFICATIONS_IDEMPOTENCY_CONFLICT', {
1673
+ message: `Key ${key} is stored for another request.`,
1674
+ });
1675
+ }
1676
+ /** Runs a send; a caller error from the gate (such as missing template data) becomes `NOTIFICATIONS_INVALID_INPUT`. */
1677
+ async function asInvalidInput(work) {
1678
+ try {
1679
+ return await work();
1680
+ }
1681
+ catch (error) {
1682
+ if (error instanceof NotificationSendInputError) {
1683
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
1684
+ message: error.message,
1685
+ cause: error,
1686
+ });
1687
+ }
1688
+ throw error;
1689
+ }
1690
+ }
1691
+ /** The policy gate, with its caller errors as `NotificationSendInputError`. */
1692
+ function decide(input) {
1693
+ try {
1694
+ return evaluateNotificationDelivery(input);
1695
+ }
1696
+ catch (error) {
1697
+ if (error instanceof TypeError) {
1698
+ throw new NotificationSendInputError(error.message, { cause: error });
1699
+ }
1700
+ throw error;
1701
+ }
1702
+ }
1703
+ function rowOutcome(row) {
1704
+ switch (row.state.status) {
1705
+ case 'delivered':
1706
+ return { status: 'delivered', alreadyProcessed: false };
1707
+ case 'in-flight':
1708
+ return { status: 'in-flight' };
1709
+ case 'deferred':
1710
+ return { status: 'deferred', reason: row.state.reason, until: row.state.until };
1711
+ }
1712
+ }
1713
+ /**
1714
+ * How far a recipient's answer got, for a send's result across recipients:
1715
+ * delivered, in flight, waiting, a provider's refusal, a policy refusal, and
1716
+ * last a recipient that did not resolve.
1717
+ */
1718
+ function outcomeRank(outcome) {
1719
+ switch (outcome.status) {
1720
+ case 'delivered':
1721
+ return 5;
1722
+ case 'in-flight':
1723
+ return 4;
1724
+ case 'deferred':
1725
+ return 3;
1726
+ case 'unavailable':
1727
+ return outcome.reason === 'no-endpoint' || outcome.reason === 'link-missing' ? 0 : 2;
1728
+ case 'suppressed':
1729
+ return 1;
1730
+ }
1731
+ }
1732
+ /** The highest-ranked outcome, the earliest among deferred ones, the first among equals. */
1733
+ function bestOutcome(outcomes) {
1734
+ let best;
1735
+ for (const outcome of outcomes) {
1736
+ if (best === undefined
1737
+ || outcomeRank(outcome) > outcomeRank(best)
1738
+ || (outcome.status === 'deferred'
1739
+ && best.status === 'deferred'
1740
+ && Date.parse(outcome.until) < Date.parse(best.until))) {
1741
+ best = outcome;
1742
+ }
1743
+ }
1744
+ return best;
1745
+ }
1746
+ function sameOutcome(left, right) {
1747
+ return JSON.stringify(left) === JSON.stringify(right);
1748
+ }
1749
+ function formatInstant(millis) {
1750
+ return normalizeNotificationTimestamp(new Date(millis).toISOString());
1751
+ }
1752
+ function compareText(left, right) {
1753
+ return left < right ? -1 : left > right ? 1 : 0;
1754
+ }
1755
+ /**
1756
+ * The reference notifications runtime (RFC "Runtime"): the provider composed
1757
+ * with the backend for the authenticated session.
1758
+ *
1759
+ * - Reads return the provider snapshot. A topic the host reads `unknown`
1760
+ * takes the latest record, `sources` says which answered, and a
1761
+ * game-rendered consent that is not opted in reads `prompt` until a
1762
+ * decision is recorded and `denied` after it. A topic whose mapping needs no
1763
+ * agreement reads `granted`. A read that observes a changed decided state
1764
+ * records it, and every read still answers when the backend cannot.
1765
+ * - Reads, requests, and revocations take each topic once, however often it
1766
+ * is listed.
1767
+ * - Requests throw `NOTIFICATIONS_USER_GESTURE_REQUIRED` without a gesture on
1768
+ * a gesture-only provider. An unsupported provider answers `unavailable`, a
1769
+ * provider in `action-required` answers that with its `requiredAction`, an
1770
+ * undeclared or unmapped topic answers `unavailable`, a topic that needs no
1771
+ * agreement answers `already-granted`, and a topic the host reads `blocked`
1772
+ * answers `blocked`, all without reaching the host; every other topic
1773
+ * reaches the prompt.
1774
+ * - Records: decided states that differ from the latest record of their kind
1775
+ * and scope, never `dismissed` or `action-required` answers. Device
1776
+ * providers write none (the endpoint holds the permission), other app-scope
1777
+ * providers write `delivery-permission`, and topic-scope providers write
1778
+ * `host-agreement` with the host evidence, plus a `topic-preference` of
1779
+ * `granted` when the player accepts a `requested` topic's agreement.
1780
+ * Per-send acceptances are appended every time. A record belongs to the
1781
+ * player signed in when the call or event began: one observed before
1782
+ * authentication waits for it (only the latest per kind and scope), and one
1783
+ * observed for a player waits for that player's session. A player action
1784
+ * (`setTopicPreference`, `setAdvertisingConsent`) needs an authenticated
1785
+ * player and throws `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` without one, as
1786
+ * does every other backend call (a server reminder, `reminders.cancel`'s
1787
+ * server part, and `notices`). A backend call belongs to the player signed
1788
+ * in when it began and is refused when another player signed in since. A
1789
+ * record the backend would refuse is dropped and its error surfaces.
1790
+ * - Registrations: the provider's registration goes to the backend at
1791
+ * authentication, on `register()`, on `endpoint-changed` (a rotated device
1792
+ * address, or a platform user's changed locale or zone), and when a read
1793
+ * or request, or a revocation, changes the device's decided permission; and
1794
+ * at start and on reads once the last one the backend acknowledged is older
1795
+ * than 7 days. An identical one for the same player and session is skipped
1796
+ * for 7 days, except on `register()`. `unregister()` unbinds the
1797
+ * installation from the player signed in when it was called and cancels the
1798
+ * pending local reminders, even when the unbind fails, and nothing binds it
1799
+ * again, across restarts, until `register()` or the next authentication.
1800
+ * - Reminders: one key across both routes, and `cancel` removes it from each
1801
+ * route that has it. Local ones pass the client gate steps (permission,
1802
+ * then the topic preference from the backend, then quiet hours, which move
1803
+ * them or refuse a topic that is not deferrable, then the topic's daily
1804
+ * cap); server ones are checked and stored by the backend. Turning a topic off cancels its local
1805
+ * reminders, a known permission other than granted cancels them, and start,
1806
+ * authentication, and every backend read reconcile them with the catalog
1807
+ * (a topic that no longer takes the local route loses its reminders), the
1808
+ * latest preference, and permission.
1809
+ * - Game listeners run after the runtime's own handling of an event; a
1810
+ * listener that throws stops neither the others nor the runtime, and its
1811
+ * error surfaces from `runUntilIdle`, as do failures of background work:
1812
+ * the first one, or an `AggregateError` that counts them all. `stop()` and
1813
+ * a later `start()` buffer events for the next first subscriber again.
1814
+ */
1815
+ export function createNotificationConformanceRuntime(input) {
1816
+ const { provider, backend, clock } = input;
1817
+ const storage = input.storage ?? {};
1818
+ const listeners = new Set();
1819
+ let buffer = [];
1820
+ let gameSubscribed = false;
1821
+ let unsubscribeProvider;
1822
+ let unsubscribeAuthentication;
1823
+ let configurationIssue;
1824
+ /** The provider's latest registration, from `register()` or `endpoint-changed`. */
1825
+ let latestRegistration;
1826
+ const background = new Set();
1827
+ /** The first background failure since the last `runUntilIdle`, and how many there were. */
1828
+ let failure;
1829
+ const fail = (error) => {
1830
+ if (failure === undefined) {
1831
+ failure = { error, count: 1 };
1832
+ }
1833
+ else {
1834
+ failure.count += 1;
1835
+ }
1836
+ };
1837
+ const waitingRecords = [];
1838
+ /** One promise chain per queue: records, registrations, and local reminder changes. */
1839
+ const queues = {
1840
+ record: Promise.resolve(),
1841
+ register: Promise.resolve(),
1842
+ local: Promise.resolve(),
1843
+ };
1844
+ /** Local reminders this runtime scheduled: the fallback for a provider without `listLocal`. */
1845
+ const scheduledLocal = new Map();
1846
+ const emit = (event) => {
1847
+ if (!gameSubscribed) {
1848
+ bufferEvent(buffer, event);
1849
+ return;
1850
+ }
1851
+ for (const error of deliverEach([...listeners], [event])) {
1852
+ fail(error);
1853
+ }
1854
+ };
1855
+ const track = (work) => {
1856
+ const tracked = work
1857
+ .catch((error) => {
1858
+ fail(error);
1859
+ })
1860
+ .finally(() => {
1861
+ background.delete(tracked);
1862
+ });
1863
+ background.add(tracked);
1864
+ };
1865
+ /** Awaits follow-up work of a call that has already answered; a failure surfaces from `runUntilIdle`. */
1866
+ const settle = async (work) => {
1867
+ try {
1868
+ await work;
1869
+ }
1870
+ catch (error) {
1871
+ fail(error);
1872
+ }
1873
+ };
1874
+ /**
1875
+ * Runs `work` after every earlier job of the same queue, in call order. A job
1876
+ * never enqueues onto its own queue and waits for it, which would deadlock.
1877
+ */
1878
+ const enqueue = (queue, work) => {
1879
+ const run = queues[queue].then(work);
1880
+ queues[queue] = run.catch(() => undefined);
1881
+ return run;
1882
+ };
1883
+ const topicOf = (topic) => input.catalog.find((entry) => entry.id === topic);
1884
+ const capabilitiesOf = async () => {
1885
+ const capabilities = await provider.getCapabilities();
1886
+ if (configurationIssue !== undefined) {
1887
+ throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
1888
+ message: `The provider disagrees with its registry entry: ${configurationIssue}.`,
1889
+ });
1890
+ }
1891
+ return capabilities;
1892
+ };
1893
+ const sessionPlayer = () => backend.session()?.player;
1894
+ // Consent records.
1895
+ /** The session player's records, with that player's records still waiting; `undefined` without a session. */
1896
+ const readRecords = async () => {
1897
+ const session = backend.session();
1898
+ if (session === undefined) {
1899
+ return undefined;
1900
+ }
1901
+ const records = await backend.listConsent();
1902
+ return [
1903
+ ...records,
1904
+ ...waitingRecords
1905
+ .filter((pending) => pending.player === undefined || pending.player === session.player)
1906
+ .map((pending) => pending.record),
1907
+ ];
1908
+ };
1909
+ const addWaiting = (next) => {
1910
+ if (next.player === undefined && next.mode !== 'each') {
1911
+ // Before authentication only the latest per kind and scope is kept.
1912
+ for (let index = waitingRecords.length - 1; index >= 0; index -= 1) {
1913
+ const waiting = waitingRecords[index];
1914
+ if (waiting !== undefined
1915
+ && waiting.player === undefined
1916
+ && waiting.mode !== 'each'
1917
+ && waiting.record.kind === next.record.kind
1918
+ && waiting.record.topic === next.record.topic) {
1919
+ waitingRecords.splice(index, 1);
1920
+ }
1921
+ }
1922
+ }
1923
+ waitingRecords.push(next);
1924
+ };
1925
+ /**
1926
+ * Inside the record queue: writes the session player's waiting records and
1927
+ * then `pending`, in order. What cannot be written waits for the next write.
1928
+ */
1929
+ const writeRecords = async (pending) => {
1930
+ for (const next of pending) {
1931
+ addWaiting(next);
1932
+ }
1933
+ const session = backend.session();
1934
+ if (session === undefined) {
1935
+ return;
1936
+ }
1937
+ const mine = waitingRecords.filter((waiting) => waiting.player === undefined || waiting.player === session.player);
1938
+ if (mine.length === 0) {
1939
+ return;
1940
+ }
1941
+ for (const waiting of mine) {
1942
+ waitingRecords.splice(waitingRecords.indexOf(waiting), 1);
1943
+ }
1944
+ try {
1945
+ const records = [...(await backend.listConsent())];
1946
+ while (mine.length > 0) {
1947
+ if (backend.session()?.id !== session.id) {
1948
+ // The session changed during the write: the rest waits for its player.
1949
+ waitingRecords.push(...mine);
1950
+ return;
1951
+ }
1952
+ const next = mine[0];
1953
+ if (next !== undefined) {
1954
+ const latest = latestNotificationConsent(records, next.record.kind, next.record.topic);
1955
+ if (next.mode !== 'changed' || latest?.state !== next.record.state) {
1956
+ await backend.recordConsent(next.record);
1957
+ records.push(next.record);
1958
+ }
1959
+ }
1960
+ mine.shift();
1961
+ }
1962
+ }
1963
+ catch (error) {
1964
+ waitingRecords.push(...mine);
1965
+ throw error;
1966
+ }
1967
+ };
1968
+ const queueRecords = (pending) => {
1969
+ // A record the backend would refuse (a provider's malformed evidence) is
1970
+ // dropped, so it never blocks the records after it; its error surfaces.
1971
+ const valid = pending.filter((next) => {
1972
+ try {
1973
+ assertNotificationConsentRecord(next.record);
1974
+ return true;
1975
+ }
1976
+ catch (error) {
1977
+ fail(error);
1978
+ return false;
1979
+ }
1980
+ });
1981
+ return valid.length === 0 && waitingRecords.length === 0
1982
+ ? Promise.resolve()
1983
+ : enqueue('record', () => writeRecords(valid));
1984
+ };
1985
+ /**
1986
+ * A player action is recorded for the authenticated player, never for
1987
+ * whoever authenticates next.
1988
+ */
1989
+ const requirePlayer = (operation) => {
1990
+ const player = sessionPlayer();
1991
+ if (player === undefined) {
1992
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
1993
+ message: `${operation} records a player action, which needs an authenticated player.`,
1994
+ });
1995
+ }
1996
+ return player;
1997
+ };
1998
+ /** Refuses a server call when another player signed in after the call began. */
1999
+ const assertSamePlayer = (operation, player) => {
2000
+ if (sessionPlayer() !== player) {
2001
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
2002
+ message: `${operation} began for another player; ask again for the player signed in now.`,
2003
+ });
2004
+ }
2005
+ };
2006
+ /**
2007
+ * A server call needs the player who was signed in when it began, and that
2008
+ * player still signed in; before sign-in it is temporarily unavailable.
2009
+ */
2010
+ const requireSignedIn = (operation, player) => {
2011
+ if (player === undefined) {
2012
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
2013
+ message: `${operation} calls the backend, which needs an authenticated player.`,
2014
+ });
2015
+ }
2016
+ assertSamePlayer(operation, player);
2017
+ };
2018
+ /**
2019
+ * The records the decided states in `observations` call for, for `player`:
2020
+ * the one authenticated when the call began, or `undefined` before
2021
+ * authentication.
2022
+ */
2023
+ const permissionRecords = (capabilities, observations, evidence, player) => {
2024
+ const pending = [];
2025
+ const proof = evidence === undefined ? {} : { evidence };
2026
+ for (const observation of observations) {
2027
+ const state = observation.state;
2028
+ if (!isRecordableNotificationPermissionState(state)) {
2029
+ continue;
2030
+ }
2031
+ const mode = capabilities.permission.allowance === 'per-send' && observation.accepted === true
2032
+ ? 'each'
2033
+ : 'changed';
2034
+ if (observation.topic === undefined) {
2035
+ // Device providers keep the permission on the endpoint, not in records.
2036
+ if (capabilities.permission.scope === 'app' && !isDeviceAddressing(capabilities)) {
2037
+ pending.push({
2038
+ mode,
2039
+ player,
2040
+ record: {
2041
+ kind: 'delivery-permission',
2042
+ state,
2043
+ source: 'client-report',
2044
+ at: clock.now(),
2045
+ ...proof,
2046
+ },
2047
+ });
2048
+ }
2049
+ continue;
2050
+ }
2051
+ const topic = topicOf(observation.topic);
2052
+ // Host policy, not the player, granted a topic that needs no agreement.
2053
+ if (capabilities.permission.scope === 'topic' && topic !== undefined && !notificationTopicNeedsNoAgreement(topic)) {
2054
+ pending.push({
2055
+ mode,
2056
+ player,
2057
+ record: {
2058
+ kind: 'host-agreement',
2059
+ topic: observation.topic,
2060
+ state,
2061
+ source: 'client-report',
2062
+ at: clock.now(),
2063
+ ...proof,
2064
+ },
2065
+ });
2066
+ }
2067
+ }
2068
+ return pending;
2069
+ };
2070
+ /**
2071
+ * The states a snapshot reports after the runtime's rules: a game-rendered
2072
+ * consent not opted in reads `denied` once a decision is recorded (or made
2073
+ * now), a topic that needs no agreement reads `granted`, and an `unknown`
2074
+ * host read takes the latest record.
2075
+ */
2076
+ const resolveStates = (capabilities, snapshot, topics, records, decidedNow = false) => {
2077
+ const scope = capabilities.permission.scope;
2078
+ const kind = notificationPermissionConsentKind(scope);
2079
+ const decided = decidedNow
2080
+ || (records !== undefined && latestNotificationConsent(records, 'delivery-permission') !== undefined);
2081
+ const resolve = (host, topic) => {
2082
+ const declared = topic === undefined ? undefined : topicOf(topic);
2083
+ if (scope === 'topic' && declared !== undefined && notificationTopicNeedsNoAgreement(declared)) {
2084
+ return { state: 'granted', source: 'host' };
2085
+ }
2086
+ const state = capabilities.permission.prompt === 'game' && host === 'prompt' && decided
2087
+ ? 'denied'
2088
+ : host;
2089
+ const recorded = state === 'unknown' && records !== undefined
2090
+ ? latestRecordState(records, kind, scope === 'app' ? undefined : topic)
2091
+ : undefined;
2092
+ return recorded === undefined
2093
+ ? { state, source: 'host' }
2094
+ : { state: recorded, source: 'record' };
2095
+ };
2096
+ const app = scope === 'app' && snapshot.app !== undefined
2097
+ ? resolve(snapshot.app, undefined)
2098
+ : undefined;
2099
+ const resolved = topics.map((topic) => [topic, resolve(notificationPermissionStateOf(snapshot, topic), topic)]);
2100
+ return {
2101
+ ...(app === undefined ? {} : { app: app.state, appSource: app.source }),
2102
+ topics: Object.fromEntries(resolved.map(([topic, entry]) => [topic, entry.state])),
2103
+ sources: Object.fromEntries(resolved.map(([topic, entry]) => [topic, entry.source])),
2104
+ };
2105
+ };
2106
+ /** The decided states the host itself reported, one per scope. */
2107
+ const hostObservations = (capabilities, states, topics) => {
2108
+ if (capabilities.permission.scope === 'app') {
2109
+ return states.app === undefined || states.appSource !== 'host' ? [] : [{ state: states.app }];
2110
+ }
2111
+ return topics
2112
+ .filter((topic) => states.sources[topic] === 'host')
2113
+ .map((topic) => ({ topic, state: stateIn(states, topic) }));
2114
+ };
2115
+ // Registrations.
2116
+ /**
2117
+ * Inside the register queue: sends `registration` for the session unless
2118
+ * the backend acknowledged the same one for this player and session within
2119
+ * 7 days. One made before authentication is sent at authentication, and
2120
+ * nothing binds after `unregister()` until `register()` or the next
2121
+ * authentication, across restarts.
2122
+ */
2123
+ const sendRegistration = async (registration, always) => {
2124
+ latestRegistration = registration;
2125
+ const session = backend.session();
2126
+ if (session === undefined || storage.unbound === true) {
2127
+ return;
2128
+ }
2129
+ const last = storage.registration;
2130
+ if (!always
2131
+ && last !== undefined
2132
+ && last.player === session.player
2133
+ && last.session === session.id
2134
+ && sameJson(last.registration, registration)
2135
+ && Date.parse(clock.now()) - Date.parse(last.at) < registrationRefreshMs) {
2136
+ return;
2137
+ }
2138
+ await backend.registerEndpoint(registration);
2139
+ storage.registration = {
2140
+ registration,
2141
+ player: session.player,
2142
+ session: session.id,
2143
+ at: clock.now(),
2144
+ };
2145
+ };
2146
+ /**
2147
+ * Registers the provider's current address or platform identity; `rebind`
2148
+ * ends an `unregister()`. A `permission` the runtime decided (a
2149
+ * game-rendered consent reads `denied` once withdrawn) replaces the one a
2150
+ * device address carries.
2151
+ */
2152
+ const registerCurrent = (always, rebind = false, permission) => enqueue('register', async () => {
2153
+ if (rebind) {
2154
+ delete storage.unbound;
2155
+ }
2156
+ const capabilities = await provider.getCapabilities();
2157
+ const register = provider.register;
2158
+ if (!capabilities.remote.supported || capabilities.remote.addressing === 'none' || register === undefined) {
2159
+ return;
2160
+ }
2161
+ const registration = (await register.call(provider)) ?? latestRegistration;
2162
+ if (registration !== undefined) {
2163
+ const { address } = registration;
2164
+ await sendRegistration(permission === undefined || address.kind === 'platform-user' || address.permission === permission
2165
+ ? registration
2166
+ : { ...registration, address: { ...address, permission } }, always);
2167
+ }
2168
+ });
2169
+ /**
2170
+ * Re-sends the registration once the one the backend last acknowledged for
2171
+ * this player and session is older than 7 days, so the endpoint of a device
2172
+ * in use never goes stale. Runs at start and on reads.
2173
+ */
2174
+ const refreshRegistration = async () => {
2175
+ const session = backend.session();
2176
+ const last = storage.registration;
2177
+ if (session === undefined
2178
+ || storage.unbound === true
2179
+ || last === undefined
2180
+ || last.player !== session.player
2181
+ || last.session !== session.id
2182
+ || Date.parse(clock.now()) - Date.parse(last.at) < registrationRefreshMs
2183
+ || !isDeviceAddressing(await provider.getCapabilities())) {
2184
+ return;
2185
+ }
2186
+ await registerCurrent(false);
2187
+ };
2188
+ /** Re-registers when the device's decided permission differs from the latest registration's. */
2189
+ const syncRegistration = async (capabilities, state) => {
2190
+ if (!isDeviceAddressing(capabilities)
2191
+ || state === undefined
2192
+ || !isRecordableNotificationPermissionState(state)) {
2193
+ return;
2194
+ }
2195
+ const address = (latestRegistration ?? storage.registration?.registration)?.address;
2196
+ if (address !== undefined && address.kind !== 'platform-user' && address.permission === state) {
2197
+ return;
2198
+ }
2199
+ await registerCurrent(false, false, state);
2200
+ };
2201
+ // Local reminders.
2202
+ const listLocalEntries = async () => provider.listLocal === undefined ? [...scheduledLocal.values()] : provider.listLocal();
2203
+ /** Inside the local queue: cancels the pending local reminders `refused` picks. */
2204
+ const cancelLocalNow = async (refused) => {
2205
+ const cancelLocal = provider.cancelLocal;
2206
+ if (cancelLocal === undefined) {
2207
+ return;
2208
+ }
2209
+ const keys = (await listLocalEntries()).filter(refused).map((entry) => entry.key);
2210
+ if (keys.length > 0) {
2211
+ await cancelLocal.call(provider, { keys });
2212
+ for (const key of keys) {
2213
+ scheduledLocal.delete(key);
2214
+ }
2215
+ }
2216
+ };
2217
+ /**
2218
+ * Cancels pending local reminders. In the local queue, so a reminder being
2219
+ * set when a withdrawal lands is scheduled before this sweep runs.
2220
+ */
2221
+ const cancelLocalWhere = (refused) => enqueue('local', async () => {
2222
+ if ((await provider.getCapabilities()).local.supported) {
2223
+ await cancelLocalNow(refused);
2224
+ }
2225
+ });
2226
+ /** The device would still show local reminders after permission stops being granted. */
2227
+ const cancelRevokedLocal = async (capabilities, states, topics) => {
2228
+ if (!capabilities.local.supported) {
2229
+ return;
2230
+ }
2231
+ if (capabilities.permission.scope === 'app') {
2232
+ if (states.app !== undefined && revokesLocalReminders(states.app)) {
2233
+ await cancelLocalWhere(() => true);
2234
+ }
2235
+ return;
2236
+ }
2237
+ const revoked = topics.filter((topic) => revokesLocalReminders(stateIn(states, topic)));
2238
+ if (revoked.length > 0) {
2239
+ await cancelLocalWhere((entry) => revoked.includes(entry.topic));
2240
+ }
2241
+ };
2242
+ /**
2243
+ * Cancels the pending local reminders that no longer pass, in two sweeps.
2244
+ * The first, without the backend, takes every one whose topic no longer
2245
+ * takes the local route: an app update removed the topic from the catalog or
2246
+ * took away its `local` channel. The second takes the others whose
2247
+ * permission is known and not granted and, with the backend's records,
2248
+ * those whose topic preference refuses them, because a preference changed
2249
+ * on another device never reaches this one as an event. The second sweep
2250
+ * runs even when the first fails, and the first one's error is thrown at
2251
+ * the end (with the second one's, if both fail).
2252
+ */
2253
+ const reconcileLocal = () => enqueue('local', async () => {
2254
+ const capabilities = await provider.getCapabilities();
2255
+ if (!capabilities.local.supported || provider.cancelLocal === undefined) {
2256
+ return;
2257
+ }
2258
+ const localTopicOf = (entry) => {
2259
+ const topic = topicOf(entry.topic);
2260
+ return topic !== undefined && routeNotificationReminder(topic, capabilities) === 'local'
2261
+ ? topic
2262
+ : undefined;
2263
+ };
2264
+ let offRoute;
2265
+ try {
2266
+ await cancelLocalNow((entry) => localTopicOf(entry) === undefined);
2267
+ }
2268
+ catch (error) {
2269
+ offRoute = { error };
2270
+ }
2271
+ try {
2272
+ await cancelRefusedLocal(capabilities, localTopicOf);
2273
+ }
2274
+ catch (error) {
2275
+ if (offRoute === undefined) {
2276
+ throw error;
2277
+ }
2278
+ throw new AggregateError([offRoute.error, error], 'Neither sweep of the local reminders completed. First: ' + describeError(offRoute.error));
2279
+ }
2280
+ if (offRoute !== undefined) {
2281
+ throw offRoute.error;
2282
+ }
2283
+ });
2284
+ /**
2285
+ * Inside the local queue: the second sweep. Reminders whose topic left the
2286
+ * local route belong to the first sweep and are skipped, so a reminder the
2287
+ * host cannot cancel is not tried twice.
2288
+ */
2289
+ const cancelRefusedLocal = async (capabilities, localTopicOf) => {
2290
+ const pending = (await listLocalEntries()).filter((entry) => localTopicOf(entry) !== undefined);
2291
+ if (pending.length === 0) {
2292
+ return;
2293
+ }
2294
+ const records = await readRecords();
2295
+ const topics = [...new Set(pending.map((entry) => entry.topic))];
2296
+ const states = resolveStates(capabilities, await provider.getPermission({ topics }), topics, records);
2297
+ await cancelLocalNow((entry) => {
2298
+ const topic = localTopicOf(entry);
2299
+ if (topic === undefined) {
2300
+ return false;
2301
+ }
2302
+ if (revokesLocalReminders(stateIn(states, entry.topic))) {
2303
+ return true;
2304
+ }
2305
+ return records !== undefined && preferenceRefuses(topic, records, clock.now());
2306
+ });
2307
+ };
2308
+ const localZone = () => {
2309
+ const zone = (latestRegistration ?? storage.registration?.registration)?.timeZone;
2310
+ return zone !== undefined && isValidNotificationTimeZone(zone) ? zone : input.timeZone;
2311
+ };
2312
+ /** Inside the local queue: the client gate steps, then the provider's schedule. */
2313
+ const scheduleLocalReminder = async (capabilities, topic, request, player) => {
2314
+ const scheduleLocal = provider.scheduleLocal;
2315
+ if (scheduleLocal === undefined) {
2316
+ return { route: 'local', status: 'unavailable' };
2317
+ }
2318
+ const records = await readRecords();
2319
+ const states = resolveStates(capabilities, await provider.getPermission({ topics: [topic.id] }), [topic.id], records);
2320
+ const decision = evaluateLocalNotificationReminder({
2321
+ topic,
2322
+ permission: stateIn(states, topic.id),
2323
+ consent: records ?? [],
2324
+ fireAt: request.fireAt,
2325
+ timeZone: localZone(),
2326
+ // The topic's other pending reminders, which only its daily cap reads;
2327
+ // replacing one does not count it against itself.
2328
+ ...(topic.frequency === undefined
2329
+ ? {}
2330
+ : {
2331
+ pending: (await listLocalEntries()).flatMap((entry) => entry.topic === topic.id && entry.key !== request.key && entry.fireAt !== undefined
2332
+ ? [{ fireAt: providerFireAt(entry.fireAt) }]
2333
+ : []),
2334
+ }),
2335
+ });
2336
+ if (decision.status !== 'schedule') {
2337
+ return { route: 'local', status: decision.status };
2338
+ }
2339
+ const locale = input.locale ?? input.defaultLocale;
2340
+ if (!hasLocalCopy(topic, locale, input.defaultLocale)) {
2341
+ return { route: 'local', status: 'unavailable' };
2342
+ }
2343
+ // Local copy is free-form content: the mapping's title and body, else the catalog copy.
2344
+ let content;
2345
+ try {
2346
+ content = resolveNotificationContent({
2347
+ topic,
2348
+ content: 'free-form',
2349
+ locale,
2350
+ defaultLocale: input.defaultLocale,
2351
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
2352
+ });
2353
+ }
2354
+ catch (error) {
2355
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2356
+ message: error instanceof Error ? error.message : String(error),
2357
+ cause: error,
2358
+ });
2359
+ }
2360
+ // One key across both routes: the server reminder with the key goes
2361
+ // first, for the player who began the call. After an account switch the
2362
+ // server reminder under the key is another player's, and it stays.
2363
+ if (capabilities.remote.supported && player !== undefined && sessionPlayer() === player) {
2364
+ await backend.cancelReminder({ key: request.key });
2365
+ }
2366
+ const scheduled = await scheduleLocal.call(provider, {
2367
+ key: request.key,
2368
+ topic: topic.id,
2369
+ fireAt: decision.fireAt,
2370
+ title: content.title ?? '',
2371
+ body: content.body ?? '',
2372
+ route: content.route,
2373
+ });
2374
+ if (scheduled.status === 'scheduled' || scheduled.status === 'replaced') {
2375
+ scheduledLocal.set(request.key, {
2376
+ key: request.key,
2377
+ topic: topic.id,
2378
+ fireAt: decision.fireAt,
2379
+ });
2380
+ }
2381
+ return {
2382
+ route: 'local',
2383
+ status: scheduled.status === 'unsupported' ? 'unavailable' : scheduled.status,
2384
+ };
2385
+ };
2386
+ /** Inside the local queue: removes a pending local reminder; whether one was pending. */
2387
+ const removeLocalKey = async (key) => {
2388
+ const cancelLocal = provider.cancelLocal;
2389
+ if (cancelLocal === undefined) {
2390
+ return false;
2391
+ }
2392
+ const pending = (await listLocalEntries()).some((entry) => entry.key === key);
2393
+ if (pending || provider.listLocal === undefined) {
2394
+ // Without `listLocal` the runtime cannot tell, so it cancels anyway.
2395
+ await cancelLocal.call(provider, { keys: [key] });
2396
+ scheduledLocal.delete(key);
2397
+ }
2398
+ return pending;
2399
+ };
2400
+ // Events and authentication.
2401
+ const onProviderEvent = (event) => {
2402
+ // The runtime's own handling first, so a game listener cannot skip it.
2403
+ if (event.type === 'endpoint-changed') {
2404
+ const { registration } = event;
2405
+ track(enqueue('register', async () => {
2406
+ const { remote } = await provider.getCapabilities();
2407
+ const platformUser = registration.address.kind === 'platform-user';
2408
+ if (remote.supported
2409
+ && (remote.addressing === 'platform-user') === platformUser
2410
+ && remote.addressing !== 'none') {
2411
+ // A rotated address replaces the old one for this installation; a
2412
+ // platform user's changed locale or zone reaches the backend too.
2413
+ await sendRegistration(registration, false);
2414
+ }
2415
+ }));
2416
+ }
2417
+ if (event.type === 'permission-changed') {
2418
+ // The change belongs to the player authenticated now.
2419
+ const player = sessionPlayer();
2420
+ track((async () => {
2421
+ const capabilities = await provider.getCapabilities();
2422
+ const topics = Object.keys(event.snapshot.topics);
2423
+ const states = resolveStates(capabilities, event.snapshot, topics, await readRecords());
2424
+ await enqueue('record', () => writeRecords(permissionRecords(capabilities, hostObservations(capabilities, states, topics), undefined, player)));
2425
+ await syncRegistration(capabilities, states.app);
2426
+ await cancelRevokedLocal(capabilities, states, topics);
2427
+ })());
2428
+ }
2429
+ emit(event);
2430
+ };
2431
+ const onAuthenticated = () => {
2432
+ // Records observed before authentication go to this session's player, the
2433
+ // registration goes again (a new session always sends it), and pending
2434
+ // local reminders meet the player's records.
2435
+ track(queueRecords([]));
2436
+ track(registerCurrent(false, true));
2437
+ track(reconcileLocal());
2438
+ };
2439
+ return {
2440
+ async start() {
2441
+ unsubscribeProvider ??= provider.subscribe(onProviderEvent);
2442
+ unsubscribeAuthentication ??= backend.onAuthenticated(onAuthenticated);
2443
+ const capabilities = await provider.getCapabilities();
2444
+ configurationIssue = input.registryEntry === undefined || !capabilities.remote.supported
2445
+ ? undefined
2446
+ : notificationRegistryMismatch(capabilities, input.registryEntry);
2447
+ // A backend that cannot answer does not fail the start; the next read reconciles.
2448
+ await settle(reconcileLocal());
2449
+ await settle(refreshRegistration());
2450
+ },
2451
+ stop() {
2452
+ unsubscribeProvider?.();
2453
+ unsubscribeProvider = undefined;
2454
+ unsubscribeAuthentication?.();
2455
+ unsubscribeAuthentication = undefined;
2456
+ listeners.clear();
2457
+ buffer = [];
2458
+ // A later start buffers events for its first subscriber again.
2459
+ gameSubscribed = false;
2460
+ },
2461
+ async runUntilIdle() {
2462
+ while (background.size > 0) {
2463
+ await Promise.all([...background]);
2464
+ }
2465
+ const first = failure;
2466
+ failure = undefined;
2467
+ if (first !== undefined) {
2468
+ throw first.count === 1
2469
+ ? first.error
2470
+ : new AggregateError([first.error], `${first.count} background failures since the last runUntilIdle; the first: `
2471
+ + describeError(first.error));
2472
+ }
2473
+ },
2474
+ async getPermission(input) {
2475
+ assertTopics(input.topics, false);
2476
+ // A topic listed twice is read once.
2477
+ const request = { topics: [...new Set(input.topics)] };
2478
+ const player = sessionPlayer();
2479
+ const capabilities = await capabilitiesOf();
2480
+ const snapshot = await provider.getPermission({ topics: request.topics });
2481
+ if (capabilities.availability === 'unsupported') {
2482
+ return {
2483
+ ...snapshot,
2484
+ sources: Object.fromEntries(request.topics.map((topic) => [topic, 'host'])),
2485
+ };
2486
+ }
2487
+ let records;
2488
+ try {
2489
+ records = await readRecords();
2490
+ }
2491
+ catch (error) {
2492
+ // A read still answers when the backend cannot.
2493
+ fail(error);
2494
+ }
2495
+ const states = resolveStates(capabilities, snapshot, request.topics, records);
2496
+ await settle(queueRecords(permissionRecords(capabilities, hostObservations(capabilities, states, request.topics), undefined, player)));
2497
+ await settle(syncRegistration(capabilities, states.app));
2498
+ await settle(cancelRevokedLocal(capabilities, states, request.topics));
2499
+ if (records !== undefined) {
2500
+ await settle(reconcileLocal());
2501
+ }
2502
+ await settle(refreshRegistration());
2503
+ return {
2504
+ ...(states.app === undefined ? {} : { app: states.app }),
2505
+ topics: states.topics,
2506
+ sources: states.sources,
2507
+ ...(snapshot.requiredAction === undefined ? {} : { requiredAction: snapshot.requiredAction }),
2508
+ };
2509
+ },
2510
+ async requestPermission(input) {
2511
+ assertTopics(input.topics, true);
2512
+ // A topic listed twice is asked, and recorded, once.
2513
+ const request = { ...input, topics: [...new Set(input.topics)] };
2514
+ // The records belong to the player signed in when the prompt opened.
2515
+ const player = sessionPlayer();
2516
+ if (typeof request.gesture !== 'boolean') {
2517
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2518
+ message: 'requestPermission needs a boolean gesture.',
2519
+ });
2520
+ }
2521
+ const capabilities = await capabilitiesOf();
2522
+ const every = (outcome) => Object.fromEntries(request.topics.map((topic) => [topic, outcome]));
2523
+ if (capabilities.availability === 'unsupported') {
2524
+ // Unsupported is an answer: no prompt is reached.
2525
+ return {
2526
+ snapshot: await provider.getPermission({ topics: request.topics }),
2527
+ outcomes: every('unavailable'),
2528
+ };
2529
+ }
2530
+ if (capabilities.permission.userGesture && !request.gesture) {
2531
+ throw new NotificationError('NOTIFICATIONS_USER_GESTURE_REQUIRED', {
2532
+ message: 'This provider asks for notification permission only inside a player gesture.',
2533
+ });
2534
+ }
2535
+ const before = await provider.getPermission({ topics: request.topics });
2536
+ if (capabilities.availability === 'action-required') {
2537
+ // The game resolves the action (identity.requestUpgrade on Devvit) and asks again.
2538
+ return {
2539
+ snapshot: before,
2540
+ outcomes: every('action-required'),
2541
+ requiredAction: before.requiredAction ?? 'login',
2542
+ };
2543
+ }
2544
+ const scope = capabilities.permission.scope;
2545
+ const outcomes = new Map();
2546
+ const asked = [];
2547
+ for (const topic of request.topics) {
2548
+ const declared = topicOf(topic);
2549
+ if (declared?.mapping === undefined) {
2550
+ outcomes.set(topic, 'unavailable');
2551
+ }
2552
+ else if (scope === 'topic' && notificationTopicNeedsNoAgreement(declared)) {
2553
+ outcomes.set(topic, 'already-granted');
2554
+ }
2555
+ else if (notificationPermissionStateOf(before, topic) === 'blocked') {
2556
+ // Only a state the provider read from the host stops a request; a record never does.
2557
+ outcomes.set(topic, 'blocked');
2558
+ }
2559
+ else {
2560
+ asked.push(topic);
2561
+ }
2562
+ }
2563
+ const result = asked.length === 0
2564
+ ? undefined
2565
+ : await provider.requestPermission({ topics: asked });
2566
+ for (const topic of asked) {
2567
+ outcomes.set(topic, result !== undefined && Object.prototype.hasOwnProperty.call(result.outcomes, topic)
2568
+ ? (result.outcomes[topic] ?? 'unavailable')
2569
+ : 'unavailable');
2570
+ }
2571
+ // The host has answered: its answer is returned whatever the backend does.
2572
+ let records;
2573
+ try {
2574
+ records = await readRecords();
2575
+ }
2576
+ catch (error) {
2577
+ fail(error);
2578
+ }
2579
+ const after = mergeSnapshots(before, result?.snapshot, asked, outcomes, scope);
2580
+ const states = resolveStates(capabilities, after, request.topics, records, asked.some((topic) => outcomes.get(topic) === 'denied'));
2581
+ const decidedTopics = asked.filter((topic) => isDecision(outcomes.get(topic)));
2582
+ const observations = scope === 'app'
2583
+ ? (states.app === undefined || states.appSource !== 'host' || decidedTopics.length === 0
2584
+ ? []
2585
+ : [{ state: states.app, accepted: asked.some((topic) => isAcceptance(outcomes.get(topic))) }])
2586
+ : decidedTopics
2587
+ .filter((topic) => states.sources[topic] === 'host')
2588
+ .map((topic) => ({
2589
+ topic,
2590
+ state: stateIn(states, topic),
2591
+ accepted: isAcceptance(outcomes.get(topic)),
2592
+ }));
2593
+ const pending = permissionRecords(capabilities, observations, result?.evidence, player);
2594
+ if (scope === 'topic') {
2595
+ for (const topic of asked) {
2596
+ // Accepting a requested topic's agreement is the player's request for it.
2597
+ if (isAcceptance(outcomes.get(topic)) && topicOf(topic)?.purpose === 'requested') {
2598
+ pending.push({
2599
+ mode: 'changed',
2600
+ player,
2601
+ record: { kind: 'topic-preference', topic, state: 'granted', source: 'player', at: clock.now() },
2602
+ });
2603
+ }
2604
+ }
2605
+ }
2606
+ await settle(queueRecords(pending));
2607
+ await settle(syncRegistration(capabilities, states.app));
2608
+ await settle(cancelRevokedLocal(capabilities, states, request.topics));
2609
+ return {
2610
+ snapshot: {
2611
+ ...(states.app === undefined ? {} : { app: states.app }),
2612
+ topics: states.topics,
2613
+ ...(result?.snapshot.requiredAction === undefined
2614
+ ? {}
2615
+ : { requiredAction: result.snapshot.requiredAction }),
2616
+ },
2617
+ outcomes: Object.fromEntries(request.topics.map((topic) => [topic, outcomes.get(topic) ?? 'unavailable'])),
2618
+ ...(result?.requiredAction === undefined ? {} : { requiredAction: result.requiredAction }),
2619
+ ...(result?.evidence === undefined ? {} : { evidence: result.evidence }),
2620
+ };
2621
+ },
2622
+ async revokePermission(input) {
2623
+ assertTopics(input.topics, true);
2624
+ const request = { topics: [...new Set(input.topics)] };
2625
+ const player = sessionPlayer();
2626
+ const capabilities = await capabilitiesOf();
2627
+ const revoke = provider.revokePermission;
2628
+ if (capabilities.permission.prompt !== 'game' || revoke === undefined) {
2629
+ throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
2630
+ message: 'Only a game-rendered consent is withdrawn in the game; others change in OS or host settings.',
2631
+ });
2632
+ }
2633
+ if (capabilities.availability === 'action-required') {
2634
+ // A logged-out player has no consent at the host to withdraw yet.
2635
+ const snapshot = await provider.getPermission({ topics: request.topics });
2636
+ return {
2637
+ snapshot,
2638
+ outcomes: Object.fromEntries(request.topics.map((topic) => [topic, 'action-required'])),
2639
+ requiredAction: snapshot.requiredAction ?? 'login',
2640
+ };
2641
+ }
2642
+ const result = await revoke.call(provider, { topics: request.topics });
2643
+ // Read again: a revocation is a decision, so not opted in reads `denied` from here on.
2644
+ const states = resolveStates(capabilities, await provider.getPermission({ topics: request.topics }), request.topics, undefined, true);
2645
+ if (capabilities.permission.scope === 'app'
2646
+ && !isDeviceAddressing(capabilities)
2647
+ && states.app !== undefined
2648
+ && isRecordableNotificationPermissionState(states.app)) {
2649
+ await settle(queueRecords([{
2650
+ mode: 'changed',
2651
+ player,
2652
+ record: { kind: 'delivery-permission', state: states.app, source: 'player', at: clock.now() },
2653
+ }]));
2654
+ }
2655
+ // A device endpoint stops at the host's answer too.
2656
+ await settle(syncRegistration(capabilities, states.app));
2657
+ await settle(cancelRevokedLocal(capabilities, states, request.topics));
2658
+ return {
2659
+ snapshot: { ...(states.app === undefined ? {} : { app: states.app }), topics: states.topics },
2660
+ outcomes: Object.fromEntries(request.topics.map((topic) => [
2661
+ topic,
2662
+ Object.prototype.hasOwnProperty.call(result.outcomes, topic)
2663
+ ? (result.outcomes[topic] ?? 'denied')
2664
+ : 'denied',
2665
+ ])),
2666
+ };
2667
+ },
2668
+ async register() {
2669
+ await capabilitiesOf();
2670
+ await registerCurrent(true, true);
2671
+ },
2672
+ async unregister() {
2673
+ // The player signing out, captured before any wait, and the unbind
2674
+ // queued at once, so an authentication that follows is never the one
2675
+ // unbound.
2676
+ const session = backend.session();
2677
+ const unbinding = enqueue('register', async () => {
2678
+ // Persisted: a restart before the next authentication binds nothing.
2679
+ storage.unbound = true;
2680
+ if (session === undefined || !isDeviceAddressing(await provider.getCapabilities())) {
2681
+ return;
2682
+ }
2683
+ await backend.unregisterEndpoint({ provider: provider.provider, player: session.player });
2684
+ if (storage.registration?.session === session.id) {
2685
+ delete storage.registration;
2686
+ }
2687
+ });
2688
+ // The device's pending local reminders belong to the player leaving.
2689
+ // They are cancelled whether or not the unbind reaches the backend (an
2690
+ // offline logout), and either failure is thrown once both have run.
2691
+ const outcomes = await Promise.allSettled([unbinding, cancelLocalWhere(() => true)]);
2692
+ const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
2693
+ if (failures.length > 1) {
2694
+ throw new AggregateError(failures, 'unregister() could neither unbind nor cancel the local reminders.');
2695
+ }
2696
+ if (failures.length === 1) {
2697
+ throw failures[0];
2698
+ }
2699
+ },
2700
+ async setTopicPreference(request) {
2701
+ if (typeof request.topic !== 'string'
2702
+ || topicOf(request.topic) === undefined
2703
+ || typeof request.enabled !== 'boolean') {
2704
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2705
+ message: 'setTopicPreference needs a declared topic and a boolean.',
2706
+ });
2707
+ }
2708
+ if (!request.enabled && sessionPlayer() === undefined) {
2709
+ // The device stops showing the topic's reminders even before the
2710
+ // player signs in; the withdrawal itself needs the player.
2711
+ await cancelLocalWhere((entry) => entry.topic === request.topic);
2712
+ }
2713
+ const player = requirePlayer('setTopicPreference');
2714
+ await capabilitiesOf();
2715
+ // The withdrawal is recorded first, so the server stops sending and no
2716
+ // new local reminder passes the gate even if cancelling fails.
2717
+ await queueRecords([{
2718
+ mode: 'changed',
2719
+ player,
2720
+ record: {
2721
+ kind: 'topic-preference',
2722
+ topic: request.topic,
2723
+ state: request.enabled ? 'granted' : 'withdrawn',
2724
+ source: 'player',
2725
+ at: clock.now(),
2726
+ },
2727
+ }]);
2728
+ if (!request.enabled) {
2729
+ await cancelLocalWhere((entry) => entry.topic === request.topic);
2730
+ }
2731
+ },
2732
+ async setAdvertisingConsent(request) {
2733
+ const states = [request.advertising, request.night];
2734
+ if (states.every((state) => state === undefined)
2735
+ || states.some((state) => state !== undefined && state !== 'granted' && state !== 'withdrawn')
2736
+ || typeof request.textVersion !== 'string'
2737
+ || request.textVersion.trim() === '') {
2738
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2739
+ message: 'setAdvertisingConsent needs an advertising or night decision and the consent text version.',
2740
+ });
2741
+ }
2742
+ const player = requirePlayer('setAdvertisingConsent');
2743
+ await capabilitiesOf();
2744
+ // Each call is a consent decision the law asks to notify, so each one is appended.
2745
+ await queueRecords(['advertising', 'night'].flatMap((kind) => {
2746
+ const state = request[kind];
2747
+ return state === undefined
2748
+ ? []
2749
+ : [{
2750
+ mode: 'always',
2751
+ player,
2752
+ record: {
2753
+ kind,
2754
+ state,
2755
+ source: 'player',
2756
+ at: clock.now(),
2757
+ textVersion: request.textVersion,
2758
+ },
2759
+ }];
2760
+ }));
2761
+ // Advertising never takes the local route, so there is nothing to cancel.
2762
+ },
2763
+ reminders: {
2764
+ async set(request) {
2765
+ const issue = reminderInputIssue(request);
2766
+ if (issue !== undefined) {
2767
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2768
+ message: `Invalid reminder: ${issue}.`,
2769
+ });
2770
+ }
2771
+ // A server reminder belongs to the player signed in when the call began.
2772
+ const player = sessionPlayer();
2773
+ const capabilities = await capabilitiesOf();
2774
+ const topic = topicOf(request.topic);
2775
+ if (topic === undefined) {
2776
+ return { route: 'none', status: 'unavailable' };
2777
+ }
2778
+ const route = routeNotificationReminder(topic, capabilities);
2779
+ if (route === 'none') {
2780
+ return { route, status: 'unavailable' };
2781
+ }
2782
+ if (route === 'remote' && capabilities.remote.content === 'host-template' && request.templateData !== undefined) {
2783
+ // Template context stays on the server, as in v1.
2784
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2785
+ message: 'A host-template provider takes no templateData in a reminder.',
2786
+ });
2787
+ }
2788
+ if (capabilities.availability === 'action-required') {
2789
+ return { route, status: 'action-required' };
2790
+ }
2791
+ if (route === 'local') {
2792
+ // In the local queue, so a withdrawal or revocation sweep that starts
2793
+ // while this reads consent runs after the schedule and cancels it.
2794
+ return enqueue('local', () => scheduleLocalReminder(capabilities, topic, request, player));
2795
+ }
2796
+ // A server reminder needs the signed-in player, checked before anything changes.
2797
+ requireSignedIn('reminders.set', player);
2798
+ // One key across both routes: the local reminder with the key goes first.
2799
+ if (capabilities.local.supported) {
2800
+ await enqueue('local', () => removeLocalKey(request.key));
2801
+ }
2802
+ assertSamePlayer('reminders.set', player);
2803
+ return {
2804
+ route,
2805
+ status: await backend.setReminder({
2806
+ key: request.key,
2807
+ topic: topic.id,
2808
+ fireAt: normalizeNotificationTimestamp(request.fireAt),
2809
+ ...(request.templateData === undefined ? {} : { templateData: request.templateData }),
2810
+ ...(request.link === undefined ? {} : { link: request.link }),
2811
+ }),
2812
+ };
2813
+ },
2814
+ async cancel(request) {
2815
+ const issue = notificationKeyIssue(request.key);
2816
+ if (issue !== undefined) {
2817
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2818
+ message: `Invalid reminder key: ${issue}.`,
2819
+ });
2820
+ }
2821
+ const player = sessionPlayer();
2822
+ const capabilities = await capabilitiesOf();
2823
+ // One key across both routes: each route that has the key loses it.
2824
+ const local = capabilities.local.supported
2825
+ && (await enqueue('local', () => removeLocalKey(request.key)));
2826
+ let remote = false;
2827
+ if (capabilities.remote.supported) {
2828
+ if (capabilities.availability === 'action-required') {
2829
+ return { route: 'remote', status: 'action-required' };
2830
+ }
2831
+ requireSignedIn('reminders.cancel', player);
2832
+ remote = await backend.cancelReminder({ key: request.key });
2833
+ }
2834
+ // Both routes have the key only after a local reminder was set while
2835
+ // signed out; the local one is the latest set, so it answers.
2836
+ if (local) {
2837
+ return { route: 'local', status: 'cancelled' };
2838
+ }
2839
+ // Nothing is pending under the key any more.
2840
+ return { route: remote ? 'remote' : 'none', status: 'cancelled' };
2841
+ },
2842
+ },
2843
+ notices: {
2844
+ async list() {
2845
+ // The notices of the player signed in when the call began.
2846
+ const player = sessionPlayer();
2847
+ await capabilitiesOf();
2848
+ requireSignedIn('notices.list', player);
2849
+ const notices = await backend.listNotices();
2850
+ assertSamePlayer('notices.list', player);
2851
+ return notices;
2852
+ },
2853
+ async recordShown(request) {
2854
+ const player = sessionPlayer();
2855
+ // The backend gets only the name, with the record time in the canonical
2856
+ // form; a name that matches no listed notice records nothing.
2857
+ const notice = noticeIdentityOf(recordNoticeInputOf(request));
2858
+ await capabilitiesOf();
2859
+ requireSignedIn('notices.recordShown', player);
2860
+ await backend.recordNotice({ ...notice, channel: 'in-game' });
2861
+ },
2862
+ },
2863
+ async openSettings() {
2864
+ const capabilities = await capabilitiesOf();
2865
+ const openSettings = provider.openSettings;
2866
+ return capabilities.permission.settings && openSettings !== undefined
2867
+ ? openSettings.call(provider)
2868
+ : 'unavailable';
2869
+ },
2870
+ subscribe(listener) {
2871
+ listeners.add(listener);
2872
+ if (!gameSubscribed) {
2873
+ gameSubscribed = true;
2874
+ const drained = buffer;
2875
+ buffer = [];
2876
+ // The first subscriber receives every buffered event once, even if it throws on one.
2877
+ for (const error of deliverEach([listener], drained)) {
2878
+ fail(error);
2879
+ }
2880
+ }
2881
+ return () => {
2882
+ listeners.delete(listener);
2883
+ };
2884
+ },
2885
+ };
2886
+ }
2887
+ function assertTopics(topics, nonEmpty) {
2888
+ if (!Array.isArray(topics)
2889
+ || (nonEmpty && topics.length === 0)
2890
+ || topics.some((topic) => typeof topic !== 'string' || topic.length === 0)) {
2891
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
2892
+ message: nonEmpty ? 'Expected at least one topic id.' : 'Expected a list of topic ids.',
2893
+ });
2894
+ }
2895
+ }
2896
+ /**
2897
+ * The snapshot after a request: the host's answer for the asked topics over
2898
+ * the read before it, and the answer a prompt just gave for a topic the host
2899
+ * cannot read back.
2900
+ */
2901
+ function mergeSnapshots(before, after, asked, outcomes, scope) {
2902
+ const topics = new Map(Object.entries(before.topics));
2903
+ for (const [topic, state] of Object.entries(after?.topics ?? {})) {
2904
+ topics.set(topic, state);
2905
+ }
2906
+ let app = after?.app ?? before.app;
2907
+ for (const topic of asked) {
2908
+ const decided = outcomeState(outcomes.get(topic));
2909
+ if (decided === undefined) {
2910
+ continue;
2911
+ }
2912
+ if (scope === 'app') {
2913
+ app = app === undefined || app === 'unknown' ? decided : app;
2914
+ }
2915
+ else if ((topics.get(topic) ?? 'unknown') === 'unknown') {
2916
+ topics.set(topic, decided);
2917
+ }
2918
+ }
2919
+ if (scope === 'app' && app !== undefined) {
2920
+ // An app-scope topic reads the app state; an `unknown` entry for it does not outlive the answer.
2921
+ for (const topic of asked) {
2922
+ if (topics.get(topic) === 'unknown') {
2923
+ topics.set(topic, app);
2924
+ }
2925
+ }
2926
+ }
2927
+ return { ...(app === undefined ? {} : { app }), topics: Object.fromEntries(topics) };
2928
+ }
2929
+ /** The state a decided outcome leaves when the host cannot be read. */
2930
+ function outcomeState(outcome) {
2931
+ switch (outcome) {
2932
+ case 'granted':
2933
+ case 'already-granted':
2934
+ return 'granted';
2935
+ case 'denied':
2936
+ return 'denied';
2937
+ case 'blocked':
2938
+ return 'blocked';
2939
+ default:
2940
+ return undefined;
2941
+ }
2942
+ }
2943
+ function stateIn(states, topic) {
2944
+ return Object.prototype.hasOwnProperty.call(states.topics, topic)
2945
+ ? (states.topics[topic] ?? 'unknown')
2946
+ : (states.app ?? 'unknown');
2947
+ }
2948
+ function isDeviceAddressing(capabilities) {
2949
+ return capabilities.remote.supported && capabilities.remote.addressing === 'device';
2950
+ }
2951
+ /** A known permission other than `granted`: the device no longer shows local reminders. */
2952
+ function revokesLocalReminders(state) {
2953
+ return state === 'prompt'
2954
+ || state === 'prompt-with-rationale'
2955
+ || state === 'denied'
2956
+ || state === 'blocked';
2957
+ }
2958
+ function isAcceptance(outcome) {
2959
+ return outcome === 'granted' || outcome === 'already-granted';
2960
+ }
2961
+ /** An answer the player gave: never `dismissed`, `action-required`, or `unavailable`. */
2962
+ function isDecision(outcome) {
2963
+ return outcomeState(outcome) !== undefined;
2964
+ }
2965
+ /** The latest permission state recorded for a kind and scope; `withdrawn` is not a permission state. */
2966
+ function latestRecordState(records, kind, topic) {
2967
+ const state = latestNotificationConsent(records, kind, topic)?.state;
2968
+ return state === undefined || state === 'withdrawn' ? undefined : state;
2969
+ }
2970
+ /**
2971
+ * A pending reminder's `fireAt` as the provider listed it, which the daily cap
2972
+ * reads: one that does not parse strictly is the provider breaking the
2973
+ * contract, not the game's input.
2974
+ */
2975
+ function providerFireAt(fireAt) {
2976
+ if (!isNotificationTimestamp(fireAt)) {
2977
+ throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
2978
+ message: `The provider listed a local reminder whose fireAt is not RFC 3339 with a date that exists: ${JSON.stringify(fireAt)}.`,
2979
+ });
2980
+ }
2981
+ return fireAt;
2982
+ }
2983
+ /** Whether the topic preference alone refuses a reminder of `topic` now. */
2984
+ function preferenceRefuses(topic, records, at) {
2985
+ const decision = evaluateLocalNotificationReminder({
2986
+ topic,
2987
+ permission: 'granted',
2988
+ consent: records,
2989
+ fireAt: at,
2990
+ timeZone: 'UTC',
2991
+ });
2992
+ return decision.status === 'request-missing' || decision.status === 'preference-off';
2993
+ }
2994
+ function hasLocalCopy(topic, locale, defaultLocale) {
2995
+ return (typeof topic.mapping?.['title'] === 'string' && typeof topic.mapping['body'] === 'string')
2996
+ || findNotificationCopy(topic, locale, defaultLocale) !== undefined;
2997
+ }
2998
+ function sameJson(left, right) {
2999
+ return JSON.stringify(left) === JSON.stringify(right);
3000
+ }
3001
+ export function createNotificationConformanceHarness(context) {
3002
+ const host = createNotificationConformanceHost({
3003
+ profile: context.profile,
3004
+ catalog: context.catalog,
3005
+ timeZone: context.timeZone,
3006
+ ...(context.vector.host === undefined ? {} : { host: context.vector.host }),
3007
+ });
3008
+ return { provider: host.provider, host };
3009
+ }
3010
+ /** Why `vector` does not apply to a provider with `capabilities`, or `undefined` when it runs. */
3011
+ export function notificationConformanceSkipReason(vector, capabilities) {
3012
+ return notificationConformanceAppliesToReason(vector.appliesTo, capabilities);
3013
+ }
3014
+ /**
3015
+ * Runs one vector: reads the provider's capabilities and skips the vector when
3016
+ * they do not satisfy `appliesTo`, executes every step (a step's `returns` is
3017
+ * matched as a subset, its `throws` by `code`), lets the runtime settle after
3018
+ * each, then matches `expect`. Lists match exactly in length and order with
3019
+ * only the listed fields of each item asserted, a field set to `null` asserts
3020
+ * that it is absent, and timestamps compare as instants. Any unscripted
3021
+ * operation fails the vector at the step that needed it.
3022
+ *
3023
+ * The backend's registry entry is derived from the provider's own
3024
+ * capabilities and the profile's limits. A `setReminder` step that expects a
3025
+ * local `permission-required` also calls the provider's `scheduleLocal`
3026
+ * directly: it must answer `permission-required` without prompting or
3027
+ * scheduling. With `options.createProvider`, the fixture provider id and the
3028
+ * fixture agreement evidence schema in `returns` and `expect` match the
3029
+ * provider under test's own id and `hostAgreementEvidenceSchema`.
3030
+ */
3031
+ export async function runNotificationConformanceVector(vector, options = {}) {
3032
+ const failed = (failures) => ({
3033
+ vector: vector.vector,
3034
+ status: 'failed',
3035
+ passed: false,
3036
+ failures,
3037
+ });
3038
+ let clock;
3039
+ try {
3040
+ clock = createNotificationConformanceClock(vector.context.now);
3041
+ }
3042
+ catch (error) {
3043
+ return failed([
3044
+ { path: 'context.now', expected: 'an RFC 3339 instant', actual: describeError(error) },
3045
+ ]);
3046
+ }
3047
+ const context = {
3048
+ vector,
3049
+ profile: vector.profile,
3050
+ catalog: vector.catalog,
3051
+ clock,
3052
+ player: vector.context.player,
3053
+ timeZone: vector.context.timeZone,
3054
+ };
3055
+ let harness;
3056
+ let capabilities;
3057
+ try {
3058
+ harness = await (options.createProvider ?? createNotificationConformanceHarness)(context);
3059
+ }
3060
+ catch (error) {
3061
+ return failed([
3062
+ { path: 'harness', expected: 'createProvider resolves', actual: describeError(error) },
3063
+ ]);
3064
+ }
3065
+ try {
3066
+ capabilities = await harness.provider.getCapabilities();
3067
+ }
3068
+ catch (error) {
3069
+ return failed([
3070
+ {
3071
+ path: 'capabilities',
3072
+ expected: 'provider.getCapabilities() resolves',
3073
+ actual: describeError(error),
3074
+ },
3075
+ ]);
3076
+ }
3077
+ const reason = notificationConformanceSkipReason(vector, capabilities);
3078
+ if (reason !== undefined) {
3079
+ return { vector: vector.vector, status: 'skipped', passed: false, failures: [], reason };
3080
+ }
3081
+ const defaultLocale = options.defaultLocale ?? notificationConformanceDefaultLocale;
3082
+ const host = harness.host;
3083
+ const readServerPermission = host?.readServerPermission;
3084
+ const entry = notificationConformanceRegistryEntry({
3085
+ provider: harness.provider.provider,
3086
+ capabilities,
3087
+ ...(vector.profile.limits === undefined ? {} : { limits: vector.profile.limits }),
3088
+ });
3089
+ let backend;
3090
+ try {
3091
+ backend = createNotificationConformanceBackend({
3092
+ entry,
3093
+ catalog: vector.catalog,
3094
+ clock,
3095
+ ...(vector.deployment === undefined ? {} : { deployment: vector.deployment }),
3096
+ ...(vector.notices === undefined ? {} : { notices: vector.notices }),
3097
+ defaultLocale,
3098
+ defaultPlayer: vector.context.player,
3099
+ ...(host === undefined || readServerPermission === undefined
3100
+ ? {}
3101
+ : { readServerPermission: (request) => readServerPermission.call(host, request) }),
3102
+ });
3103
+ }
3104
+ catch (error) {
3105
+ return failed([
3106
+ {
3107
+ path: 'backend',
3108
+ expected: 'the deployment and the notices are valid',
3109
+ actual: describeError(error),
3110
+ },
3111
+ ]);
3112
+ }
3113
+ const storage = {};
3114
+ const createRuntime = () => createNotificationConformanceRuntime({
3115
+ provider: harness.provider,
3116
+ backend,
3117
+ catalog: vector.catalog,
3118
+ clock,
3119
+ timeZone: vector.deployment?.timeZone ?? notificationDefaultDeploymentTimeZone,
3120
+ defaultLocale,
3121
+ storage,
3122
+ ...(entry === undefined ? {} : { registryEntry: entry }),
3123
+ });
3124
+ const execution = {
3125
+ harness,
3126
+ backend,
3127
+ clock,
3128
+ runtime: createRuntime(),
3129
+ createRuntime,
3130
+ events: {},
3131
+ failures: [],
3132
+ };
3133
+ const adapted = adaptVector(vector, harness);
3134
+ for (const [index, step] of adapted.steps.entries()) {
3135
+ const path = `steps[${index}]`;
3136
+ try {
3137
+ await runStep(step, path, execution);
3138
+ await execution.runtime.runUntilIdle();
3139
+ }
3140
+ catch (error) {
3141
+ execution.failures.push({
3142
+ path,
3143
+ expected: `${step.actor}.${step.action} completes`,
3144
+ actual: describeError(error),
3145
+ });
3146
+ return failed(execution.failures);
3147
+ }
3148
+ }
3149
+ if (execution.failures.length > 0) {
3150
+ return failed(execution.failures);
3151
+ }
3152
+ try {
3153
+ await matchExpectations(adapted.expect, execution);
3154
+ }
3155
+ catch (error) {
3156
+ // A provider read that throws while expectations are evaluated is a failed
3157
+ // run, never a rejected promise.
3158
+ execution.failures.push({
3159
+ path: 'expect',
3160
+ expected: 'expectations evaluate',
3161
+ actual: describeError(error),
3162
+ });
3163
+ }
3164
+ return {
3165
+ vector: vector.vector,
3166
+ status: execution.failures.length === 0 ? 'passed' : 'failed',
3167
+ passed: execution.failures.length === 0,
3168
+ failures: execution.failures,
3169
+ };
3170
+ }
3171
+ export async function runNotificationConformanceVectors(vectors, options = {}) {
3172
+ const results = [];
3173
+ for (const vector of vectors) {
3174
+ results.push(await runNotificationConformanceVector(vector, options));
3175
+ }
3176
+ return results;
3177
+ }
3178
+ function hostOf(execution, step) {
3179
+ if (execution.harness.host === undefined) {
3180
+ throw new NotificationConformanceScriptError(`The harness has no host actor for ${step.actor}.${step.action}.`);
3181
+ }
3182
+ return execution.harness.host;
3183
+ }
3184
+ async function runStep(step, path, execution) {
3185
+ switch (step.actor) {
3186
+ case 'runtime':
3187
+ if (step.action === 'restart') {
3188
+ execution.runtime.stop();
3189
+ execution.harness.host?.restart?.();
3190
+ execution.runtime = execution.createRuntime();
3191
+ }
3192
+ await execution.runtime.start();
3193
+ return;
3194
+ case 'clock':
3195
+ if (step.action === 'set') {
3196
+ execution.clock.set(step.at);
3197
+ }
3198
+ else {
3199
+ execution.clock.advance(step.seconds);
3200
+ }
3201
+ return;
3202
+ case 'host': {
3203
+ const host = hostOf(execution, step);
3204
+ switch (step.action) {
3205
+ case 'queuePrompt':
3206
+ host.queuePrompt(step.result);
3207
+ return;
3208
+ case 'setPermission':
3209
+ host.setPermission(step.state);
3210
+ return;
3211
+ case 'issueEndpoint':
3212
+ host.issueEndpoint(step.endpoint);
3213
+ return;
3214
+ case 'completeAction':
3215
+ host.completeAction();
3216
+ return;
3217
+ case 'tap':
3218
+ host.tap(step.input);
3219
+ return;
3220
+ }
3221
+ return;
3222
+ }
3223
+ case 'game':
3224
+ await runGameStep(step, path, execution);
3225
+ return;
3226
+ case 'backend':
3227
+ await runBackendStep(step, path, execution);
3228
+ }
3229
+ }
3230
+ async function runBackendStep(step, path, execution) {
3231
+ const { backend } = execution;
3232
+ switch (step.action) {
3233
+ case 'authenticate':
3234
+ backend.authenticate(step.player);
3235
+ return;
3236
+ case 'deliver':
3237
+ await expectAnswer(step, path, execution, () => backend.deliver(step.input));
3238
+ return;
3239
+ case 'queueProviderResult':
3240
+ backend.queueProviderResult(providerResultOf(step));
3241
+ return;
3242
+ case 'registerEndpoint':
3243
+ backend.registerInstallation(step.input);
3244
+ return;
3245
+ case 'revokeSession':
3246
+ backend.revokeSession();
3247
+ return;
3248
+ case 'runScheduler':
3249
+ await backend.runScheduler();
3250
+ }
3251
+ }
3252
+ function providerResultOf(step) {
3253
+ switch (step.result) {
3254
+ case 'rate-limited':
3255
+ return step.retryAfterSeconds === undefined
3256
+ ? { status: 'rate-limited' }
3257
+ : { status: 'rate-limited', retryAfterSeconds: step.retryAfterSeconds };
3258
+ case 'rejected':
3259
+ if (step.reason === undefined) {
3260
+ throw new NotificationConformanceScriptError('A rejected provider answer needs its reason.');
3261
+ }
3262
+ return { status: 'rejected', reason: step.reason };
3263
+ default:
3264
+ return { status: step.result };
3265
+ }
3266
+ }
3267
+ async function runGameStep(step, path, execution) {
3268
+ const { runtime } = execution;
3269
+ switch (step.action) {
3270
+ case 'subscribe': {
3271
+ const events = execution.events[step.listener] ?? [];
3272
+ execution.events[step.listener] = events;
3273
+ runtime.subscribe((event) => {
3274
+ events.push(event);
3275
+ });
3276
+ return;
3277
+ }
3278
+ case 'getPermission':
3279
+ await expectAnswer(step, path, execution, () => runtime.getPermission(step.input));
3280
+ return;
3281
+ case 'requestPermission':
3282
+ await expectAnswer(step, path, execution, () => runtime.requestPermission(step.input));
3283
+ return;
3284
+ case 'revokePermission':
3285
+ await expectAnswer(step, path, execution, () => runtime.revokePermission(step.input));
3286
+ return;
3287
+ case 'register':
3288
+ await expectAnswer(step, path, execution, () => runtime.register());
3289
+ return;
3290
+ case 'unregister':
3291
+ await expectAnswer(step, path, execution, () => runtime.unregister());
3292
+ return;
3293
+ case 'setTopicPreference':
3294
+ await expectAnswer(step, path, execution, () => runtime.setTopicPreference(step.input));
3295
+ return;
3296
+ case 'setAdvertisingConsent':
3297
+ await expectAnswer(step, path, execution, () => runtime.setAdvertisingConsent(step.input));
3298
+ return;
3299
+ case 'setReminder': {
3300
+ const answered = await expectAnswer(step, path, execution, () => runtime.reminders.set(step.input));
3301
+ if (answered && step.returns?.status === 'permission-required' && step.returns.route !== 'remote') {
3302
+ await probeLocalPermission(step, path, execution);
3303
+ }
3304
+ return;
3305
+ }
3306
+ case 'cancelReminder':
3307
+ await expectAnswer(step, path, execution, () => runtime.reminders.cancel(step.input));
3308
+ return;
3309
+ case 'listNotices':
3310
+ await expectAnswer(step, path, execution, () => runtime.notices.list());
3311
+ return;
3312
+ case 'recordNotice':
3313
+ await expectAnswer(step, path, execution, () => runtime.notices.recordShown(step.input));
3314
+ return;
3315
+ case 'openSettings':
3316
+ await expectAnswer(step, path, execution, () => runtime.openSettings());
3317
+ }
3318
+ }
3319
+ /**
3320
+ * Runs a step's call and matches its `returns` or `throws`; an error the step
3321
+ * does not expect fails the vector. Whether the call answered.
3322
+ */
3323
+ async function expectAnswer(step, path, execution, call) {
3324
+ let value;
3325
+ try {
3326
+ value = await call();
3327
+ }
3328
+ catch (error) {
3329
+ if (step.throws === undefined) {
3330
+ throw error;
3331
+ }
3332
+ matchPartial(step.throws, { code: errorCodeOf(error) }, `${path}.throws`, execution.failures);
3333
+ return false;
3334
+ }
3335
+ if (step.throws !== undefined) {
3336
+ execution.failures.push({ path: `${path}.throws`, expected: step.throws, actual: 'returned' });
3337
+ return false;
3338
+ }
3339
+ if (step.returns !== undefined) {
3340
+ matchPartial(step.returns, value, `${path}.returns`, execution.failures);
3341
+ }
3342
+ return true;
3343
+ }
3344
+ /**
3345
+ * The provider rule behind `permission-required` (RFC "Conformance vectors"):
3346
+ * scheduling without permission answers `permission-required` and neither
3347
+ * prompts nor schedules. The runtime answers before it reaches the provider,
3348
+ * so the runner calls the provider itself.
3349
+ */
3350
+ async function probeLocalPermission(step, path, execution) {
3351
+ const { harness } = execution;
3352
+ if (harness.provider.scheduleLocal === undefined) {
3353
+ return;
3354
+ }
3355
+ // Prompts and operations are counted only where a host actor counts them.
3356
+ const { host } = harness;
3357
+ const counts = () => host === undefined ? {} : { prompts: host.prompts, operations: host.localOperations.length };
3358
+ const before = counts();
3359
+ const result = await harness.provider.scheduleLocal({
3360
+ key: step.input.key,
3361
+ topic: step.input.topic,
3362
+ fireAt: step.input.fireAt,
3363
+ title: 'probe',
3364
+ body: 'probe',
3365
+ });
3366
+ matchPartial({ status: 'permission-required', ...before }, { status: result.status, ...counts() }, `${path}.provider.scheduleLocal`, execution.failures);
3367
+ }
3368
+ function errorCodeOf(error) {
3369
+ return typeof error === 'object' && error !== null && 'code' in error
3370
+ ? error.code
3371
+ : describeError(error);
3372
+ }
3373
+ /**
3374
+ * The vector with the fixture provider id replaced by the provider under
3375
+ * test's where a field names a provider (`provider` in every step's
3376
+ * `returns`, a send's input, and `expect`), and the fixture agreement
3377
+ * evidence schema by the provider's own where an evidence `schema` names it.
3378
+ * Any other field that holds the same text, such as an address or a topic,
3379
+ * stays as written.
3380
+ */
3381
+ function adaptVector(vector, harness) {
3382
+ const providerId = harness.provider.provider;
3383
+ const schema = harness.hostAgreementEvidenceSchema ?? notificationConformanceAgreementSchema;
3384
+ if (providerId === vector.profile.provider && schema === notificationConformanceAgreementSchema) {
3385
+ return vector;
3386
+ }
3387
+ const substitute = (value, key) => {
3388
+ if (key === 'provider' && value === vector.profile.provider) {
3389
+ return providerId;
3390
+ }
3391
+ if (key === 'schema' && value === notificationConformanceAgreementSchema) {
3392
+ return schema;
3393
+ }
3394
+ if (Array.isArray(value)) {
3395
+ return value.map((item) => substitute(item));
3396
+ }
3397
+ if (typeof value === 'object' && value !== null) {
3398
+ return Object.fromEntries(Object.entries(value).map(([nestedKey, nested]) => [
3399
+ nestedKey,
3400
+ substitute(nested, nestedKey),
3401
+ ]));
3402
+ }
3403
+ return value;
3404
+ };
3405
+ const steps = vector.steps.map((step) => {
3406
+ let adapted = step;
3407
+ if ('returns' in adapted && adapted.returns !== undefined) {
3408
+ adapted = { ...adapted, returns: substitute(adapted.returns) };
3409
+ }
3410
+ if (adapted.actor === 'backend' && adapted.action === 'deliver' && adapted.input.provider !== undefined) {
3411
+ adapted = { ...adapted, input: { ...adapted.input, provider: providerId } };
3412
+ }
3413
+ return adapted;
3414
+ });
3415
+ return {
3416
+ ...vector,
3417
+ steps: steps,
3418
+ expect: substitute(vector.expect),
3419
+ };
3420
+ }
3421
+ async function matchExpectations(expect, execution) {
3422
+ const { backend, failures, harness } = execution;
3423
+ // Only a host actor counts prompts and local operations; without one these
3424
+ // expectations fail rather than read zero or nothing.
3425
+ const { host } = harness;
3426
+ const hostless = (path, expected) => {
3427
+ failures.push({ path, expected, actual: 'the harness has no host actor to observe it' });
3428
+ };
3429
+ if (expect.prompts !== undefined) {
3430
+ if (host === undefined) {
3431
+ hostless('prompts', expect.prompts);
3432
+ }
3433
+ else {
3434
+ matchPartial(expect.prompts, host.prompts, 'prompts', failures);
3435
+ }
3436
+ }
3437
+ if (expect.consent !== undefined) {
3438
+ matchPartial(expect.consent, backend.consent, 'consent', failures);
3439
+ }
3440
+ if (expect.endpoints !== undefined) {
3441
+ matchPartial(expect.endpoints, backend.endpoints, 'endpoints', failures);
3442
+ }
3443
+ if (expect.deliveries !== undefined) {
3444
+ matchPartial(expect.deliveries, backend.deliveries, 'deliveries', failures);
3445
+ }
3446
+ if (expect.providerCalls !== undefined) {
3447
+ matchPartial(expect.providerCalls, backend.providerCalls, 'providerCalls', failures);
3448
+ }
3449
+ if (expect.local !== undefined) {
3450
+ if (expect.local.operations !== undefined) {
3451
+ if (host === undefined) {
3452
+ hostless('local.operations', expect.local.operations);
3453
+ }
3454
+ else {
3455
+ matchPartial(expect.local.operations, host.localOperations, 'local.operations', failures);
3456
+ }
3457
+ }
3458
+ if (expect.local.pending !== undefined) {
3459
+ if (harness.provider.listLocal !== undefined) {
3460
+ matchPartial(expect.local.pending, await harness.provider.listLocal(), 'local.pending', failures);
3461
+ }
3462
+ else if (host === undefined) {
3463
+ hostless('local.pending', expect.local.pending);
3464
+ }
3465
+ else {
3466
+ matchPartial(expect.local.pending, host.localPending, 'local.pending', failures);
3467
+ }
3468
+ }
3469
+ }
3470
+ if (expect.reminders !== undefined) {
3471
+ matchPartial(expect.reminders, backend.reminders, 'reminders', failures);
3472
+ }
3473
+ if (expect.events !== undefined) {
3474
+ for (const [listener, events] of Object.entries(expect.events)) {
3475
+ matchPartial(events, execution.events[listener] ?? [], `events.${listener}`, failures);
3476
+ }
3477
+ }
3478
+ if (expect.notices !== undefined) {
3479
+ matchPartial(expect.notices, backend.notices, 'notices', failures);
3480
+ }
3481
+ }
3482
+ /**
3483
+ * Partial match: an expected object asserts only its listed keys, `null`
3484
+ * asserts that the field is absent, an expected array asserts its length and
3485
+ * each element in order, two timestamps compare as instants, and other values
3486
+ * compare strictly. Failures name the deepest differing path.
3487
+ */
3488
+ function matchPartial(expected, actual, path, failures) {
3489
+ if (expected === null) {
3490
+ if (actual !== undefined) {
3491
+ failures.push({ path, expected: 'absent', actual });
3492
+ }
3493
+ return;
3494
+ }
3495
+ if (Array.isArray(expected)) {
3496
+ if (!Array.isArray(actual)) {
3497
+ failures.push({ path, expected, actual });
3498
+ return;
3499
+ }
3500
+ if (expected.length !== actual.length) {
3501
+ failures.push({ path: `${path}.length`, expected: expected.length, actual: actual.length });
3502
+ return;
3503
+ }
3504
+ expected.forEach((item, index) => {
3505
+ matchPartial(item, actual[index], `${path}[${index}]`, failures);
3506
+ });
3507
+ return;
3508
+ }
3509
+ if (typeof expected === 'object') {
3510
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
3511
+ failures.push({ path, expected, actual });
3512
+ return;
3513
+ }
3514
+ const record = actual;
3515
+ for (const [key, value] of Object.entries(expected)) {
3516
+ matchPartial(value, Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined, `${path}.${key}`, failures);
3517
+ }
3518
+ return;
3519
+ }
3520
+ // Two strict timestamps compare as instants; a spelling that does not parse
3521
+ // strictly, such as 31 September, is compared as text and never rolls over.
3522
+ if (typeof expected === 'string'
3523
+ && typeof actual === 'string'
3524
+ && isNotificationTimestamp(expected)
3525
+ && isNotificationTimestamp(actual)) {
3526
+ if (normalizeNotificationTimestamp(expected) !== normalizeNotificationTimestamp(actual)) {
3527
+ failures.push({ path, expected, actual });
3528
+ }
3529
+ return;
3530
+ }
3531
+ if (expected !== actual) {
3532
+ failures.push({ path, expected, actual });
3533
+ }
3534
+ }
3535
+ function describeError(error) {
3536
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3537
+ }