@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.
- package/dist/notifications/ait-functional-delivery.d.ts +165 -0
- package/dist/notifications/ait-functional-delivery.js +445 -0
- package/dist/notifications/delivery-provider.d.ts +121 -0
- package/dist/notifications/delivery-provider.js +23 -0
- package/dist/notifications/fcm.d.ts +138 -0
- package/dist/notifications/fcm.js +602 -0
- package/dist/notifications/index.d.ts +12 -0
- package/dist/notifications/index.js +12 -0
- package/dist/notifications/registry.d.ts +86 -0
- package/dist/notifications/registry.js +131 -0
- package/dist/notifications/service.d.ts +222 -0
- package/dist/notifications/service.js +2609 -0
- package/dist/notifications/store-conformance.d.ts +11 -0
- package/dist/notifications/store-conformance.js +795 -0
- package/dist/notifications/store.d.ts +423 -0
- package/dist/notifications/store.js +443 -0
- package/dist/notifications/text.d.ts +2 -0
- package/dist/notifications/text.js +4 -0
- package/package.json +17 -5
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { referenceNotificationProviders } from '@mpgd/platform/notifications';
|
|
2
|
+
import { fcmInstallationIdRefusal, } from './delivery-provider.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 const fcmMessagingScope = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
22
|
+
export 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 const fcmAddressKinds = Object.freeze(['fcm-token']);
|
|
30
|
+
/** Data keys this provider writes; a send's routing data may not use them. */
|
|
31
|
+
export const fcmReservedDataKeys = Object.freeze(['topic', 'route']);
|
|
32
|
+
/** The iOS notification category of advertising, which carries the opt-out action. */
|
|
33
|
+
export const fcmAdvertisingCategory = 'mpgd-advertising';
|
|
34
|
+
/**
|
|
35
|
+
* FCM's 4,096-byte limit, less room for what FCM and APNs add on the way
|
|
36
|
+
* (the `aps` wrapper and FCM's own keys), so a message this check passes is
|
|
37
|
+
* not refused for its size.
|
|
38
|
+
*/
|
|
39
|
+
const maxPayloadBytes = 3840;
|
|
40
|
+
/** The longest `Retry-After` this provider passes on; the service gives up after a day anyway. */
|
|
41
|
+
const maxRetryAfterSeconds = 86_400;
|
|
42
|
+
/** How long a refused grant or an unusable key answers without asking Google again. */
|
|
43
|
+
const failedTokenCacheMs = 30_000;
|
|
44
|
+
/** How long a token endpoint that is down answers without asking again. */
|
|
45
|
+
const retryableTokenCacheMs = 5_000;
|
|
46
|
+
const tokenLifetimeSeconds = 3600;
|
|
47
|
+
/** A cached token is refreshed this long before it expires. */
|
|
48
|
+
const tokenRefreshMarginMs = 5 * 60 * 1000;
|
|
49
|
+
const defaultTimeoutMs = 10_000;
|
|
50
|
+
const defaultRetryAfterSeconds = 60;
|
|
51
|
+
/**
|
|
52
|
+
* Reads a service-account key file (its JSON text or the parsed object).
|
|
53
|
+
* Errors name the missing field and never include a value.
|
|
54
|
+
*/
|
|
55
|
+
export function parseFcmServiceAccount(input) {
|
|
56
|
+
let value = input;
|
|
57
|
+
if (typeof input === 'string') {
|
|
58
|
+
try {
|
|
59
|
+
value = JSON.parse(input);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new TypeError('The FCM service account is not JSON.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
66
|
+
throw new TypeError('The FCM service account must be an object.');
|
|
67
|
+
}
|
|
68
|
+
const record = value;
|
|
69
|
+
const field = (name) => {
|
|
70
|
+
const text = record[name];
|
|
71
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
72
|
+
throw new TypeError(`The FCM service account has no ${name}.`);
|
|
73
|
+
}
|
|
74
|
+
return text;
|
|
75
|
+
};
|
|
76
|
+
if (record.type !== undefined && record.type !== 'service_account') {
|
|
77
|
+
throw new TypeError('The FCM credential is not a service account key.');
|
|
78
|
+
}
|
|
79
|
+
const privateKeyId = record.private_key_id;
|
|
80
|
+
return {
|
|
81
|
+
projectId: field('project_id'),
|
|
82
|
+
clientEmail: field('client_email'),
|
|
83
|
+
privateKey: field('private_key'),
|
|
84
|
+
...(typeof privateKeyId === 'string' && privateKeyId !== '' ? { privateKeyId } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Why no access token could be minted, and whether trying again later can help. */
|
|
88
|
+
export class FcmAccessTokenError extends Error {
|
|
89
|
+
name = 'FcmAccessTokenError';
|
|
90
|
+
retryable;
|
|
91
|
+
code;
|
|
92
|
+
retryAfterSeconds;
|
|
93
|
+
constructor(code, retryable, retryAfterSeconds, options) {
|
|
94
|
+
super(`FCM access token request failed: ${code}.`, options);
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.retryable = retryable;
|
|
97
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Mints and caches OAuth access tokens for the FCM scope. The token lives
|
|
102
|
+
* only in this object's memory: it is never logged, persisted, or put in an
|
|
103
|
+
* error. Concurrent callers share one request, a failed request is not
|
|
104
|
+
* cached, and a token is refreshed five minutes before it expires.
|
|
105
|
+
*/
|
|
106
|
+
export function createFcmAccessTokenSource(input) {
|
|
107
|
+
const { serviceAccount } = input;
|
|
108
|
+
const fetcher = input.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
109
|
+
const now = input.now ?? Date.now;
|
|
110
|
+
const subtle = input.subtle ?? globalThis.crypto.subtle;
|
|
111
|
+
const timeoutMs = input.timeoutMs ?? defaultTimeoutMs;
|
|
112
|
+
let signingKey;
|
|
113
|
+
let cached;
|
|
114
|
+
let failed;
|
|
115
|
+
let pending;
|
|
116
|
+
const key = () => {
|
|
117
|
+
signingKey ??= importPrivateKey(subtle, serviceAccount.privateKey).catch((error) => {
|
|
118
|
+
signingKey = undefined;
|
|
119
|
+
throw error;
|
|
120
|
+
});
|
|
121
|
+
return signingKey;
|
|
122
|
+
};
|
|
123
|
+
/** The signed assertion; a key that cannot sign is a configuration error, never retried as a rate limit. */
|
|
124
|
+
const signedAssertion = async (claims) => {
|
|
125
|
+
try {
|
|
126
|
+
return await signJwt(subtle, await key(), {
|
|
127
|
+
header: {
|
|
128
|
+
alg: 'RS256',
|
|
129
|
+
typ: 'JWT',
|
|
130
|
+
...(serviceAccount.privateKeyId === undefined ? {} : { kid: serviceAccount.privateKeyId }),
|
|
131
|
+
},
|
|
132
|
+
claims,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// The cause says which step failed (the PEM, the import, or signing); it never holds the key.
|
|
137
|
+
throw new FcmAccessTokenError('TOKEN_KEY_UNUSABLE', false, undefined, { cause: error });
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const mint = async () => {
|
|
141
|
+
const issuedAt = Math.floor(now() / 1000);
|
|
142
|
+
const assertion = await signedAssertion({
|
|
143
|
+
iss: serviceAccount.clientEmail,
|
|
144
|
+
scope: fcmMessagingScope,
|
|
145
|
+
aud: fcmTokenEndpoint,
|
|
146
|
+
iat: issuedAt,
|
|
147
|
+
exp: issuedAt + tokenLifetimeSeconds,
|
|
148
|
+
});
|
|
149
|
+
let response;
|
|
150
|
+
try {
|
|
151
|
+
response = await fetcher(fcmTokenEndpoint, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
154
|
+
body: new URLSearchParams({
|
|
155
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
156
|
+
assertion,
|
|
157
|
+
}).toString(),
|
|
158
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new FcmAccessTokenError('TOKEN_NETWORK_ERROR', true);
|
|
163
|
+
}
|
|
164
|
+
const body = await readJson(response);
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
const error = typeof body?.error === 'string' ? body.error : `HTTP_${response.status}`;
|
|
167
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
168
|
+
throw new FcmAccessTokenError(`TOKEN_${providerCodeOf(error)}`, retryable, retryable ? retryAfterOf(response, now()) : undefined);
|
|
169
|
+
}
|
|
170
|
+
const token = body?.access_token;
|
|
171
|
+
if (typeof token !== 'string' || token === '') {
|
|
172
|
+
throw new FcmAccessTokenError('TOKEN_MALFORMED_RESPONSE', true);
|
|
173
|
+
}
|
|
174
|
+
const expiresIn = typeof body?.expires_in === 'number' && Number.isFinite(body.expires_in) && body.expires_in > 0
|
|
175
|
+
? Math.min(body.expires_in, tokenLifetimeSeconds)
|
|
176
|
+
: tokenLifetimeSeconds;
|
|
177
|
+
const expiresAt = issuedAt * 1000 + expiresIn * 1000;
|
|
178
|
+
cached = { token, refreshAt: expiresAt - tokenRefreshMarginMs, expiresAt };
|
|
179
|
+
return token;
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
async get() {
|
|
183
|
+
if (cached !== undefined && now() < cached.refreshAt) {
|
|
184
|
+
return cached.token;
|
|
185
|
+
}
|
|
186
|
+
// Inside the refresh margin a token that has not expired still sends
|
|
187
|
+
// while a refresh fails.
|
|
188
|
+
const usable = cached !== undefined && now() < cached.expiresAt ? cached.token : undefined;
|
|
189
|
+
// A refresh already under way, or one that failed a moment ago, does not hold up a usable token.
|
|
190
|
+
if (usable !== undefined && pending !== undefined) {
|
|
191
|
+
return usable;
|
|
192
|
+
}
|
|
193
|
+
if (failed !== undefined && now() < failed.until) {
|
|
194
|
+
if (usable !== undefined) {
|
|
195
|
+
return usable;
|
|
196
|
+
}
|
|
197
|
+
// A refused grant fails the same way for a while: a fan-out does not ask Google per send.
|
|
198
|
+
throw failed.error;
|
|
199
|
+
}
|
|
200
|
+
pending ??= mint()
|
|
201
|
+
.then((token) => {
|
|
202
|
+
failed = undefined;
|
|
203
|
+
return token;
|
|
204
|
+
})
|
|
205
|
+
.catch((error) => {
|
|
206
|
+
if (error instanceof FcmAccessTokenError) {
|
|
207
|
+
// A refused grant waits 30 s; an outage waits a few seconds, or
|
|
208
|
+
// as long as it asked, so a fan-out does not ask once per send.
|
|
209
|
+
const wait = error.retryable
|
|
210
|
+
? Math.max(retryableTokenCacheMs, Math.min((error.retryAfterSeconds ?? 0) * 1000, 60_000))
|
|
211
|
+
: failedTokenCacheMs;
|
|
212
|
+
failed = { error, until: now() + wait };
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
})
|
|
216
|
+
.finally(() => {
|
|
217
|
+
pending = undefined;
|
|
218
|
+
});
|
|
219
|
+
if (usable === undefined) {
|
|
220
|
+
return pending;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return await pending;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return usable;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
invalidate(token) {
|
|
230
|
+
if (cached?.token === token) {
|
|
231
|
+
cached = undefined;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Builds the HTTP v1 `message` for one delivery and checks it against FCM's
|
|
238
|
+
* rules before anything is sent: a registration token address, a title and
|
|
239
|
+
* a body, string routing data without FCM's or this provider's reserved
|
|
240
|
+
* keys, an expiry at least a second away, and a payload within FCM's limit
|
|
241
|
+
* with room for what APNs adds. A message that passes is "known valid", so an
|
|
242
|
+
* `INVALID_ARGUMENT` answer that names the token means a dead token.
|
|
243
|
+
*/
|
|
244
|
+
export function buildFcmMessage(request) {
|
|
245
|
+
const { recipient, content } = request;
|
|
246
|
+
if (recipient.kind === 'device' && recipient.address.kind === 'fcm-installation') {
|
|
247
|
+
return {
|
|
248
|
+
status: 'invalid',
|
|
249
|
+
reason: fcmInstallationIdRefusal,
|
|
250
|
+
code: 'INSTALLATION_ID_RESERVED',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (recipient.kind !== 'device' || recipient.address.kind !== 'fcm-token') {
|
|
254
|
+
return {
|
|
255
|
+
status: 'invalid',
|
|
256
|
+
reason: 'FCM sends to registration tokens only',
|
|
257
|
+
code: 'INVALID_MESSAGE',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (content.title === undefined || content.body === undefined) {
|
|
261
|
+
return {
|
|
262
|
+
status: 'invalid',
|
|
263
|
+
reason: 'FCM content needs a title and a body',
|
|
264
|
+
code: 'INVALID_MESSAGE',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// A null-prototype object keeps a `__proto__` key from vanishing silently.
|
|
268
|
+
const data = Object.create(null);
|
|
269
|
+
for (const [key, value] of Object.entries(request.data ?? {})) {
|
|
270
|
+
if (isReservedDataKey(key)) {
|
|
271
|
+
return {
|
|
272
|
+
status: 'invalid',
|
|
273
|
+
reason: `the data key ${JSON.stringify(key)} is reserved`,
|
|
274
|
+
code: 'INVALID_MESSAGE',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (typeof value !== 'string') {
|
|
278
|
+
return {
|
|
279
|
+
status: 'invalid',
|
|
280
|
+
reason: `the data value of ${JSON.stringify(key)} is not a string`,
|
|
281
|
+
code: 'INVALID_MESSAGE',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
data[key] = value;
|
|
285
|
+
}
|
|
286
|
+
data.topic = request.topic.id;
|
|
287
|
+
data.route = content.route;
|
|
288
|
+
const notification = { title: content.title, body: content.body };
|
|
289
|
+
const mapped = request.topic.mapping?.['channelId'];
|
|
290
|
+
const channelId = typeof mapped === 'string' && mapped !== ''
|
|
291
|
+
? mapped
|
|
292
|
+
: `mpgd-${request.topic.purpose}`;
|
|
293
|
+
const expiresAt = request.expiresAt === undefined ? undefined : Date.parse(request.expiresAt);
|
|
294
|
+
const at = Date.parse(request.at);
|
|
295
|
+
if (expiresAt !== undefined && (!Number.isFinite(expiresAt) || !Number.isFinite(at))) {
|
|
296
|
+
return {
|
|
297
|
+
status: 'invalid',
|
|
298
|
+
reason: 'the expiry or the call time is not a timestamp',
|
|
299
|
+
code: 'INVALID_MESSAGE',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (expiresAt !== undefined && expiresAt - at < 1000) {
|
|
303
|
+
// A time to live of 0 means "now, if reachable" to FCM: an expired message is not sent at all.
|
|
304
|
+
return {
|
|
305
|
+
status: 'invalid',
|
|
306
|
+
reason: 'the message expires before it could arrive',
|
|
307
|
+
code: 'MESSAGE_EXPIRED',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const android = {
|
|
311
|
+
...(expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` }),
|
|
312
|
+
notification: { channel_id: channelId },
|
|
313
|
+
};
|
|
314
|
+
const category = request.topic.purpose === 'advertising' ? fcmAdvertisingCategory : undefined;
|
|
315
|
+
const apns = {
|
|
316
|
+
...(expiresAt === undefined
|
|
317
|
+
? {}
|
|
318
|
+
: { headers: { 'apns-expiration': String(Math.floor(expiresAt / 1000)) } }),
|
|
319
|
+
...(category === undefined ? {} : { payload: { aps: { category } } }),
|
|
320
|
+
};
|
|
321
|
+
const payload = {
|
|
322
|
+
notification,
|
|
323
|
+
data: { ...data },
|
|
324
|
+
android,
|
|
325
|
+
...(Object.keys(apns).length === 0 ? {} : { apns }),
|
|
326
|
+
};
|
|
327
|
+
// Everything but the token counts toward the size FCM and APNs carry.
|
|
328
|
+
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)).length;
|
|
329
|
+
if (payloadBytes > maxPayloadBytes) {
|
|
330
|
+
return {
|
|
331
|
+
status: 'invalid',
|
|
332
|
+
reason: `the payload is ${payloadBytes} bytes; this provider sends at most ${maxPayloadBytes}`,
|
|
333
|
+
code: 'INVALID_MESSAGE',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
status: 'ready',
|
|
338
|
+
message: { token: recipient.address.value, ...payload },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Maps an FCM error answer to the provider answer kinds (RFC "Delivery
|
|
343
|
+
* providers"): `UNREGISTERED` is `unregistered`, and so is
|
|
344
|
+
* `INVALID_ARGUMENT` for a message `buildFcmMessage` passed when the answer
|
|
345
|
+
* names the token and no other field; `QUOTA_EXCEEDED` is
|
|
346
|
+
* `rate-limited`; `UNAVAILABLE` and `INTERNAL` are `ambiguous`;
|
|
347
|
+
* `SENDER_ID_MISMATCH`, `THIRD_PARTY_AUTH_ERROR`, and every other refusal are
|
|
348
|
+
* `rejected` with reason `configuration`. An answer without an FCM error code
|
|
349
|
+
* falls back on its HTTP status: 5xx is `ambiguous`, 429 `rate-limited`.
|
|
350
|
+
*/
|
|
351
|
+
export function classifyFcmError(input) {
|
|
352
|
+
const error = isRecord(input.body) && isRecord(input.body.error) ? input.body.error : undefined;
|
|
353
|
+
const details = errorDetailsOf(input.body);
|
|
354
|
+
const raw = errorCodeOf(details) ?? (typeof error?.status === 'string' ? error.status : undefined);
|
|
355
|
+
// FCM's own codes are short upper-case words; anything else is cut down before it reaches logs.
|
|
356
|
+
const code = raw === undefined ? undefined : providerCodeOf(raw);
|
|
357
|
+
const rateLimited = () => ({
|
|
358
|
+
status: 'rate-limited',
|
|
359
|
+
retryAfterSeconds: input.retryAfterSeconds ?? defaultRetryAfterSeconds,
|
|
360
|
+
});
|
|
361
|
+
switch (code) {
|
|
362
|
+
case 'UNREGISTERED':
|
|
363
|
+
return { status: 'unregistered' };
|
|
364
|
+
case 'INVALID_ARGUMENT': {
|
|
365
|
+
const fields = details
|
|
366
|
+
.flatMap((detail) => (Array.isArray(detail.fieldViolations) ? detail.fieldViolations : []))
|
|
367
|
+
.filter(isRecord)
|
|
368
|
+
.map((violation) => violation.field);
|
|
369
|
+
// Only an answer that names the token proves the address is dead; any
|
|
370
|
+
// other refusal of a message that passed buildFcmMessage is the setup's.
|
|
371
|
+
return fields.length > 0 && fields.every((field) => field === 'message.token')
|
|
372
|
+
? { status: 'unregistered' }
|
|
373
|
+
: { status: 'rejected', reason: 'configuration', providerCode: code };
|
|
374
|
+
}
|
|
375
|
+
case 'QUOTA_EXCEEDED':
|
|
376
|
+
case 'RESOURCE_EXHAUSTED':
|
|
377
|
+
return rateLimited();
|
|
378
|
+
case 'UNAVAILABLE':
|
|
379
|
+
case 'INTERNAL':
|
|
380
|
+
return { status: 'ambiguous', providerCode: code };
|
|
381
|
+
case 'SENDER_ID_MISMATCH':
|
|
382
|
+
case 'THIRD_PARTY_AUTH_ERROR':
|
|
383
|
+
return { status: 'rejected', reason: 'configuration', providerCode: code };
|
|
384
|
+
default:
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (input.status === 429) {
|
|
388
|
+
return rateLimited();
|
|
389
|
+
}
|
|
390
|
+
if (input.status >= 500) {
|
|
391
|
+
return { status: 'ambiguous', providerCode: code ?? `HTTP_${input.status}` };
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
status: 'rejected',
|
|
395
|
+
reason: 'configuration',
|
|
396
|
+
providerCode: code ?? `HTTP_${input.status}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** The FCM provider: `{ id, send }` over HTTP v1. */
|
|
400
|
+
export function createFcmDeliveryProvider(input) {
|
|
401
|
+
const projectId = input.projectId ?? input.serviceAccount.projectId;
|
|
402
|
+
// Project ids, including legacy domain-scoped ones (`example.com:my-project`).
|
|
403
|
+
if (!isFirebaseProjectId(projectId)) {
|
|
404
|
+
throw new TypeError('The FCM project id is not a Firebase project id.');
|
|
405
|
+
}
|
|
406
|
+
const fetcher = input.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
407
|
+
const timeoutMs = input.timeoutMs ?? defaultTimeoutMs;
|
|
408
|
+
const tokens = input.tokens ?? createFcmAccessTokenSource({
|
|
409
|
+
serviceAccount: input.serviceAccount,
|
|
410
|
+
fetch: fetcher,
|
|
411
|
+
...(input.now === undefined ? {} : { now: input.now }),
|
|
412
|
+
...(input.subtle === undefined ? {} : { subtle: input.subtle }),
|
|
413
|
+
timeoutMs,
|
|
414
|
+
});
|
|
415
|
+
const endpoint = `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/messages:send`;
|
|
416
|
+
return {
|
|
417
|
+
id: input.id ?? 'fcm',
|
|
418
|
+
async send(request) {
|
|
419
|
+
const built = buildFcmMessage(request);
|
|
420
|
+
if (built.status === 'invalid') {
|
|
421
|
+
// Nothing was sent, and the same send would fail again (an expired
|
|
422
|
+
// message keeps its own code, so lateness is not read as a setup error).
|
|
423
|
+
return { status: 'rejected', reason: 'configuration', providerCode: built.code };
|
|
424
|
+
}
|
|
425
|
+
const body = JSON.stringify({ message: built.message });
|
|
426
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
427
|
+
let token;
|
|
428
|
+
try {
|
|
429
|
+
token = await tokens.get();
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
// No token means nothing was sent.
|
|
433
|
+
if (error instanceof FcmAccessTokenError && !error.retryable) {
|
|
434
|
+
return { status: 'rejected', reason: 'configuration', providerCode: error.code };
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
status: 'rate-limited',
|
|
438
|
+
retryAfterSeconds: error instanceof FcmAccessTokenError
|
|
439
|
+
? (error.retryAfterSeconds ?? defaultRetryAfterSeconds)
|
|
440
|
+
: defaultRetryAfterSeconds,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
let response;
|
|
444
|
+
try {
|
|
445
|
+
response = await fetcher(endpoint, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
448
|
+
body,
|
|
449
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// A timeout or a dropped connection: FCM may have taken the message.
|
|
454
|
+
return { status: 'ambiguous', providerCode: 'NETWORK_ERROR' };
|
|
455
|
+
}
|
|
456
|
+
const answer = await readJson(response);
|
|
457
|
+
if (response.ok) {
|
|
458
|
+
const name = answer?.name;
|
|
459
|
+
return typeof name === 'string' && name !== ''
|
|
460
|
+
? { status: 'accepted', providerMessageId: name }
|
|
461
|
+
: { status: 'accepted' };
|
|
462
|
+
}
|
|
463
|
+
if (response.status === 401 && attempt === 0 && fcmErrorCodeOf(answer) === undefined) {
|
|
464
|
+
// The token was revoked or expired early: mint a new one once.
|
|
465
|
+
tokens.invalidate(token);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const retryAfterSeconds = retryAfterOf(response, (input.now ?? Date.now)());
|
|
469
|
+
return classifyFcmError({
|
|
470
|
+
status: response.status,
|
|
471
|
+
body: answer,
|
|
472
|
+
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// Not reached: the second attempt always answers above. The loop's
|
|
476
|
+
// type needs an answer here, and a fresh token refused again would be
|
|
477
|
+
// the setup's to fix.
|
|
478
|
+
return { status: 'rejected', reason: 'configuration', providerCode: 'UNAUTHENTICATED' };
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The registry registration for FCM: the reference `fcm` entry, the
|
|
484
|
+
* `android` and `ios` targets, and registration-token addresses only.
|
|
485
|
+
*/
|
|
486
|
+
export function createFcmProviderRegistration(input) {
|
|
487
|
+
const { targets, ...provider } = input;
|
|
488
|
+
const delivery = createFcmDeliveryProvider(provider);
|
|
489
|
+
return {
|
|
490
|
+
entry: { ...referenceNotificationProviders.fcm, provider: delivery.id },
|
|
491
|
+
delivery,
|
|
492
|
+
targets: targets ?? ['android', 'ios'],
|
|
493
|
+
addressKinds: [...fcmAddressKinds],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* A Firebase project id (lowercase letters, digits, and hyphens), or a
|
|
498
|
+
* legacy domain-scoped one (`example.com:my-project`): every label starts
|
|
499
|
+
* and ends with a letter or digit, 5 to 100 characters in all.
|
|
500
|
+
*/
|
|
501
|
+
function isFirebaseProjectId(id) {
|
|
502
|
+
const label = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
|
|
503
|
+
return id.length >= 5
|
|
504
|
+
&& id.length <= 100
|
|
505
|
+
&& new RegExp(`^(?:${label}(?:\\.${label})*:)?${label}$`, 'u').test(id);
|
|
506
|
+
}
|
|
507
|
+
/** FCM's reserved data keys (`from`, `message_type`, and any `google` or `gcm` prefix), `topic` and `route`, and keys a plain object cannot hold. */
|
|
508
|
+
function isReservedDataKey(key) {
|
|
509
|
+
const lower = key.toLowerCase();
|
|
510
|
+
return key === ''
|
|
511
|
+
|| key === '__proto__'
|
|
512
|
+
|| fcmReservedDataKeys.includes(key)
|
|
513
|
+
|| lower === 'from'
|
|
514
|
+
|| lower === 'notification'
|
|
515
|
+
|| lower === 'message_type'
|
|
516
|
+
// Keys the Capacitor provider drops from an open's data.
|
|
517
|
+
|| key === 'collapse_key'
|
|
518
|
+
|| key === 'aps'
|
|
519
|
+
|| key === 'fcm_options'
|
|
520
|
+
|| lower.startsWith('google')
|
|
521
|
+
|| lower.startsWith('gcm');
|
|
522
|
+
}
|
|
523
|
+
/** The `details` of an FCM error answer. */
|
|
524
|
+
function errorDetailsOf(body) {
|
|
525
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : undefined;
|
|
526
|
+
return Array.isArray(error?.details) ? error.details.filter(isRecord) : [];
|
|
527
|
+
}
|
|
528
|
+
/** The FCM `errorCode` among an error answer's details, if any. */
|
|
529
|
+
function errorCodeOf(details) {
|
|
530
|
+
return details
|
|
531
|
+
.map((detail) => detail.errorCode)
|
|
532
|
+
.find((code) => typeof code === 'string');
|
|
533
|
+
}
|
|
534
|
+
/** The FCM `errorCode` in an error answer, if any. */
|
|
535
|
+
function fcmErrorCodeOf(body) {
|
|
536
|
+
return errorCodeOf(errorDetailsOf(body));
|
|
537
|
+
}
|
|
538
|
+
/** An upstream error string as a short provider code: upper case, `[A-Z0-9_]`, at most 64 characters. */
|
|
539
|
+
function providerCodeOf(text) {
|
|
540
|
+
return text.toUpperCase().replace(/[^A-Z0-9_]+/gu, '_').slice(0, 64) || 'UNKNOWN';
|
|
541
|
+
}
|
|
542
|
+
async function importPrivateKey(subtle, pem) {
|
|
543
|
+
const body = pem
|
|
544
|
+
.replace(/-----(?:BEGIN|END) PRIVATE KEY-----/gu, '')
|
|
545
|
+
.replace(/\\n|\s/gu, '');
|
|
546
|
+
let der;
|
|
547
|
+
try {
|
|
548
|
+
der = Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
throw new TypeError('The FCM service account private key is not PKCS #8 PEM.');
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
return await subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// The key itself never appears in the error.
|
|
558
|
+
throw new TypeError('The FCM service account private key could not be imported.');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function signJwt(subtle, key, input) {
|
|
562
|
+
const encoder = new TextEncoder();
|
|
563
|
+
const signingInput = `${base64Url(encoder.encode(JSON.stringify(input.header)))}.${base64Url(encoder.encode(JSON.stringify(input.claims)))}`;
|
|
564
|
+
const signature = await subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(signingInput));
|
|
565
|
+
return `${signingInput}.${base64Url(new Uint8Array(signature))}`;
|
|
566
|
+
}
|
|
567
|
+
function base64Url(bytes) {
|
|
568
|
+
let binary = '';
|
|
569
|
+
for (const byte of bytes) {
|
|
570
|
+
binary += String.fromCharCode(byte);
|
|
571
|
+
}
|
|
572
|
+
return btoa(binary).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/=+$/u, '');
|
|
573
|
+
}
|
|
574
|
+
async function readJson(response) {
|
|
575
|
+
try {
|
|
576
|
+
const parsed = await response.json();
|
|
577
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** `Retry-After` in seconds (a number or an HTTP date), at most a day. */
|
|
584
|
+
function retryAfterOf(response, now) {
|
|
585
|
+
const header = response.headers.get('retry-after');
|
|
586
|
+
if (header === null || header.trim() === '') {
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
const seconds = Number(header);
|
|
590
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
591
|
+
return Math.min(Math.ceil(seconds), maxRetryAfterSeconds);
|
|
592
|
+
}
|
|
593
|
+
const date = Date.parse(header);
|
|
594
|
+
if (Number.isFinite(date)) {
|
|
595
|
+
const delta = Math.ceil((date - now) / 1000);
|
|
596
|
+
return delta > 0 ? Math.min(delta, maxRetryAfterSeconds) : undefined;
|
|
597
|
+
}
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
function isRecord(value) {
|
|
601
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
602
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications v2 server for `@mpgd/game-services` (notifications v2 RFC,
|
|
3
|
+
* "Server"): the provider registry, the endpoint registry, consent records
|
|
4
|
+
* and the notices API, sends through the policy gate, server reminders and
|
|
5
|
+
* the scheduler (consent notice pushes, paced due rows), and the remote
|
|
6
|
+
* delivery provider contract, with the FCM provider.
|
|
7
|
+
*/
|
|
8
|
+
export * from './delivery-provider.js';
|
|
9
|
+
export * from './fcm.js';
|
|
10
|
+
export * from './registry.js';
|
|
11
|
+
export * from './service.js';
|
|
12
|
+
export * from './store.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications v2 server for `@mpgd/game-services` (notifications v2 RFC,
|
|
3
|
+
* "Server"): the provider registry, the endpoint registry, consent records
|
|
4
|
+
* and the notices API, sends through the policy gate, server reminders and
|
|
5
|
+
* the scheduler (consent notice pushes, paced due rows), and the remote
|
|
6
|
+
* delivery provider contract, with the FCM provider.
|
|
7
|
+
*/
|
|
8
|
+
export * from './delivery-provider.js';
|
|
9
|
+
export * from './fcm.js';
|
|
10
|
+
export * from './registry.js';
|
|
11
|
+
export * from './service.js';
|
|
12
|
+
export * from './store.js';
|