@mpgd/game-services 0.24.1 → 0.25.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,86 @@
1
+ import { type NotificationDeliveryProviderEntry, type NotificationDeployment, type NotificationNoticeSettings, type NotificationProviderRegistryEntry, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
2
+ import type { NotificationDeviceAddress, NotificationRemoteDeliveryProvider } from './delivery-provider.js';
3
+ /**
4
+ * The server-side provider registry (notifications v2 RFC, "Provider
5
+ * registry"): each delivery provider's registry entry, which is the policy
6
+ * gate's only capability input, the provider that sends for it, and the
7
+ * catalog targets whose mappings it reads.
8
+ */
9
+ /** Days a delivery stays in the gate's history; no provider limit may count further back. */
10
+ export declare const notificationHistoryRetentionDays = 30;
11
+ export interface NotificationProviderRegistration {
12
+ /**
13
+ * The registry entry, as the backend publishes it. A
14
+ * `NotificationProviderRegistryEntry` also carries the mapping rules the
15
+ * catalog validator checks.
16
+ */
17
+ readonly entry: NotificationDeliveryProviderEntry | NotificationProviderRegistryEntry;
18
+ readonly delivery: NotificationRemoteDeliveryProvider;
19
+ /**
20
+ * The catalog targets this provider serves, in preference order. Each
21
+ * target's mapping is what the gate reads for a recipient on that target;
22
+ * two targets may share one provider (FCM on `android` and `ios`). Defaults
23
+ * to the reference targets of the entry's provider id
24
+ * (`referenceNotificationTargetProviders`).
25
+ */
26
+ readonly targets?: readonly string[];
27
+ /** For a device provider: the address kinds it can send to. Defaults to both FCM kinds. */
28
+ readonly addressKinds?: readonly NotificationDeviceAddress['kind'][];
29
+ }
30
+ export interface RegisteredNotificationProvider {
31
+ readonly entry: NotificationDeliveryProviderEntry;
32
+ readonly delivery: NotificationRemoteDeliveryProvider;
33
+ readonly targets: readonly string[];
34
+ readonly addressKinds: readonly NotificationDeviceAddress['kind'][];
35
+ /** The entry with mapping rules, for the catalog validator; empty rules when none were given. */
36
+ readonly validation: NotificationProviderRegistryEntry;
37
+ }
38
+ /** What the backend publishes, so a runtime can compare its provider's capabilities at start. */
39
+ export interface NotificationProviderRegistrySnapshot {
40
+ readonly providers: readonly NotificationDeliveryProviderEntry[];
41
+ /** Each served target and the provider id that delivers to it. */
42
+ readonly targets: Readonly<Record<string, string>>;
43
+ }
44
+ export interface NotificationProviderRegistry {
45
+ readonly providers: readonly RegisteredNotificationProvider[];
46
+ provider(id: string): RegisteredNotificationProvider | undefined;
47
+ /** The provider a catalog target enables, if this backend serves the target. */
48
+ providerForTarget(target: string): RegisteredNotificationProvider | undefined;
49
+ /** A fresh copy of the published entries and targets. */
50
+ snapshot(): NotificationProviderRegistrySnapshot;
51
+ }
52
+ /**
53
+ * Builds the registry and checks it once: one registration per provider id
54
+ * and per target, an entry that passes `assertNotificationDeliveryProviderEntry`,
55
+ * a delivery provider whose `id` equals the entry's `provider`, a
56
+ * `readPermission` for a server-readable entry, provider limits no longer
57
+ * than the delivery history keeps, and no `per-send` entry.
58
+ *
59
+ * Per-send providers (WeChat one-time subscriptions, not planned) need the
60
+ * acceptance reserved in the same atomic step that claims the delivery, which
61
+ * this backend does not implement, so they are refused rather than served
62
+ * without the reservation.
63
+ */
64
+ export declare function createNotificationProviderRegistry(registrations: readonly NotificationProviderRegistration[]): NotificationProviderRegistry;
65
+ /**
66
+ * The catalog as the notifications service reads it: the topics resolved for
67
+ * one target, with that target's mapping, the default locale, and the
68
+ * catalog's `notices` block taken as is.
69
+ */
70
+ export interface NotificationServiceCatalog {
71
+ readonly defaultLocale: string;
72
+ readonly notices?: NotificationNoticeSettings;
73
+ topicsFor(target: string): readonly NotificationTopicDeclaration[];
74
+ }
75
+ /**
76
+ * Validates a topic catalog against the registry's providers and the
77
+ * deployment (`assertNotificationTopicCatalog` from `@mpgd/catalog`, with
78
+ * each served target checked against its provider's mapping rules), and
79
+ * resolves it per target. An invalid catalog throws one error listing every
80
+ * issue, so a deployment never starts on a catalog the release validator
81
+ * would refuse.
82
+ */
83
+ export declare function createNotificationServiceCatalog(catalog: unknown, options: {
84
+ readonly registry: NotificationProviderRegistry;
85
+ readonly deployment?: NotificationDeployment;
86
+ }): NotificationServiceCatalog;
@@ -0,0 +1,131 @@
1
+ import { assertNotificationTopicCatalog, resolveNotificationTopicsForTarget, } from '@mpgd/catalog';
2
+ import { assertNotificationDeliveryProviderEntry, assertNotificationDeployment, referenceNotificationTargetProviders, } from '@mpgd/platform/notifications';
3
+ import { describeError as describe } from './text.js';
4
+ /**
5
+ * The server-side provider registry (notifications v2 RFC, "Provider
6
+ * registry"): each delivery provider's registry entry, which is the policy
7
+ * gate's only capability input, the provider that sends for it, and the
8
+ * catalog targets whose mappings it reads.
9
+ */
10
+ /** Days a delivery stays in the gate's history; no provider limit may count further back. */
11
+ export const notificationHistoryRetentionDays = 30;
12
+ const deviceAddressKinds = ['fcm-token', 'fcm-installation'];
13
+ const maxHistoryWindowSeconds = notificationHistoryRetentionDays * 86_400;
14
+ /**
15
+ * Builds the registry and checks it once: one registration per provider id
16
+ * and per target, an entry that passes `assertNotificationDeliveryProviderEntry`,
17
+ * a delivery provider whose `id` equals the entry's `provider`, a
18
+ * `readPermission` for a server-readable entry, provider limits no longer
19
+ * than the delivery history keeps, and no `per-send` entry.
20
+ *
21
+ * Per-send providers (WeChat one-time subscriptions, not planned) need the
22
+ * acceptance reserved in the same atomic step that claims the delivery, which
23
+ * this backend does not implement, so they are refused rather than served
24
+ * without the reservation.
25
+ */
26
+ export function createNotificationProviderRegistry(registrations) {
27
+ const byId = new Map();
28
+ const byTarget = new Map();
29
+ for (const [index, registration] of registrations.entries()) {
30
+ const at = `providers[${index}]`;
31
+ const { mapping, ...plain } = registration.entry;
32
+ let entry;
33
+ try {
34
+ entry = structuredClone(assertNotificationDeliveryProviderEntry(plain));
35
+ }
36
+ catch (error) {
37
+ throw new TypeError(`${at}.entry is not a provider registry entry: ${describe(error)}`, {
38
+ cause: error,
39
+ });
40
+ }
41
+ if (registration.delivery.id !== entry.provider) {
42
+ throw new TypeError(`${at}: the delivery provider ${registration.delivery.id} is registered for entry ${entry.provider}.`);
43
+ }
44
+ if (byId.has(entry.provider)) {
45
+ throw new TypeError(`${at}: provider ${entry.provider} is registered twice.`);
46
+ }
47
+ if (entry.permission.allowance === 'per-send') {
48
+ throw new TypeError(`${at}: provider ${entry.provider} is per-send; this backend cannot reserve an acceptance atomically with the delivery claim.`);
49
+ }
50
+ if (entry.permission.serverReadable && registration.delivery.readPermission === undefined) {
51
+ throw new TypeError(`${at}: provider ${entry.provider} is server-readable but its delivery provider has no readPermission.`);
52
+ }
53
+ for (const limit of entry.limits ?? []) {
54
+ if (limit.windowSeconds > maxHistoryWindowSeconds) {
55
+ throw new TypeError(`${at}: a limit window of ${limit.windowSeconds} seconds is longer than the ${notificationHistoryRetentionDays}-day delivery history.`);
56
+ }
57
+ }
58
+ const targets = registration.targets
59
+ ?? Object.entries(referenceNotificationTargetProviders)
60
+ .filter(([, provider]) => provider === entry.provider)
61
+ .map(([target]) => target);
62
+ if (targets.length === 0 || new Set(targets).size !== targets.length) {
63
+ throw new TypeError(`${at}: provider ${entry.provider} needs its catalog targets, each once (no reference targets name it).`);
64
+ }
65
+ const addressKinds = registration.addressKinds ?? deviceAddressKinds;
66
+ if (entry.addressing === 'device' && addressKinds.length === 0) {
67
+ throw new TypeError(`${at}: device provider ${entry.provider} accepts no address kind.`);
68
+ }
69
+ const registered = {
70
+ entry,
71
+ delivery: registration.delivery,
72
+ targets: [...targets],
73
+ addressKinds: [...addressKinds],
74
+ validation: { ...structuredClone(entry), mapping: structuredClone(mapping ?? {}) },
75
+ };
76
+ for (const target of targets) {
77
+ const other = byTarget.get(target);
78
+ if (other !== undefined) {
79
+ throw new TypeError(`${at}: target ${target} is served by both ${other.entry.provider} and ${entry.provider}.`);
80
+ }
81
+ byTarget.set(target, registered);
82
+ }
83
+ byId.set(entry.provider, registered);
84
+ }
85
+ const providers = [...byId.values()];
86
+ return {
87
+ providers,
88
+ provider: (id) => byId.get(id),
89
+ providerForTarget: (target) => byTarget.get(target),
90
+ snapshot: () => ({
91
+ providers: providers.map((provider) => structuredClone(provider.entry)),
92
+ targets: Object.fromEntries([...byTarget].map(([target, provider]) => [target, provider.entry.provider])),
93
+ }),
94
+ };
95
+ }
96
+ /**
97
+ * Validates a topic catalog against the registry's providers and the
98
+ * deployment (`assertNotificationTopicCatalog` from `@mpgd/catalog`, with
99
+ * each served target checked against its provider's mapping rules), and
100
+ * resolves it per target. An invalid catalog throws one error listing every
101
+ * issue, so a deployment never starts on a catalog the release validator
102
+ * would refuse.
103
+ */
104
+ export function createNotificationServiceCatalog(catalog, options) {
105
+ const deployment = options.deployment === undefined
106
+ ? undefined
107
+ : assertNotificationDeployment(options.deployment);
108
+ const targetProviders = {};
109
+ for (const provider of options.registry.providers) {
110
+ for (const target of provider.targets) {
111
+ targetProviders[target] = provider.validation;
112
+ }
113
+ }
114
+ const validated = assertNotificationTopicCatalog(catalog, {
115
+ targetProviders,
116
+ ...(deployment === undefined ? {} : { deployment }),
117
+ });
118
+ const resolved = new Map();
119
+ return {
120
+ defaultLocale: validated.defaultLocale,
121
+ ...(validated.notices === undefined ? {} : { notices: validated.notices }),
122
+ topicsFor(target) {
123
+ let topics = resolved.get(target);
124
+ if (topics === undefined) {
125
+ topics = resolveNotificationTopicsForTarget(validated, target);
126
+ resolved.set(target, topics);
127
+ }
128
+ return topics;
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,222 @@
1
+ import { NotificationError, type NotificationConsentRecord, type NotificationDeliveryOutcome, type NotificationDeployment, type NotificationNotice, type NotificationTemplateData } from '@mpgd/platform/notifications';
2
+ import { type NotificationProviderRegistry, type NotificationProviderRegistrySnapshot, type NotificationServiceCatalog } from './registry.js';
3
+ import { type NotificationNoticeChannel, type NotificationPurgeCounts, type NotificationStore } from './store.js';
4
+ /**
5
+ * Client-written consent records a player may add per rolling day. It is
6
+ * generous: it only stops a client that writes in a loop, since every send
7
+ * reads the player's whole history. It is checked before the write, so
8
+ * concurrent writes may pass it by a few.
9
+ */
10
+ export declare const maxClientNotificationConsentRecordsPerDay = 200;
11
+ /**
12
+ * Pending server reminders a player may keep, as many as iOS keeps local
13
+ * notifications. Replacing a pending one is always allowed; a new key past
14
+ * it answers `capacity-exceeded`. The count is read before the write, so
15
+ * sets that race may pass it by a few, as the daily consent cap may.
16
+ */
17
+ export declare const maxPendingNotificationReminders = 64;
18
+ /** How far ahead a server reminder may be set; a later `fireAt` is refused. */
19
+ export declare const notificationReminderHorizonDays = 90;
20
+ /** An authenticated game-services session, as the deployment's identity service resolves it. */
21
+ export interface NotificationSession {
22
+ readonly player: string;
23
+ /** Stable for the session's life (its refresh family); its end unbinds what it registered. */
24
+ readonly sessionId: string;
25
+ /** The guest session's installation id. */
26
+ readonly installationId: string;
27
+ /**
28
+ * When the session ends on its own, RFC 3339; endpoints it registered
29
+ * count as unbound from then. Each call moves their stored end to a later
30
+ * one (or clears it when this is absent), never to an earlier one.
31
+ */
32
+ readonly expiresAt?: string;
33
+ }
34
+ /** A send request from the game's server (never from a client). */
35
+ export interface NotificationDeliverInput {
36
+ readonly idempotencyKey: string;
37
+ readonly player: string;
38
+ readonly topic: string;
39
+ /** One provider; without it the send goes to every provider the player has a recipient on. */
40
+ readonly provider?: string;
41
+ readonly templateData?: Readonly<Record<string, string | number | boolean>>;
42
+ /** Untrusted routing data the open carries. */
43
+ readonly data?: Readonly<Record<string, string>>;
44
+ readonly locale?: string;
45
+ /** A post or comment id for providers whose sends need one (Devvit). */
46
+ readonly link?: string;
47
+ }
48
+ /** One recipient's delivery to run: a send key and its row. */
49
+ export interface NotificationDispatchJob {
50
+ readonly sendKey: string;
51
+ readonly provider: string;
52
+ readonly rowKey: string;
53
+ }
54
+ /** A queue that runs each job later through `NotificationService.dispatch` (a Cloudflare Queue). */
55
+ export interface NotificationDispatchQueue {
56
+ enqueue(jobs: readonly NotificationDispatchJob[]): Promise<void>;
57
+ }
58
+ export interface CreateNotificationServiceInput {
59
+ readonly store: NotificationStore;
60
+ readonly registry: NotificationProviderRegistry;
61
+ readonly catalog: NotificationServiceCatalog;
62
+ readonly deployment?: NotificationDeployment;
63
+ /**
64
+ * The recipient id of a platform-user provider for a player, from the
65
+ * player's authenticated identity on that platform; `undefined` when the
66
+ * player has none.
67
+ */
68
+ readonly resolvePlatformRecipient?: (input: {
69
+ readonly provider: string;
70
+ readonly player: string;
71
+ }) => string | undefined | Promise<string | undefined>;
72
+ /**
73
+ * Runs dispatch jobs later, so a fan-out never exceeds one invocation's
74
+ * subrequest limit. Without it every job runs inline before `deliver`
75
+ * answers.
76
+ */
77
+ readonly queue?: NotificationDispatchQueue;
78
+ readonly now?: () => string;
79
+ /**
80
+ * A monotonic clock in milliseconds that bounds one scheduler run's wall
81
+ * time (`runScheduler`'s `budgetMs`); `performance.now()` by default.
82
+ */
83
+ readonly elapsedMs?: () => number;
84
+ /** Jitter for repeated rate-limit backoff, in [0, 1). */
85
+ readonly random?: () => number;
86
+ /** How long a claim holds a delivery before another worker may replay it; 300 by default. */
87
+ readonly claimLeaseSeconds?: number;
88
+ /** Errors the service handles without failing the call, such as a queue that refused a job. */
89
+ readonly onError?: (error: unknown, context: {
90
+ readonly operation: string;
91
+ readonly sendKey?: string;
92
+ readonly provider?: string;
93
+ readonly rowKey?: string;
94
+ /**
95
+ * True when the error is a delivery provider's own failure, whose
96
+ * message may quote a device token or a credential: log its name only.
97
+ */
98
+ readonly fromProvider?: boolean;
99
+ }) => void;
100
+ }
101
+ export interface NotificationDueResult {
102
+ readonly sendKey: string;
103
+ /** The send's result after the replay. */
104
+ readonly outcome: NotificationDeliveryOutcome;
105
+ }
106
+ /** A server reminder as a game sets it (RFC `reminders.set`, with the time already in the request). */
107
+ export interface NotificationReminderInput {
108
+ readonly key: string;
109
+ readonly topic: string;
110
+ /** RFC 3339 with an explicit offset. */
111
+ readonly fireAt: string;
112
+ readonly templateData?: NotificationTemplateData;
113
+ /** A post or comment id for providers whose sends need a link (Devvit). */
114
+ readonly link?: string;
115
+ }
116
+ /** What setting a server reminder answers: stored, or the refusal of gate steps 3 to 5. */
117
+ export type NotificationReminderSetStatus = 'scheduled' | 'replaced' | 'permission-required' | 'request-missing' | 'preference-off' | 'advertising-consent-missing'
118
+ /** The player keeps `maxPendingNotificationReminders` pending reminders already. */
119
+ | 'capacity-exceeded'
120
+ /** No provider the player can be reached on maps the topic for its target. */
121
+ | 'unavailable';
122
+ export interface NotificationService {
123
+ /** The registry entries and served targets the backend publishes. */
124
+ providers(): NotificationProviderRegistrySnapshot;
125
+ /**
126
+ * Appends a consent record for the session's player, dated by the backend.
127
+ * A repeat of the player's latest permission, agreement, or preference
128
+ * record for the same kind and topic answers that record unwritten; an
129
+ * `advertising` or `night` record is always written, even a repeat, and
130
+ * counts toward the cap. A player past
131
+ * `maxClientNotificationConsentRecordsPerDay` gets a
132
+ * `NotificationConsentRateLimitError`.
133
+ */
134
+ recordConsent(session: NotificationSession, record: unknown): Promise<NotificationConsentRecord>;
135
+ listConsent(session: NotificationSession): Promise<readonly NotificationConsentRecord[]>;
136
+ /**
137
+ * Binds a device registration to the session's installation and player, or
138
+ * keeps a platform-user registration's locale and zone. `target` names the
139
+ * catalog target whose mapping applies to the device when two targets share
140
+ * a provider (FCM on `android` and `ios`).
141
+ */
142
+ registerEndpoint(session: NotificationSession, input: {
143
+ readonly registration: unknown;
144
+ readonly target?: string;
145
+ }): Promise<void>;
146
+ /** Unbinds the session's installation from the session's player (one provider, or all). */
147
+ unregisterEndpoint(session: NotificationSession, input?: {
148
+ readonly provider?: string;
149
+ }): Promise<void>;
150
+ /** The session ended (logout, revocation): every endpoint it registered is unbound. */
151
+ endSession(input: {
152
+ readonly sessionId: string;
153
+ }): Promise<void>;
154
+ /** The session player's listed notices; none unless the deployment applies the Korean rules. */
155
+ listNotices(session: NotificationSession): Promise<readonly NotificationNotice[]>;
156
+ /** Records a listed notice the game showed; a notice that is not listed changes nothing. */
157
+ recordNoticeShown(session: NotificationSession, notice: unknown, input?: {
158
+ readonly channel?: Exclude<NotificationNoticeChannel, 'push'>;
159
+ }): Promise<void>;
160
+ /** One send through the ledger, the gate, and the providers. */
161
+ deliver(input: unknown): Promise<NotificationDeliveryOutcome>;
162
+ /** Runs one queued dispatch job; a job that is no longer due does nothing. */
163
+ dispatch(job: NotificationDispatchJob): Promise<void>;
164
+ /**
165
+ * Replays up to `limit` sends with a due row, at no more than each
166
+ * provider's registry `rate`: through the queue when one is bound (each
167
+ * row is taken for its lease and enqueued), inline otherwise. One failing
168
+ * send never stops the others.
169
+ */
170
+ dispatchDue(input?: {
171
+ readonly limit?: number;
172
+ }): Promise<{
173
+ readonly results: readonly NotificationDueResult[];
174
+ readonly failed: number;
175
+ }>;
176
+ /**
177
+ * Sets the session player's server reminder (RFC "Reminders"). Gate steps
178
+ * 3 to 5 run first against the backend's records, the device permission
179
+ * each endpoint reported, or the host read, for any provider the player
180
+ * can be reached on; a refusal stores nothing. Otherwise the reminder with
181
+ * this key is stored or replaced, and a send of the previous one that
182
+ * still waits (deferred or in flight) is stopped.
183
+ */
184
+ setReminder(session: NotificationSession, input: unknown): Promise<{
185
+ readonly status: NotificationReminderSetStatus;
186
+ }>;
187
+ /**
188
+ * Removes the session player's reminder with this key and stops a send of
189
+ * it that still waits; answers whether either existed.
190
+ */
191
+ cancelReminder(session: NotificationSession, input: unknown): Promise<{
192
+ readonly cancelled: boolean;
193
+ }>;
194
+ /**
195
+ * One scheduler run, in rounds: each takes up to `limit` due schedule
196
+ * entries (server reminders become `reminder:` sends; each player's notice
197
+ * check pushes the consent notices owed by now), then `dispatchDue` with
198
+ * the same `limit`. Rounds go on while a round filled a batch and got
199
+ * somewhere, until `budgetMs` of wall time (20 seconds by default) is used.
200
+ * Answers each send it touched with its result after the run, in the order
201
+ * it first touched them.
202
+ */
203
+ runScheduler(input?: {
204
+ readonly limit?: number;
205
+ readonly budgetMs?: number;
206
+ }): Promise<{
207
+ readonly results: readonly NotificationDueResult[];
208
+ readonly failed: number;
209
+ }>;
210
+ /** Forgets keys after 30 days, the history past the limit windows, and dead or stale endpoints. */
211
+ purge(): Promise<NotificationPurgeCounts>;
212
+ }
213
+ /**
214
+ * A player wrote more consent records than one rolling day allows. It is
215
+ * retryable after `retryAfterSeconds`, when the oldest counted record leaves
216
+ * the window.
217
+ */
218
+ export declare class NotificationConsentRateLimitError extends NotificationError {
219
+ readonly retryAfterSeconds: number;
220
+ constructor(retryAfterSeconds: number);
221
+ }
222
+ export declare function createNotificationService(input: CreateNotificationServiceInput): NotificationService;