@hatk/hatk 0.0.1-alpha.77 → 0.0.1-alpha.79
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/config.d.ts +14 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/main.js +3 -3
- package/dist/pds-proxy.d.ts.map +1 -1
- package/dist/pds-proxy.js +27 -2
- package/dist/push.d.ts +23 -3
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +215 -38
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -52,8 +52,21 @@ export interface ApnsPushConfig {
|
|
|
52
52
|
bundleId: string;
|
|
53
53
|
production?: boolean;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Firebase Cloud Messaging, for Android devices.
|
|
57
|
+
*
|
|
58
|
+
* `keyFile` is a Google service-account JSON carrying the Firebase Messaging
|
|
59
|
+
* role, resolved relative to the config file like the APNs key. The project id
|
|
60
|
+
* comes from that file unless it's overridden here.
|
|
61
|
+
*/
|
|
62
|
+
export interface FcmPushConfig {
|
|
63
|
+
keyFile: string;
|
|
64
|
+
projectId?: string;
|
|
65
|
+
}
|
|
66
|
+
/** Either transport may stand alone; a platform without one simply isn't sent to. */
|
|
55
67
|
export interface PushConfig {
|
|
56
|
-
apns
|
|
68
|
+
apns?: ApnsPushConfig;
|
|
69
|
+
fcm?: FcmPushConfig;
|
|
57
70
|
}
|
|
58
71
|
export interface CdnConfig {
|
|
59
72
|
url: string;
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;IACrC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAC1C,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,sBAAsB,EAAE,CAAA;CAC7C;AAED,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC5B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,WAAW,EAAE,OAAO,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/main.js
CHANGED
|
@@ -34,7 +34,7 @@ import { runBackfill } from "./backfill.js";
|
|
|
34
34
|
import { initOAuth } from "./oauth/server.js";
|
|
35
35
|
import { parseSessionCookie, getSessionCookieName } from "./oauth/session.js";
|
|
36
36
|
import { loadOnLoginHook } from "./hooks.js";
|
|
37
|
-
import { initPush, isPushEnabled } from "./push.js";
|
|
37
|
+
import { enabledPushTransports, initPush, isPushEnabled } from "./push.js";
|
|
38
38
|
import { initSetup } from "./setup.js";
|
|
39
39
|
import { initServer } from "./server-init.js";
|
|
40
40
|
const configPath = process.argv[2] || 'hatk.config.ts';
|
|
@@ -140,10 +140,10 @@ if (config.oauth) {
|
|
|
140
140
|
if (config.push) {
|
|
141
141
|
initPush(config.push, configDir);
|
|
142
142
|
if (isPushEnabled()) {
|
|
143
|
-
log(`[main] Push initialized (
|
|
143
|
+
log(`[main] Push initialized (${enabledPushTransports().join(', ')})`);
|
|
144
144
|
}
|
|
145
145
|
else {
|
|
146
|
-
log(`[main] Push configured but
|
|
146
|
+
log(`[main] Push configured but no usable credential — push disabled`);
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
// 5. Start server immediately (don't wait for backfill)
|
package/dist/pds-proxy.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pds-proxy.d.ts","sourceRoot":"","sources":["../src/pds-proxy.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAU9C,qBAAa,UAAW,SAAQ,KAAK;IAE1B,MAAM,EAAE,MAAM;gBAAd,MAAM,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAIlB;AAED,qBAAa,sBAAuB,SAAQ,UAAU;;CAIrD;
|
|
1
|
+
{"version":3,"file":"pds-proxy.d.ts","sourceRoot":"","sources":["../src/pds-proxy.ts"],"names":[],"mappings":"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"}
|
package/dist/pds-proxy.js
CHANGED
|
@@ -27,7 +27,7 @@ async function withDpopRetry(oauthConfig, session, doFetch) {
|
|
|
27
27
|
return result;
|
|
28
28
|
let nonce;
|
|
29
29
|
// Step 1: handle DPoP nonce requirement
|
|
30
|
-
if (result
|
|
30
|
+
if (isNonceChallenge(result)) {
|
|
31
31
|
nonce = result.headers.get('DPoP-Nonce') || undefined;
|
|
32
32
|
if (nonce) {
|
|
33
33
|
result = await doFetch(accessToken, nonce);
|
|
@@ -48,7 +48,7 @@ async function withDpopRetry(oauthConfig, session, doFetch) {
|
|
|
48
48
|
result = await doFetch(accessToken, nonce);
|
|
49
49
|
if (result.ok)
|
|
50
50
|
return result;
|
|
51
|
-
if (result
|
|
51
|
+
if (isNonceChallenge(result)) {
|
|
52
52
|
nonce = result.headers.get('DPoP-Nonce') || undefined;
|
|
53
53
|
if (nonce)
|
|
54
54
|
result = await doFetch(accessToken, nonce);
|
|
@@ -71,6 +71,31 @@ async function withDpopRetry(oauthConfig, session, doFetch) {
|
|
|
71
71
|
}
|
|
72
72
|
return result;
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Is this refusal a DPoP nonce challenge — a request to retry, not a dead token?
|
|
76
|
+
*
|
|
77
|
+
* A nonce challenge is the one 401 that costs nothing to satisfy: the server
|
|
78
|
+
* hands back the nonce it wants in the same response. Miss it and the failure
|
|
79
|
+
* falls through to the token-error branch below, which refreshes, replays the
|
|
80
|
+
* identical proof, is refused identically, and drops the user's session — a
|
|
81
|
+
* re-authorization to fix a header.
|
|
82
|
+
*
|
|
83
|
+
* So it is read the way RFC 9449 §8 writes it, from
|
|
84
|
+
* `WWW-Authenticate: DPoP error="use_dpop_nonce"`, and only then from the body.
|
|
85
|
+
* The body is not always available to read: zds sends that header but an
|
|
86
|
+
* `InvalidToken` body, because its XRPC error handler overwrites the
|
|
87
|
+
* `UseDpopNonce` one on the way out.
|
|
88
|
+
*
|
|
89
|
+
* The `DPoP-Nonce` header alone is not the signal. A server may attach one to
|
|
90
|
+
* every 401 it sends, including 401s about a genuinely invalid token, and
|
|
91
|
+
* treating those as challenges would replay every dead token once for nothing.
|
|
92
|
+
*/
|
|
93
|
+
function isNonceChallenge(result) {
|
|
94
|
+
const error = result.body.error;
|
|
95
|
+
if (error === 'use_dpop_nonce' || error === 'UseDpopNonce')
|
|
96
|
+
return true;
|
|
97
|
+
return result.status === 401 && (result.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce');
|
|
98
|
+
}
|
|
74
99
|
/**
|
|
75
100
|
* The PDS returns 'InvalidToken' or 'ExpiredToken' (AT Proto PascalCase
|
|
76
101
|
* convention) while the OAuth spec uses 'invalid_token' (RFC 6750 snake_case).
|
package/dist/push.d.ts
CHANGED
|
@@ -5,8 +5,20 @@ export interface ApnsConfig {
|
|
|
5
5
|
bundleId: string;
|
|
6
6
|
production?: boolean;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Firebase Cloud Messaging, for Android devices.
|
|
10
|
+
*
|
|
11
|
+
* `keyFile` is a Google service-account JSON with the Firebase Messaging role,
|
|
12
|
+
* resolved like the APNs key: relative to the config file. The project id is
|
|
13
|
+
* read from that file unless overridden here.
|
|
14
|
+
*/
|
|
15
|
+
export interface FcmConfig {
|
|
16
|
+
keyFile: string;
|
|
17
|
+
projectId?: string;
|
|
18
|
+
}
|
|
8
19
|
export interface PushConfig {
|
|
9
|
-
apns
|
|
20
|
+
apns?: ApnsConfig;
|
|
21
|
+
fcm?: FcmConfig;
|
|
10
22
|
}
|
|
11
23
|
export interface PushPayload {
|
|
12
24
|
did: string;
|
|
@@ -19,10 +31,18 @@ export interface PushPayload {
|
|
|
19
31
|
export interface PushInterface {
|
|
20
32
|
send: (payload: PushPayload) => Promise<void>;
|
|
21
33
|
}
|
|
22
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Initialize push with config. Must be called before send().
|
|
36
|
+
*
|
|
37
|
+
* Each transport is loaded on its own: a missing or malformed credential
|
|
38
|
+
* disables that platform and leaves the other one sending, rather than taking
|
|
39
|
+
* push down entirely.
|
|
40
|
+
*/
|
|
23
41
|
export declare function initPush(config: PushConfig, configDir: string): void;
|
|
24
|
-
/** Check if push is configured and
|
|
42
|
+
/** Check if push is configured and at least one transport is usable. */
|
|
25
43
|
export declare function isPushEnabled(): boolean;
|
|
44
|
+
/** Which platforms this process can actually deliver to, for startup logging. */
|
|
45
|
+
export declare function enabledPushTransports(): string[];
|
|
26
46
|
/** Build the push interface injected into hook contexts. */
|
|
27
47
|
export declare function buildPushInterface(): PushInterface;
|
|
28
48
|
/** Register a push token for a DID. Upserts on conflict. */
|
package/dist/push.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,GAAG,CAAC,EAAE,SAAS,CAAA;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAsBD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CA4CpE;AAED,wEAAwE;AACxE,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,iFAAiF;AACjF,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAKhD;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,IAAI,aAAa,CAElD;AAmSD,4DAA4D;AAC5D,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO/F;AAED,2BAA2B;AAC3B,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAED,6CAA6C;AAC7C,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E"}
|
package/dist/push.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Push notification delivery via APNs HTTP/2.
|
|
2
|
+
* Push notification delivery via APNs HTTP/2 and FCM HTTP v1.
|
|
3
3
|
*
|
|
4
4
|
* Provides `push.send()` for use in on-commit hook context. Looks up device
|
|
5
|
-
* tokens, builds
|
|
6
|
-
* tokens
|
|
5
|
+
* tokens, builds a payload per platform, and sends over the transport that
|
|
6
|
+
* platform's tokens belong to. Self-cleans tokens the vendor reports as dead —
|
|
7
|
+
* Apple's 410, Google's UNREGISTERED. Fire-and-forget — failures are logged via
|
|
7
8
|
* `emit()` but never throw.
|
|
9
|
+
*
|
|
10
|
+
* Either transport can be configured alone. A deployment with only an APNs key
|
|
11
|
+
* still sends to iOS, and Android tokens simply sit unused until an FCM service
|
|
12
|
+
* account is supplied.
|
|
8
13
|
*/
|
|
9
14
|
import { connect } from 'node:http2';
|
|
10
15
|
import { readFileSync } from 'node:fs';
|
|
@@ -16,21 +21,71 @@ let pushConfig = null;
|
|
|
16
21
|
let apnsKey = null;
|
|
17
22
|
let cachedJwt = null;
|
|
18
23
|
let http2Session = null;
|
|
19
|
-
|
|
24
|
+
let fcmCredentials = null;
|
|
25
|
+
let cachedFcmToken = null;
|
|
26
|
+
/** Where a service account trades its signed JWT for a bearer token. */
|
|
27
|
+
const GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token';
|
|
28
|
+
/** The only scope FCM sending needs. */
|
|
29
|
+
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
30
|
+
/**
|
|
31
|
+
* Initialize push with config. Must be called before send().
|
|
32
|
+
*
|
|
33
|
+
* Each transport is loaded on its own: a missing or malformed credential
|
|
34
|
+
* disables that platform and leaves the other one sending, rather than taking
|
|
35
|
+
* push down entirely.
|
|
36
|
+
*/
|
|
20
37
|
export function initPush(config, configDir) {
|
|
21
38
|
pushConfig = config;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
39
|
+
apnsKey = null;
|
|
40
|
+
fcmCredentials = null;
|
|
41
|
+
cachedJwt = null;
|
|
42
|
+
cachedFcmToken = null;
|
|
43
|
+
if (config.apns) {
|
|
44
|
+
const keyPath = resolve(configDir, config.apns.keyFile);
|
|
45
|
+
try {
|
|
46
|
+
apnsKey = readFileSync(keyPath, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
emit('push', 'init_error', { transport: 'apns', error: `APNs key file not found: ${keyPath}` });
|
|
50
|
+
}
|
|
25
51
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
if (config.fcm) {
|
|
53
|
+
const keyPath = resolve(configDir, config.fcm.keyFile);
|
|
54
|
+
try {
|
|
55
|
+
const account = JSON.parse(readFileSync(keyPath, 'utf8'));
|
|
56
|
+
const projectId = config.fcm.projectId ?? account.project_id;
|
|
57
|
+
if (!account.client_email || !account.private_key || !projectId) {
|
|
58
|
+
throw new Error('needs client_email, private_key and a project id');
|
|
59
|
+
}
|
|
60
|
+
fcmCredentials = {
|
|
61
|
+
clientEmail: account.client_email,
|
|
62
|
+
privateKey: account.private_key,
|
|
63
|
+
projectId,
|
|
64
|
+
tokenUri: account.token_uri ?? GOOGLE_TOKEN_URI,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
emit('push', 'init_error', {
|
|
69
|
+
transport: 'fcm',
|
|
70
|
+
error: `FCM service account unusable (${keyPath}): ${err.message}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
29
73
|
}
|
|
74
|
+
if (!apnsKey && !fcmCredentials)
|
|
75
|
+
pushConfig = null;
|
|
30
76
|
}
|
|
31
|
-
/** Check if push is configured and
|
|
77
|
+
/** Check if push is configured and at least one transport is usable. */
|
|
32
78
|
export function isPushEnabled() {
|
|
33
|
-
return pushConfig !== null && apnsKey !== null;
|
|
79
|
+
return pushConfig !== null && (apnsKey !== null || fcmCredentials !== null);
|
|
80
|
+
}
|
|
81
|
+
/** Which platforms this process can actually deliver to, for startup logging. */
|
|
82
|
+
export function enabledPushTransports() {
|
|
83
|
+
const transports = [];
|
|
84
|
+
if (apnsKey)
|
|
85
|
+
transports.push('apns');
|
|
86
|
+
if (fcmCredentials)
|
|
87
|
+
transports.push('fcm');
|
|
88
|
+
return transports;
|
|
34
89
|
}
|
|
35
90
|
/** Build the push interface injected into hook contexts. */
|
|
36
91
|
export function buildPushInterface() {
|
|
@@ -40,15 +95,16 @@ export function buildPushInterface() {
|
|
|
40
95
|
function getApnsJwt() {
|
|
41
96
|
if (cachedJwt && Date.now() < cachedJwt.expires)
|
|
42
97
|
return cachedJwt.token;
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
const apns = pushConfig?.apns;
|
|
99
|
+
if (!apns || !apnsKey)
|
|
100
|
+
throw new Error('APNs not initialized');
|
|
45
101
|
const header = Buffer.from(JSON.stringify({
|
|
46
102
|
alg: 'ES256',
|
|
47
|
-
kid:
|
|
103
|
+
kid: apns.keyId,
|
|
48
104
|
})).toString('base64url');
|
|
49
105
|
const now = Math.floor(Date.now() / 1000);
|
|
50
106
|
const claims = Buffer.from(JSON.stringify({
|
|
51
|
-
iss:
|
|
107
|
+
iss: apns.teamId,
|
|
52
108
|
iat: now,
|
|
53
109
|
})).toString('base64url');
|
|
54
110
|
const signer = createSign('SHA256');
|
|
@@ -63,7 +119,7 @@ function getHttp2Session() {
|
|
|
63
119
|
if (http2Session && !http2Session.closed && !http2Session.destroyed) {
|
|
64
120
|
return http2Session;
|
|
65
121
|
}
|
|
66
|
-
const host = pushConfig?.apns
|
|
122
|
+
const host = pushConfig?.apns?.production !== false ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com';
|
|
67
123
|
emit('push', 'connecting', { host });
|
|
68
124
|
http2Session = connect(host, {
|
|
69
125
|
peerMaxConcurrentStreams: 100,
|
|
@@ -82,41 +138,60 @@ function getHttp2Session() {
|
|
|
82
138
|
}
|
|
83
139
|
/** Send a push notification to all devices registered for a DID. */
|
|
84
140
|
async function send(payload) {
|
|
85
|
-
if (!
|
|
141
|
+
if (!isPushEnabled())
|
|
86
142
|
return;
|
|
87
143
|
const tokens = (await querySQL(`SELECT token, platform FROM _push_tokens WHERE did = $1`, [payload.did]));
|
|
88
144
|
if (tokens.length === 0)
|
|
89
145
|
return;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
146
|
+
// A device is reachable over exactly one transport, named by the platform it
|
|
147
|
+
// registered under. An unrecognized platform is left alone rather than
|
|
148
|
+
// guessed at — the row may belong to a client this build doesn't serve yet.
|
|
149
|
+
const apnsTokens = tokens.filter((t) => t.platform === 'apns');
|
|
150
|
+
const fcmTokens = tokens.filter((t) => t.platform === 'fcm');
|
|
151
|
+
if (apnsKey && apnsTokens.length > 0) {
|
|
152
|
+
const jwt = getApnsJwt();
|
|
153
|
+
const aps = {
|
|
154
|
+
alert: { title: payload.title, body: payload.body },
|
|
155
|
+
sound: 'default',
|
|
156
|
+
};
|
|
157
|
+
if (payload.badge !== undefined) {
|
|
158
|
+
aps.badge = payload.badge;
|
|
159
|
+
}
|
|
160
|
+
const apnsPayload = JSON.stringify({
|
|
161
|
+
aps,
|
|
162
|
+
...(payload.data || {}),
|
|
163
|
+
});
|
|
164
|
+
for (const { token } of apnsTokens) {
|
|
165
|
+
sendToApns(token, apnsPayload, jwt, payload).catch(() => { });
|
|
166
|
+
}
|
|
101
167
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
168
|
+
if (fcmCredentials && fcmTokens.length > 0) {
|
|
169
|
+
// One access token serves every device in this fan-out; it's minted once
|
|
170
|
+
// and cached for the hour Google grants it.
|
|
171
|
+
let accessToken;
|
|
172
|
+
try {
|
|
173
|
+
accessToken = await getFcmAccessToken();
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
emit('push', 'send_error', { did: payload.did, transport: 'fcm', error: err.message });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const { token } of fcmTokens) {
|
|
180
|
+
sendToFcm(token, accessToken, payload).catch(() => { });
|
|
181
|
+
}
|
|
110
182
|
}
|
|
111
183
|
}
|
|
112
184
|
/** Send a single APNs push and handle the response. */
|
|
113
185
|
async function sendToApns(token, payload, jwt, original) {
|
|
186
|
+
const apns = pushConfig?.apns;
|
|
187
|
+
if (!apns)
|
|
188
|
+
return;
|
|
114
189
|
const session = getHttp2Session();
|
|
115
190
|
const headers = {
|
|
116
191
|
':method': 'POST',
|
|
117
192
|
':path': `/3/device/${token}`,
|
|
118
193
|
authorization: `bearer ${jwt}`,
|
|
119
|
-
'apns-topic':
|
|
194
|
+
'apns-topic': apns.bundleId,
|
|
120
195
|
'apns-push-type': 'alert',
|
|
121
196
|
};
|
|
122
197
|
if (original.collapseId) {
|
|
@@ -174,6 +249,108 @@ async function sendToApns(token, payload, jwt, original) {
|
|
|
174
249
|
req.end();
|
|
175
250
|
});
|
|
176
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Mint an OAuth2 access token for the FCM HTTP v1 API.
|
|
254
|
+
*
|
|
255
|
+
* The service account signs a JWT asserting the messaging scope, and Google
|
|
256
|
+
* trades it for a bearer token good for an hour. Cached with a minute of
|
|
257
|
+
* headroom so one can't expire between minting and the send that uses it.
|
|
258
|
+
*/
|
|
259
|
+
async function getFcmAccessToken() {
|
|
260
|
+
if (cachedFcmToken && Date.now() < cachedFcmToken.expires)
|
|
261
|
+
return cachedFcmToken.token;
|
|
262
|
+
if (!fcmCredentials)
|
|
263
|
+
throw new Error('FCM not initialized');
|
|
264
|
+
const now = Math.floor(Date.now() / 1000);
|
|
265
|
+
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
266
|
+
const claims = Buffer.from(JSON.stringify({
|
|
267
|
+
iss: fcmCredentials.clientEmail,
|
|
268
|
+
scope: FCM_SCOPE,
|
|
269
|
+
aud: fcmCredentials.tokenUri,
|
|
270
|
+
iat: now,
|
|
271
|
+
exp: now + 3600,
|
|
272
|
+
})).toString('base64url');
|
|
273
|
+
const signer = createSign('RSA-SHA256');
|
|
274
|
+
signer.update(`${header}.${claims}`);
|
|
275
|
+
const assertion = `${header}.${claims}.${signer.sign(fcmCredentials.privateKey, 'base64url')}`;
|
|
276
|
+
const res = await fetch(fcmCredentials.tokenUri, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
279
|
+
body: new URLSearchParams({
|
|
280
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
281
|
+
assertion,
|
|
282
|
+
}),
|
|
283
|
+
signal: AbortSignal.timeout(15_000),
|
|
284
|
+
});
|
|
285
|
+
if (!res.ok) {
|
|
286
|
+
const body = await res.text().catch(() => '');
|
|
287
|
+
throw new Error(`FCM token exchange failed: ${res.status} ${body.slice(0, 200)}`);
|
|
288
|
+
}
|
|
289
|
+
const granted = (await res.json());
|
|
290
|
+
if (!granted.access_token)
|
|
291
|
+
throw new Error('FCM token exchange returned no access_token');
|
|
292
|
+
cachedFcmToken = {
|
|
293
|
+
token: granted.access_token,
|
|
294
|
+
expires: Date.now() + ((granted.expires_in ?? 3600) - 60) * 1000,
|
|
295
|
+
};
|
|
296
|
+
return cachedFcmToken.token;
|
|
297
|
+
}
|
|
298
|
+
/** Send a single FCM push and handle the response. */
|
|
299
|
+
async function sendToFcm(token, accessToken, original) {
|
|
300
|
+
// Data-only, deliberately: a `notification` block has the Android SDK draw
|
|
301
|
+
// the alert itself while the app is backgrounded, which skips the client's
|
|
302
|
+
// handler and with it the tap destination it derives from `type` and `uri`.
|
|
303
|
+
// FCM only carries strings in `data`, so everything is sent as one.
|
|
304
|
+
const data = { title: original.title, body: original.body };
|
|
305
|
+
for (const [key, value] of Object.entries(original.data ?? {})) {
|
|
306
|
+
data[key] = String(value);
|
|
307
|
+
}
|
|
308
|
+
const message = {
|
|
309
|
+
token,
|
|
310
|
+
data,
|
|
311
|
+
android: {
|
|
312
|
+
// Data-only messages are "normal" priority by default, which lets Doze
|
|
313
|
+
// hold one until the device next wakes. These are alerts somebody is
|
|
314
|
+
// waiting on, so they go high, the way APNs alert pushes do.
|
|
315
|
+
priority: 'HIGH',
|
|
316
|
+
...(original.collapseId ? { collapse_key: original.collapseId } : {}),
|
|
317
|
+
},
|
|
318
|
+
// `badge` has no FCM equivalent — an Android launcher badges from the
|
|
319
|
+
// notification the client posts, not from the payload.
|
|
320
|
+
};
|
|
321
|
+
const res = await fetch(`https://fcm.googleapis.com/v1/projects/${fcmCredentials.projectId}/messages:send`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: {
|
|
324
|
+
authorization: `Bearer ${accessToken}`,
|
|
325
|
+
'content-type': 'application/json',
|
|
326
|
+
},
|
|
327
|
+
body: JSON.stringify({ message }),
|
|
328
|
+
signal: AbortSignal.timeout(15_000),
|
|
329
|
+
});
|
|
330
|
+
if (res.ok) {
|
|
331
|
+
emit('push', 'sent', { did: original.did, transport: 'fcm', token: token.slice(0, 8) + '...' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const body = await res.text().catch(() => '');
|
|
335
|
+
// Google's word for a token that no longer belongs to an install — the same
|
|
336
|
+
// fact Apple reports as a 410.
|
|
337
|
+
if (res.status === 404 || body.includes('UNREGISTERED')) {
|
|
338
|
+
await removeToken(token).catch(() => { });
|
|
339
|
+
emit('push', 'token_removed', { did: original.did, transport: 'fcm', reason: 'unregistered' });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// The bearer token was refused: drop it so the next send mints a fresh one
|
|
343
|
+
// rather than replaying a credential Google has already rejected.
|
|
344
|
+
if (res.status === 401 || res.status === 403) {
|
|
345
|
+
cachedFcmToken = null;
|
|
346
|
+
}
|
|
347
|
+
emit('push', 'send_error', {
|
|
348
|
+
did: original.did,
|
|
349
|
+
transport: 'fcm',
|
|
350
|
+
status: res.status,
|
|
351
|
+
body: body.slice(0, 200),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
177
354
|
/** Register a push token for a DID. Upserts on conflict. */
|
|
178
355
|
export async function registerToken(did, token, platform) {
|
|
179
356
|
await runSQL(`INSERT INTO _push_tokens (did, token, platform, created_at)
|