@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
@@ -1,12 +1,21 @@
1
- import type { NotificationData, OptionChoice, ResponseKind } from '$lib/types';
1
+ import type {
2
+ APNsFanOutResult,
3
+ AppEnv,
4
+ DeviceRecord,
5
+ FCMFanOutResult,
6
+ NotificationData,
7
+ OptionChoice,
8
+ ResponseKind,
9
+ } from '$lib/types';
2
10
 
3
11
  import { env } from '$env/dynamic/private';
4
- import { readPersistedDeviceToken, resolveDeviceToken } from '$lib/modules/server/apn/device-token';
5
12
  import { LibraryAPNsService } from '$lib/modules/server/apn/library-apns';
6
13
  import { addNotification, getNotifications } from '$lib/modules/server/apn/notification-history';
14
+ import { selectPlatforms, summarizeNotifyDelivery } from '$lib/modules/server/apn/notify-fanout';
7
15
  import { createPendingRequest } from '$lib/modules/server/apn/pending-requests';
8
16
  import { validateAuth } from '$lib/modules/server/auth';
9
- import { isFCMConfigured, sendFCMNotification } from '$lib/modules/server/fcm/fcm-service.js';
17
+ import { isFCMConfigured, sendFCMNotificationMulti } from '$lib/modules/server/fcm/fcm-service.js';
18
+ import { deviceTokenStore } from '$lib/modules/server/push/device-token-store';
10
19
  import { toErrorMessage } from '$lib/modules/server/utils/error';
11
20
  import { broadcastEvent } from '$lib/modules/server/ws/server';
12
21
  import { json } from '@sveltejs/kit';
@@ -22,6 +31,73 @@ function getAPNsClient(): LibraryAPNsService {
22
31
  return apnsSingleton;
23
32
  }
24
33
 
34
+ const EMPTY_APNS_RESULT: APNsFanOutResult = {
35
+ results: [],
36
+ staleTokens: [],
37
+ totalFailed: 0,
38
+ totalSent: 0,
39
+ };
40
+ const EMPTY_FCM_RESULT: FCMFanOutResult = {
41
+ failureCount: 0,
42
+ results: [],
43
+ staleTokens: [],
44
+ successCount: 0,
45
+ };
46
+
47
+ /** Android tokens to deliver to: active registry rows, else the ANDROID_DEVICE_TOKEN seed. */
48
+ function resolveAndroidTokens(): string[] {
49
+ const rows = deviceTokenStore.listActive('android');
50
+ if (rows.length > 0) {
51
+ return rows.map((r) => r.token);
52
+ }
53
+ const seed = env.ANDROID_DEVICE_TOKEN?.trim();
54
+ return seed ? [seed] : [];
55
+ }
56
+
57
+ /**
58
+ * iOS devices to deliver to: an explicit request override, else all active
59
+ * registry rows for the server's gateway, else the legacy DEVICE_TOKEN env seed
60
+ * (so .env-only deployments keep working without ever writing to the DB).
61
+ */
62
+ function resolveIosDevices(override?: string): DeviceRecord[] {
63
+ const appEnv = serverApnEnv();
64
+ if (override) {
65
+ return [syntheticSeedDevice(override, 'ios', appEnv)];
66
+ }
67
+ const rows = deviceTokenStore.listActiveForEnv('ios', appEnv);
68
+ if (rows.length > 0) {
69
+ return rows;
70
+ }
71
+ const seed = env.DEVICE_TOKEN?.trim();
72
+ return seed ? [syntheticSeedDevice(seed, 'ios', appEnv)] : [];
73
+ }
74
+
75
+ function serverApnEnv(): AppEnv {
76
+ return env.APNS_PRODUCTION === 'true' ? 'production' : 'sandbox';
77
+ }
78
+
79
+ /** A throwaway DeviceRecord wrapping a legacy env-seed or request-override token. */
80
+ function syntheticSeedDevice(
81
+ token: string,
82
+ platform: 'android' | 'ios',
83
+ appEnv: AppEnv
84
+ ): DeviceRecord {
85
+ const nowIso = new Date().toISOString();
86
+ return {
87
+ appEnv,
88
+ bundleId: null,
89
+ deviceId: null,
90
+ failureCount: 0,
91
+ friendlyName: null,
92
+ id: `seed-${platform}`,
93
+ isActive: true,
94
+ lastSeenAt: nowIso,
95
+ platform,
96
+ registeredAt: nowIso,
97
+ token,
98
+ };
99
+ }
100
+
25
101
  // NOTIFICATION DEDUPLICATION CACHE
26
102
  const notificationCache = new Map<string, number>();
27
103
  const DEDUP_WINDOW = 10000; // 10 seconds deduplication window
@@ -136,6 +212,13 @@ function intelligentNotificationFilter(
136
212
  ): { reason: string; send: boolean } {
137
213
  const source = data?.source || 'unknown';
138
214
 
215
+ // Never filter a bidirectional (waitForResponse) request: the caller blocks
216
+ // on polling, and a filtered 200 would read as success:true and hang it for
217
+ // the full permission timeout. These are also dedup-exempt below.
218
+ if (waitForResponse) {
219
+ return { reason: 'Bidirectional request — never filtered', send: true };
220
+ }
221
+
139
222
  // Check for duplicate notifications first
140
223
  if (isDuplicateNotification(title, message, data, waitForResponse)) {
141
224
  return {
@@ -265,7 +348,7 @@ function releaseNotification(title: string, message: string, data?: Notification
265
348
  // helpers. This handler grew past the 300-line guideline organically; PR-2
266
349
  // adds 4 lines for dynamic-options fields. A dedicated cleanup PR is the
267
350
  // right place to split it, not a feature PR.
268
- // eslint-disable-next-line max-lines-per-function
351
+
269
352
  export const POST: RequestHandler = async ({ request }) => {
270
353
  try {
271
354
  // Use singleton APNs client to reuse HTTP/2 connection
@@ -420,175 +503,99 @@ export const POST: RequestHandler = async ({ request }) => {
420
503
  title,
421
504
  };
422
505
 
423
- // Platform-based routing: Android (FCM) or iOS (APNs)
424
- const platform = env.DEVICE_PLATFORM || 'ios';
425
-
426
- if (platform === 'android') {
427
- // --- FCM (Android) path ---
428
- if (!isFCMConfigured()) {
429
- return json(
430
- {
431
- details:
432
- 'Missing FCM_PROJECT_ID, FCM_CLIENT_EMAIL, or FCM_PRIVATE_KEY environment variables',
433
- error: 'FCM not configured',
434
- },
435
- { status: 500 }
436
- );
437
- }
438
-
439
- // Resolve the FCM target: explicit request token, then the token the
440
- // Android app persisted on launch, then the Android-specific env var.
441
- // env.DEVICE_TOKEN is intentionally NOT in this chain — it holds the iOS
442
- // APNs token, which must never be shipped to FCM in mixed-platform setups.
443
- const androidToken = resolveDeviceToken(
444
- requestDeviceToken,
445
- readPersistedDeviceToken('android'),
446
- env.ANDROID_DEVICE_TOKEN
447
- );
448
-
449
- if (!androidToken) {
450
- return json(
451
- {
452
- details:
453
- 'No Android device token available — set ANDROID_DEVICE_TOKEN, pass deviceToken in the request body, or open the Android app so it can auto-register its FCM token.',
454
- error: 'No device token configured',
455
- },
456
- { status: 500 }
457
- );
458
- }
506
+ // Multi-device fan-out. DEVICE_PLATFORM is now a FILTER (unset → both
507
+ // platforms), not a binary switch. A request-scoped deviceToken override
508
+ // (config "send test") bypasses the registry and targets one token.
509
+ const override = requestDeviceToken?.trim() || undefined;
510
+ const { doAndroid, doIos } = selectPlatforms(env.DEVICE_PLATFORM);
511
+
512
+ // An override is a single token of unknown platform; honour the platform
513
+ // FILTER (unset → try both) rather than assuming iOS, so an Android override
514
+ // is not silently dropped when DEVICE_PLATFORM is unset. Firing a token at
515
+ // the wrong gateway is harmless here because override sends never prune (see
516
+ // the pruning guard below).
517
+ const iosDevices: DeviceRecord[] = doIos
518
+ ? override
519
+ ? resolveIosDevices(override)
520
+ : resolveIosDevices()
521
+ : [];
522
+
523
+ const androidTokens: string[] = doAndroid
524
+ ? override
525
+ ? [override]
526
+ : resolveAndroidTokens()
527
+ : [];
528
+
529
+ // Collapse permission pushes by requestId so a re-notify replaces (not
530
+ // stacks) the prompt on every device, and answering on one clears it.
531
+ const collapseId = waitForResponse ? canonicalRequestId : undefined;
532
+
533
+ const [apnsResult, fcmResult] = await Promise.all([
534
+ iosDevices.length > 0 && apnsClient.isConfigured()
535
+ ? apnsClient.sendToMany(iosDevices, payload, collapseId)
536
+ : Promise.resolve(EMPTY_APNS_RESULT),
537
+ androidTokens.length > 0 && isFCMConfigured()
538
+ ? sendFCMNotificationMulti(androidTokens, payload)
539
+ : Promise.resolve(EMPTY_FCM_RESULT),
540
+ ]);
541
+
542
+ const summary = summarizeNotifyDelivery(apnsResult, fcmResult);
543
+
544
+ // Lazy prune dead tokens; bump last-seen for the ones that delivered.
545
+ // Never prune on an override send: the override token is a one-off explicit
546
+ // target (not necessarily a registry row), and firing it at the wrong
547
+ // gateway must not soft-delete a real device that happens to hold it.
548
+ const pruned =
549
+ !override && summary.staleTokens.length
550
+ ? deviceTokenStore.pruneByTokens(summary.staleTokens)
551
+ : 0;
552
+ if (summary.succeededTokens.length > 0) {
553
+ deviceTokenStore.touchLastSeen(summary.succeededTokens);
554
+ }
459
555
 
460
- const fcmResult = await sendFCMNotification(androidToken, payload);
461
-
462
- if (fcmResult.success) {
463
- // Record in dedup cache only after successful delivery
464
- recordNotification(title, message, data);
465
-
466
- if (waitForResponse) {
467
- createPendingRequest(canonicalRequestId, {
468
- options,
469
- question: question ?? null,
470
- responseKind: responseKind ?? 'hook',
471
- sessionId: (data?.sessionId as string) || '',
472
- toolInput: (data?.toolInput as Record<string, unknown>) || {},
473
- toolName: (data?.toolName as string) || '',
474
- });
475
- }
476
-
477
- addNotification(buildNotificationRecord(canonicalRequestId, title, message, 'sent', data));
478
-
479
- return json({
480
- message: 'Notification sent successfully',
481
- requestId: canonicalRequestId,
482
- result: { messageId: fcmResult.messageId },
483
- success: true,
484
- timestamp: new Date().toISOString(),
485
- });
486
- } else {
487
- console.error(`[notify] FCM delivery failed: ${fcmResult.error}`);
488
- releaseNotification(title, message, data); // free the reserved dedup slot for a retry
489
-
490
- addNotification(
491
- buildNotificationRecord(
492
- canonicalRequestId,
493
- title,
494
- message,
495
- 'failed',
496
- data,
497
- fcmResult.error
498
- )
499
- );
500
-
501
- return json(
502
- {
503
- details: fcmResult.error,
504
- error: 'Failed to send notification',
505
- requestId: canonicalRequestId,
506
- timestamp: new Date().toISOString(),
507
- },
508
- { status: 500 }
509
- );
510
- }
556
+ // Keep the dedup reservation only on a clean, fully-successful delivery;
557
+ // otherwise release it so a legitimate retry is not blocked (a transient
558
+ // failure for one device must not poison the cache for everyone).
559
+ if (summary.delivered && summary.failed === 0) {
560
+ recordNotification(title, message, data);
511
561
  } else {
512
- // --- APNs (iOS) path ---
513
- if (!apnsClient.isConfigured()) {
514
- return json(
515
- {
516
- details: 'Missing APNS_KEY, APNS_KEY_ID, or APNS_TEAM_ID environment variables',
517
- error: 'APNs client not configured',
518
- },
519
- { status: 500 }
520
- );
521
- }
562
+ releaseNotification(title, message, data);
563
+ }
522
564
 
523
- // Resolve the APNs target: explicit request token (e.g. config-page test),
524
- // then the token the iOS app persisted on launch, then the env fallback.
525
- // Persisted beats env so a stale .env DEVICE_TOKEN can't shadow the live
526
- // device token after a restart (the BadDeviceToken trap this fixes).
527
- const deviceToken = resolveDeviceToken(
528
- requestDeviceToken,
529
- readPersistedDeviceToken('ios'),
530
- env.DEVICE_TOKEN
531
- );
565
+ // Register the pending request for bidirectional polling only if at least
566
+ // one device received the push otherwise no one can answer.
567
+ if (waitForResponse && summary.delivered) {
568
+ createPendingRequest(canonicalRequestId, {
569
+ options,
570
+ question: question ?? null,
571
+ responseKind: responseKind ?? 'hook',
572
+ sessionId: (data?.sessionId as string) || '',
573
+ toolInput: (data?.toolInput as Record<string, unknown>) || {},
574
+ toolName: (data?.toolName as string) || '',
575
+ });
576
+ }
532
577
 
533
- if (!deviceToken) {
534
- return json(
535
- {
536
- details:
537
- 'No iOS device token available — pass deviceToken in the request body, ensure ~/.shooter/device-tokens.json contains an ios token, or set DEVICE_TOKEN.',
538
- error: 'No device token configured',
539
- },
540
- { status: 500 }
541
- );
542
- }
578
+ addNotification(
579
+ buildNotificationRecord(
580
+ canonicalRequestId,
581
+ title,
582
+ message,
583
+ summary.delivered ? 'sent' : 'failed',
584
+ data,
585
+ summary.delivered ? null : 'No registered device accepted the notification'
586
+ )
587
+ );
543
588
 
544
- try {
545
- const result = await apnsClient.sendNotification(deviceToken, payload);
546
-
547
- // Record in dedup cache only after successful delivery
548
- recordNotification(title, message, data);
549
-
550
- // If this is a bidirectional permission request, store it for polling
551
- // only after confirming APNs delivery succeeded
552
- if (waitForResponse) {
553
- createPendingRequest(canonicalRequestId, {
554
- options,
555
- question: question ?? null,
556
- responseKind: responseKind ?? 'hook',
557
- sessionId: (data?.sessionId as string) || '',
558
- toolInput: (data?.toolInput as Record<string, unknown>) || {},
559
- toolName: (data?.toolName as string) || '',
560
- });
561
- }
562
-
563
- addNotification(buildNotificationRecord(canonicalRequestId, title, message, 'sent', data));
564
-
565
- return json({
566
- message: 'Notification sent successfully',
567
- requestId: canonicalRequestId,
568
- result,
569
- success: true,
570
- timestamp: new Date().toISOString(),
571
- });
572
- } catch (notificationError) {
573
- const notifErrMsg = toErrorMessage(notificationError);
574
- console.error(`[notify] APNs delivery failed: ${notifErrMsg}`);
575
- releaseNotification(title, message, data); // free the reserved dedup slot for a retry
576
-
577
- addNotification(
578
- buildNotificationRecord(canonicalRequestId, title, message, 'failed', data, notifErrMsg)
579
- );
580
-
581
- return json(
582
- {
583
- details: notifErrMsg,
584
- error: 'Failed to send notification',
585
- requestId: canonicalRequestId,
586
- timestamp: new Date().toISOString(),
587
- },
588
- { status: 500 }
589
- );
590
- }
591
- }
589
+ // 200 even when nothing was delivered (success:false) so the notifier hook
590
+ // fast-fails instead of hanging for the full permission timeout.
591
+ return json({
592
+ failed: summary.failed,
593
+ pruned,
594
+ requestId: canonicalRequestId,
595
+ sent: summary.sent,
596
+ success: summary.delivered,
597
+ timestamp: new Date().toISOString(),
598
+ });
592
599
  } catch (error) {
593
600
  console.error('Notification error:', error);
594
601
  return json(
@@ -19,9 +19,16 @@ export const GET: RequestHandler = async ({ request, url }) => {
19
19
 
20
20
  // Use a configured server URL from env when available (trusted), otherwise
21
21
  // fall back to url.origin (derived from the incoming request URL by SvelteKit).
22
- const serverUrl = env.ORIGIN?.trim() || url.origin;
22
+ // Strip any trailing slash so registerUrl never becomes "https://x//api/...".
23
+ const serverUrl = (env.ORIGIN?.trim() || url.origin).replace(/\/+$/, '');
23
24
 
24
- const configPayload = JSON.stringify({ apiKey, serverUrl });
25
+ // registerUrl lets a scanning app POST its push token straight to the
26
+ // device registry after pairing (multi-device auto-registration).
27
+ const configPayload = JSON.stringify({
28
+ apiKey,
29
+ registerUrl: `${serverUrl}/api/device-token`,
30
+ serverUrl,
31
+ });
25
32
 
26
33
  try {
27
34
  const dataUrl = await QRCode.toDataURL(configPayload, {