@juspay/shooter 1.24.1 → 1.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.
- package/.claude/hooks/notifier.cjs +32 -4
- package/bin/lib/service-manager.cjs +148 -0
- package/bin/shooter.cjs +151 -62
- package/build/client/_app/immutable/assets/{4.D4EDSN4H.css → 4.ChO_hlLs.css} +1 -1
- package/build/client/_app/immutable/assets/4.ChO_hlLs.css.br +0 -0
- package/build/client/_app/immutable/assets/4.ChO_hlLs.css.gz +0 -0
- package/build/client/_app/immutable/chunks/{DjWRwZyr.js → BstJSK2K.js} +1 -1
- package/build/client/_app/immutable/chunks/BstJSK2K.js.br +0 -0
- package/build/client/_app/immutable/chunks/BstJSK2K.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{CfzjLyJm.js → DINqYbXU.js} +1 -1
- package/build/client/_app/immutable/chunks/DINqYbXU.js.br +0 -0
- package/build/client/_app/immutable/chunks/DINqYbXU.js.gz +0 -0
- package/build/client/_app/immutable/chunks/ssAhjWfF.js +3 -0
- package/build/client/_app/immutable/chunks/ssAhjWfF.js.br +0 -0
- package/build/client/_app/immutable/chunks/ssAhjWfF.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.9F0rhLIY.js → app.BPK9s2o5.js} +2 -2
- package/build/client/_app/immutable/entry/app.BPK9s2o5.js.br +0 -0
- package/build/client/_app/immutable/entry/app.BPK9s2o5.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.DFPHuGE3.js +1 -0
- package/build/client/_app/immutable/entry/start.DFPHuGE3.js.br +2 -0
- package/build/client/_app/immutable/entry/start.DFPHuGE3.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.MlMcxYLT.js → 0.BEbzRcwm.js} +1 -1
- package/build/client/_app/immutable/nodes/0.BEbzRcwm.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.BEbzRcwm.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1._Bn-HQ9b.js → 1.C_V0SOr9.js} +1 -1
- package/build/client/_app/immutable/nodes/1.C_V0SOr9.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.C_V0SOr9.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{10.CsX0V4R9.js → 10.6V-37_Rl.js} +1 -1
- package/build/client/_app/immutable/nodes/10.6V-37_Rl.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.6V-37_Rl.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{11.lM_b6yUv.js → 11.Lzf-KC6L.js} +1 -1
- package/build/client/_app/immutable/nodes/11.Lzf-KC6L.js.br +0 -0
- package/build/client/_app/immutable/nodes/11.Lzf-KC6L.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{2.sAnHf5go.js → 2.Cl2dMP2L.js} +1 -1
- package/build/client/_app/immutable/nodes/2.Cl2dMP2L.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.Cl2dMP2L.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.DEHWta3G.js → 3.CuX2eSna.js} +1 -1
- package/build/client/_app/immutable/nodes/3.CuX2eSna.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.CuX2eSna.js.gz +0 -0
- package/build/client/_app/immutable/nodes/4.2DvqoOaB.js +17 -0
- package/build/client/_app/immutable/nodes/4.2DvqoOaB.js.br +0 -0
- package/build/client/_app/immutable/nodes/4.2DvqoOaB.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{6.-gDjaLSz.js → 6.C7e6zQyP.js} +1 -1
- package/build/client/_app/immutable/nodes/6.C7e6zQyP.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.C7e6zQyP.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.R-emDnvX.js → 7.1ygvNTnO.js} +1 -1
- package/build/client/_app/immutable/nodes/7.1ygvNTnO.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.1ygvNTnO.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{8.DXNcFetv.js → 8.CBVmgOk0.js} +1 -1
- package/build/client/_app/immutable/nodes/8.CBVmgOk0.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.CBVmgOk0.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{9.Brv6N-Ji.js → 9.B-_ZFZhj.js} +1 -1
- package/build/client/_app/immutable/nodes/9.B-_ZFZhj.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.B-_ZFZhj.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/server/chunks/{0-CezVlDLP.js → 0-BHU07xt9.js} +2 -2
- package/build/server/chunks/{0-CezVlDLP.js.map → 0-BHU07xt9.js.map} +1 -1
- package/build/server/chunks/{1-CAwGzW00.js → 1-OVOd8GUH.js} +2 -2
- package/build/server/chunks/{1-CAwGzW00.js.map → 1-OVOd8GUH.js.map} +1 -1
- package/build/server/chunks/{10-pqorW2OP.js → 10-CRHtvb_u.js} +2 -2
- package/build/server/chunks/{10-pqorW2OP.js.map → 10-CRHtvb_u.js.map} +1 -1
- package/build/server/chunks/{11-Byxg_lSY.js → 11-CdSex9j1.js} +2 -2
- package/build/server/chunks/{11-Byxg_lSY.js.map → 11-CdSex9j1.js.map} +1 -1
- package/build/server/chunks/{2-Bbck5mN2.js → 2-bb78aIZ6.js} +2 -2
- package/build/server/chunks/{2-Bbck5mN2.js.map → 2-bb78aIZ6.js.map} +1 -1
- package/build/server/chunks/{3-BBFbA1P8.js → 3-CsTC6Lrn.js} +2 -2
- package/build/server/chunks/{3-BBFbA1P8.js.map → 3-CsTC6Lrn.js.map} +1 -1
- package/build/server/chunks/{4-w2W_T8ax.js → 4-DTTu8_Gr.js} +4 -4
- package/build/server/chunks/{4-w2W_T8ax.js.map → 4-DTTu8_Gr.js.map} +1 -1
- package/build/server/chunks/{6-BI3p-t3i.js → 6-BUgQGB_4.js} +2 -2
- package/build/server/chunks/{6-BI3p-t3i.js.map → 6-BUgQGB_4.js.map} +1 -1
- package/build/server/chunks/{7-6XKPVOSu.js → 7-CsQZnkcG.js} +2 -2
- package/build/server/chunks/{7-6XKPVOSu.js.map → 7-CsQZnkcG.js.map} +1 -1
- package/build/server/chunks/{8-VOztiJG_.js → 8-DcIuPdyW.js} +2 -2
- package/build/server/chunks/{8-VOztiJG_.js.map → 8-DcIuPdyW.js.map} +1 -1
- package/build/server/chunks/{9-DNUlBLEz.js → 9-D8bkM1uj.js} +2 -2
- package/build/server/chunks/{9-DNUlBLEz.js.map → 9-D8bkM1uj.js.map} +1 -1
- package/build/server/chunks/{_page.svelte-DDE2nChH.js → _page.svelte-BBbaKwNz.js} +101 -27
- package/build/server/chunks/_page.svelte-BBbaKwNz.js.map +1 -0
- package/build/server/chunks/{_server.ts-DfscXcFe.js → _server.ts-B7BLxK5u.js} +233 -186
- package/build/server/chunks/_server.ts-B7BLxK5u.js.map +1 -0
- package/build/server/chunks/{_server.ts-EJVmhLtg.js → _server.ts-BSS8cO80.js} +15 -3
- package/build/server/chunks/_server.ts-BSS8cO80.js.map +1 -0
- package/build/server/chunks/_server.ts-DNTxPoxO.js +115 -0
- package/build/server/chunks/_server.ts-DNTxPoxO.js.map +1 -0
- package/build/server/chunks/{_server.ts-D9_hkPQ6.js → _server.ts-DUb7fbuW.js} +17 -3
- package/build/server/chunks/_server.ts-DUb7fbuW.js.map +1 -0
- package/build/server/chunks/{_server.ts-v7TaT83B.js → _server.ts-FdKi8RwL.js} +7 -3
- package/build/server/chunks/_server.ts-FdKi8RwL.js.map +1 -0
- package/build/server/chunks/device-format-DTgEz4Yr.js +29 -0
- package/build/server/chunks/device-format-DTgEz4Yr.js.map +1 -0
- package/build/server/chunks/device-token-store-Ct7aTeR8.js +259 -0
- package/build/server/chunks/device-token-store-Ct7aTeR8.js.map +1 -0
- package/build/server/chunks/{library-apns-D8RPINlv.js → library-apns-DMlL1BAg.js} +158 -26
- package/build/server/chunks/library-apns-DMlL1BAg.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +17 -17
- package/build/server/manifest.js.map +1 -1
- package/package.json +2 -2
- package/server.ts +15 -1
- package/src/app.d.ts +4 -0
- package/src/lib/modules/server/apn/apns-classify.ts +103 -0
- package/src/lib/modules/server/apn/library-apns.ts +151 -35
- package/src/lib/modules/server/apn/notify-fanout.ts +40 -0
- package/src/lib/modules/server/fcm/fcm-classify.ts +61 -0
- package/src/lib/modules/server/fcm/fcm-service.ts +128 -29
- package/src/lib/modules/server/push/device-format.ts +42 -0
- package/src/lib/modules/server/push/device-token-store.ts +354 -0
- package/src/lib/types/apn.ts +4 -0
- package/src/lib/types/device.ts +156 -0
- package/src/lib/types/index.ts +1 -0
- package/src/routes/api/debug/+server.ts +13 -1
- package/src/routes/api/device-token/+server.ts +122 -37
- package/src/routes/api/health/+server.ts +16 -2
- package/src/routes/api/notify/+server.ts +175 -168
- package/src/routes/api/qr-config/+server.ts +9 -2
- package/src/routes/config/+page.svelte +182 -44
- package/build/client/_app/immutable/assets/4.D4EDSN4H.css.br +0 -0
- package/build/client/_app/immutable/assets/4.D4EDSN4H.css.gz +0 -0
- package/build/client/_app/immutable/chunks/BzW0vlVX.js +0 -3
- package/build/client/_app/immutable/chunks/BzW0vlVX.js.br +0 -0
- package/build/client/_app/immutable/chunks/BzW0vlVX.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CfzjLyJm.js.br +0 -0
- package/build/client/_app/immutable/chunks/CfzjLyJm.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DjWRwZyr.js.br +0 -0
- package/build/client/_app/immutable/chunks/DjWRwZyr.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.9F0rhLIY.js.br +0 -0
- package/build/client/_app/immutable/entry/app.9F0rhLIY.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.DGFWmhrj.js +0 -1
- package/build/client/_app/immutable/entry/start.DGFWmhrj.js.br +0 -2
- package/build/client/_app/immutable/entry/start.DGFWmhrj.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.MlMcxYLT.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.MlMcxYLT.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1._Bn-HQ9b.js.br +0 -0
- package/build/client/_app/immutable/nodes/1._Bn-HQ9b.js.gz +0 -0
- package/build/client/_app/immutable/nodes/10.CsX0V4R9.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.CsX0V4R9.js.gz +0 -0
- package/build/client/_app/immutable/nodes/11.lM_b6yUv.js.br +0 -0
- package/build/client/_app/immutable/nodes/11.lM_b6yUv.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.sAnHf5go.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.sAnHf5go.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.DEHWta3G.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.DEHWta3G.js.gz +0 -0
- package/build/client/_app/immutable/nodes/4.C9fv_m2R.js +0 -16
- package/build/client/_app/immutable/nodes/4.C9fv_m2R.js.br +0 -0
- package/build/client/_app/immutable/nodes/4.C9fv_m2R.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.-gDjaLSz.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.-gDjaLSz.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.R-emDnvX.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.R-emDnvX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.DXNcFetv.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.DXNcFetv.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.Brv6N-Ji.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.Brv6N-Ji.js.gz +0 -0
- package/build/server/chunks/_page.svelte-DDE2nChH.js.map +0 -1
- package/build/server/chunks/_server.ts-D2RS8TFd.js +0 -72
- package/build/server/chunks/_server.ts-D2RS8TFd.js.map +0 -1
- package/build/server/chunks/_server.ts-D9_hkPQ6.js.map +0 -1
- package/build/server/chunks/_server.ts-DfscXcFe.js.map +0 -1
- package/build/server/chunks/_server.ts-EJVmhLtg.js.map +0 -1
- package/build/server/chunks/_server.ts-v7TaT83B.js.map +0 -1
- package/build/server/chunks/library-apns-D8RPINlv.js.map +0 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure orchestration helpers for the /api/notify multi-device fan-out (PR 5).
|
|
3
|
+
*
|
|
4
|
+
* Separated from the route so the platform-selection and result-combination
|
|
5
|
+
* logic is unit-testable; the route wires these to the registry, the APNs/FCM
|
|
6
|
+
* services, and pruning.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APNsFanOutResult, FCMFanOutResult, NotifyDeliverySummary } from '$lib/types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DEVICE_PLATFORM is a delivery FILTER, not a binary switch. Unset (or an
|
|
13
|
+
* unrecognized value) fans out to BOTH platforms — the new multi-device default.
|
|
14
|
+
*/
|
|
15
|
+
export function selectPlatforms(platformFilter: string | undefined): {
|
|
16
|
+
doAndroid: boolean;
|
|
17
|
+
doIos: boolean;
|
|
18
|
+
} {
|
|
19
|
+
if (platformFilter === 'ios') {
|
|
20
|
+
return { doAndroid: false, doIos: true };
|
|
21
|
+
}
|
|
22
|
+
if (platformFilter === 'android') {
|
|
23
|
+
return { doAndroid: true, doIos: false };
|
|
24
|
+
}
|
|
25
|
+
return { doAndroid: true, doIos: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function summarizeNotifyDelivery(
|
|
29
|
+
apns: APNsFanOutResult,
|
|
30
|
+
fcm: FCMFanOutResult
|
|
31
|
+
): NotifyDeliverySummary {
|
|
32
|
+
const sent = apns.totalSent + fcm.successCount;
|
|
33
|
+
const failed = apns.totalFailed + fcm.failureCount;
|
|
34
|
+
const staleTokens = [...apns.staleTokens, ...fcm.staleTokens];
|
|
35
|
+
const succeededTokens = [
|
|
36
|
+
...apns.results.filter((r) => r.success).map((r) => r.token),
|
|
37
|
+
...fcm.results.filter((r) => r.success).map((r) => r.token),
|
|
38
|
+
];
|
|
39
|
+
return { delivered: sent > 0, failed, sent, staleTokens, succeededTokens };
|
|
40
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure FCM stale-token classification, multicast chunking, and fan-out
|
|
3
|
+
* aggregation. Kept separate from fcm-service.ts (which pulls in firebase-admin
|
|
4
|
+
* and needs credentials) so the prune decision is unit-testable in isolation.
|
|
5
|
+
*
|
|
6
|
+
* Rule (plan §8): ONLY `messaging/registration-token-not-registered` is a dead
|
|
7
|
+
* token. `messaging/invalid-argument` is explicitly NOT prunable — it usually
|
|
8
|
+
* signals a payload format bug affecting the whole batch, and pruning on it
|
|
9
|
+
* would silently wipe out every Android device.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { FcmDeliveryOutcome, FCMFanOutResult } from '$lib/types';
|
|
13
|
+
|
|
14
|
+
const PRUNABLE_FCM_CODES: ReadonlySet<string> = new Set([
|
|
15
|
+
'messaging/registration-token-not-registered',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Split into groups of at most `size`, preserving order. */
|
|
19
|
+
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
20
|
+
if (size <= 0) {
|
|
21
|
+
// `i += size` would never advance — guard the public API against a hang.
|
|
22
|
+
throw new RangeError('chunk size must be >= 1');
|
|
23
|
+
}
|
|
24
|
+
const out: T[][] = [];
|
|
25
|
+
for (let i = 0; i < items.length; i += size) {
|
|
26
|
+
out.push(items.slice(i, i + size));
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Decide whether one FCM error code means the token is dead ('prune') or should be kept. */
|
|
32
|
+
export function classifyFcmError(code: null | string): 'keep' | 'prune' {
|
|
33
|
+
return code && PRUNABLE_FCM_CODES.has(code) ? 'prune' : 'keep';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aggregate per-token FCM outcomes into success/failure counts and the prune-worthy stale-token list. */
|
|
37
|
+
export function summarizeFcmFanOut(outcomes: readonly FcmDeliveryOutcome[]): FCMFanOutResult {
|
|
38
|
+
const results: FCMFanOutResult['results'] = [];
|
|
39
|
+
const staleTokens: string[] = [];
|
|
40
|
+
let successCount = 0;
|
|
41
|
+
let failureCount = 0;
|
|
42
|
+
|
|
43
|
+
for (const o of outcomes) {
|
|
44
|
+
if (o.success) {
|
|
45
|
+
successCount += 1;
|
|
46
|
+
} else {
|
|
47
|
+
failureCount += 1;
|
|
48
|
+
if (classifyFcmError(o.errorCode) === 'prune') {
|
|
49
|
+
staleTokens.push(o.token);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
results.push({
|
|
53
|
+
error: o.errorCode,
|
|
54
|
+
messageId: o.messageId,
|
|
55
|
+
success: o.success,
|
|
56
|
+
token: o.token,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { failureCount, results, staleTokens, successCount };
|
|
61
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type { NotificationPayload } from '$lib/types';
|
|
1
|
+
import type { FcmDeliveryOutcome, FCMFanOutResult, NotificationPayload } from '$lib/types';
|
|
2
2
|
|
|
3
3
|
import admin from 'firebase-admin';
|
|
4
4
|
|
|
5
|
+
import { maskToken } from '../push/device-format.js';
|
|
6
|
+
import { chunk, summarizeFcmFanOut } from './fcm-classify.js';
|
|
7
|
+
|
|
5
8
|
let app: admin.app.App | null = null;
|
|
6
9
|
|
|
10
|
+
// FCM multicast accepts up to 500 tokens; 100 keeps each HTTP/2 batch small.
|
|
11
|
+
const FCM_MULTICAST_CHUNK = 100;
|
|
12
|
+
|
|
7
13
|
export function isFCMConfigured(): boolean {
|
|
8
14
|
return !!(
|
|
9
15
|
process.env.FCM_PROJECT_ID &&
|
|
@@ -12,40 +18,17 @@ export function isFCMConfigured(): boolean {
|
|
|
12
18
|
);
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Send one data push to a single device. Thin backward-compat wrapper retained
|
|
23
|
+
* for existing callers; multi-device fan-out goes through sendFCMNotificationMulti.
|
|
24
|
+
*/
|
|
15
25
|
export async function sendFCMNotification(
|
|
16
26
|
deviceToken: string,
|
|
17
27
|
payload: NotificationPayload
|
|
18
28
|
): Promise<{ error?: string; messageId?: string; success: boolean }> {
|
|
19
29
|
try {
|
|
20
30
|
const fcmApp = getApp();
|
|
21
|
-
|
|
22
|
-
// Use DATA-ONLY messages (not notification messages)
|
|
23
|
-
// This is critical: notification messages are auto-displayed by Android
|
|
24
|
-
// and cannot have custom action buttons. Data messages always reach
|
|
25
|
-
// onMessageReceived() giving the app full control.
|
|
26
|
-
const message: admin.messaging.Message = {
|
|
27
|
-
android: {
|
|
28
|
-
priority: 'high', // Ensures delivery even in Doze mode
|
|
29
|
-
ttl: 300000, // 5 minutes TTL (matches pending request expiry)
|
|
30
|
-
},
|
|
31
|
-
data: {
|
|
32
|
-
body: payload.body || payload.message || '',
|
|
33
|
-
category:
|
|
34
|
-
payload.category ??
|
|
35
|
-
(typeof payload.data?.category === 'string' ? payload.data.category : ''),
|
|
36
|
-
project: typeof payload.data?.project === 'string' ? payload.data.project : '',
|
|
37
|
-
requestId: typeof payload.data?.requestId === 'string' ? payload.data.requestId : '',
|
|
38
|
-
source: typeof payload.data?.source === 'string' ? payload.data.source : '',
|
|
39
|
-
subtitle: payload.subtitle ?? '',
|
|
40
|
-
timestamp: new Date().toISOString(),
|
|
41
|
-
title: payload.title,
|
|
42
|
-
toolInput: payload.data?.toolInput ? JSON.stringify(payload.data.toolInput) : '',
|
|
43
|
-
toolName: typeof payload.data?.toolName === 'string' ? payload.data.toolName : '',
|
|
44
|
-
type: typeof payload.data?.type === 'string' ? payload.data.type : '',
|
|
45
|
-
},
|
|
46
|
-
token: deviceToken,
|
|
47
|
-
};
|
|
48
|
-
|
|
31
|
+
const message: admin.messaging.Message = { ...buildDataMessage(payload), token: deviceToken };
|
|
49
32
|
const messageId = await admin.messaging(fcmApp).send(message);
|
|
50
33
|
return { messageId, success: true };
|
|
51
34
|
} catch (error) {
|
|
@@ -55,6 +38,122 @@ export async function sendFCMNotification(
|
|
|
55
38
|
}
|
|
56
39
|
}
|
|
57
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Fan out one data push to many devices via sendEachForMulticast (one HTTP call
|
|
43
|
+
* per 100-token chunk). Returns per-token results + the set of stale tokens
|
|
44
|
+
* (only `registration-token-not-registered`) for the caller to prune.
|
|
45
|
+
*/
|
|
46
|
+
export async function sendFCMNotificationMulti(
|
|
47
|
+
tokens: readonly string[],
|
|
48
|
+
payload: NotificationPayload
|
|
49
|
+
): Promise<FCMFanOutResult> {
|
|
50
|
+
if (tokens.length === 0) {
|
|
51
|
+
return { failureCount: 0, results: [], staleTokens: [], successCount: 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let fcmApp: admin.app.App;
|
|
55
|
+
try {
|
|
56
|
+
fcmApp = getApp();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// FCM not configured / init failure — return a structured all-failed result
|
|
59
|
+
// rather than throwing, matching sendFCMNotification's contract so callers
|
|
60
|
+
// always get an FCMFanOutResult instead of an unhandled rejection.
|
|
61
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
62
|
+
console.error('[FCM] multi-send setup failed:', fcmErrorLabel(error));
|
|
63
|
+
return {
|
|
64
|
+
failureCount: tokens.length,
|
|
65
|
+
results: tokens.map((token) => ({ error: msg, messageId: null, success: false, token })),
|
|
66
|
+
staleTokens: [],
|
|
67
|
+
successCount: 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const base = buildDataMessage(payload);
|
|
71
|
+
const outcomes: FcmDeliveryOutcome[] = [];
|
|
72
|
+
|
|
73
|
+
for (const group of chunk(tokens, FCM_MULTICAST_CHUNK)) {
|
|
74
|
+
try {
|
|
75
|
+
const batch = await admin
|
|
76
|
+
.messaging(fcmApp)
|
|
77
|
+
.sendEachForMulticast({ ...base, tokens: [...group] });
|
|
78
|
+
batch.responses.forEach((resp, i) => {
|
|
79
|
+
const errorCode = resp.error?.code ?? null;
|
|
80
|
+
if (errorCode === 'messaging/invalid-argument') {
|
|
81
|
+
// Per-token invalid-argument = this specific token string is malformed
|
|
82
|
+
// (wrong length/characters), NOT a batch payload bug (that would throw
|
|
83
|
+
// and be caught below). Surface it, but never prune — the token may be
|
|
84
|
+
// valid once its registration data is corrected.
|
|
85
|
+
// Log the structured error code + masked token (never the raw FCM
|
|
86
|
+
// message, which is free-text and could grow more verbose across SDK
|
|
87
|
+
// versions) so diagnostics stay token-safe.
|
|
88
|
+
console.error(`[FCM] invalid-argument for token ${maskToken(group[i])}: ${errorCode}`);
|
|
89
|
+
}
|
|
90
|
+
outcomes.push({
|
|
91
|
+
errorCode,
|
|
92
|
+
messageId: resp.messageId ?? null,
|
|
93
|
+
success: resp.success,
|
|
94
|
+
token: group[i],
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Whole-chunk failure (auth, network) — count as failures, never stale.
|
|
99
|
+
console.error('[FCM] multicast chunk failed:', fcmErrorLabel(error));
|
|
100
|
+
for (const token of group) {
|
|
101
|
+
outcomes.push({ errorCode: null, messageId: null, success: false, token });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return summarizeFcmFanOut(outcomes);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the shared DATA-ONLY message (no token). Data messages always reach
|
|
111
|
+
* onMessageReceived() so the Android app keeps full control over rendering and
|
|
112
|
+
* action buttons (notification messages are auto-displayed and can't).
|
|
113
|
+
*/
|
|
114
|
+
function buildDataMessage(
|
|
115
|
+
payload: NotificationPayload
|
|
116
|
+
): Pick<admin.messaging.MulticastMessage, 'android' | 'data'> {
|
|
117
|
+
return {
|
|
118
|
+
android: {
|
|
119
|
+
priority: 'high', // Ensures delivery even in Doze mode
|
|
120
|
+
ttl: 300000, // 5 minutes TTL (matches pending request expiry)
|
|
121
|
+
},
|
|
122
|
+
data: {
|
|
123
|
+
body: payload.body || payload.message || '',
|
|
124
|
+
category:
|
|
125
|
+
payload.category ??
|
|
126
|
+
(typeof payload.data?.category === 'string' ? payload.data.category : ''),
|
|
127
|
+
project: typeof payload.data?.project === 'string' ? payload.data.project : '',
|
|
128
|
+
requestId: typeof payload.data?.requestId === 'string' ? payload.data.requestId : '',
|
|
129
|
+
source: typeof payload.data?.source === 'string' ? payload.data.source : '',
|
|
130
|
+
subtitle: payload.subtitle ?? '',
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
title: payload.title,
|
|
133
|
+
toolInput: payload.data?.toolInput ? JSON.stringify(payload.data.toolInput) : '',
|
|
134
|
+
toolName: typeof payload.data?.toolName === 'string' ? payload.data.toolName : '',
|
|
135
|
+
type: typeof payload.data?.type === 'string' ? payload.data.type : '',
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Log-safe label for an FCM error: prefer the Firebase SDK's structured `.code`
|
|
142
|
+
* (e.g. 'app/invalid-credential', 'messaging/authentication-error') over its
|
|
143
|
+
* free-text `.message`, which can echo project/config details. Plain Errors
|
|
144
|
+
* (e.g. our own "FCM not configured: missing FCM_*" — env-var names only) fall
|
|
145
|
+
* back to the message.
|
|
146
|
+
*/
|
|
147
|
+
function fcmErrorLabel(error: unknown): string {
|
|
148
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
149
|
+
const code = (error as { code?: unknown }).code;
|
|
150
|
+
if (typeof code === 'string' && code.length > 0) {
|
|
151
|
+
return code;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return error instanceof Error ? error.message : String(error);
|
|
155
|
+
}
|
|
156
|
+
|
|
58
157
|
function getApp(): admin.app.App {
|
|
59
158
|
if (!app) {
|
|
60
159
|
const projectId = process.env.FCM_PROJECT_ID;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation helpers for the device registry HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* `maskToken` keeps a short prefix + suffix and hides the middle so the
|
|
5
|
+
* registered-devices list (and any log of it) never carries a full APNs/FCM
|
|
6
|
+
* push token. `toDeviceListItem` maps an internal DeviceRecord to the masked
|
|
7
|
+
* API shape returned by GET /api/device-token — deliberately dropping the raw
|
|
8
|
+
* `token` field.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DeviceListItem, DeviceRecord } from '$lib/types';
|
|
12
|
+
|
|
13
|
+
export function maskToken(token: null | string | undefined): string {
|
|
14
|
+
if (!token) {
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
if (token.length < 8) {
|
|
18
|
+
// Too short to reveal prefix + suffix without the slices overlapping (a
|
|
19
|
+
// 4-char token would be fully exposed) — mask entirely. Real APNs (64) /
|
|
20
|
+
// FCM (~152) tokens never reach this branch.
|
|
21
|
+
return '••••';
|
|
22
|
+
}
|
|
23
|
+
if (token.length <= 10) {
|
|
24
|
+
return `${token.slice(0, 2)}…${token.slice(-2)}`;
|
|
25
|
+
}
|
|
26
|
+
return `${token.slice(0, 6)}…${token.slice(-4)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toDeviceListItem(record: DeviceRecord): DeviceListItem {
|
|
30
|
+
return {
|
|
31
|
+
appEnv: record.appEnv,
|
|
32
|
+
deviceId: record.deviceId,
|
|
33
|
+
failureCount: record.failureCount,
|
|
34
|
+
friendlyName: record.friendlyName,
|
|
35
|
+
id: record.id,
|
|
36
|
+
isActive: record.isActive,
|
|
37
|
+
lastSeenAt: record.lastSeenAt,
|
|
38
|
+
platform: record.platform,
|
|
39
|
+
registeredAt: record.registeredAt,
|
|
40
|
+
tokenMasked: maskToken(record.token),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Token Store — SQLite registry for multi-device push notifications.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the flat `~/.shooter/device-tokens.json` ({ ios?, android? }) with
|
|
5
|
+
* a `device_tokens` table in the existing `~/.shooter/shooter.db`. Supports any
|
|
6
|
+
* number of devices per platform, idempotent upsert with token rotation keyed
|
|
7
|
+
* by a stable deviceId, lazy stale-token pruning, a 30-day inactive-row
|
|
8
|
+
* cleanup, and migration from the legacy JSON file + a setup-wizard seed file.
|
|
9
|
+
*
|
|
10
|
+
* Same better-sqlite3 + WAL + globalThis-singleton pattern as terminal-store.ts.
|
|
11
|
+
* Timestamps are injectable (`now`) for deterministic tests — the presence-store
|
|
12
|
+
* idiom. Migration/cleanup are explicit methods (called from server.ts at
|
|
13
|
+
* startup in a later PR), not constructor side effects, so importing the module
|
|
14
|
+
* never touches the registry.
|
|
15
|
+
*
|
|
16
|
+
* Deferred to PR 3 (added where first used, inside APNs sendToMany): a
|
|
17
|
+
* `getAppEnv(token)` accessor and `failure_count` accumulation. `pruneByTokens`
|
|
18
|
+
* here only soft-deactivates; the failure-count threshold lives with PR 3.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AppEnv, DevicePlatform, DeviceRecord, DeviceUpsertInput } from '$lib/types';
|
|
22
|
+
|
|
23
|
+
import Database from 'better-sqlite3';
|
|
24
|
+
import { randomUUID } from 'crypto';
|
|
25
|
+
import * as fs from 'fs';
|
|
26
|
+
import * as path from 'path';
|
|
27
|
+
|
|
28
|
+
// `isDeviceRecord` is a runtime VALUE, imported relatively (not via the `$lib`
|
|
29
|
+
// alias) so this module loads under tsx in server.ts at startup — the container
|
|
30
|
+
// runs `node --import tsx server.ts`, where the SvelteKit `$lib` alias is
|
|
31
|
+
// unresolvable. Type-only `$lib/types` imports above are erased, so they're fine.
|
|
32
|
+
import { isDeviceRecord } from '../../../types/device.js';
|
|
33
|
+
import { shooterDataDir } from '../utils/shooter-home.js';
|
|
34
|
+
|
|
35
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
// Defensive upper bounds on caller-supplied strings. Real values are far smaller
|
|
38
|
+
// (APNs tokens ~64 hex, FCM ~152 chars; ids/names/bundle-ids are short), so these
|
|
39
|
+
// only reject pathological input that would bloat a SQLite row — they never affect
|
|
40
|
+
// a legitimate client. Enforced at the store boundary and the HTTP route.
|
|
41
|
+
export const MAX_TOKEN_LENGTH = 512;
|
|
42
|
+
export const MAX_DEVICE_ID_LENGTH = 256;
|
|
43
|
+
export const MAX_NAME_LENGTH = 256;
|
|
44
|
+
export const MAX_BUNDLE_ID_LENGTH = 256;
|
|
45
|
+
|
|
46
|
+
export class DeviceTokenStore {
|
|
47
|
+
private dataDir: string;
|
|
48
|
+
private db: Database.Database;
|
|
49
|
+
|
|
50
|
+
constructor(dataDir: string = shooterDataDir()) {
|
|
51
|
+
this.dataDir = dataDir;
|
|
52
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
this.db = new Database(path.join(dataDir, 'shooter.db'));
|
|
55
|
+
this.db.pragma('journal_mode = WAL');
|
|
56
|
+
|
|
57
|
+
this.db.exec(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS device_tokens (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
token TEXT NOT NULL,
|
|
61
|
+
platform TEXT NOT NULL CHECK(platform IN ('ios', 'android')),
|
|
62
|
+
app_env TEXT NOT NULL DEFAULT 'sandbox'
|
|
63
|
+
CHECK(app_env IN ('sandbox', 'production')),
|
|
64
|
+
device_id TEXT,
|
|
65
|
+
friendly_name TEXT,
|
|
66
|
+
bundle_id TEXT,
|
|
67
|
+
registered_at TEXT NOT NULL,
|
|
68
|
+
last_seen_at TEXT NOT NULL,
|
|
69
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
71
|
+
UNIQUE(token),
|
|
72
|
+
UNIQUE(device_id, platform)
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_device_tokens_active
|
|
75
|
+
ON device_tokens(platform, is_active);
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): void {
|
|
80
|
+
this.db.close();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Soft-delete a device by id (sets is_active = 0; auditable until cleanup). */
|
|
84
|
+
deleteById(id: string): number {
|
|
85
|
+
return this.db.prepare('UPDATE device_tokens SET is_active = 0 WHERE id = ?').run(id).changes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getByToken(token: string): DeviceRecord | null {
|
|
89
|
+
const row = this.db.prepare('SELECT * FROM device_tokens WHERE token = ?').get(token) as
|
|
90
|
+
| Record<string, unknown>
|
|
91
|
+
| undefined;
|
|
92
|
+
return row ? rowToRecord(row) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** All active tokens for a platform, most-recently-seen first. */
|
|
96
|
+
listActive(platform: DevicePlatform): DeviceRecord[] {
|
|
97
|
+
const rows = this.db
|
|
98
|
+
.prepare(
|
|
99
|
+
'SELECT * FROM device_tokens WHERE platform = ? AND is_active = 1 ORDER BY last_seen_at DESC'
|
|
100
|
+
)
|
|
101
|
+
.all(platform) as Record<string, unknown>[];
|
|
102
|
+
return rows.map(rowToRecord);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Active tokens for a platform filtered by APNs gateway env. */
|
|
106
|
+
listActiveForEnv(platform: DevicePlatform, appEnv: AppEnv): DeviceRecord[] {
|
|
107
|
+
const rows = this.db
|
|
108
|
+
.prepare(
|
|
109
|
+
`SELECT * FROM device_tokens
|
|
110
|
+
WHERE platform = ? AND app_env = ? AND is_active = 1
|
|
111
|
+
ORDER BY last_seen_at DESC`
|
|
112
|
+
)
|
|
113
|
+
.all(platform, appEnv) as Record<string, unknown>[];
|
|
114
|
+
return rows.map(rowToRecord);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Import tokens from the legacy device-tokens.json and the setup-wizard
|
|
119
|
+
* device-token-seeds.json, then rename the consumed files. Idempotent: the
|
|
120
|
+
* rename prevents re-import, and INSERT OR IGNORE prevents UNIQUE(token) dups.
|
|
121
|
+
*/
|
|
122
|
+
migrate(now: Date = new Date()): void {
|
|
123
|
+
this.importFile(path.join(this.dataDir, 'device-tokens.json'), '.migrated', now);
|
|
124
|
+
this.importFile(path.join(this.dataDir, 'device-token-seeds.json'), '.processed', now);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Soft-delete a set of dead tokens (lazy pruning after a failed delivery). */
|
|
128
|
+
pruneByTokens(tokens: readonly string[]): number {
|
|
129
|
+
if (tokens.length === 0) {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
const placeholders = tokens.map(() => '?').join(', ');
|
|
133
|
+
return this.db
|
|
134
|
+
.prepare(`UPDATE device_tokens SET is_active = 0 WHERE token IN (${placeholders})`)
|
|
135
|
+
.run(...tokens).changes;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Hard-delete inactive rows whose last_seen_at is older than 30 days.
|
|
140
|
+
* Active rows are NEVER deleted by age — a device that is still on but
|
|
141
|
+
* hasn't re-registered should keep receiving notifications.
|
|
142
|
+
*/
|
|
143
|
+
startupCleanup(now: Date = new Date()): number {
|
|
144
|
+
const cutoff = new Date(now.getTime() - THIRTY_DAYS_MS).toISOString();
|
|
145
|
+
return this.db
|
|
146
|
+
.prepare('DELETE FROM device_tokens WHERE is_active = 0 AND last_seen_at < ?')
|
|
147
|
+
.run(cutoff).changes;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Bump last_seen_at for tokens that just delivered successfully. */
|
|
151
|
+
touchLastSeen(tokens: readonly string[], now: Date = new Date()): number {
|
|
152
|
+
if (tokens.length === 0) {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
const placeholders = tokens.map(() => '?').join(', ');
|
|
156
|
+
return this.db
|
|
157
|
+
.prepare(`UPDATE device_tokens SET last_seen_at = ? WHERE token IN (${placeholders})`)
|
|
158
|
+
.run(now.toISOString(), ...tokens).changes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register or refresh a device. Three cases, resolved in one transaction:
|
|
163
|
+
* 1. Known device (deviceId matches an existing row) → rotate token in place.
|
|
164
|
+
* If the incoming token is currently held by a DIFFERENT row, APNs has
|
|
165
|
+
* recycled it to us, so that stale row is hard-deleted first — otherwise
|
|
166
|
+
* the rotation UPDATE would violate UNIQUE(token). (A soft-delete would
|
|
167
|
+
* NOT free the constraint: the token value stays in the inactive row.)
|
|
168
|
+
* 2. New legacy device (no deviceId) → deactivate prior null-device rows for
|
|
169
|
+
* the platform (can't tell them apart) then insert/refresh by token.
|
|
170
|
+
* 3. New device (deviceId not yet known) → insert; on token conflict, the
|
|
171
|
+
* ownership WHERE guard gates the WHOLE update: an unknown device cannot
|
|
172
|
+
* steal OR clobber the metadata of a token already owned by another active
|
|
173
|
+
* device (the conflict resolves to a no-op, leaving that row untouched).
|
|
174
|
+
*/
|
|
175
|
+
upsert(input: DeviceUpsertInput, now: Date = new Date()): DeviceRecord {
|
|
176
|
+
const ts = now.toISOString();
|
|
177
|
+
// Old apps omit appEnv → default to the server's configured APNs gateway
|
|
178
|
+
// (matches importTokenMap and the spec), NOT a hardcoded 'sandbox' — else a
|
|
179
|
+
// production server would file old-app tokens under 'sandbox' and the
|
|
180
|
+
// listActiveForEnv('ios','production') fan-out would silently skip them.
|
|
181
|
+
const appEnv: AppEnv =
|
|
182
|
+
input.appEnv ?? (process.env.APNS_PRODUCTION === 'true' ? 'production' : 'sandbox');
|
|
183
|
+
const deviceId = input.deviceId ?? null;
|
|
184
|
+
const friendlyName = input.friendlyName ?? null;
|
|
185
|
+
const bundleId = input.bundleId ?? null;
|
|
186
|
+
const { platform } = input;
|
|
187
|
+
// Normalize at the store boundary so a caller passing an un-trimmed token
|
|
188
|
+
// can't create a logically-duplicate row (whitespace) that would poison
|
|
189
|
+
// fan-out/pruning. The HTTP route already trims, but the store shouldn't
|
|
190
|
+
// trust every caller.
|
|
191
|
+
const token = input.token.trim();
|
|
192
|
+
if (token.length === 0) {
|
|
193
|
+
throw new Error('DeviceTokenStore.upsert: token must be a non-empty string');
|
|
194
|
+
}
|
|
195
|
+
// Reject pathologically large fields so a single row can't exhaust storage.
|
|
196
|
+
if (token.length > MAX_TOKEN_LENGTH) {
|
|
197
|
+
throw new Error(`DeviceTokenStore.upsert: token exceeds ${MAX_TOKEN_LENGTH} chars`);
|
|
198
|
+
}
|
|
199
|
+
if (deviceId !== null && deviceId.length > MAX_DEVICE_ID_LENGTH) {
|
|
200
|
+
throw new Error(`DeviceTokenStore.upsert: deviceId exceeds ${MAX_DEVICE_ID_LENGTH} chars`);
|
|
201
|
+
}
|
|
202
|
+
if (friendlyName !== null && friendlyName.length > MAX_NAME_LENGTH) {
|
|
203
|
+
throw new Error(`DeviceTokenStore.upsert: friendlyName exceeds ${MAX_NAME_LENGTH} chars`);
|
|
204
|
+
}
|
|
205
|
+
if (bundleId !== null && bundleId.length > MAX_BUNDLE_ID_LENGTH) {
|
|
206
|
+
throw new Error(`DeviceTokenStore.upsert: bundleId exceeds ${MAX_BUNDLE_ID_LENGTH} chars`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.db.transaction(() => {
|
|
210
|
+
if (deviceId !== null) {
|
|
211
|
+
const ownRow = this.db
|
|
212
|
+
.prepare('SELECT id FROM device_tokens WHERE device_id = ? AND platform = ?')
|
|
213
|
+
.get(deviceId, platform) as undefined | { id: string };
|
|
214
|
+
if (ownRow) {
|
|
215
|
+
// Retire any other row holding the (possibly recycled) incoming token
|
|
216
|
+
// before the rotation UPDATE touches UNIQUE(token). Tokens are globally
|
|
217
|
+
// unique, so at most one other row can hold it; if APNs gave it to us,
|
|
218
|
+
// its previous owner is dead.
|
|
219
|
+
this.db
|
|
220
|
+
.prepare('DELETE FROM device_tokens WHERE token = ? AND id != ?')
|
|
221
|
+
.run(token, ownRow.id);
|
|
222
|
+
this.db
|
|
223
|
+
.prepare(
|
|
224
|
+
`UPDATE device_tokens SET
|
|
225
|
+
token = ?,
|
|
226
|
+
last_seen_at = ?,
|
|
227
|
+
app_env = ?,
|
|
228
|
+
failure_count = 0,
|
|
229
|
+
is_active = 1,
|
|
230
|
+
friendly_name = COALESCE(?, friendly_name),
|
|
231
|
+
bundle_id = COALESCE(?, bundle_id)
|
|
232
|
+
WHERE id = ?`
|
|
233
|
+
)
|
|
234
|
+
.run(token, ts, appEnv, friendlyName, bundleId, ownRow.id);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// No row for this device yet → fall through to the token-keyed INSERT,
|
|
238
|
+
// whose CASE guard preserves an existing token owner (identity theft).
|
|
239
|
+
} else {
|
|
240
|
+
// Legacy device: collapse indistinguishable null-device rows so an old
|
|
241
|
+
// app's token rotation doesn't leave a dead row drawing duplicate pushes.
|
|
242
|
+
this.db
|
|
243
|
+
.prepare(
|
|
244
|
+
`UPDATE device_tokens SET is_active = 0
|
|
245
|
+
WHERE platform = ? AND device_id IS NULL AND token != ? AND is_active = 1`
|
|
246
|
+
)
|
|
247
|
+
.run(platform, token);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.db
|
|
251
|
+
.prepare(
|
|
252
|
+
`INSERT INTO device_tokens
|
|
253
|
+
(id, token, platform, app_env, device_id, friendly_name, bundle_id,
|
|
254
|
+
registered_at, last_seen_at, failure_count, is_active)
|
|
255
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1)
|
|
256
|
+
ON CONFLICT(token) DO UPDATE SET
|
|
257
|
+
last_seen_at = excluded.last_seen_at,
|
|
258
|
+
app_env = excluded.app_env,
|
|
259
|
+
failure_count = 0,
|
|
260
|
+
is_active = 1,
|
|
261
|
+
friendly_name = COALESCE(excluded.friendly_name, device_tokens.friendly_name),
|
|
262
|
+
bundle_id = COALESCE(excluded.bundle_id, device_tokens.bundle_id),
|
|
263
|
+
device_id = excluded.device_id
|
|
264
|
+
WHERE device_tokens.device_id IS NULL
|
|
265
|
+
OR device_tokens.device_id = excluded.device_id
|
|
266
|
+
OR device_tokens.is_active = 0`
|
|
267
|
+
)
|
|
268
|
+
.run(randomUUID(), token, platform, appEnv, deviceId, friendlyName, bundleId, ts, ts);
|
|
269
|
+
})();
|
|
270
|
+
|
|
271
|
+
const rec = this.getByToken(token);
|
|
272
|
+
if (!rec) {
|
|
273
|
+
throw new Error('DeviceTokenStore.upsert: row not found after write');
|
|
274
|
+
}
|
|
275
|
+
return rec;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private importFile(filePath: string, renameSuffix: string, now: Date): void {
|
|
279
|
+
if (!fs.existsSync(filePath)) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const raw: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
284
|
+
this.importTokenMap(raw, now);
|
|
285
|
+
fs.renameSync(filePath, filePath + renameSuffix);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// Corrupt/unreadable file: skip without crashing startup or losing data
|
|
288
|
+
// (left in place, harmlessly retried next startup).
|
|
289
|
+
console.warn(`[device-token-store] failed to import ${filePath}:`, err);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private importTokenMap(raw: unknown, now: Date): void {
|
|
294
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const obj = raw as Record<string, unknown>;
|
|
298
|
+
const ts = now.toISOString();
|
|
299
|
+
const appEnv: AppEnv = process.env.APNS_PRODUCTION === 'true' ? 'production' : 'sandbox';
|
|
300
|
+
const insert = this.db.prepare(
|
|
301
|
+
`INSERT OR IGNORE INTO device_tokens
|
|
302
|
+
(id, token, platform, app_env, device_id, friendly_name, bundle_id,
|
|
303
|
+
registered_at, last_seen_at, failure_count, is_active)
|
|
304
|
+
VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?, 0, 1)`
|
|
305
|
+
);
|
|
306
|
+
for (const platform of ['ios', 'android'] as const) {
|
|
307
|
+
const value = obj[platform];
|
|
308
|
+
const tokens = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
|
309
|
+
for (const tok of tokens) {
|
|
310
|
+
if (typeof tok !== 'string') {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const normalized = tok.trim();
|
|
314
|
+
if (normalized.length > 0) {
|
|
315
|
+
insert.run(randomUUID(), normalized, platform, appEnv, ts, ts);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function rowToRecord(row: Record<string, unknown>): DeviceRecord {
|
|
323
|
+
const rec: DeviceRecord = {
|
|
324
|
+
appEnv: row.app_env as AppEnv,
|
|
325
|
+
bundleId: (row.bundle_id as string) ?? null,
|
|
326
|
+
deviceId: (row.device_id as string) ?? null,
|
|
327
|
+
failureCount: row.failure_count as number,
|
|
328
|
+
friendlyName: (row.friendly_name as string) ?? null,
|
|
329
|
+
id: row.id as string,
|
|
330
|
+
isActive: row.is_active === 1,
|
|
331
|
+
lastSeenAt: row.last_seen_at as string,
|
|
332
|
+
platform: row.platform as DevicePlatform,
|
|
333
|
+
registeredAt: row.registered_at as string,
|
|
334
|
+
token: row.token as string,
|
|
335
|
+
};
|
|
336
|
+
// The columns are CHECK/NOT NULL-constrained, but validate the assembled
|
|
337
|
+
// record so a future schema drift or an unexpected NULL surfaces as a clear
|
|
338
|
+
// error instead of a malformed DeviceRecord that type-checks yet is wrong.
|
|
339
|
+
if (!isDeviceRecord(rec)) {
|
|
340
|
+
throw new Error(`DeviceTokenStore: row failed DeviceRecord validation (id=${String(row.id)})`);
|
|
341
|
+
}
|
|
342
|
+
return rec;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Singleton ────────────────────────────────────────────────────────
|
|
346
|
+
// globalThis-keyed so a single instance is shared across module loaders
|
|
347
|
+
// (same pattern as terminal-store / pty-manager). Schema-only side effect at
|
|
348
|
+
// import; migrate()/startupCleanup() are invoked explicitly at server startup.
|
|
349
|
+
|
|
350
|
+
const DTS_GLOBAL_KEY = '__shooter_device_token_store';
|
|
351
|
+
export const deviceTokenStore: DeviceTokenStore =
|
|
352
|
+
((globalThis as Record<string, unknown>)[DTS_GLOBAL_KEY] as DeviceTokenStore) ||
|
|
353
|
+
new DeviceTokenStore();
|
|
354
|
+
(globalThis as Record<string, unknown>)[DTS_GLOBAL_KEY] = deviceTokenStore;
|