@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +31 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js +23 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js +43 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js +64 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js.map +7 -0
- package/dist/modules/channel_fcm/acl.js +10 -0
- package/dist/modules/channel_fcm/acl.js.map +7 -0
- package/dist/modules/channel_fcm/di.js +25 -0
- package/dist/modules/channel_fcm/di.js.map +7 -0
- package/dist/modules/channel_fcm/index.js +9 -0
- package/dist/modules/channel_fcm/index.js.map +7 -0
- package/dist/modules/channel_fcm/integration.js +54 -0
- package/dist/modules/channel_fcm/integration.js.map +7 -0
- package/dist/modules/channel_fcm/lib/adapter.js +160 -0
- package/dist/modules/channel_fcm/lib/adapter.js.map +7 -0
- package/dist/modules/channel_fcm/lib/credentials.js +56 -0
- package/dist/modules/channel_fcm/lib/credentials.js.map +7 -0
- package/dist/modules/channel_fcm/lib/fake-provider.js +32 -0
- package/dist/modules/channel_fcm/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_fcm/lib/health.js +10 -0
- package/dist/modules/channel_fcm/lib/health.js.map +7 -0
- package/dist/modules/channel_fcm/setup.js +25 -0
- package/dist/modules/channel_fcm/setup.js.map +7 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js +176 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_fcm/widgets/injection-table.js +15 -0
- package/dist/modules/channel_fcm/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +96 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.ts +39 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.ts +64 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.ts +3 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.ts +88 -0
- package/src/modules/channel_fcm/acl.ts +6 -0
- package/src/modules/channel_fcm/di.ts +27 -0
- package/src/modules/channel_fcm/index.ts +6 -0
- package/src/modules/channel_fcm/integration.ts +55 -0
- package/src/modules/channel_fcm/lib/__tests__/adapter-app-cache.test.ts +84 -0
- package/src/modules/channel_fcm/lib/__tests__/adapter.test.ts +176 -0
- package/src/modules/channel_fcm/lib/__tests__/credentials.test.ts +36 -0
- package/src/modules/channel_fcm/lib/__tests__/fake-provider.test.ts +96 -0
- package/src/modules/channel_fcm/lib/__tests__/message-golden.test.ts +98 -0
- package/src/modules/channel_fcm/lib/adapter.ts +238 -0
- package/src/modules/channel_fcm/lib/credentials.ts +79 -0
- package/src/modules/channel_fcm/lib/fake-provider.ts +46 -0
- package/src/modules/channel_fcm/lib/health.ts +13 -0
- package/src/modules/channel_fcm/setup.ts +34 -0
- package/src/modules/channel_fcm/widgets/injection/connect/widget.client.tsx +208 -0
- package/src/modules/channel_fcm/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_fcm/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- 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
|