@mpgd/platform 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
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';
1
+ import { assertNotificationConsentRecord, assertNotificationDeployment, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, listNotificationNotices, normalizeNotificationTimestamp, notificationDefaultDeploymentTimeZone, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, notificationTopicNeedsNoAgreement, toSeoulCalendarDate, } from './notifications.js';
2
+ import { createNotificationRuntime, } from './notifications-runtime.js';
3
+ import { bufferEvent, deliverEach, describeError, isBoundedText, noticeIdentityOf, recordNoticeInputOf, reminderInputIssue, } from './notifications-runtime-support.js';
2
4
  import { notificationConformanceAppliesToReason, notificationConformanceDefaultLocale, } from './notifications-vectors.js';
3
5
  /**
4
6
  * Notifications v2 conformance runner (RFC "Conformance vectors") with the
@@ -6,7 +8,8 @@ import { notificationConformanceAppliesToReason, notificationConformanceDefaultL
6
8
  * with its fixture `NotificationProvider`; a fake backend that keeps sessions,
7
9
  * consent records, endpoints, server reminders, notices, and an idempotency
8
10
  * 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
11
+ * notifications runtime the runner drives as the `game` actor, which is the
12
+ * production one from `@mpgd/platform/notifications-runtime`. Pure
10
13
  * in-process code: no Phaser, DOM, network, or SDK imports. Nothing here sends
11
14
  * a real notification or grants anything.
12
15
  *
@@ -25,8 +28,6 @@ export const notificationConformanceRetryAfterSeconds = 60;
25
28
  export const notificationConformanceLeaseSeconds = 300;
26
29
  const secondMs = 1000;
27
30
  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
31
  /** An endpoint not refreshed for this long counts as missing at gate step 2. */
31
32
  const endpointStaleMs = 60 * dayMs;
32
33
  /** A send still rate-limited this long after its first deferral ends `unavailable`. */
@@ -61,7 +62,6 @@ export function createNotificationConformanceClock(initial) {
61
62
  },
62
63
  };
63
64
  }
64
- const eventBufferLimit = 64;
65
65
  const appKey = '\u0000app';
66
66
  /**
67
67
  * The fake host and its fixture provider (RFC "Vector format", fake defaults).
@@ -425,36 +425,9 @@ export function createNotificationConformanceHost(input) {
425
425
  },
426
426
  };
427
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
428
  function isDecidedAnswer(outcome) {
441
429
  return outcome === 'granted' || outcome === 'already-granted' || outcome === 'denied';
442
430
  }
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
431
  function throwFirst(errors) {
459
432
  if (errors.length > 0) {
460
433
  throw errors[0];
@@ -1558,50 +1531,6 @@ export function createNotificationConformanceBackend(input) {
1558
1531
  },
1559
1532
  };
1560
1533
  }
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
1534
  function sendRequestOf(request, defaultPlayer) {
1606
1535
  const issue = sendInputIssue(request);
1607
1536
  if (issue !== undefined) {
@@ -1654,20 +1583,6 @@ function sendInputIssue(request) {
1654
1583
  }
1655
1584
  return undefined;
1656
1585
  }
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
1586
  function conflictError(key) {
1672
1587
  return new NotificationError('NOTIFICATIONS_IDEMPOTENCY_CONFLICT', {
1673
1588
  message: `Key ${key} is stored for another request.`,
@@ -1753,1250 +1668,31 @@ function compareText(left, right) {
1753
1668
  return left < right ? -1 : left > right ? 1 : 0;
1754
1669
  }
1755
1670
  /**
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.
1671
+ * The production runtime over the conformance inputs: `storage` is a plain
1672
+ * object the runtime reads when it is created and rewrites after each
1673
+ * change, so a restart that passes the same object resumes from it.
1814
1674
  */
1815
1675
  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' });
1676
+ const kept = input.storage ?? {};
1677
+ return createNotificationRuntime({
1678
+ provider: input.provider,
1679
+ backend: input.backend,
1680
+ catalog: input.catalog,
1681
+ clock: input.clock,
1682
+ timeZone: input.timeZone,
1683
+ defaultLocale: input.defaultLocale,
1684
+ ...(input.locale === undefined ? {} : { locale: input.locale }),
1685
+ ...(input.registryEntry === undefined ? {} : { registryEntry: input.registryEntry }),
1686
+ storage: {
1687
+ load: () => ({ ...kept }),
1688
+ save(state) {
1689
+ delete kept.registration;
1690
+ delete kept.unbound;
1691
+ delete kept.unboundSession;
1692
+ Object.assign(kept, state);
2861
1693
  },
2862
1694
  },
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
1695
  });
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
1696
  }
3001
1697
  export function createNotificationConformanceHarness(context) {
3002
1698
  const host = createNotificationConformanceHost({
@@ -3537,6 +2233,3 @@ function matchPartial(expected, actual, path, failures) {
3537
2233
  failures.push({ path, expected, actual });
3538
2234
  }
3539
2235
  }
3540
- function describeError(error) {
3541
- return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3542
- }