@juspay/shooter 1.24.2 → 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.
Files changed (163) hide show
  1. package/.claude/hooks/notifier.cjs +32 -4
  2. package/build/client/_app/immutable/assets/{4.D4EDSN4H.css → 4.ChO_hlLs.css} +1 -1
  3. package/build/client/_app/immutable/assets/4.ChO_hlLs.css.br +0 -0
  4. package/build/client/_app/immutable/assets/4.ChO_hlLs.css.gz +0 -0
  5. package/build/client/_app/immutable/chunks/{BBuzUXZ9.js → BstJSK2K.js} +1 -1
  6. package/build/client/_app/immutable/chunks/BstJSK2K.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/BstJSK2K.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/{Dqmsaccg.js → DINqYbXU.js} +1 -1
  9. package/build/client/_app/immutable/chunks/DINqYbXU.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/DINqYbXU.js.gz +0 -0
  11. package/build/client/_app/immutable/chunks/ssAhjWfF.js +3 -0
  12. package/build/client/_app/immutable/chunks/ssAhjWfF.js.br +0 -0
  13. package/build/client/_app/immutable/chunks/ssAhjWfF.js.gz +0 -0
  14. package/build/client/_app/immutable/entry/{app.Bu4nq_bk.js → app.BPK9s2o5.js} +2 -2
  15. package/build/client/_app/immutable/entry/app.BPK9s2o5.js.br +0 -0
  16. package/build/client/_app/immutable/entry/app.BPK9s2o5.js.gz +0 -0
  17. package/build/client/_app/immutable/entry/start.DFPHuGE3.js +1 -0
  18. package/build/client/_app/immutable/entry/start.DFPHuGE3.js.br +2 -0
  19. package/build/client/_app/immutable/entry/start.DFPHuGE3.js.gz +0 -0
  20. package/build/client/_app/immutable/nodes/{0.CnSSjKHX.js → 0.BEbzRcwm.js} +1 -1
  21. package/build/client/_app/immutable/nodes/0.BEbzRcwm.js.br +0 -0
  22. package/build/client/_app/immutable/nodes/0.BEbzRcwm.js.gz +0 -0
  23. package/build/client/_app/immutable/nodes/{1.D_5wZINa.js → 1.C_V0SOr9.js} +1 -1
  24. package/build/client/_app/immutable/nodes/1.C_V0SOr9.js.br +0 -0
  25. package/build/client/_app/immutable/nodes/1.C_V0SOr9.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{10.C_B2eMms.js → 10.6V-37_Rl.js} +1 -1
  27. package/build/client/_app/immutable/nodes/10.6V-37_Rl.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/10.6V-37_Rl.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{11.DUKnn5ja.js → 11.Lzf-KC6L.js} +1 -1
  30. package/build/client/_app/immutable/nodes/11.Lzf-KC6L.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/11.Lzf-KC6L.js.gz +0 -0
  32. package/build/client/_app/immutable/nodes/{2.C37CZKYv.js → 2.Cl2dMP2L.js} +1 -1
  33. package/build/client/_app/immutable/nodes/2.Cl2dMP2L.js.br +0 -0
  34. package/build/client/_app/immutable/nodes/2.Cl2dMP2L.js.gz +0 -0
  35. package/build/client/_app/immutable/nodes/{3.C48G514u.js → 3.CuX2eSna.js} +1 -1
  36. package/build/client/_app/immutable/nodes/3.CuX2eSna.js.br +0 -0
  37. package/build/client/_app/immutable/nodes/3.CuX2eSna.js.gz +0 -0
  38. package/build/client/_app/immutable/nodes/4.2DvqoOaB.js +17 -0
  39. package/build/client/_app/immutable/nodes/4.2DvqoOaB.js.br +0 -0
  40. package/build/client/_app/immutable/nodes/4.2DvqoOaB.js.gz +0 -0
  41. package/build/client/_app/immutable/nodes/{6.DGlutNk6.js → 6.C7e6zQyP.js} +1 -1
  42. package/build/client/_app/immutable/nodes/6.C7e6zQyP.js.br +0 -0
  43. package/build/client/_app/immutable/nodes/6.C7e6zQyP.js.gz +0 -0
  44. package/build/client/_app/immutable/nodes/{7.BXAYsEHF.js → 7.1ygvNTnO.js} +1 -1
  45. package/build/client/_app/immutable/nodes/7.1ygvNTnO.js.br +0 -0
  46. package/build/client/_app/immutable/nodes/7.1ygvNTnO.js.gz +0 -0
  47. package/build/client/_app/immutable/nodes/{8.D-SxCq24.js → 8.CBVmgOk0.js} +1 -1
  48. package/build/client/_app/immutable/nodes/8.CBVmgOk0.js.br +0 -0
  49. package/build/client/_app/immutable/nodes/8.CBVmgOk0.js.gz +0 -0
  50. package/build/client/_app/immutable/nodes/{9.Bek2YR4U.js → 9.B-_ZFZhj.js} +1 -1
  51. package/build/client/_app/immutable/nodes/9.B-_ZFZhj.js.br +0 -0
  52. package/build/client/_app/immutable/nodes/9.B-_ZFZhj.js.gz +0 -0
  53. package/build/client/_app/version.json +1 -1
  54. package/build/client/_app/version.json.br +0 -0
  55. package/build/client/_app/version.json.gz +0 -0
  56. package/build/server/chunks/{0-ByOnI9Md.js → 0-BHU07xt9.js} +2 -2
  57. package/build/server/chunks/{0-ByOnI9Md.js.map → 0-BHU07xt9.js.map} +1 -1
  58. package/build/server/chunks/{1-DcJwehLu.js → 1-OVOd8GUH.js} +2 -2
  59. package/build/server/chunks/{1-DcJwehLu.js.map → 1-OVOd8GUH.js.map} +1 -1
  60. package/build/server/chunks/{10-B5bdfDfK.js → 10-CRHtvb_u.js} +2 -2
  61. package/build/server/chunks/{10-B5bdfDfK.js.map → 10-CRHtvb_u.js.map} +1 -1
  62. package/build/server/chunks/{11-DOJ9j7KC.js → 11-CdSex9j1.js} +2 -2
  63. package/build/server/chunks/{11-DOJ9j7KC.js.map → 11-CdSex9j1.js.map} +1 -1
  64. package/build/server/chunks/{2-BY0LGSMl.js → 2-bb78aIZ6.js} +2 -2
  65. package/build/server/chunks/{2-BY0LGSMl.js.map → 2-bb78aIZ6.js.map} +1 -1
  66. package/build/server/chunks/{3-DEa6RtDT.js → 3-CsTC6Lrn.js} +2 -2
  67. package/build/server/chunks/{3-DEa6RtDT.js.map → 3-CsTC6Lrn.js.map} +1 -1
  68. package/build/server/chunks/{4-w2W_T8ax.js → 4-DTTu8_Gr.js} +4 -4
  69. package/build/server/chunks/{4-w2W_T8ax.js.map → 4-DTTu8_Gr.js.map} +1 -1
  70. package/build/server/chunks/{6-DoTu6ygH.js → 6-BUgQGB_4.js} +2 -2
  71. package/build/server/chunks/{6-DoTu6ygH.js.map → 6-BUgQGB_4.js.map} +1 -1
  72. package/build/server/chunks/{7-CYQ9V6d4.js → 7-CsQZnkcG.js} +2 -2
  73. package/build/server/chunks/{7-CYQ9V6d4.js.map → 7-CsQZnkcG.js.map} +1 -1
  74. package/build/server/chunks/{8-DMwmCD5X.js → 8-DcIuPdyW.js} +2 -2
  75. package/build/server/chunks/{8-DMwmCD5X.js.map → 8-DcIuPdyW.js.map} +1 -1
  76. package/build/server/chunks/{9-CNmPa2Jo.js → 9-D8bkM1uj.js} +2 -2
  77. package/build/server/chunks/{9-CNmPa2Jo.js.map → 9-D8bkM1uj.js.map} +1 -1
  78. package/build/server/chunks/{_page.svelte-DDE2nChH.js → _page.svelte-BBbaKwNz.js} +101 -27
  79. package/build/server/chunks/_page.svelte-BBbaKwNz.js.map +1 -0
  80. package/build/server/chunks/{_server.ts-DfscXcFe.js → _server.ts-B7BLxK5u.js} +233 -186
  81. package/build/server/chunks/_server.ts-B7BLxK5u.js.map +1 -0
  82. package/build/server/chunks/{_server.ts-EJVmhLtg.js → _server.ts-BSS8cO80.js} +15 -3
  83. package/build/server/chunks/_server.ts-BSS8cO80.js.map +1 -0
  84. package/build/server/chunks/_server.ts-DNTxPoxO.js +115 -0
  85. package/build/server/chunks/_server.ts-DNTxPoxO.js.map +1 -0
  86. package/build/server/chunks/{_server.ts-D9_hkPQ6.js → _server.ts-DUb7fbuW.js} +17 -3
  87. package/build/server/chunks/_server.ts-DUb7fbuW.js.map +1 -0
  88. package/build/server/chunks/{_server.ts-v7TaT83B.js → _server.ts-FdKi8RwL.js} +7 -3
  89. package/build/server/chunks/_server.ts-FdKi8RwL.js.map +1 -0
  90. package/build/server/chunks/device-format-DTgEz4Yr.js +29 -0
  91. package/build/server/chunks/device-format-DTgEz4Yr.js.map +1 -0
  92. package/build/server/chunks/device-token-store-Ct7aTeR8.js +259 -0
  93. package/build/server/chunks/device-token-store-Ct7aTeR8.js.map +1 -0
  94. package/build/server/chunks/{library-apns-D8RPINlv.js → library-apns-DMlL1BAg.js} +158 -26
  95. package/build/server/chunks/library-apns-DMlL1BAg.js.map +1 -0
  96. package/build/server/index.js +1 -1
  97. package/build/server/index.js.map +1 -1
  98. package/build/server/manifest.js +17 -17
  99. package/build/server/manifest.js.map +1 -1
  100. package/package.json +2 -2
  101. package/server.ts +15 -1
  102. package/src/app.d.ts +4 -0
  103. package/src/lib/modules/server/apn/apns-classify.ts +103 -0
  104. package/src/lib/modules/server/apn/library-apns.ts +151 -35
  105. package/src/lib/modules/server/apn/notify-fanout.ts +40 -0
  106. package/src/lib/modules/server/fcm/fcm-classify.ts +61 -0
  107. package/src/lib/modules/server/fcm/fcm-service.ts +128 -29
  108. package/src/lib/modules/server/push/device-format.ts +42 -0
  109. package/src/lib/modules/server/push/device-token-store.ts +354 -0
  110. package/src/lib/types/apn.ts +4 -0
  111. package/src/lib/types/device.ts +156 -0
  112. package/src/lib/types/index.ts +1 -0
  113. package/src/routes/api/debug/+server.ts +13 -1
  114. package/src/routes/api/device-token/+server.ts +122 -37
  115. package/src/routes/api/health/+server.ts +16 -2
  116. package/src/routes/api/notify/+server.ts +175 -168
  117. package/src/routes/api/qr-config/+server.ts +9 -2
  118. package/src/routes/config/+page.svelte +182 -44
  119. package/build/client/_app/immutable/assets/4.D4EDSN4H.css.br +0 -0
  120. package/build/client/_app/immutable/assets/4.D4EDSN4H.css.gz +0 -0
  121. package/build/client/_app/immutable/chunks/BBuzUXZ9.js.br +0 -0
  122. package/build/client/_app/immutable/chunks/BBuzUXZ9.js.gz +0 -0
  123. package/build/client/_app/immutable/chunks/BPIV28L3.js +0 -3
  124. package/build/client/_app/immutable/chunks/BPIV28L3.js.br +0 -0
  125. package/build/client/_app/immutable/chunks/BPIV28L3.js.gz +0 -0
  126. package/build/client/_app/immutable/chunks/Dqmsaccg.js.br +0 -0
  127. package/build/client/_app/immutable/chunks/Dqmsaccg.js.gz +0 -0
  128. package/build/client/_app/immutable/entry/app.Bu4nq_bk.js.br +0 -0
  129. package/build/client/_app/immutable/entry/app.Bu4nq_bk.js.gz +0 -0
  130. package/build/client/_app/immutable/entry/start.CbpzR6Gp.js +0 -1
  131. package/build/client/_app/immutable/entry/start.CbpzR6Gp.js.br +0 -2
  132. package/build/client/_app/immutable/entry/start.CbpzR6Gp.js.gz +0 -0
  133. package/build/client/_app/immutable/nodes/0.CnSSjKHX.js.br +0 -0
  134. package/build/client/_app/immutable/nodes/0.CnSSjKHX.js.gz +0 -0
  135. package/build/client/_app/immutable/nodes/1.D_5wZINa.js.br +0 -0
  136. package/build/client/_app/immutable/nodes/1.D_5wZINa.js.gz +0 -0
  137. package/build/client/_app/immutable/nodes/10.C_B2eMms.js.br +0 -0
  138. package/build/client/_app/immutable/nodes/10.C_B2eMms.js.gz +0 -0
  139. package/build/client/_app/immutable/nodes/11.DUKnn5ja.js.br +0 -0
  140. package/build/client/_app/immutable/nodes/11.DUKnn5ja.js.gz +0 -0
  141. package/build/client/_app/immutable/nodes/2.C37CZKYv.js.br +0 -0
  142. package/build/client/_app/immutable/nodes/2.C37CZKYv.js.gz +0 -0
  143. package/build/client/_app/immutable/nodes/3.C48G514u.js.br +0 -0
  144. package/build/client/_app/immutable/nodes/3.C48G514u.js.gz +0 -0
  145. package/build/client/_app/immutable/nodes/4.C9fv_m2R.js +0 -16
  146. package/build/client/_app/immutable/nodes/4.C9fv_m2R.js.br +0 -0
  147. package/build/client/_app/immutable/nodes/4.C9fv_m2R.js.gz +0 -0
  148. package/build/client/_app/immutable/nodes/6.DGlutNk6.js.br +0 -0
  149. package/build/client/_app/immutable/nodes/6.DGlutNk6.js.gz +0 -0
  150. package/build/client/_app/immutable/nodes/7.BXAYsEHF.js.br +0 -0
  151. package/build/client/_app/immutable/nodes/7.BXAYsEHF.js.gz +0 -0
  152. package/build/client/_app/immutable/nodes/8.D-SxCq24.js.br +0 -0
  153. package/build/client/_app/immutable/nodes/8.D-SxCq24.js.gz +0 -0
  154. package/build/client/_app/immutable/nodes/9.Bek2YR4U.js.br +0 -0
  155. package/build/client/_app/immutable/nodes/9.Bek2YR4U.js.gz +0 -0
  156. package/build/server/chunks/_page.svelte-DDE2nChH.js.map +0 -1
  157. package/build/server/chunks/_server.ts-D2RS8TFd.js +0 -72
  158. package/build/server/chunks/_server.ts-D2RS8TFd.js.map +0 -1
  159. package/build/server/chunks/_server.ts-D9_hkPQ6.js.map +0 -1
  160. package/build/server/chunks/_server.ts-DfscXcFe.js.map +0 -1
  161. package/build/server/chunks/_server.ts-EJVmhLtg.js.map +0 -1
  162. package/build/server/chunks/_server.ts-v7TaT83B.js.map +0 -1
  163. package/build/server/chunks/library-apns-D8RPINlv.js.map +0 -1
@@ -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
+ }
@@ -9,6 +9,7 @@ export type * from './codex';
9
9
  export type * from './common';
10
10
  export type * from './dashboard';
11
11
  export * from './decision';
12
+ export * from './device';
12
13
  export type * from './gemini';
13
14
  export * from './generated';
14
15
  export type * from './neurolink';
@@ -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
- const deviceToken = env.DEVICE_TOKEN?.trim();
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
- const TOKENS_DIR = join(homedir(), '.shooter');
10
- const TOKENS_FILE = join(TOKENS_DIR, 'device-tokens.json');
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
- function readTokens(): { android?: string; ios?: string } {
49
+ let body: unknown;
13
50
  try {
14
- if (existsSync(TOKENS_FILE)) {
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
- // Corrupt file -- start fresh
53
+ return json({ error: 'Invalid JSON body' }, { status: 400 });
24
54
  }
25
- return {};
26
- }
27
55
 
28
- function writeTokens(tokens: { android?: string; ios?: string }): void {
29
- if (!existsSync(TOKENS_DIR)) {
30
- mkdirSync(TOKENS_DIR, { mode: 0o700, recursive: true });
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
- writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2), { encoding: 'utf-8', mode: 0o600 });
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: { bundleId?: string; deviceToken?: string; platform: string; token?: string };
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
- // Persist to ~/.shooter/device-tokens.json
68
- const tokens = readTokens();
69
- tokens[platform] = token;
70
- writeTokens(tokens);
71
-
72
- // Update in-memory env so APNs can use it immediately (iOS is the primary APNs target).
73
- // SvelteKit's $env/dynamic/private exposes a Proxy whose getter reads process.env at
74
- // access time but whose setter does NOT propagate to process.env. Assigning via the
75
- // Proxy is a silent no-op, so subsequent /api/notify calls still read the stale value
76
- // from .env. Write straight to process.env so env.DEVICE_TOKEN picks up the new token
77
- // on the next read.
78
- if (platform === 'ios') {
79
- process.env.DEVICE_TOKEN = token;
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(`[device-token] Registered ${platform} token (length: ${token.length})`);
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
- hasDeviceToken: !!env.DEVICE_TOKEN?.trim(),
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 device token set — push notifications have no target');
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(),