@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
package/src/lib/types/apn.ts
CHANGED
|
@@ -17,8 +17,12 @@ export interface APNsSendResult {
|
|
|
17
17
|
details?: unknown[];
|
|
18
18
|
error?: string;
|
|
19
19
|
failed: number;
|
|
20
|
+
/** APNs HTTP status (200, 400, 410, …); 0 on a transport error. */
|
|
21
|
+
httpStatus?: number;
|
|
20
22
|
sent: number;
|
|
21
23
|
success: boolean;
|
|
24
|
+
/** Parsed `timestamp` from a 410 Unregistered body, in ms (for the prune guard). */
|
|
25
|
+
timestampMs?: number;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export interface LibraryResult {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Device registry types for multi-device push notification fan-out.
|
|
2
|
+
//
|
|
3
|
+
// Hand-written (not generated from specs/types/api.yaml) because the
|
|
4
|
+
// type-crafter generator currently truncates generated/API.ts before these
|
|
5
|
+
// types, and because the fan-out result shapes are richer than the flat
|
|
6
|
+
// interfaces YAML can express. Mirrors the pattern in decision.ts.
|
|
7
|
+
//
|
|
8
|
+
// NOTE: deliberately free of Node built-in imports (e.g. `crypto`) so the
|
|
9
|
+
// $lib/types barrel stays safe to import from client bundles. Row-id
|
|
10
|
+
// generation lives in the server-only DeviceTokenStore via crypto.randomUUID().
|
|
11
|
+
|
|
12
|
+
/** One device's raw APNs delivery outcome, before aggregation (PR 3 fan-out). */
|
|
13
|
+
export interface ApnsDeliveryOutcome {
|
|
14
|
+
appEnv: AppEnv;
|
|
15
|
+
httpStatus: number;
|
|
16
|
+
reason: null | string;
|
|
17
|
+
registeredAt: string;
|
|
18
|
+
timestampMs: number;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
/** Aggregate APNs fan-out result across all iOS tokens (PR 3). */
|
|
22
|
+
export interface APNsFanOutResult {
|
|
23
|
+
results: APNsTokenResult[];
|
|
24
|
+
staleTokens: string[];
|
|
25
|
+
totalFailed: number;
|
|
26
|
+
totalSent: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Outcome of classifying a single APNs delivery result for pruning. */
|
|
30
|
+
export type ApnsTokenDisposition = 'sent' | 'stale_token' | 'transient_error';
|
|
31
|
+
|
|
32
|
+
/** Per-token APNs delivery result (PR 3 fan-out). */
|
|
33
|
+
export interface APNsTokenResult {
|
|
34
|
+
disposition: ApnsTokenDisposition;
|
|
35
|
+
httpStatus: number;
|
|
36
|
+
reason: null | string;
|
|
37
|
+
success: boolean;
|
|
38
|
+
timestampMs: number;
|
|
39
|
+
token: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type AppEnv = 'production' | 'sandbox';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A registered device as exposed by GET /api/device-token. The raw push token
|
|
46
|
+
* is replaced by a masked form so the registered-devices UI/logs never carry a
|
|
47
|
+
* full token.
|
|
48
|
+
*/
|
|
49
|
+
export interface DeviceListItem {
|
|
50
|
+
appEnv: AppEnv;
|
|
51
|
+
deviceId: null | string;
|
|
52
|
+
failureCount: number;
|
|
53
|
+
friendlyName: null | string;
|
|
54
|
+
id: string;
|
|
55
|
+
isActive: boolean;
|
|
56
|
+
lastSeenAt: string;
|
|
57
|
+
platform: DevicePlatform;
|
|
58
|
+
registeredAt: string;
|
|
59
|
+
tokenMasked: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DevicePlatform = 'android' | 'ios';
|
|
63
|
+
|
|
64
|
+
/** One registered push device. Mirrors a `device_tokens` row (camelCased). */
|
|
65
|
+
export interface DeviceRecord {
|
|
66
|
+
appEnv: AppEnv;
|
|
67
|
+
bundleId: null | string;
|
|
68
|
+
deviceId: null | string;
|
|
69
|
+
failureCount: number;
|
|
70
|
+
friendlyName: null | string;
|
|
71
|
+
id: string;
|
|
72
|
+
isActive: boolean;
|
|
73
|
+
lastSeenAt: string;
|
|
74
|
+
platform: DevicePlatform;
|
|
75
|
+
registeredAt: string;
|
|
76
|
+
token: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fields accepted by DeviceTokenStore.upsert(); server fills the rest. */
|
|
80
|
+
export interface DeviceUpsertInput {
|
|
81
|
+
appEnv?: AppEnv;
|
|
82
|
+
bundleId?: null | string;
|
|
83
|
+
deviceId?: null | string;
|
|
84
|
+
friendlyName?: null | string;
|
|
85
|
+
platform: DevicePlatform;
|
|
86
|
+
token: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One device's raw FCM delivery outcome, before aggregation (PR 4 fan-out). */
|
|
90
|
+
export interface FcmDeliveryOutcome {
|
|
91
|
+
errorCode: null | string;
|
|
92
|
+
messageId: null | string;
|
|
93
|
+
success: boolean;
|
|
94
|
+
token: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Aggregate FCM fan-out result across all Android tokens (PR 4). */
|
|
98
|
+
export interface FCMFanOutResult {
|
|
99
|
+
failureCount: number;
|
|
100
|
+
results: FCMTokenResult[];
|
|
101
|
+
staleTokens: string[];
|
|
102
|
+
successCount: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Per-token FCM delivery result (PR 4 fan-out). */
|
|
106
|
+
export interface FCMTokenResult {
|
|
107
|
+
error: null | string;
|
|
108
|
+
messageId: null | string;
|
|
109
|
+
success: boolean;
|
|
110
|
+
token: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Shape returned by POST /api/notify after multi-device fan-out (PR 5). */
|
|
114
|
+
export interface MultiDeviceNotifyResult {
|
|
115
|
+
failed: number;
|
|
116
|
+
pruned: number;
|
|
117
|
+
requestId: string;
|
|
118
|
+
sent: number;
|
|
119
|
+
success: boolean;
|
|
120
|
+
timestamp: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Combined APNs + FCM delivery summary for one /api/notify fan-out (PR 5). */
|
|
124
|
+
export interface NotifyDeliverySummary {
|
|
125
|
+
delivered: boolean;
|
|
126
|
+
failed: number;
|
|
127
|
+
sent: number;
|
|
128
|
+
staleTokens: string[];
|
|
129
|
+
succeededTokens: string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Consecutive delivery failures before a token is considered dead. */
|
|
133
|
+
export const MAX_FAILURE_COUNT = 3;
|
|
134
|
+
|
|
135
|
+
/** Runtime guard for a DeviceRecord coming from an untrusted boundary. */
|
|
136
|
+
export function isDeviceRecord(v: unknown): v is DeviceRecord {
|
|
137
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const r = v as Record<string, unknown>;
|
|
141
|
+
const isNullableString = (x: unknown): x is null | string => x === null || typeof x === 'string';
|
|
142
|
+
return (
|
|
143
|
+
typeof r.id === 'string' &&
|
|
144
|
+
typeof r.token === 'string' &&
|
|
145
|
+
(r.platform === 'ios' || r.platform === 'android') &&
|
|
146
|
+
(r.appEnv === 'sandbox' || r.appEnv === 'production') &&
|
|
147
|
+
typeof r.registeredAt === 'string' &&
|
|
148
|
+
typeof r.lastSeenAt === 'string' &&
|
|
149
|
+
typeof r.isActive === 'boolean' &&
|
|
150
|
+
Number.isInteger(r.failureCount) &&
|
|
151
|
+
(r.failureCount as number) >= 0 &&
|
|
152
|
+
isNullableString(r.bundleId) &&
|
|
153
|
+
isNullableString(r.deviceId) &&
|
|
154
|
+
isNullableString(r.friendlyName)
|
|
155
|
+
);
|
|
156
|
+
}
|
package/src/lib/types/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { env } from '$env/dynamic/private';
|
|
2
2
|
import { LibraryAPNsService } from '$lib/modules/server/apn/library-apns';
|
|
3
3
|
import { validateAuth } from '$lib/modules/server/auth';
|
|
4
|
+
import { deviceTokenStore } from '$lib/modules/server/push/device-token-store';
|
|
4
5
|
import { json } from '@sveltejs/kit';
|
|
5
6
|
|
|
6
7
|
import type { RequestHandler } from './$types';
|
|
@@ -12,7 +13,14 @@ export const GET: RequestHandler = ({ request }) => {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const apnsClient = new LibraryAPNsService();
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
// Prefer the most-recently-seen active iOS device matching the server's APNs
|
|
18
|
+
// gateway (the same filter /api/notify uses), so the debug view never lists a
|
|
19
|
+
// wrong-env device that pushes silently skip; fall back to the legacy env var.
|
|
20
|
+
const apnsEnv: 'production' | 'sandbox' =
|
|
21
|
+
env.APNS_PRODUCTION === 'true' ? 'production' : 'sandbox';
|
|
22
|
+
const iosDevices = deviceTokenStore.listActiveForEnv('ios', apnsEnv);
|
|
23
|
+
const deviceToken = iosDevices[0]?.token ?? env.DEVICE_TOKEN?.trim() ?? '';
|
|
16
24
|
|
|
17
25
|
return json({
|
|
18
26
|
apns: {
|
|
@@ -30,6 +38,10 @@ export const GET: RequestHandler = ({ request }) => {
|
|
|
30
38
|
},
|
|
31
39
|
environment: env.NODE_ENV || 'development',
|
|
32
40
|
hasApiKey: !!env.API_KEY,
|
|
41
|
+
registeredDevices: {
|
|
42
|
+
android: deviceTokenStore.listActive('android').length,
|
|
43
|
+
ios: iosDevices.length,
|
|
44
|
+
},
|
|
33
45
|
timestamp: new Date().toISOString(),
|
|
34
46
|
});
|
|
35
47
|
};
|
|
@@ -1,36 +1,77 @@
|
|
|
1
|
+
import type { AppEnv } from '$lib/types';
|
|
2
|
+
|
|
3
|
+
import { env } from '$env/dynamic/private';
|
|
1
4
|
import { validateAuth } from '$lib/modules/server/auth';
|
|
5
|
+
import { toDeviceListItem } from '$lib/modules/server/push/device-format';
|
|
6
|
+
import {
|
|
7
|
+
deviceTokenStore,
|
|
8
|
+
MAX_BUNDLE_ID_LENGTH,
|
|
9
|
+
MAX_DEVICE_ID_LENGTH,
|
|
10
|
+
MAX_NAME_LENGTH,
|
|
11
|
+
MAX_TOKEN_LENGTH,
|
|
12
|
+
} from '$lib/modules/server/push/device-token-store';
|
|
2
13
|
import { json } from '@sveltejs/kit';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
import { join } from 'path';
|
|
6
14
|
|
|
7
15
|
import type { RequestHandler } from './$types';
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
function resolveAppEnv(requested: unknown): AppEnv {
|
|
18
|
+
if (requested === 'production' || requested === 'sandbox') {
|
|
19
|
+
return requested;
|
|
20
|
+
}
|
|
21
|
+
// Old apps omit appEnv → match the server's configured APNs gateway.
|
|
22
|
+
return env.APNS_PRODUCTION === 'true' ? 'production' : 'sandbox';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** List all registered devices (masked tokens) plus per-platform counts. */
|
|
26
|
+
export const GET: RequestHandler = ({ request }) => {
|
|
27
|
+
const authError = validateAuth(request);
|
|
28
|
+
if (authError) {
|
|
29
|
+
return authError;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ios = deviceTokenStore.listActive('ios');
|
|
33
|
+
const android = deviceTokenStore.listActive('android');
|
|
34
|
+
const devices = [...ios, ...android].map(toDeviceListItem);
|
|
35
|
+
|
|
36
|
+
return json({
|
|
37
|
+
counts: { android: android.length, ios: ios.length, total: devices.length },
|
|
38
|
+
devices,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Remove a registered device by its registry row id. */
|
|
43
|
+
export const DELETE: RequestHandler = async ({ request }) => {
|
|
44
|
+
const authError = validateAuth(request);
|
|
45
|
+
if (authError) {
|
|
46
|
+
return authError;
|
|
47
|
+
}
|
|
11
48
|
|
|
12
|
-
|
|
49
|
+
let body: unknown;
|
|
13
50
|
try {
|
|
14
|
-
|
|
15
|
-
const parsed: unknown = JSON.parse(readFileSync(TOKENS_FILE, 'utf-8'));
|
|
16
|
-
// Guard against valid-but-wrong JSON (null, array, number, string)
|
|
17
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
18
|
-
return {};
|
|
19
|
-
}
|
|
20
|
-
return parsed as { android?: string; ios?: string };
|
|
21
|
-
}
|
|
51
|
+
body = await request.json();
|
|
22
52
|
} catch {
|
|
23
|
-
|
|
53
|
+
return json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
24
54
|
}
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
27
55
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
const id =
|
|
57
|
+
body && typeof body === 'object' && !Array.isArray(body)
|
|
58
|
+
? (body as { id?: unknown }).id
|
|
59
|
+
: undefined;
|
|
60
|
+
if (typeof id !== 'string' || id.trim().length === 0) {
|
|
61
|
+
return json({ error: 'Missing required field: id' }, { status: 400 });
|
|
31
62
|
}
|
|
32
|
-
|
|
33
|
-
|
|
63
|
+
|
|
64
|
+
const removed = deviceTokenStore.deleteById(id.trim());
|
|
65
|
+
if (removed === 0) {
|
|
66
|
+
// Unknown id or already-removed device → 404 so a caller checking only the
|
|
67
|
+
// HTTP status can tell "already gone" apart from a real deletion.
|
|
68
|
+
return json({ error: 'Device not found or already removed', id: id.trim() }, { status: 404 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Registry is the single source of truth post-cutover (/api/notify reads it
|
|
72
|
+
// directly), so removing the row fully stops delivery — no legacy sink to sync.
|
|
73
|
+
return json({ removed, success: true });
|
|
74
|
+
};
|
|
34
75
|
|
|
35
76
|
export const POST: RequestHandler = async ({ request }) => {
|
|
36
77
|
const authError = validateAuth(request);
|
|
@@ -38,7 +79,15 @@ export const POST: RequestHandler = async ({ request }) => {
|
|
|
38
79
|
return authError;
|
|
39
80
|
}
|
|
40
81
|
|
|
41
|
-
let body: {
|
|
82
|
+
let body: {
|
|
83
|
+
appEnv?: string;
|
|
84
|
+
bundleId?: string;
|
|
85
|
+
deviceId?: string;
|
|
86
|
+
deviceName?: string;
|
|
87
|
+
deviceToken?: string;
|
|
88
|
+
platform: string;
|
|
89
|
+
token?: string;
|
|
90
|
+
};
|
|
42
91
|
try {
|
|
43
92
|
const parsed: unknown = await request.json();
|
|
44
93
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
@@ -63,25 +112,61 @@ export const POST: RequestHandler = async ({ request }) => {
|
|
|
63
112
|
return json({ error: 'Missing device token (deviceToken or token)' }, { status: 400 });
|
|
64
113
|
}
|
|
65
114
|
const token = rawToken.trim();
|
|
115
|
+
const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() || null : null;
|
|
116
|
+
const friendlyName = typeof body.deviceName === 'string' ? body.deviceName.trim() || null : null;
|
|
117
|
+
const bundleId = typeof body.bundleId === 'string' ? body.bundleId.trim() || null : null;
|
|
66
118
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
119
|
+
// Reject oversized fields up front with a clean 400 (the store enforces the
|
|
120
|
+
// same caps defensively). Limits are generous vs. real values, so no
|
|
121
|
+
// legitimate client is affected; they bound per-row storage from bad input.
|
|
122
|
+
const tooLong =
|
|
123
|
+
token.length > MAX_TOKEN_LENGTH
|
|
124
|
+
? 'token'
|
|
125
|
+
: deviceId && deviceId.length > MAX_DEVICE_ID_LENGTH
|
|
126
|
+
? 'deviceId'
|
|
127
|
+
: friendlyName && friendlyName.length > MAX_NAME_LENGTH
|
|
128
|
+
? 'deviceName'
|
|
129
|
+
: bundleId && bundleId.length > MAX_BUNDLE_ID_LENGTH
|
|
130
|
+
? 'bundleId'
|
|
131
|
+
: null;
|
|
132
|
+
if (tooLong) {
|
|
133
|
+
return json({ error: `Field "${tooLong}" exceeds maximum allowed length` }, { status: 400 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const appEnv = resolveAppEnv(body.appEnv);
|
|
137
|
+
|
|
138
|
+
// Register in the multi-device SQLite registry (one row per device). This is
|
|
139
|
+
// now the single source of truth — /api/notify reads it directly, so there is
|
|
140
|
+
// no longer a legacy JSON dual-write or a process.env.DEVICE_TOKEN mutation.
|
|
141
|
+
const record = deviceTokenStore.upsert({
|
|
142
|
+
appEnv,
|
|
143
|
+
bundleId,
|
|
144
|
+
deviceId,
|
|
145
|
+
friendlyName,
|
|
146
|
+
platform,
|
|
147
|
+
token,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// The store's anti-theft guard intentionally no-ops when the token is held by
|
|
151
|
+
// a different device, returning that device's row unchanged. If the row we got
|
|
152
|
+
// back isn't this caller's (its deviceId differs from the requested one — incl.
|
|
153
|
+
// a legacy null-deviceId caller hitting a token owned by a deviceId'd device),
|
|
154
|
+
// surface a 409 instead of leaking the other device's metadata as a misleading
|
|
155
|
+
// success.
|
|
156
|
+
if (record.deviceId !== deviceId) {
|
|
157
|
+
return json(
|
|
158
|
+
{ error: 'Token is already registered to a different active device' },
|
|
159
|
+
{ status: 409 }
|
|
160
|
+
);
|
|
80
161
|
}
|
|
81
162
|
|
|
82
|
-
console.log(
|
|
163
|
+
console.log(
|
|
164
|
+
`[device-token] Registered ${platform} device ${record.id} (token len ${token.length})`
|
|
165
|
+
);
|
|
83
166
|
|
|
84
167
|
return json({
|
|
168
|
+
deviceId: record.deviceId,
|
|
169
|
+
id: record.id,
|
|
85
170
|
platform,
|
|
86
171
|
success: true,
|
|
87
172
|
timestamp: new Date().toISOString(),
|
|
@@ -2,6 +2,7 @@ import type { FCMConfiguration, HealthChecks, HealthConfiguration, HealthStatus
|
|
|
2
2
|
|
|
3
3
|
import { env } from '$env/dynamic/private';
|
|
4
4
|
import { validateAuth } from '$lib/modules/server/auth';
|
|
5
|
+
import { deviceTokenStore } from '$lib/modules/server/push/device-token-store';
|
|
5
6
|
import { getProviderAvailability } from '$lib/modules/shared/providers';
|
|
6
7
|
import { json } from '@sveltejs/kit';
|
|
7
8
|
import { readFileSync } from 'fs';
|
|
@@ -36,11 +37,19 @@ export const GET: RequestHandler = ({ request, url }) => {
|
|
|
36
37
|
const hasClientEmail = !!env.FCM_CLIENT_EMAIL?.trim();
|
|
37
38
|
const hasPrivateKey = !!env.FCM_PRIVATE_KEY?.trim();
|
|
38
39
|
|
|
40
|
+
// Multi-device registry counts (replace the single-token env check).
|
|
41
|
+
const iosDeviceCount = deviceTokenStore.listActive('ios').length;
|
|
42
|
+
const androidDeviceCount = deviceTokenStore.listActive('android').length;
|
|
43
|
+
const registeredDeviceCount = iosDeviceCount + androidDeviceCount;
|
|
44
|
+
|
|
39
45
|
const checks: HealthChecks = {
|
|
40
46
|
hasApiKey: !!env.API_KEY?.trim(),
|
|
41
47
|
hasAPNsConfig: !!(env.APNS_KEY_ID?.trim() && env.APNS_TEAM_ID?.trim() && env.APNS_KEY?.trim()),
|
|
42
48
|
hasBundleId: !!env.APNS_BUNDLE_ID?.trim(),
|
|
43
|
-
|
|
49
|
+
// Backward-compat alias: true if any device is registered OR the legacy
|
|
50
|
+
// single-token env var is still set.
|
|
51
|
+
hasDeviceToken:
|
|
52
|
+
registeredDeviceCount > 0 || !!env.DEVICE_TOKEN?.trim() || !!env.ANDROID_DEVICE_TOKEN?.trim(),
|
|
44
53
|
hasFCMConfig: hasProjectId && hasClientEmail && hasPrivateKey,
|
|
45
54
|
};
|
|
46
55
|
|
|
@@ -68,7 +77,7 @@ export const GET: RequestHandler = ({ request, url }) => {
|
|
|
68
77
|
warnings.push('APNs not configured — iOS push notifications disabled');
|
|
69
78
|
}
|
|
70
79
|
if (!checks.hasDeviceToken) {
|
|
71
|
-
warnings.push('No
|
|
80
|
+
warnings.push('No devices registered — push notifications have no target');
|
|
72
81
|
}
|
|
73
82
|
if (!checks.hasFCMConfig) {
|
|
74
83
|
warnings.push('FCM not configured — Android push notifications disabled');
|
|
@@ -92,6 +101,11 @@ export const GET: RequestHandler = ({ request, url }) => {
|
|
|
92
101
|
},
|
|
93
102
|
checks,
|
|
94
103
|
configuration,
|
|
104
|
+
devices: {
|
|
105
|
+
android: androidDeviceCount,
|
|
106
|
+
ios: iosDeviceCount,
|
|
107
|
+
total: registeredDeviceCount,
|
|
108
|
+
},
|
|
95
109
|
environment: env.NODE_ENV || 'development',
|
|
96
110
|
status: 'healthy' as HealthStatus,
|
|
97
111
|
timestamp: new Date().toISOString(),
|