@mpgd/platform 0.26.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ import { type NotificationEvent, type NotificationNoticeIdentity, type RecordNotificationNoticeInput, type SetNotificationReminderInput } from './notifications.js';
2
+ /**
3
+ * Buffers an event for the first listener, keeping at most
4
+ * `eventBufferLimit`: past it the oldest event that is not an open goes first,
5
+ * so a cold-start open is not lost to later events.
6
+ */
7
+ export declare function bufferEvent(buffer: NotificationEvent[], event: NotificationEvent): void;
8
+ /** Calls every listener with every event, in order; returns the errors they threw. */
9
+ export declare function deliverEach(listeners: readonly ((event: NotificationEvent) => void)[], events: readonly NotificationEvent[]): unknown[];
10
+ /** A well-formed `notices.recordShown` input, or `NOTIFICATIONS_INVALID_INPUT`. */
11
+ export declare function recordNoticeInputOf(input: unknown): RecordNotificationNoticeInput;
12
+ /**
13
+ * Only the fields that name a notice, with the record time in the canonical
14
+ * form; a listed notice's `record.state` and `textVersion` are left out.
15
+ */
16
+ export declare function noticeIdentityOf(notice: NotificationNoticeIdentity): NotificationNoticeIdentity;
17
+ /** Why a reminder's input breaks the RFC bounds, or `undefined`. */
18
+ export declare function reminderInputIssue(input: SetNotificationReminderInput): string | undefined;
19
+ export declare function isBoundedText(value: unknown): boolean;
20
+ export declare function describeError(error: unknown): string;
@@ -0,0 +1,96 @@
1
+ import { assertRecordNotificationNoticeInput, normalizeNotificationTimestamp, NotificationError, notificationKeyIssue, notificationTemplateDataIssue, } from './notifications.js';
2
+ /**
3
+ * Helpers the notifications runtime and the conformance runner share. Not a
4
+ * package entry: `@mpgd/platform/notifications-runtime` and
5
+ * `@mpgd/platform/notifications-conformance` are.
6
+ */
7
+ /** Events a provider or runtime keeps for its first listener. */
8
+ const eventBufferLimit = 64;
9
+ /**
10
+ * Buffers an event for the first listener, keeping at most
11
+ * `eventBufferLimit`: past it the oldest event that is not an open goes first,
12
+ * so a cold-start open is not lost to later events.
13
+ */
14
+ export function bufferEvent(buffer, event) {
15
+ buffer.push(event);
16
+ if (buffer.length > eventBufferLimit) {
17
+ const evicted = buffer.findIndex((buffered) => buffered.type !== 'opened');
18
+ buffer.splice(evicted === -1 ? 0 : evicted, 1);
19
+ }
20
+ }
21
+ /** Calls every listener with every event, in order; returns the errors they threw. */
22
+ export function deliverEach(listeners, events) {
23
+ const errors = [];
24
+ for (const event of events) {
25
+ for (const listener of listeners) {
26
+ try {
27
+ listener(event);
28
+ }
29
+ catch (error) {
30
+ errors.push(error);
31
+ }
32
+ }
33
+ }
34
+ return errors;
35
+ }
36
+ /** A well-formed `notices.recordShown` input, or `NOTIFICATIONS_INVALID_INPUT`. */
37
+ export function recordNoticeInputOf(input) {
38
+ try {
39
+ return assertRecordNotificationNoticeInput(input);
40
+ }
41
+ catch (error) {
42
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
43
+ message: 'A notice is a listed notice, or its kind, the kind and time of the record it reports '
44
+ + `on, and its dueBy: ${describeError(error)}`,
45
+ cause: error,
46
+ });
47
+ }
48
+ }
49
+ /**
50
+ * Only the fields that name a notice, with the record time in the canonical
51
+ * form; a listed notice's `record.state` and `textVersion` are left out.
52
+ */
53
+ export function noticeIdentityOf(notice) {
54
+ return {
55
+ kind: notice.kind,
56
+ record: { kind: notice.record.kind, at: normalizeNotificationTimestamp(notice.record.at) },
57
+ dueBy: notice.dueBy,
58
+ };
59
+ }
60
+ /** Why a reminder's input breaks the RFC bounds, or `undefined`. */
61
+ export function reminderInputIssue(input) {
62
+ const keyIssue = notificationKeyIssue(input.key);
63
+ if (keyIssue !== undefined) {
64
+ return `key: ${keyIssue}`;
65
+ }
66
+ if (timestampOrUndefined(input.fireAt) === undefined) {
67
+ return 'fireAt: expected RFC 3339 with an explicit offset';
68
+ }
69
+ if (input.templateData !== undefined) {
70
+ const issue = notificationTemplateDataIssue(input.templateData);
71
+ if (issue !== undefined) {
72
+ return `templateData: ${issue}`;
73
+ }
74
+ }
75
+ if (input.link !== undefined && !isBoundedText(input.link)) {
76
+ return 'link: expected a non-empty string';
77
+ }
78
+ return undefined;
79
+ }
80
+ export function isBoundedText(value) {
81
+ return typeof value === 'string' && value.length > 0 && value.length <= 16_384;
82
+ }
83
+ function timestampOrUndefined(value) {
84
+ if (typeof value !== 'string') {
85
+ return undefined;
86
+ }
87
+ try {
88
+ return normalizeNotificationTimestamp(value);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ export function describeError(error) {
95
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
96
+ }
@@ -0,0 +1,262 @@
1
+ import type { StorageAdapter } from './index.js';
2
+ import { type CancelNotificationReminderInput, type NotificationConsentRecord, type NotificationDeliveryProviderEntry, type NotificationEndpointRegistration, type NotificationNotice, type NotificationNoticeIdentity, type NotificationProvider, type NotificationRuntime, type NotificationTopicDeclaration, type SetNotificationReminderInput } from './notifications.js';
3
+ /** The authenticated session a backend serves; the backend binds the installation to it. */
4
+ export interface NotificationRuntimeSession {
5
+ /** Stable for the session's life: refreshing the session keeps it. */
6
+ readonly id: string;
7
+ readonly player: string;
8
+ }
9
+ /**
10
+ * What setting a server reminder answers: stored, the gate step 3 to 5
11
+ * refusal, `capacity-exceeded` past a backend's cap on a player's pending
12
+ * reminders, or `unavailable` when no provider the player can be reached on
13
+ * maps the topic.
14
+ */
15
+ export type NotificationRuntimeReminderSetStatus = 'scheduled' | 'replaced' | 'permission-required' | 'request-missing' | 'preference-off' | 'advertising-consent-missing' | 'capacity-exceeded' | 'unavailable';
16
+ /**
17
+ * The backend surface the runtime calls for the authenticated session. Every
18
+ * call acts for the session `session()` names when it is made; the backend
19
+ * learns the session from its own authentication, never from a request field.
20
+ * A call without a session, or for a session the backend no longer accepts,
21
+ * throws `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`.
22
+ */
23
+ export interface NotificationRuntimeBackend {
24
+ /** The authenticated session; `undefined` before authentication and after the session ends. */
25
+ session(): NotificationRuntimeSession | undefined;
26
+ /**
27
+ * Calls `listener` whenever a session starts, including a restored one and
28
+ * an account switch; returns the unsubscribe. Refreshing the same session
29
+ * (the same `id`) is not a start.
30
+ */
31
+ onAuthenticated(listener: (session: NotificationRuntimeSession) => void): () => void;
32
+ /** Appends a record for the session's player; the backend stamps `at` with its own clock. */
33
+ recordConsent(record: NotificationConsentRecord): Promise<void>;
34
+ /** The session player's consent records, oldest first. */
35
+ listConsent(): Promise<readonly NotificationConsentRecord[]>;
36
+ /**
37
+ * Binds a device registration to the session's installation and player, or
38
+ * keeps a platform-user registration's locale and zone for the player.
39
+ */
40
+ registerEndpoint(registration: NotificationEndpointRegistration): Promise<void>;
41
+ /**
42
+ * Unbinds the session's installation from `player`, the player whose
43
+ * session sent the unbind; an installation another player took over since
44
+ * stays bound. Unlike the other calls, it may answer without a request when
45
+ * `player` is no longer signed in: that session's end unbinds what it
46
+ * registered (the game-services client does).
47
+ */
48
+ unregisterEndpoint(input: {
49
+ readonly provider: string;
50
+ readonly player: string;
51
+ }): Promise<void>;
52
+ /** Checks gate steps 3 to 5, then stores or replaces the session player's reminder with this key. */
53
+ setReminder(input: SetNotificationReminderInput): Promise<NotificationRuntimeReminderSetStatus>;
54
+ /** Whether a pending reminder with this key, or a waiting send of one, existed. */
55
+ cancelReminder(input: CancelNotificationReminderInput): Promise<boolean>;
56
+ /** The session player's listed notices; none unless the deployment applies the Korean rules. */
57
+ listNotices(): Promise<readonly NotificationNotice[]>;
58
+ /**
59
+ * Records a notice the game showed the session's player, named by its
60
+ * identity fields, while that notice is listed; recording one that is not
61
+ * listed changes nothing.
62
+ */
63
+ recordNotice(input: NotificationNoticeIdentity & {
64
+ readonly channel: 'in-game';
65
+ }): Promise<void>;
66
+ }
67
+ /** The last registration the backend acknowledged, with the player and session it was sent for. */
68
+ export interface NotificationRuntimePersistedRegistration {
69
+ readonly registration: NotificationEndpointRegistration;
70
+ readonly player: string;
71
+ readonly session: string;
72
+ readonly at: string;
73
+ }
74
+ /** What the runtime keeps across restarts (RFC "Runtime", "Restarts"). */
75
+ export interface NotificationRuntimeState {
76
+ /** The last registration the backend acknowledged. */
77
+ readonly registration?: NotificationRuntimePersistedRegistration;
78
+ /**
79
+ * Set by `unregister()`; `register()` or the next authentication clears it,
80
+ * including one the runtime did not observe (see `unboundSession`).
81
+ */
82
+ readonly unbound?: boolean;
83
+ /**
84
+ * With `unbound`: the session signed in when `unregister()` ran. Another
85
+ * session found at start or on a read, or any session when none was signed
86
+ * in then, is a sign-in the runtime did not observe, so it binds again.
87
+ */
88
+ readonly unboundSession?: {
89
+ readonly player: string;
90
+ readonly session: string;
91
+ };
92
+ }
93
+ /**
94
+ * Where the runtime keeps its state across restarts. `load` runs once, when
95
+ * the runtime is created, and again after a load that failed; a failed load
96
+ * is reported by the call that needed the state. `save` runs after each
97
+ * change, with the whole state. Either may answer synchronously. A value
98
+ * `load` answers that is not a runtime state reads as an empty one. One
99
+ * runtime owns a storage: a game with two runtimes gives each its own key.
100
+ * The state holds the last registration as sent, push address included (an
101
+ * FCM token): an address, not a credential, kept like other game data.
102
+ */
103
+ export interface NotificationRuntimeStorage {
104
+ load(): unknown;
105
+ save(state: NotificationRuntimeState): void | Promise<void>;
106
+ }
107
+ /** The clock the runtime dates its records and registrations by (RFC 3339 instants). */
108
+ export interface NotificationRuntimeClock {
109
+ now(): string;
110
+ }
111
+ export interface CreateNotificationRuntimeInput {
112
+ readonly provider: NotificationProvider;
113
+ readonly backend: NotificationRuntimeBackend;
114
+ /** The provider's topics, each with this target's mapping (`resolveNotificationTopicsForTarget`). */
115
+ readonly catalog: readonly NotificationTopicDeclaration[];
116
+ /** The catalog's default locale. */
117
+ readonly defaultLocale: string;
118
+ /** The device locale for local reminder copy; defaults to `defaultLocale`. */
119
+ readonly locale?: string;
120
+ /**
121
+ * The deployment's zone, for local quiet hours while the device has
122
+ * registered none; defaults to `notificationDefaultDeploymentTimeZone`.
123
+ */
124
+ readonly timeZone?: string;
125
+ /** Defaults to the system clock. */
126
+ readonly clock?: NotificationRuntimeClock;
127
+ /**
128
+ * Survives restarts. Without it the state lives in memory, so a restart
129
+ * re-sends the registration and forgets an `unregister()`; pass
130
+ * `createNotificationRuntimeStorage({ storage: gateway.storage })` or
131
+ * another durable port.
132
+ */
133
+ readonly storage?: NotificationRuntimeStorage;
134
+ /**
135
+ * The backend's registry entry for this provider. When the provider's
136
+ * capabilities disagree with it at start, every call throws
137
+ * `NOTIFICATIONS_CONFIGURATION_REQUIRED`, and nothing is registered, read,
138
+ * or recorded in the background either. `unregister()` is the exception:
139
+ * it still unbinds, since removing a device from a player is always safe. While a `loadRegistryEntry` is in
140
+ * flight, registrations (including `register()`, which then resolves
141
+ * before it is sent) and records that grant something wait without
142
+ * holding any queue, and go out when the load settles without a mismatch;
143
+ * withdrawals and refusals go out at once.
144
+ */
145
+ readonly registryEntry?: NotificationDeliveryProviderEntry;
146
+ /**
147
+ * Loads the registry entry when `start()` runs without `registryEntry`,
148
+ * for example from the game-services Worker's `GET /providers`. The load
149
+ * runs on its own: neither `start()` nor `runUntilIdle()` waits for it.
150
+ * Until it answers, reads, prompts, and local reminders run without the
151
+ * comparison, while registrations and granting records wait for it (see
152
+ * `registryEntry`). A load that fails is
153
+ * reported through `onError` and the next `runUntilIdle()`, and the runtime
154
+ * goes on without the entry, so a backend it cannot reach does not turn notifications off;
155
+ * the next `start()` loads it again. An entry once loaded is kept for the
156
+ * runtime's life.
157
+ */
158
+ readonly loadRegistryEntry?: () => Promise<NotificationDeliveryProviderEntry | undefined>;
159
+ /**
160
+ * Called with each failure of background work (records, registrations,
161
+ * local reminders, game listeners) as it happens; `runUntilIdle` still
162
+ * rethrows them. It runs synchronously: an error it throws, or a promise
163
+ * it returns, is ignored.
164
+ */
165
+ readonly onError?: (error: unknown) => void;
166
+ }
167
+ export interface NotificationRuntimeController extends NotificationRuntime {
168
+ /**
169
+ * Subscribes to the provider and to authentication, compares the provider
170
+ * with its registry entry, and reconciles pending local reminders. Provider
171
+ * events are buffered for the game's first listener.
172
+ */
173
+ start(): Promise<void>;
174
+ /** Drops listeners, buffered events, and both subscriptions. */
175
+ stop(): void;
176
+ /** Waits for event-driven work (records, registrations, local reminders) and rethrows its first failure. */
177
+ runUntilIdle(): Promise<void>;
178
+ }
179
+ /** The storage key `createNotificationRuntimeStorage` uses by default. */
180
+ export declare const defaultNotificationRuntimeStorageKey = "mpgd:notifications-runtime";
181
+ /**
182
+ * A `NotificationRuntimeStorage` over the platform storage adapter
183
+ * (`gateway.storage`), one JSON value under `key`. The host owns its
184
+ * durability; a failed load or save surfaces from the runtime call that
185
+ * needed it as `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`.
186
+ */
187
+ export declare function createNotificationRuntimeStorage(input: {
188
+ readonly storage: Pick<StorageAdapter, 'load' | 'save'>;
189
+ readonly key?: string;
190
+ }): NotificationRuntimeStorage;
191
+ /** A `NotificationRuntimeStorage` that lives in memory: nothing survives a restart. */
192
+ export declare function createMemoryNotificationRuntimeStorage(): NotificationRuntimeStorage;
193
+ /** A stored value as a runtime state; anything else reads as an empty state. */
194
+ export declare function notificationRuntimeStateOf(value: unknown): NotificationRuntimeState;
195
+ /**
196
+ * Creates the notifications runtime (RFC "Runtime"): the provider composed
197
+ * with the backend for the authenticated session. The conformance runner's
198
+ * reference runtime is this one, so every vector exercises it.
199
+ *
200
+ * - Reads return the provider snapshot. A topic the host reads `unknown`
201
+ * takes the latest record, `sources` says which answered, and a
202
+ * game-rendered consent that is not opted in reads `prompt` until a
203
+ * decision is recorded and `denied` after it. A topic whose mapping needs no
204
+ * agreement reads `granted`. A read that observes a changed decided state
205
+ * records it, and every read still answers when the backend cannot.
206
+ * - Reads, requests, and revocations take each topic once, however often it
207
+ * is listed.
208
+ * - Requests throw `NOTIFICATIONS_USER_GESTURE_REQUIRED` without a gesture on
209
+ * a gesture-only provider. An unsupported provider answers `unavailable`, a
210
+ * provider in `action-required` answers that with its `requiredAction`, an
211
+ * undeclared or unmapped topic answers `unavailable`, a topic that needs no
212
+ * agreement answers `already-granted`, and a topic the host reads `blocked`
213
+ * answers `blocked`, all without reaching the host; every other topic
214
+ * reaches the prompt.
215
+ * - Records: decided states that differ from the latest record of their kind
216
+ * and scope, never `dismissed` or `action-required` answers. Device
217
+ * providers write none (the endpoint holds the permission), other app-scope
218
+ * providers write `delivery-permission`, and topic-scope providers write
219
+ * `host-agreement` with the host evidence, plus a `topic-preference` of
220
+ * `granted` when the player accepts a `requested` topic's agreement.
221
+ * Per-send acceptances are appended every time. A record belongs to the
222
+ * player signed in when the call or event began: one observed before
223
+ * authentication waits for it (only the latest per kind and scope), and one
224
+ * observed for a player waits for that player's session. A player action
225
+ * (`setTopicPreference`, `setAdvertisingConsent`) needs an authenticated
226
+ * player and throws `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` without one, as
227
+ * does every other backend call (a server reminder, `reminders.cancel`'s
228
+ * server part, and `notices`). A backend call belongs to the player signed
229
+ * in when it began and is refused when another player signed in since. A
230
+ * record the backend would refuse is dropped and its error surfaces.
231
+ * - Registrations: the provider's registration goes to the backend at
232
+ * authentication, on `register()`, on `endpoint-changed` (a rotated device
233
+ * address, or a platform user's changed locale or zone), and when a read
234
+ * or request, or a revocation, changes the device's decided permission; and
235
+ * at start and on reads once the last one the backend acknowledged is older
236
+ * than 7 days, belongs to another session, or was never acknowledged (a
237
+ * first registration that failed). An identical one for the same player and session is skipped
238
+ * for 7 days, except on `register()`. `unregister()` unbinds the
239
+ * installation from the player signed in when it was called and cancels the
240
+ * pending local reminders, even when the unbind fails, and nothing binds it
241
+ * again, across restarts, until `register()` or the next authentication. A
242
+ * session found at start or on a read other than the one `unregister()`
243
+ * left (any session, when none was signed in then) is such an
244
+ * authentication even when the runtime did not observe it. While bound, a
245
+ * registration acknowledged for another session is sent again for this
246
+ * one, and so is, at start, one for a session with nothing acknowledged.
247
+ * - Reminders: one key across both routes, and `cancel` removes it from each
248
+ * route that has it. Local ones pass the client gate steps (permission,
249
+ * then the topic preference from the backend, then quiet hours, which move
250
+ * them or refuse a topic that is not deferrable, then the topic's daily
251
+ * cap); server ones are checked and stored by the backend. Turning a topic off cancels its local
252
+ * reminders, a known permission other than granted cancels them, and start,
253
+ * authentication, and every backend read reconcile them with the catalog
254
+ * (a topic that no longer takes the local route loses its reminders), the
255
+ * latest preference, and permission.
256
+ * - Game listeners run after the runtime's own handling of an event; a
257
+ * listener that throws stops neither the others nor the runtime, and its
258
+ * error surfaces from `runUntilIdle`, as do failures of background work:
259
+ * the first one, or an `AggregateError` that counts them all. `stop()` and
260
+ * a later `start()` buffer events for the next first subscriber again.
261
+ */
262
+ export declare function createNotificationRuntime(input: CreateNotificationRuntimeInput): NotificationRuntimeController;