@open-mercato/channel-fcm 0.6.8-develop.6985.1.fb93574faa

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 (63) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +31 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js +23 -0
  7. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js.map +7 -0
  8. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js +43 -0
  9. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js.map +7 -0
  10. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js +7 -0
  11. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js.map +7 -0
  12. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js +64 -0
  13. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js.map +7 -0
  14. package/dist/modules/channel_fcm/acl.js +10 -0
  15. package/dist/modules/channel_fcm/acl.js.map +7 -0
  16. package/dist/modules/channel_fcm/di.js +25 -0
  17. package/dist/modules/channel_fcm/di.js.map +7 -0
  18. package/dist/modules/channel_fcm/index.js +9 -0
  19. package/dist/modules/channel_fcm/index.js.map +7 -0
  20. package/dist/modules/channel_fcm/integration.js +54 -0
  21. package/dist/modules/channel_fcm/integration.js.map +7 -0
  22. package/dist/modules/channel_fcm/lib/adapter.js +160 -0
  23. package/dist/modules/channel_fcm/lib/adapter.js.map +7 -0
  24. package/dist/modules/channel_fcm/lib/credentials.js +56 -0
  25. package/dist/modules/channel_fcm/lib/credentials.js.map +7 -0
  26. package/dist/modules/channel_fcm/lib/fake-provider.js +32 -0
  27. package/dist/modules/channel_fcm/lib/fake-provider.js.map +7 -0
  28. package/dist/modules/channel_fcm/lib/health.js +10 -0
  29. package/dist/modules/channel_fcm/lib/health.js.map +7 -0
  30. package/dist/modules/channel_fcm/setup.js +25 -0
  31. package/dist/modules/channel_fcm/setup.js.map +7 -0
  32. package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js +176 -0
  33. package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js.map +7 -0
  34. package/dist/modules/channel_fcm/widgets/injection/connect/widget.js +17 -0
  35. package/dist/modules/channel_fcm/widgets/injection/connect/widget.js.map +7 -0
  36. package/dist/modules/channel_fcm/widgets/injection-table.js +15 -0
  37. package/dist/modules/channel_fcm/widgets/injection-table.js.map +7 -0
  38. package/jest.config.cjs +34 -0
  39. package/package.json +96 -0
  40. package/src/index.ts +1 -0
  41. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.ts +39 -0
  42. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.ts +64 -0
  43. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.ts +3 -0
  44. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.ts +88 -0
  45. package/src/modules/channel_fcm/acl.ts +6 -0
  46. package/src/modules/channel_fcm/di.ts +27 -0
  47. package/src/modules/channel_fcm/index.ts +6 -0
  48. package/src/modules/channel_fcm/integration.ts +55 -0
  49. package/src/modules/channel_fcm/lib/__tests__/adapter-app-cache.test.ts +84 -0
  50. package/src/modules/channel_fcm/lib/__tests__/adapter.test.ts +176 -0
  51. package/src/modules/channel_fcm/lib/__tests__/credentials.test.ts +36 -0
  52. package/src/modules/channel_fcm/lib/__tests__/fake-provider.test.ts +96 -0
  53. package/src/modules/channel_fcm/lib/__tests__/message-golden.test.ts +98 -0
  54. package/src/modules/channel_fcm/lib/adapter.ts +238 -0
  55. package/src/modules/channel_fcm/lib/credentials.ts +79 -0
  56. package/src/modules/channel_fcm/lib/fake-provider.ts +46 -0
  57. package/src/modules/channel_fcm/lib/health.ts +13 -0
  58. package/src/modules/channel_fcm/setup.ts +34 -0
  59. package/src/modules/channel_fcm/widgets/injection/connect/widget.client.tsx +208 -0
  60. package/src/modules/channel_fcm/widgets/injection/connect/widget.ts +16 -0
  61. package/src/modules/channel_fcm/widgets/injection-table.ts +13 -0
  62. package/tsconfig.json +9 -0
  63. package/watch.mjs +7 -0
@@ -0,0 +1,98 @@
1
+ import type { PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
2
+ import { buildFcmMessage } from '../adapter'
3
+
4
+ /**
5
+ * Golden assertions pinning the FCM message we build against Google's published reference shape.
6
+ *
7
+ * These are the only tests that catch serialization drift in *our* builder: the integration fakes
8
+ * replace the SDK client, so nothing else ever validates the message body. Drift in *Google's* schema
9
+ * is out of reach of any fake and stays a manual live-key check.
10
+ *
11
+ * Reference: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Message
12
+ * APNs headers within FCM: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#ApnsConfig
13
+ *
14
+ * Written as exact `toEqual` fixtures rather than snapshots: a snapshot would re-record drift on
15
+ * `--updateSnapshot` instead of failing.
16
+ */
17
+ const TOKEN = 'device-token-abcdef12'
18
+
19
+ function envelope(overrides: Partial<PushEnvelope> = {}): PushEnvelope {
20
+ return {
21
+ title: 'Order shipped',
22
+ body: 'Your order #42 is on its way',
23
+ data: { type: 'orders.shipped', notificationId: 'n1' },
24
+ options: {},
25
+ silent: false,
26
+ ...overrides,
27
+ }
28
+ }
29
+
30
+ describe('buildFcmMessage — golden payloads', () => {
31
+ it('visible notification matches the reference shape', () => {
32
+ expect(buildFcmMessage(TOKEN, envelope())).toEqual({
33
+ token: TOKEN,
34
+ notification: {
35
+ title: 'Order shipped',
36
+ body: 'Your order #42 is on its way',
37
+ },
38
+ data: { type: 'orders.shipped', notificationId: 'n1' },
39
+ android: {
40
+ notification: { sound: 'default' },
41
+ },
42
+ apns: {
43
+ headers: { 'apns-priority': '10' },
44
+ payload: { aps: { sound: 'default' } },
45
+ },
46
+ })
47
+ })
48
+
49
+ it('silent notification is data-only with content-available and a background push type', () => {
50
+ expect(buildFcmMessage(TOKEN, envelope({ silent: true }))).toEqual({
51
+ token: TOKEN,
52
+ data: { type: 'orders.shipped', notificationId: 'n1' },
53
+ android: { priority: 'high' },
54
+ apns: {
55
+ headers: { 'apns-push-type': 'background', 'apns-priority': '5' },
56
+ payload: { aps: { 'content-available': 1 } },
57
+ },
58
+ })
59
+ })
60
+
61
+ it('full pushOptions map onto each platform', () => {
62
+ const message = buildFcmMessage(
63
+ TOKEN,
64
+ envelope({
65
+ options: {
66
+ sound: 'chime.caf',
67
+ badge: 7,
68
+ image: 'https://cdn.example.com/hero.png',
69
+ priority: 'normal',
70
+ channelId: 'orders',
71
+ body: 'Overridden push body',
72
+ },
73
+ }),
74
+ )
75
+
76
+ expect(message).toEqual({
77
+ token: TOKEN,
78
+ notification: {
79
+ title: 'Order shipped',
80
+ body: 'Overridden push body',
81
+ imageUrl: 'https://cdn.example.com/hero.png',
82
+ },
83
+ data: { type: 'orders.shipped', notificationId: 'n1' },
84
+ android: {
85
+ priority: 'normal',
86
+ notification: {
87
+ sound: 'chime.caf',
88
+ channelId: 'orders',
89
+ imageUrl: 'https://cdn.example.com/hero.png',
90
+ },
91
+ },
92
+ apns: {
93
+ headers: { 'apns-priority': '5' },
94
+ payload: { aps: { sound: 'chime.caf', badge: 7 } },
95
+ },
96
+ })
97
+ })
98
+ })
@@ -0,0 +1,238 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type {
3
+ SendMessageInput,
4
+ SendMessageResult,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
6
+ import {
7
+ BasePushChannelAdapter,
8
+ deviceUnregisteredResult,
9
+ MISSING_PUSH_TOKEN_RESULT,
10
+ readPushToken,
11
+ } from '@open-mercato/core/modules/communication_channels/lib/push-adapter'
12
+ import { createRefCountedClientCache } from '@open-mercato/core/modules/communication_channels/lib/refcounted-client-cache'
13
+ import { readPushEnvelope, resolvePushBody, type PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
14
+ import {
15
+ fcmCredentialsSchema,
16
+ parseFcmServiceAccount,
17
+ type FcmServiceAccount,
18
+ } from './credentials'
19
+
20
+ /**
21
+ * FCM error codes that mean the device *token* is permanently invalid. Mapped to
22
+ * the uniform `device_unregistered` sentinel so the push worker soft-deletes the
23
+ * device (identical contract across fcm/apns/expo — see push-stub-adapter).
24
+ *
25
+ * Deliberately excludes `messaging/invalid-argument`: FCM v1 returns it for ANY
26
+ * malformed request field (oversized payload, bad data key, bad notification
27
+ * field), not just a bad token — treating it as unregistered would let a single
28
+ * payload-shape bug progressively soft-delete every targeted device tenant-wide.
29
+ * It falls through to the generic retryable `failed` path instead.
30
+ */
31
+ const PERMANENT_FCM_ERROR_CODES = new Set([
32
+ 'messaging/registration-token-not-registered',
33
+ 'messaging/invalid-registration-token',
34
+ ])
35
+
36
+ type FirebaseMessaging = {
37
+ send(message: Record<string, unknown>): Promise<string>
38
+ }
39
+
40
+ /**
41
+ * Pluggable messaging factory so tests can inject a fake without importing
42
+ * firebase-admin. Production path lazily loads firebase-admin and caches one app
43
+ * per service-account hash (re-initializing per send is wasteful and
44
+ * firebase-admin throws on duplicate app names).
45
+ */
46
+ export type FcmMessagingFactory = (serviceAccount: FcmServiceAccount) => FirebaseMessaging
47
+
48
+ let messagingFactory: FcmMessagingFactory | null = null
49
+
50
+ /** Test-only seam to swap the firebase-admin messaging factory. */
51
+ export function setFcmMessagingFactory(factory: FcmMessagingFactory | null): void {
52
+ messagingFactory = factory
53
+ }
54
+
55
+ function cacheKeyForServiceAccount(serviceAccount: FcmServiceAccount): string {
56
+ const hash = createHash('sha256')
57
+ .update(`${serviceAccount.projectId}:${serviceAccount.clientEmail}:${serviceAccount.privateKey}`)
58
+ .digest('hex')
59
+ .slice(0, 16)
60
+ return `om-fcm-${hash}`
61
+ }
62
+
63
+ type FcmAppLike = {
64
+ name?: string
65
+ /**
66
+ * firebase-admin's App exposes async `delete()`, which stops the background
67
+ * OAuth token-refresh timer bound to the service-account credential and drops
68
+ * the app from the SDK's registry.
69
+ */
70
+ delete(): Promise<void>
71
+ }
72
+
73
+ /**
74
+ * Bounds the number of live firebase-admin apps cached at once. Each app holds a
75
+ * service-account OAuth credential with a background token-refresh timer, so an
76
+ * unbounded cache would leak an app (and its timer) every time a tenant rotates
77
+ * their service account (the hash changes → a new app, while the stale app never
78
+ * gets deleted). LRU-evicting the least-recently used app and calling `delete()`
79
+ * keeps the app and timer count bounded. Disposal is fenced on in-flight sends: an
80
+ * evicted app is deleted only once its last concurrent `send` releases (see
81
+ * refcounted-client-cache), so eviction never tears an app out from under a send.
82
+ */
83
+ const APP_CACHE_MAX = 32
84
+ const appCache = createRefCountedClientCache<FcmAppLike>({
85
+ max: APP_CACHE_MAX,
86
+ dispose: (app) => {
87
+ void app.delete().catch(() => {})
88
+ },
89
+ })
90
+
91
+ // firebase-admin registers every app by name in a process-global registry and throws on a duplicate
92
+ // name. Cache keys are per-credential-identity (a rotated service account churns keys in and out), so a
93
+ // stable name-per-credential would let a re-created entry reuse — via getApps() — an app a still-in-flight
94
+ // evicted entry is about to delete(). A monotonic suffix makes every initialized app name unique, so each
95
+ // cache entry owns exactly one app and disposing an evicted app can never affect a live one.
96
+ let appInitSeq = 0
97
+
98
+ async function createFirebaseApp(serviceAccount: FcmServiceAccount, appName: string): Promise<FcmAppLike> {
99
+ const { initializeApp, cert } = await import('firebase-admin/app')
100
+ return initializeApp(
101
+ {
102
+ credential: cert({
103
+ projectId: serviceAccount.projectId,
104
+ clientEmail: serviceAccount.clientEmail,
105
+ privateKey: serviceAccount.privateKey,
106
+ }),
107
+ },
108
+ appName,
109
+ ) as unknown as FcmAppLike
110
+ }
111
+
112
+ /**
113
+ * Build the firebase-admin message from the push envelope, branching on
114
+ * `envelope.silent` (data-only content-available wake-up) and applying the
115
+ * recognized `pushOptions` (sound/badge/image/priority/channelId) per platform.
116
+ */
117
+ export function buildFcmMessage(token: string, envelope: PushEnvelope): Record<string, unknown> {
118
+ const { options, silent } = envelope
119
+ const apnsPriority = options.priority === 'normal' ? '5' : '10'
120
+
121
+ if (silent) {
122
+ return {
123
+ token,
124
+ data: envelope.data,
125
+ android: { priority: 'high' },
126
+ apns: {
127
+ headers: { 'apns-push-type': 'background', 'apns-priority': '5' },
128
+ payload: { aps: { 'content-available': 1 } },
129
+ },
130
+ }
131
+ }
132
+
133
+ const body = resolvePushBody(envelope)
134
+ const sound = options.sound ?? 'default'
135
+ const androidNotification: Record<string, unknown> = { sound }
136
+ if (options.channelId) androidNotification.channelId = options.channelId
137
+ if (options.image) androidNotification.imageUrl = options.image
138
+ const aps: Record<string, unknown> = { sound }
139
+ if (typeof options.badge === 'number') aps.badge = options.badge
140
+
141
+ return {
142
+ token,
143
+ notification: {
144
+ title: envelope.title,
145
+ body,
146
+ ...(options.image ? { imageUrl: options.image } : {}),
147
+ },
148
+ data: envelope.data,
149
+ android: {
150
+ ...(options.priority ? { priority: options.priority } : {}),
151
+ notification: androidNotification,
152
+ },
153
+ apns: {
154
+ headers: { 'apns-priority': apnsPriority },
155
+ payload: { aps },
156
+ },
157
+ }
158
+ }
159
+
160
+ class FcmChannelAdapter extends BasePushChannelAdapter {
161
+ readonly providerKey = 'fcm'
162
+ protected readonly credentialsSchema = fcmCredentialsSchema
163
+
164
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
165
+ const token = readPushToken(input)
166
+ if (!token) return MISSING_PUSH_TOKEN_RESULT
167
+
168
+ const parsedCredentials = fcmCredentialsSchema.safeParse(input.credentials)
169
+ if (!parsedCredentials.success) {
170
+ return { externalMessageId: '', status: 'failed', error: 'invalid_fcm_credentials' }
171
+ }
172
+
173
+ const envelope = readPushEnvelope(input.content)
174
+ let serviceAccount: FcmServiceAccount
175
+ try {
176
+ serviceAccount = parseFcmServiceAccount(parsedCredentials.data)
177
+ } catch (err) {
178
+ return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }
179
+ }
180
+ const message = buildFcmMessage(token, envelope)
181
+
182
+ // Test seam: an injected factory bypasses the app cache entirely.
183
+ if (messagingFactory) {
184
+ let messaging: FirebaseMessaging
185
+ try {
186
+ messaging = messagingFactory(serviceAccount)
187
+ } catch (err) {
188
+ return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }
189
+ }
190
+ return this.performSend(messaging, message)
191
+ }
192
+
193
+ // Production path: borrow a cached firebase-admin app for the duration of the send. Holding the
194
+ // lease keeps an evicted app alive until the send completes (release() in finally).
195
+ const cacheKey = cacheKeyForServiceAccount(serviceAccount)
196
+ let lease
197
+ try {
198
+ appInitSeq += 1
199
+ const appName = `${cacheKey}-${appInitSeq}`
200
+ lease = await appCache.acquire(cacheKey, () => createFirebaseApp(serviceAccount, appName))
201
+ } catch (err) {
202
+ return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }
203
+ }
204
+ try {
205
+ const { getMessaging } = await import('firebase-admin/messaging')
206
+ const messaging = getMessaging(lease.client as never) as unknown as FirebaseMessaging
207
+ return await this.performSend(messaging, message)
208
+ } catch (err) {
209
+ return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }
210
+ } finally {
211
+ lease.release()
212
+ }
213
+ }
214
+
215
+ private async performSend(
216
+ messaging: FirebaseMessaging,
217
+ message: Record<string, unknown>,
218
+ ): Promise<SendMessageResult> {
219
+ try {
220
+ const externalMessageId = await messaging.send(message)
221
+ return { externalMessageId, status: 'sent' }
222
+ } catch (err) {
223
+ const code = typeof (err as { code?: unknown }).code === 'string' ? (err as { code: string }).code : undefined
224
+ const errorMessage = err instanceof Error ? err.message : String(err)
225
+ if (code && PERMANENT_FCM_ERROR_CODES.has(code)) {
226
+ return deviceUnregisteredResult({ code })
227
+ }
228
+ return { externalMessageId: '', status: 'failed', error: errorMessage }
229
+ }
230
+ }
231
+ }
232
+
233
+ let cachedAdapter: FcmChannelAdapter | null = null
234
+
235
+ export function getFcmChannelAdapter(): FcmChannelAdapter {
236
+ if (!cachedAdapter) cachedAdapter = new FcmChannelAdapter()
237
+ return cachedAdapter
238
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ PUSH_CREDENTIAL_ERROR_INVALID_JSON,
4
+ PUSH_CREDENTIAL_ERROR_MISSING_FIELDS,
5
+ PUSH_CREDENTIAL_ERROR_REQUIRED,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/push-credential-errors'
7
+
8
+ /**
9
+ * Firebase service account shape (camelCase) used to mint FCM credentials.
10
+ * Source JSON from the Firebase console uses snake_case keys, normalized by
11
+ * {@link parseFcmServiceAccount}.
12
+ */
13
+ export const fcmServiceAccountSchema = z
14
+ .object({
15
+ projectId: z.string().min(1, 'project_id missing'),
16
+ clientEmail: z.string().min(1, 'client_email missing'),
17
+ privateKey: z.string().min(1, 'private_key missing'),
18
+ })
19
+ .passthrough()
20
+
21
+ export type FcmServiceAccount = z.infer<typeof fcmServiceAccountSchema>
22
+
23
+ function normalizeServiceAccount(raw: Record<string, unknown>): FcmServiceAccount {
24
+ return fcmServiceAccountSchema.parse({
25
+ projectId: raw.projectId ?? raw.project_id,
26
+ clientEmail: raw.clientEmail ?? raw.client_email,
27
+ privateKey: raw.privateKey ?? raw.private_key,
28
+ })
29
+ }
30
+
31
+ /**
32
+ * Tenant-level FCM credentials persisted on `IntegrationCredentials` for provider
33
+ * `channel_fcm`. `serviceAccountJson` is the full Firebase service-account JSON
34
+ * (stored encrypted at rest); `appName` is an optional label for the cached
35
+ * firebase-admin app.
36
+ */
37
+ export const fcmCredentialsSchema = z
38
+ .object({
39
+ serviceAccountJson: z.string().min(1, PUSH_CREDENTIAL_ERROR_REQUIRED),
40
+ appName: z.string().optional(),
41
+ })
42
+ .passthrough()
43
+ .superRefine((value, ctx) => {
44
+ // Two distinct failures, two distinct codes. Never interpolate the caught
45
+ // error: a `normalizeServiceAccount` rejection is a ZodError whose
46
+ // `.message` is a JSON dump of the issue array, which used to reach the
47
+ // operator verbatim through `fieldErrors`.
48
+ let parsed: Record<string, unknown>
49
+ try {
50
+ parsed = JSON.parse(value.serviceAccountJson) as Record<string, unknown>
51
+ } catch {
52
+ ctx.addIssue({
53
+ code: z.ZodIssueCode.custom,
54
+ path: ['serviceAccountJson'],
55
+ message: PUSH_CREDENTIAL_ERROR_INVALID_JSON,
56
+ })
57
+ return
58
+ }
59
+ const account = fcmServiceAccountSchema.safeParse({
60
+ projectId: parsed.projectId ?? parsed.project_id,
61
+ clientEmail: parsed.clientEmail ?? parsed.client_email,
62
+ privateKey: parsed.privateKey ?? parsed.private_key,
63
+ })
64
+ if (!account.success) {
65
+ ctx.addIssue({
66
+ code: z.ZodIssueCode.custom,
67
+ path: ['serviceAccountJson'],
68
+ message: PUSH_CREDENTIAL_ERROR_MISSING_FIELDS,
69
+ })
70
+ }
71
+ })
72
+
73
+ export type FcmCredentials = z.infer<typeof fcmCredentialsSchema>
74
+
75
+ /** Parse and normalize the service account out of validated credentials. Throws on malformed JSON. */
76
+ export function parseFcmServiceAccount(credentials: FcmCredentials): FcmServiceAccount {
77
+ const parsed = JSON.parse(credentials.serviceAccountJson) as Record<string, unknown>
78
+ return normalizeServiceAccount(parsed)
79
+ }
@@ -0,0 +1,46 @@
1
+ import {
2
+ isPushFakeProvidersEnabled,
3
+ recordFakePush,
4
+ warnPushFakeProvidersActive,
5
+ } from '@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder'
6
+ import { setFcmMessagingFactory } from './adapter'
7
+
8
+ /**
9
+ * Network-free `firebase-admin` messaging client used ONLY by integration tests.
10
+ *
11
+ * Swaps the SDK client behind the adapter's existing seam, so every line of the real adapter still
12
+ * runs — message construction, credential parsing, client caching, and the
13
+ * `messaging/registration-token-not-registered` → `device_unregistered` mapping. The adapter itself is
14
+ * never replaced (unlike `push_stub`), and is never re-registered: `registerChannelAdapter` throws on a
15
+ * duplicate provider key.
16
+ *
17
+ * Token sentinels match `push_stub`'s convention (see push-stub-adapter.ts):
18
+ * - token containing `unregistered` → FCM's native permanent-token error code
19
+ * - token containing `fail` → a retryable error (no `code`, so the adapter retries)
20
+ * - otherwise → success
21
+ *
22
+ * Production safety: never installed at module import; no-op unless `OM_PUSH_FAKE_PROVIDERS` is set.
23
+ */
24
+ function fakeSendError(message: string, code?: string): Error {
25
+ const error = new Error(message)
26
+ if (code) Object.assign(error, { code })
27
+ return error
28
+ }
29
+
30
+ export function ensureFcmFakeProviderInstalled(): void {
31
+ if (!isPushFakeProvidersEnabled()) return
32
+ warnPushFakeProvidersActive('fcm')
33
+ setFcmMessagingFactory(() => ({
34
+ async send(message: Record<string, unknown>): Promise<string> {
35
+ const token = typeof message.token === 'string' ? message.token : ''
36
+ recordFakePush('fcm', token, message)
37
+ if (token.includes('unregistered')) {
38
+ throw fakeSendError('fake fcm token not registered', 'messaging/registration-token-not-registered')
39
+ }
40
+ if (token.includes('fail')) {
41
+ throw fakeSendError('fake fcm transient failure')
42
+ }
43
+ return `fcm-fake-${token.slice(-8) || 'token'}`
44
+ },
45
+ }))
46
+ }
@@ -0,0 +1,13 @@
1
+ import { makePushClientConfigHealthCheck } from '@open-mercato/core/modules/push_notifications/lib/push-health'
2
+ import { fcmCredentialsSchema } from './credentials'
3
+
4
+ /**
5
+ * Liveness probe for the FCM integration. The hub passes the tenant-scoped
6
+ * credentials (the service-account JSON), so the probe just confirms they are
7
+ * present and parse into a valid service account — no network call. Per-device
8
+ * token validity surfaces on delivery (`device_unregistered` soft-deletes).
9
+ */
10
+ export const channelFcmHealthCheck = makePushClientConfigHealthCheck({
11
+ schema: fcmCredentialsSchema,
12
+ providerLabel: 'FCM',
13
+ })
@@ -0,0 +1,34 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import {
3
+ hasChannelAdapter,
4
+ registerChannelAdapter,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
6
+ import { getFcmChannelAdapter } from './lib/adapter'
7
+
8
+ /**
9
+ * Register the FCM `ChannelAdapter` once per process at import time. The registry
10
+ * is process-wide; we guard with `hasChannelAdapter` to silence the duplicate
11
+ * error on dev-mode HMR + repeated test imports.
12
+ *
13
+ * Provider credentials (the Firebase service account) are persisted per tenant via
14
+ * the standard `IntegrationCredentials` flow for the `channel_fcm` provider; this
15
+ * module never preconfigures credentials from env.
16
+ */
17
+ function ensureFcmAdapterRegistered(): void {
18
+ if (hasChannelAdapter('fcm')) return
19
+ registerChannelAdapter(getFcmChannelAdapter())
20
+ }
21
+
22
+ ensureFcmAdapterRegistered()
23
+
24
+ export const setup: ModuleSetupConfig = {
25
+ defaultRoleFeatures: {
26
+ superadmin: ['channel_fcm.view', 'channel_fcm.configure'],
27
+ admin: ['channel_fcm.view', 'channel_fcm.configure'],
28
+ },
29
+ async onTenantCreated() {
30
+ ensureFcmAdapterRegistered()
31
+ },
32
+ }
33
+
34
+ export default setup