@mpgd/game-services 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ import type { NotificationDeliveryContent, NotificationPermissionState, NotificationProviderId, NotificationTemplateData, NotificationTopicDeclaration } from '@mpgd/platform/notifications';
2
+ /**
3
+ * The server-side remote delivery provider contract (notifications v2 RFC,
4
+ * "Delivery providers" and "Provider answers"). It is built only from
5
+ * `@mpgd/platform/notifications` types and literal unions, so a provider
6
+ * module (FCM here, Apps in Toss in its own module) fits it structurally
7
+ * without importing anything from this package.
8
+ *
9
+ * A provider sends exactly what the policy gate rendered: the service runs
10
+ * `evaluateNotificationDelivery` immediately before every call, and a provider
11
+ * never decides policy.
12
+ */
13
+ /**
14
+ * Why a Firebase installation id (FID, `fcm-installation`) is refused, at
15
+ * registration and at send. The kind is reserved: FCM is moving to FIDs, but
16
+ * HTTP v1 has no FID send target yet. It lives here, beside the address
17
+ * kinds, so the service can use it without importing the FCM provider.
18
+ */
19
+ export declare const fcmInstallationIdRefusal = "Firebase installation ids (fcm-installation) are reserved and refused until FCM can send to them; register the FCM registration token (fcm-token).";
20
+ /** A device address as the endpoint registry stores it; FCM keeps the kinds apart. */
21
+ export interface NotificationDeviceAddress {
22
+ readonly kind: 'fcm-token' | 'fcm-installation';
23
+ readonly value: string;
24
+ readonly environment?: 'production' | 'sandbox';
25
+ }
26
+ /** Who one delivery goes to: a device endpoint, or a resolved platform identity. */
27
+ export type NotificationRemoteRecipient = {
28
+ readonly kind: 'device';
29
+ /** The game-services session's installation the endpoint belongs to. */
30
+ readonly installationId: string;
31
+ readonly address: NotificationDeviceAddress;
32
+ } | {
33
+ readonly kind: 'platform-user';
34
+ /**
35
+ * The provider's own recipient id, resolved by the backend from the
36
+ * player's authenticated identity (an Apps in Toss user key, a Reddit
37
+ * user id), never from a key the client sent.
38
+ */
39
+ readonly id: string;
40
+ };
41
+ /** One recipient's delivery of one send, after the gate allowed it. */
42
+ export interface NotificationRemoteDeliveryRequest {
43
+ /**
44
+ * The ledger key of this delivery: the send key for a platform-user
45
+ * recipient, `<send key>:<encodeURIComponent(installationId)>` for a device
46
+ * endpoint. A replay of the same delivery carries the same key, so a
47
+ * provider that can deduplicate (a provider-side idempotency key or
48
+ * outbox) uses it.
49
+ */
50
+ readonly idempotencyKey: string;
51
+ /** The send's own idempotency key. */
52
+ readonly sendKey: string;
53
+ readonly provider: NotificationProviderId;
54
+ readonly player: string;
55
+ /** The topic as declared for the recipient's target, with that target's mapping. */
56
+ readonly topic: NotificationTopicDeclaration;
57
+ readonly recipient: NotificationRemoteRecipient;
58
+ /** The content the gate rendered; under `kr` it already carries the advertising label, sender, opt-out line, and action. */
59
+ readonly content: NotificationDeliveryContent;
60
+ /** RFC 3339: the message must not be shown after this instant (FCM `android.ttl` and `apns-expiration`). */
61
+ readonly expiresAt?: string;
62
+ readonly templateData?: NotificationTemplateData;
63
+ /** Untrusted routing data the open carries; never a grant. */
64
+ readonly data?: Readonly<Record<string, string>>;
65
+ /** The send's post or comment link, else the mapping's (providers whose entry sets `link`). */
66
+ readonly link?: string;
67
+ /** The locale the content was resolved for. */
68
+ readonly locale?: string;
69
+ /** The time of the call, RFC 3339; a provider computes relative expiries from it. */
70
+ readonly at: string;
71
+ }
72
+ /**
73
+ * Every provider answer maps to one of five kinds (RFC "Provider answers"):
74
+ *
75
+ * - `accepted`: the provider took the message.
76
+ * - `unregistered`: the address is dead; the endpoint is invalidated.
77
+ * - `rate-limited`: the provider proves nothing was sent; the delivery waits
78
+ * `retryAfterSeconds` (60 when absent), then backs off.
79
+ * - `rejected`: the provider proves nothing was sent and the same send would
80
+ * fail again. `agreement` also records a provider denial.
81
+ * - `ambiguous`: the provider cannot say whether it sent (a timeout, a 5xx
82
+ * answer). The claim is kept until its lease ends.
83
+ *
84
+ * A provider that throws is treated as `ambiguous`.
85
+ */
86
+ export type NotificationRemoteDeliveryAnswer = {
87
+ readonly status: 'accepted';
88
+ readonly providerMessageId?: string;
89
+ } | {
90
+ readonly status: 'unregistered';
91
+ } | {
92
+ readonly status: 'rate-limited';
93
+ readonly retryAfterSeconds?: number;
94
+ } | {
95
+ readonly status: 'rejected';
96
+ readonly reason: 'configuration' | 'recipient' | 'agreement';
97
+ /** The provider's own error code, for logs; never shown to players. */
98
+ readonly providerCode?: string;
99
+ } | {
100
+ readonly status: 'ambiguous';
101
+ readonly providerCode?: string;
102
+ };
103
+ /**
104
+ * The provider code of a `rejected` answer for a message that would arrive
105
+ * after its expiry: nothing was sent, and it is routine, not a setup error.
106
+ */
107
+ export declare const notificationExpiredProviderCode = "MESSAGE_EXPIRED";
108
+ /** A server-side remote delivery provider: `{ id, send }`. */
109
+ export interface NotificationRemoteDeliveryProvider {
110
+ /** Equals the registry entry's `provider`. */
111
+ readonly id: NotificationProviderId;
112
+ send(request: NotificationRemoteDeliveryRequest): Promise<NotificationRemoteDeliveryAnswer>;
113
+ /**
114
+ * The host's permission at send time, for a provider whose registry entry
115
+ * is `serverReadable` (Devvit `isOptedIn`). The gate requires it there.
116
+ */
117
+ readPermission?(input: {
118
+ readonly player: string;
119
+ readonly topic: string;
120
+ }): Promise<NotificationPermissionState>;
121
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The server-side remote delivery provider contract (notifications v2 RFC,
3
+ * "Delivery providers" and "Provider answers"). It is built only from
4
+ * `@mpgd/platform/notifications` types and literal unions, so a provider
5
+ * module (FCM here, Apps in Toss in its own module) fits it structurally
6
+ * without importing anything from this package.
7
+ *
8
+ * A provider sends exactly what the policy gate rendered: the service runs
9
+ * `evaluateNotificationDelivery` immediately before every call, and a provider
10
+ * never decides policy.
11
+ */
12
+ /**
13
+ * Why a Firebase installation id (FID, `fcm-installation`) is refused, at
14
+ * registration and at send. The kind is reserved: FCM is moving to FIDs, but
15
+ * HTTP v1 has no FID send target yet. It lives here, beside the address
16
+ * kinds, so the service can use it without importing the FCM provider.
17
+ */
18
+ export const fcmInstallationIdRefusal = 'Firebase installation ids (fcm-installation) are reserved and refused until FCM can send to them; register the FCM registration token (fcm-token).';
19
+ /**
20
+ * The provider code of a `rejected` answer for a message that would arrive
21
+ * after its expiry: nothing was sent, and it is routine, not a setup error.
22
+ */
23
+ export const notificationExpiredProviderCode = 'MESSAGE_EXPIRED';
@@ -0,0 +1,138 @@
1
+ import { notificationExpiredProviderCode, type NotificationRemoteDeliveryAnswer, type NotificationRemoteDeliveryProvider, type NotificationRemoteDeliveryRequest } from './delivery-provider.js';
2
+ import type { NotificationProviderRegistration } from './registry.js';
3
+ /**
4
+ * The FCM remote delivery provider (notifications v2 RFC, "Delivery
5
+ * providers"): HTTP v1 `messages:send` with an OAuth access token minted from
6
+ * a service-account JWT signed through WebCrypto. It needs only `fetch` and
7
+ * `crypto.subtle`, so it runs on Cloudflare Workers (no HTTP/2, no Node
8
+ * crypto). iOS devices register through Firebase Messaging, so FCM relays to
9
+ * APNs.
10
+ *
11
+ * Payload, as the Capacitor provider reads it (RFC "Capacitor"): the gate's
12
+ * title and body as the FCM `notification`; `data` carries the send's routing
13
+ * data plus `topic` (the topic id) and `route` (its root-relative route).
14
+ * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
+ * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
+ * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
+ * registers; an Android FCM notification message cannot carry actions, so
18
+ * the shell renders advertising natively (C1). An expiry becomes
19
+ * `android.ttl` and the `apns-expiration` header.
20
+ */
21
+ export declare const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging";
22
+ export declare const fcmTokenEndpoint = "https://oauth2.googleapis.com/token";
23
+ /**
24
+ * The FCM HTTP v1 API takes registration tokens. A Firebase installation id
25
+ * (FID, the `fcm-installation` kind) is reserved: the kit types it because
26
+ * FCM is moving to FIDs, but HTTP v1 has no FID send target, so this
27
+ * provider refuses one at registration and at send until FCM supports it.
28
+ */
29
+ export declare const fcmAddressKinds: readonly ["fcm-token"];
30
+ /** Data keys this provider writes; a send's routing data may not use them. */
31
+ export declare const fcmReservedDataKeys: readonly ["topic", "route"];
32
+ /** The iOS notification category of advertising, which carries the opt-out action. */
33
+ export declare const fcmAdvertisingCategory = "mpgd-advertising";
34
+ /** The fields of a Google service-account key this provider reads. */
35
+ export interface FcmServiceAccount {
36
+ readonly projectId: string;
37
+ readonly clientEmail: string;
38
+ /** A PKCS #8 PEM `PRIVATE KEY` block, as a service-account key file holds it. */
39
+ readonly privateKey: string;
40
+ readonly privateKeyId?: string;
41
+ }
42
+ /**
43
+ * Reads a service-account key file (its JSON text or the parsed object).
44
+ * Errors name the missing field and never include a value.
45
+ */
46
+ export declare function parseFcmServiceAccount(input: unknown): FcmServiceAccount;
47
+ type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
48
+ export interface FcmAccessTokenSource {
49
+ /** A valid access token, minted or from the cache. */
50
+ get(): Promise<string>;
51
+ /** Drops `token` from the cache after FCM refused it. */
52
+ invalidate(token: string): void;
53
+ }
54
+ /** Why no access token could be minted, and whether trying again later can help. */
55
+ export declare class FcmAccessTokenError extends Error {
56
+ readonly name = "FcmAccessTokenError";
57
+ readonly retryable: boolean;
58
+ readonly code: string;
59
+ readonly retryAfterSeconds: number | undefined;
60
+ constructor(code: string, retryable: boolean, retryAfterSeconds?: number, options?: {
61
+ readonly cause?: unknown;
62
+ });
63
+ }
64
+ /**
65
+ * Mints and caches OAuth access tokens for the FCM scope. The token lives
66
+ * only in this object's memory: it is never logged, persisted, or put in an
67
+ * error. Concurrent callers share one request, a failed request is not
68
+ * cached, and a token is refreshed five minutes before it expires.
69
+ */
70
+ export declare function createFcmAccessTokenSource(input: {
71
+ readonly serviceAccount: FcmServiceAccount;
72
+ readonly fetch?: FetchLike;
73
+ readonly now?: () => number;
74
+ readonly subtle?: SubtleCrypto;
75
+ readonly timeoutMs?: number;
76
+ }): FcmAccessTokenSource;
77
+ export interface CreateFcmDeliveryProviderInput {
78
+ readonly serviceAccount: FcmServiceAccount;
79
+ /** Defaults to the service account's project. */
80
+ readonly projectId?: string;
81
+ /** The provider id; `fcm` by default. */
82
+ readonly id?: string;
83
+ readonly fetch?: FetchLike;
84
+ readonly now?: () => number;
85
+ readonly subtle?: SubtleCrypto;
86
+ /** Per HTTP request; 10 seconds by default. */
87
+ readonly timeoutMs?: number;
88
+ /** A shared token source, for example one per isolate. */
89
+ readonly tokens?: FcmAccessTokenSource;
90
+ }
91
+ /** The FCM HTTP v1 message this provider sends, or why it cannot send one. */
92
+ export type FcmMessageBuild = {
93
+ readonly status: 'ready';
94
+ readonly message: Readonly<Record<string, unknown>>;
95
+ } | {
96
+ readonly status: 'invalid';
97
+ readonly reason: string;
98
+ /**
99
+ * `MESSAGE_EXPIRED` (`notificationExpiredProviderCode`) for a message
100
+ * that would arrive too late, `INSTALLATION_ID_RESERVED` for an FID
101
+ * recipient, else `INVALID_MESSAGE`.
102
+ */
103
+ readonly code: 'INVALID_MESSAGE' | 'INSTALLATION_ID_RESERVED' | typeof notificationExpiredProviderCode;
104
+ };
105
+ /**
106
+ * Builds the HTTP v1 `message` for one delivery and checks it against FCM's
107
+ * rules before anything is sent: a registration token address, a title and
108
+ * a body, string routing data without FCM's or this provider's reserved
109
+ * keys, an expiry at least a second away, and a payload within FCM's limit
110
+ * with room for what APNs adds. A message that passes is "known valid", so an
111
+ * `INVALID_ARGUMENT` answer that names the token means a dead token.
112
+ */
113
+ export declare function buildFcmMessage(request: NotificationRemoteDeliveryRequest): FcmMessageBuild;
114
+ /**
115
+ * Maps an FCM error answer to the provider answer kinds (RFC "Delivery
116
+ * providers"): `UNREGISTERED` is `unregistered`, and so is
117
+ * `INVALID_ARGUMENT` for a message `buildFcmMessage` passed when the answer
118
+ * names the token and no other field; `QUOTA_EXCEEDED` is
119
+ * `rate-limited`; `UNAVAILABLE` and `INTERNAL` are `ambiguous`;
120
+ * `SENDER_ID_MISMATCH`, `THIRD_PARTY_AUTH_ERROR`, and every other refusal are
121
+ * `rejected` with reason `configuration`. An answer without an FCM error code
122
+ * falls back on its HTTP status: 5xx is `ambiguous`, 429 `rate-limited`.
123
+ */
124
+ export declare function classifyFcmError(input: {
125
+ readonly status: number;
126
+ readonly body: unknown;
127
+ readonly retryAfterSeconds?: number;
128
+ }): NotificationRemoteDeliveryAnswer;
129
+ /** The FCM provider: `{ id, send }` over HTTP v1. */
130
+ export declare function createFcmDeliveryProvider(input: CreateFcmDeliveryProviderInput): NotificationRemoteDeliveryProvider;
131
+ /**
132
+ * The registry registration for FCM: the reference `fcm` entry, the
133
+ * `android` and `ios` targets, and registration-token addresses only.
134
+ */
135
+ export declare function createFcmProviderRegistration(input: CreateFcmDeliveryProviderInput & {
136
+ readonly targets?: readonly string[];
137
+ }): NotificationProviderRegistration;
138
+ export {};