@meshwhisper/push-service 0.1.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.
package/dist/apns.js ADDED
@@ -0,0 +1,111 @@
1
+ // ============================================================
2
+ // APNs dispatch
3
+ // Uses the HTTP/2 APNs provider API with a .p8 auth key.
4
+ //
5
+ // Required env vars:
6
+ // APNS_KEY_ID — 10-char key ID from Apple Developer Portal
7
+ // APNS_TEAM_ID — 10-char team ID from Apple Developer Portal
8
+ // APNS_KEY_PATH — path to the .p8 private key file
9
+ // APNS_BUNDLE_ID — app bundle ID (used as topic if not provided per-push)
10
+ // APNS_PRODUCTION — set to "true" for production, omit for sandbox
11
+ // ============================================================
12
+ import * as http2 from 'node:http2';
13
+ import * as fs from 'node:fs';
14
+ import * as crypto from 'node:crypto';
15
+ const APNS_HOST_SANDBOX = 'api.sandbox.push.apple.com';
16
+ const APNS_HOST_PRODUCTION = 'api.push.apple.com';
17
+ // ---- JWT token generation ----
18
+ let cachedToken = null;
19
+ function generateApnsJwt(keyId, teamId, privateKeyPem) {
20
+ // Reuse the token for up to 55 minutes (APNs tokens expire after 60)
21
+ if (cachedToken && Date.now() - cachedToken.generatedAt < 55 * 60 * 1000) {
22
+ return cachedToken.jwt;
23
+ }
24
+ const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: keyId })).toString('base64url');
25
+ const payload = Buffer.from(JSON.stringify({
26
+ iss: teamId,
27
+ iat: Math.floor(Date.now() / 1000),
28
+ })).toString('base64url');
29
+ const signingInput = `${header}.${payload}`;
30
+ const sign = crypto.createSign('SHA256');
31
+ sign.update(signingInput);
32
+ const signature = sign.sign({ key: privateKeyPem, dsaEncoding: 'ieee-p1363' })
33
+ .toString('base64url');
34
+ const jwt = `${signingInput}.${signature}`;
35
+ cachedToken = { jwt, generatedAt: Date.now() };
36
+ return jwt;
37
+ }
38
+ // ---- HTTP/2 session pool (one per host) ----
39
+ let h2Session = null;
40
+ let h2Host = null;
41
+ function getH2Session(host) {
42
+ if (h2Session && !h2Session.destroyed && h2Host === host)
43
+ return h2Session;
44
+ h2Session?.destroy();
45
+ h2Session = http2.connect(`https://${host}`);
46
+ h2Session.on('error', () => { h2Session = null; });
47
+ h2Host = host;
48
+ return h2Session;
49
+ }
50
+ export function loadApnsConfig() {
51
+ const keyId = process.env.APNS_KEY_ID;
52
+ const teamId = process.env.APNS_TEAM_ID;
53
+ const keyPath = process.env.APNS_KEY_PATH;
54
+ const bundleId = process.env.APNS_BUNDLE_ID;
55
+ if (!keyId || !teamId || !keyPath || !bundleId)
56
+ return null;
57
+ if (!fs.existsSync(keyPath)) {
58
+ console.warn(`[APNs] Key file not found: ${keyPath}`);
59
+ return null;
60
+ }
61
+ return {
62
+ keyId,
63
+ teamId,
64
+ keyPath,
65
+ bundleId,
66
+ production: process.env.APNS_PRODUCTION === 'true',
67
+ };
68
+ }
69
+ /**
70
+ * Send a silent background push to an APNs device token.
71
+ * Resolves when APNs accepts the request. Throws on rejection.
72
+ */
73
+ export async function sendApns(config, token, topic) {
74
+ const privateKey = fs.readFileSync(config.keyPath, 'utf-8');
75
+ const jwt = generateApnsJwt(config.keyId, config.teamId, privateKey);
76
+ const host = config.production ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
77
+ const session = getH2Session(host);
78
+ return new Promise((resolve, reject) => {
79
+ const req = session.request({
80
+ ':method': 'POST',
81
+ ':path': `/3/device/${token}`,
82
+ ':scheme': 'https',
83
+ ':authority': host,
84
+ 'authorization': `bearer ${jwt}`,
85
+ 'apns-push-type': 'background',
86
+ 'apns-priority': '5', // 5 = low priority, required for background
87
+ 'apns-topic': topic,
88
+ 'content-type': 'application/json',
89
+ });
90
+ const body = JSON.stringify({ aps: { 'content-available': 1 } });
91
+ req.end(body);
92
+ let statusCode = 0;
93
+ let responseBody = '';
94
+ req.on('response', (headers) => {
95
+ statusCode = headers[':status'];
96
+ });
97
+ req.on('data', (chunk) => {
98
+ responseBody += chunk.toString();
99
+ });
100
+ req.on('end', () => {
101
+ if (statusCode === 200) {
102
+ resolve();
103
+ }
104
+ else {
105
+ reject(new Error(`APNs error ${statusCode}: ${responseBody}`));
106
+ }
107
+ });
108
+ req.on('error', reject);
109
+ });
110
+ }
111
+ //# sourceMappingURL=apns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apns.js","sourceRoot":"","sources":["../src/apns.ts"],"names":[],"mappings":"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"}
package/dist/fcm.js ADDED
@@ -0,0 +1,73 @@
1
+ // ============================================================
2
+ // FCM v1 API dispatch (Firebase Cloud Messaging)
3
+ //
4
+ // Required env vars:
5
+ // FCM_SERVICE_ACCOUNT_PATH — path to the Firebase service account JSON file
6
+ // FCM_PROJECT_ID — Firebase project ID (also in the service account file)
7
+ // ============================================================
8
+ import * as fs from 'node:fs';
9
+ import * as https from 'node:https';
10
+ import { GoogleAuth } from 'google-auth-library';
11
+ const FCM_ENDPOINT = 'https://fcm.googleapis.com/v1/projects';
12
+ export function loadFcmConfig() {
13
+ const serviceAccountPath = process.env.FCM_SERVICE_ACCOUNT_PATH;
14
+ const projectId = process.env.FCM_PROJECT_ID;
15
+ if (!serviceAccountPath || !projectId)
16
+ return null;
17
+ if (!fs.existsSync(serviceAccountPath)) {
18
+ console.warn(`[FCM] Service account file not found: ${serviceAccountPath}`);
19
+ return null;
20
+ }
21
+ const auth = new GoogleAuth({
22
+ keyFile: serviceAccountPath,
23
+ scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
24
+ });
25
+ return { projectId, auth };
26
+ }
27
+ /**
28
+ * Send a silent data-only push to an FCM registration token.
29
+ * Resolves when FCM accepts the request. Throws on rejection.
30
+ */
31
+ export async function sendFcm(config, token) {
32
+ const accessToken = await config.auth.getAccessToken();
33
+ if (!accessToken)
34
+ throw new Error('FCM: failed to obtain access token');
35
+ const url = `${FCM_ENDPOINT}/${config.projectId}/messages:send`;
36
+ const payload = JSON.stringify({
37
+ message: {
38
+ token,
39
+ // Data-only message — no notification key means no visible alert
40
+ data: { source: 'meshwhisper' },
41
+ android: { priority: 'normal' },
42
+ apns: {
43
+ headers: { 'apns-priority': '5' },
44
+ payload: { aps: { 'content-available': 1 } },
45
+ },
46
+ },
47
+ });
48
+ return new Promise((resolve, reject) => {
49
+ const req = https.request(url, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Authorization': `Bearer ${accessToken}`,
53
+ 'Content-Type': 'application/json',
54
+ 'Content-Length': Buffer.byteLength(payload),
55
+ },
56
+ }, (res) => {
57
+ let body = '';
58
+ res.on('data', (chunk) => { body += chunk.toString(); });
59
+ res.on('end', () => {
60
+ if (res.statusCode === 200) {
61
+ resolve();
62
+ }
63
+ else {
64
+ reject(new Error(`FCM error ${res.statusCode}: ${body}`));
65
+ }
66
+ });
67
+ });
68
+ req.on('error', reject);
69
+ req.write(payload);
70
+ req.end();
71
+ });
72
+ }
73
+ //# sourceMappingURL=fcm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fcm.js","sourceRoot":"","sources":["../src/fcm.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================
3
+ // MeshWhisper Push Service
4
+ // Accepts webhook POSTs from the MeshWhisper Node and dispatches
5
+ // silent wake notifications via APNs and/or FCM.
6
+ //
7
+ // Usage:
8
+ // PUSH_PORT=4000 \
9
+ // APNS_KEY_ID=XXXXXXXXXX \
10
+ // APNS_TEAM_ID=YYYYYYYYYY \
11
+ // APNS_KEY_PATH=./AuthKey_XXXXXXXXXX.p8 \
12
+ // APNS_BUNDLE_ID=com.example.myapp \
13
+ // FCM_SERVICE_ACCOUNT_PATH=./firebase-service-account.json \
14
+ // FCM_PROJECT_ID=my-firebase-project \
15
+ // node dist/index.js
16
+ //
17
+ // Configure the MeshWhisper Node to point at this service:
18
+ // PUSH_WEBHOOK_URL=http://localhost:4000/notify
19
+ //
20
+ // Webhook payload (from Node):
21
+ // { token: string, platform: "apns" | "fcm", topic?: string, destHash: string }
22
+ // ============================================================
23
+ import * as http from 'node:http';
24
+ import { loadApnsConfig, sendApns } from './apns.js';
25
+ import { loadFcmConfig, sendFcm } from './fcm.js';
26
+ import { loadWebPushConfig, sendWebPush } from './webpush.js';
27
+ // ============================================================
28
+ // Configuration
29
+ // ============================================================
30
+ const PORT = parseInt(process.env.PUSH_PORT ?? '4000', 10);
31
+ const apnsConfig = loadApnsConfig();
32
+ const fcmConfig = loadFcmConfig();
33
+ const webPushConfig = loadWebPushConfig();
34
+ if (!apnsConfig && !fcmConfig && !webPushConfig) {
35
+ console.warn('[push-service] WARNING: No push provider configured.');
36
+ console.warn('[push-service] Set APNS_*, FCM_*, or VAPID_* environment variables to enable push delivery.');
37
+ }
38
+ async function handleNotify(req, res) {
39
+ // Read body
40
+ const chunks = [];
41
+ await new Promise((resolve, reject) => {
42
+ req.on('data', (c) => chunks.push(c));
43
+ req.on('end', resolve);
44
+ req.on('error', reject);
45
+ });
46
+ let payload;
47
+ try {
48
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
49
+ }
50
+ catch {
51
+ res.writeHead(400).end('Invalid JSON');
52
+ return;
53
+ }
54
+ const { token, platform, topic, destHash } = payload;
55
+ if (!token || !platform || !destHash) {
56
+ res.writeHead(400).end('Missing required fields: token, platform, destHash');
57
+ return;
58
+ }
59
+ // Dispatch — best effort
60
+ try {
61
+ if (platform === 'apns') {
62
+ if (!apnsConfig) {
63
+ console.warn(`[push-service] APNs not configured — dropping push for ${destHash}`);
64
+ }
65
+ else {
66
+ const apnsTopic = topic ?? apnsConfig.bundleId;
67
+ await sendApns(apnsConfig, token, apnsTopic);
68
+ console.log(`[push-service] APNs sent for destHash=${destHash}`);
69
+ }
70
+ }
71
+ else if (platform === 'fcm') {
72
+ if (!fcmConfig) {
73
+ console.warn(`[push-service] FCM not configured — dropping push for ${destHash}`);
74
+ }
75
+ else {
76
+ await sendFcm(fcmConfig, token);
77
+ console.log(`[push-service] FCM sent for destHash=${destHash}`);
78
+ }
79
+ }
80
+ else if (platform === 'webpush') {
81
+ if (!webPushConfig) {
82
+ console.warn(`[push-service] Web Push not configured — dropping push for ${destHash}`);
83
+ }
84
+ else {
85
+ // Subscription may arrive as a pre-parsed object or as a JSON string in token
86
+ let subscription = payload.pushSubscription ?? null;
87
+ if (!subscription && token) {
88
+ try {
89
+ subscription = JSON.parse(token);
90
+ }
91
+ catch { /* bad token */ }
92
+ }
93
+ if (!subscription) {
94
+ console.warn(`[push-service] webpush: no valid subscription for ${destHash}`);
95
+ }
96
+ else {
97
+ await sendWebPush(webPushConfig, subscription, destHash);
98
+ console.log(`[push-service] Web Push sent for destHash=${destHash}`);
99
+ }
100
+ }
101
+ }
102
+ else {
103
+ console.warn(`[push-service] Unknown platform: ${platform}`);
104
+ }
105
+ }
106
+ catch (err) {
107
+ console.error(`[push-service] Push failed for destHash=${destHash}:`, err);
108
+ // Still return 200 — the Node shouldn't retry on push failures
109
+ }
110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({ ok: true }));
112
+ }
113
+ // ============================================================
114
+ // HTTP server
115
+ // ============================================================
116
+ const server = http.createServer((req, res) => {
117
+ if (req.method === 'POST' && req.url === '/notify') {
118
+ handleNotify(req, res).catch((err) => {
119
+ console.error('[push-service] Handler error:', err);
120
+ res.writeHead(500).end();
121
+ });
122
+ return;
123
+ }
124
+ if (req.method === 'GET' && req.url === '/health') {
125
+ res.writeHead(200, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify({
127
+ status: 'ok',
128
+ apns: !!apnsConfig,
129
+ fcm: !!fcmConfig,
130
+ webpush: !!webPushConfig,
131
+ }));
132
+ return;
133
+ }
134
+ res.writeHead(404).end();
135
+ });
136
+ server.listen(PORT, () => {
137
+ console.log(`MeshWhisper Push Service listening on port ${PORT}`);
138
+ console.log(` APNs: ${apnsConfig ? `enabled (${apnsConfig.production ? 'production' : 'sandbox'})` : 'disabled'}`);
139
+ console.log(` FCM: ${fcmConfig ? `enabled (project: ${fcmConfig.projectId})` : 'disabled'}`);
140
+ console.log(` Web Push: ${webPushConfig ? 'enabled' : 'disabled (set VAPID_* to enable)'}`);
141
+ console.log(` Webhook endpoint: POST http://localhost:${PORT}/notify`);
142
+ });
143
+ function shutdown() {
144
+ console.log('\nShutting down...');
145
+ server.close(() => process.exit(0));
146
+ setTimeout(() => process.exit(1), 3000).unref();
147
+ }
148
+ process.on('SIGINT', shutdown);
149
+ process.on('SIGTERM', shutdown);
150
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ // ============================================================
2
+ // MeshWhisper Push Service — Web Push (VAPID)
3
+ // Sends silent wake notifications to browser/PWA subscribers
4
+ // using the Web Push Protocol (RFC 8030) with VAPID auth.
5
+ //
6
+ // Environment variables:
7
+ // VAPID_PUBLIC_KEY — Base64url VAPID public key
8
+ // VAPID_PRIVATE_KEY — Base64url VAPID private key
9
+ // VAPID_SUBJECT — mailto: or https: contact URI (required by spec)
10
+ //
11
+ // Generate VAPID keys once and store them:
12
+ // node -e "const wp = require('web-push'); console.log(wp.generateVAPIDKeys())"
13
+ // ============================================================
14
+ import webpush from 'web-push';
15
+ // ---- Config loading ----
16
+ export function loadWebPushConfig() {
17
+ const pub = process.env.VAPID_PUBLIC_KEY;
18
+ const priv = process.env.VAPID_PRIVATE_KEY;
19
+ const subject = process.env.VAPID_SUBJECT;
20
+ if (!pub || !priv || !subject)
21
+ return null;
22
+ webpush.setVapidDetails(subject, pub, priv);
23
+ return { vapidPublicKey: pub, vapidPrivateKey: priv, subject };
24
+ }
25
+ // ---- Send ----
26
+ /**
27
+ * Sends a silent data push to the subscriber's browser/PWA.
28
+ * The payload is intentionally minimal — the app reconnects on wake
29
+ * and pulls queued messages. E2EE content never passes through the
30
+ * push service.
31
+ */
32
+ export async function sendWebPush(_config, subscription, destHash) {
33
+ // Silent data message — just enough for the service worker to know
34
+ // there is something to fetch. No E2EE content here.
35
+ const payload = JSON.stringify({ type: 'meshwhisper:wake', destHash });
36
+ await webpush.sendNotification({
37
+ endpoint: subscription.endpoint,
38
+ expirationTime: subscription.expirationTime ?? null,
39
+ keys: subscription.keys,
40
+ }, payload, {
41
+ // TTL in seconds — keep alive for 24h so the device receives it
42
+ // when it next comes online.
43
+ TTL: 86400,
44
+ });
45
+ }
46
+ //# sourceMappingURL=webpush.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webpush.js","sourceRoot":"","sources":["../src/webpush.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,8CAA8C;AAC9C,6DAA6D;AAC7D,0DAA0D;AAC1D,EAAE;AACF,yBAAyB;AACzB,oDAAoD;AACpD,qDAAqD;AACrD,0EAA0E;AAC1E,EAAE;AACF,2CAA2C;AAC3C,kFAAkF;AAClF,+DAA+D;AAE/D,OAAO,OAAO,MAAM,UAAU,CAAC;AAmB/B,2BAA2B;AAE3B,MAAM,UAAU,iBAAiB;IAC/B,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;IACzC,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;IAC3C,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;IAE1C,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAE3C,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IAE5C,OAAO,EAAE,cAAc,EAAE,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AACjE,CAAC;AAED,iBAAiB;AAEjB;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,OAAsB,EACtB,YAAiC,EACjC,QAAgB;IAEhB,mEAAmE;IACnE,qDAAqD;IACrD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,kBAAkB,EAAE,QAAQ,EAAE,CAAC,CAAC;IAEvE,MAAM,OAAO,CAAC,gBAAgB,CAC5B;QACE,QAAQ,EAAE,YAAY,CAAC,QAAQ;QAC/B,cAAc,EAAE,YAAY,CAAC,cAAc,IAAI,IAAI;QACnD,IAAI,EAAE,YAAY,CAAC,IAAI;KACxB,EACD,OAAO,EACP;QACE,gEAAgE;QAChE,6BAA6B;QAC7B,GAAG,EAAE,KAAK;KACX,CACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@meshwhisper/push-service",
3
+ "version": "0.1.0",
4
+ "description": "MeshWhisper push notification service — dispatches APNs and FCM wake signals",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "meshwhisper-push": "dist/index.js"
9
+ },
10
+ "files": ["dist"],
11
+ "publishConfig": { "access": "public" },
12
+ "scripts": {
13
+ "prepublishOnly": "npm run build",
14
+ "build": "tsc",
15
+ "start": "node dist/index.js",
16
+ "dev": "tsx src/index.ts"
17
+ },
18
+ "dependencies": {
19
+ "google-auth-library": "^9.0.0",
20
+ "web-push": "^3.6.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.0.0",
24
+ "@types/web-push": "^3.6.0",
25
+ "tsx": "^4.21.0",
26
+ "typescript": "^5.7.0"
27
+ },
28
+ "license": "MIT"
29
+ }