@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
package/AGENTS.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `@open-mercato/channel-fcm` — Agent Guidelines
|
|
2
|
+
|
|
3
|
+
Firebase Cloud Messaging push provider for the Communications Hub (`communication_channels`). Registers a `push` `ChannelAdapter` (providerKey `fcm`) consumed by the `push_notifications` delivery strategy/worker.
|
|
4
|
+
|
|
5
|
+
- **Package**: `@open-mercato/channel-fcm` ⇒ **module id**: `channel_fcm`
|
|
6
|
+
- **Provider key**: `fcm` · **channelType**: `push`
|
|
7
|
+
- Integration provider package — keep all FCM-specific logic here. Do NOT add it to `packages/core`.
|
|
8
|
+
|
|
9
|
+
## Key Files (`src/modules/channel_fcm/`)
|
|
10
|
+
|
|
11
|
+
| File | Purpose |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| `integration.ts` | `IntegrationDefinition` (credentials fields, `healthCheck.service`) |
|
|
14
|
+
| `di.ts` | `register(container)` — registers the adapter AND `channelFcmHealthCheck` |
|
|
15
|
+
| `setup.ts` | Registers the adapter at import; `defaultRoleFeatures` |
|
|
16
|
+
| `acl.ts` | `channel_fcm.view`, `channel_fcm.configure` |
|
|
17
|
+
| `lib/adapter.ts` | `FcmChannelAdapter` — `sendMessage` via firebase-admin; `setFcmMessagingFactory` test seam |
|
|
18
|
+
| `lib/credentials.ts` | Zod schema for the service-account JSON credentials |
|
|
19
|
+
| `lib/health.ts` | `channelFcmHealthCheck` liveness probe |
|
|
20
|
+
|
|
21
|
+
## Adapter Contract
|
|
22
|
+
|
|
23
|
+
Implements the push-adapter contract shared with the `push_stub`, `channel-apns`, and `channel-expo` adapters:
|
|
24
|
+
- reads `metadata.pushToken` + the push envelope from `content.raw` (`@open-mercato/core/.../push-envelope`)
|
|
25
|
+
- capabilities come from the shared `pushChannelCapabilities` baseline
|
|
26
|
+
- a permanently-invalid token returns the **uniform** `device_unregistered` sentinel (`error:'device_unregistered'`, `metadata.unregistered:true`) so the worker soft-deletes the device. FCM codes mapped: `messaging/registration-token-not-registered`, `messaging/invalid-registration-token`. `messaging/invalid-argument` is **deliberately excluded** — FCM returns it for any malformed request field (oversized payload, bad data key), so mapping it would let a single payload-shape bug soft-delete every targeted device tenant-wide; it falls through to the generic retryable `failed` path instead.
|
|
27
|
+
- one firebase-admin app is cached per service-account hash (re-init per send is wasteful + errors on duplicate app names).
|
|
28
|
+
|
|
29
|
+
## Credentials
|
|
30
|
+
|
|
31
|
+
Tenant-level credentials live on `IntegrationCredentials` for provider `channel_fcm`: `{ serviceAccountJson, appName? }`. Connect via the existing `POST /api/communication_channels/channels/connect/credentials` flow. Per-device tokens live in `devices.UserDevice.push_token`, never here.
|
package/build.mjs
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/authFixtures";
|
|
3
|
+
import { apiRequest } from "@open-mercato/core/helpers/integration/api";
|
|
4
|
+
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
5
|
+
test.describe("TC-CHANNEL-PUSH-001: FCM registered + rejected on the per-user route", () => {
|
|
6
|
+
test("POST per-user connect/credentials with providerKey=fcm is refused (403, tenant-scoped)", async ({ request }) => {
|
|
7
|
+
const token = await getAuthToken(request);
|
|
8
|
+
const response = await apiRequest(
|
|
9
|
+
request,
|
|
10
|
+
"POST",
|
|
11
|
+
"/api/communication_channels/channels/connect/credentials",
|
|
12
|
+
{
|
|
13
|
+
token,
|
|
14
|
+
data: { providerKey: "fcm", displayName: "FCM \u2014 per-user (should be refused)", credentials: {} }
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
18
|
+
expect(response.status()).toBe(403);
|
|
19
|
+
const body = await readJsonSafe(response);
|
|
20
|
+
expect(body?.code).toBe("provider_is_tenant_scoped");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-001.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\n/**\n * TC-CHANNEL-PUSH-001 \u2014 FCM is registered AND tenant-scoped, so the per-user\n * connect route refuses it.\n *\n * `@open-mercato/channel-fcm` registers the `fcm` push `ChannelAdapter` at import\n * with `channelScope: 'tenant'`. Push providers are connected tenant-wide by an\n * admin through `POST /channels/connect/tenant-credentials` (see\n * TC-CHANNEL-PUSH-002), NOT the per-user route. The per-user route\n * `POST /channels/connect/credentials` now short-circuits tenant-scoped providers\n * with 403 (`provider_is_tenant_scoped`) before touching credentials \u2014 this both\n * proves the adapter is registered (an UNregistered provider would fall through to\n * 404 `no_adapter`) and guards against a non-admin minting a tenant-wide channel.\n * No channel is created, so there is nothing to clean up.\n */\ntest.describe('TC-CHANNEL-PUSH-001: FCM registered + rejected on the per-user route', () => {\n test('POST per-user connect/credentials with providerKey=fcm is refused (403, tenant-scoped)', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: { providerKey: 'fcm', displayName: 'FCM \u2014 per-user (should be refused)', credentials: {} },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n // 403 (not 404) proves the FCM adapter is registered and declares tenant scope,\n // and that the per-user route refuses it before any credential/channel work.\n expect(response.status()).toBe(403)\n const body = await readJsonSafe<{ code?: string }>(response)\n expect(body?.code).toBe('provider_is_tenant_scoped')\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAgB7B,KAAK,SAAS,wEAAwE,MAAM;AAC1F,OAAK,0FAA0F,OAAO,EAAE,QAAQ,MAAM;AACpH,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM,EAAE,aAAa,OAAO,aAAa,2CAAsC,aAAa,CAAC,EAAE;AAAA,MACjG;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAGlE,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,UAAM,OAAO,MAAM,aAAgC,QAAQ;AAC3D,WAAO,MAAM,IAAI,EAAE,KAAK,2BAA2B;AAAA,EACrD,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/authFixtures";
|
|
3
|
+
import { apiRequest } from "@open-mercato/core/helpers/integration/api";
|
|
4
|
+
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
5
|
+
test.describe("TC-CHANNEL-PUSH-002: tenant-wide push connect route", () => {
|
|
6
|
+
test("reaches the registered FCM adapter and rejects missing credentials (422)", async ({ request }) => {
|
|
7
|
+
const token = await getAuthToken(request);
|
|
8
|
+
const response = await apiRequest(
|
|
9
|
+
request,
|
|
10
|
+
"POST",
|
|
11
|
+
"/api/communication_channels/channels/connect/tenant-credentials",
|
|
12
|
+
{
|
|
13
|
+
token,
|
|
14
|
+
// Missing serviceAccountJson → the adapter's validateCredentials rejects it,
|
|
15
|
+
// so the flow never creates a tenant channel (no side effect / no cleanup).
|
|
16
|
+
data: { providerKey: "fcm", displayName: "FCM \u2014 tenant connect", credentials: {} }
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
20
|
+
expect(response.status()).toBe(422);
|
|
21
|
+
const body = await readJsonSafe(response);
|
|
22
|
+
expect(body?.fieldErrors ?? body?.error).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
test("rejects a per-user provider on the tenant route (400)", async ({ request }) => {
|
|
25
|
+
const token = await getAuthToken(request);
|
|
26
|
+
const response = await apiRequest(
|
|
27
|
+
request,
|
|
28
|
+
"POST",
|
|
29
|
+
"/api/communication_channels/channels/connect/tenant-credentials",
|
|
30
|
+
{
|
|
31
|
+
token,
|
|
32
|
+
// IMAP is a per-user provider (no channelScope) — an admin must not be able
|
|
33
|
+
// to force it into a shared tenant-wide channel through this route.
|
|
34
|
+
data: { providerKey: "imap", displayName: "IMAP \u2014 should be rejected", credentials: {} }
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
38
|
+
expect(response.status()).toBe(400);
|
|
39
|
+
const body = await readJsonSafe(response);
|
|
40
|
+
expect(body?.code).toBe("provider_not_tenant_scoped");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-002.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\n/**\n * TC-CHANNEL-PUSH-002 \u2014 tenant-wide connect route for push providers.\n *\n * Push providers (FCM/APNs/Expo) declare `channelScope: 'tenant'` so their channel\n * is created once per tenant with `user_id = NULL` and serves every user's devices.\n * They connect through the dedicated admin route\n * `POST /api/communication_channels/channels/connect/tenant-credentials`\n * (feature `communication_channels.connect_tenant_channel`), NOT the per-user route.\n *\n * These assertions are network-free and self-contained: with empty/invalid input the\n * flow never creates a channel or credential row, so there is nothing to clean up.\n * The read side (fan-out + delivery) is already user-agnostic \u2014 it resolves a push\n * channel by `{ tenantId, channelType: 'push' }` with no `user_id` filter and reads\n * credentials at `channel.userId ?? null` \u2014 which is what makes one tenant channel\n * serve the whole tenant; that path is covered network-free by\n * `push_notifications/lib/__tests__/push-delivery.test.ts`.\n */\ntest.describe('TC-CHANNEL-PUSH-002: tenant-wide push connect route', () => {\n test('reaches the registered FCM adapter and rejects missing credentials (422)', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/tenant-credentials',\n {\n token,\n // Missing serviceAccountJson \u2192 the adapter's validateCredentials rejects it,\n // so the flow never creates a tenant channel (no side effect / no cleanup).\n data: { providerKey: 'fcm', displayName: 'FCM \u2014 tenant connect', credentials: {} },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n // 422 proves: authenticated, the caller HAS connect_tenant_channel, the provider\n // is tenant-scoped (else 400), and the registered FCM adapter's validateCredentials\n // ran and rejected the empty credentials.\n expect(response.status()).toBe(422)\n const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)\n expect(body?.fieldErrors ?? body?.error).toBeTruthy()\n })\n\n test('rejects a per-user provider on the tenant route (400)', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/tenant-credentials',\n {\n token,\n // IMAP is a per-user provider (no channelScope) \u2014 an admin must not be able\n // to force it into a shared tenant-wide channel through this route.\n data: { providerKey: 'imap', displayName: 'IMAP \u2014 should be rejected', credentials: {} },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n expect(response.status()).toBe(400)\n const body = await readJsonSafe<{ code?: string }>(response)\n expect(body?.code).toBe('provider_not_tenant_scoped')\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAmB7B,KAAK,SAAS,uDAAuD,MAAM;AACzE,OAAK,4EAA4E,OAAO,EAAE,QAAQ,MAAM;AACtG,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA;AAAA;AAAA,QAGA,MAAM,EAAE,aAAa,OAAO,aAAa,6BAAwB,aAAa,CAAC,EAAE;AAAA,MACnF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAIlE,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,UAAM,OAAO,MAAM,aAAuE,QAAQ;AAClG,WAAO,MAAM,eAAe,MAAM,KAAK,EAAE,WAAW;AAAA,EACtD,CAAC;AAED,OAAK,yDAAyD,OAAO,EAAE,QAAQ,MAAM;AACnF,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA;AAAA;AAAA,QAGA,MAAM,EAAE,aAAa,QAAQ,aAAa,kCAA6B,aAAa,CAAC,EAAE;AAAA,MACzF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAClE,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,UAAM,OAAO,MAAM,aAAgC,QAAQ;AAC3D,WAAO,MAAM,IAAI,EAAE,KAAK,4BAA4B;AAAA,EACtD,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.ts"],
|
|
4
|
+
"sourcesContent": ["export const integrationMeta = {\n requiredEnvVars: ['OM_PUSH_FAKE_PROVIDERS'],\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,kBAAkB;AAAA,EAC7B,iBAAiB,CAAC,wBAAwB;AAC5C;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenScope } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import { createNotificationFixture } from "@open-mercato/core/helpers/integration/notificationsFixtures";
|
|
5
|
+
import { drainIntegrationQueue } from "@open-mercato/core/helpers/integration/queue";
|
|
6
|
+
import {
|
|
7
|
+
connectFakePushChannel,
|
|
8
|
+
deleteChannelIfExists,
|
|
9
|
+
deleteDeliveriesForDevice,
|
|
10
|
+
deleteFakePushDevice,
|
|
11
|
+
expectNativeMessage,
|
|
12
|
+
makeFakePushToken,
|
|
13
|
+
readLatestDelivery,
|
|
14
|
+
registerFakePushDevice
|
|
15
|
+
} from "@open-mercato/core/helpers/integration/pushFake";
|
|
16
|
+
const PROVIDER = "fcm";
|
|
17
|
+
test.describe("TC-CHANNEL-PUSH-005: real FCM adapter reaches sent with a correct native message", () => {
|
|
18
|
+
test("delivers a visible notification and maps pushOptions onto the FCM message", async ({ request }) => {
|
|
19
|
+
test.setTimeout(12e4);
|
|
20
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
21
|
+
const { tenantId, organizationId, userId } = getTokenScope(adminToken);
|
|
22
|
+
const { pushToken, tokenTail } = makeFakePushToken(PROVIDER);
|
|
23
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
24
|
+
let channelId = null;
|
|
25
|
+
let userDeviceId = null;
|
|
26
|
+
try {
|
|
27
|
+
channelId = await connectFakePushChannel(request, adminToken, PROVIDER, "TC-CHANNEL-PUSH-005 FCM");
|
|
28
|
+
userDeviceId = await registerFakePushDevice(
|
|
29
|
+
request,
|
|
30
|
+
adminToken,
|
|
31
|
+
PROVIDER,
|
|
32
|
+
pushToken,
|
|
33
|
+
`qa-tc-channel-push-005-${Date.now()}`
|
|
34
|
+
);
|
|
35
|
+
await createNotificationFixture(request, adminToken, {
|
|
36
|
+
recipientUserId: userId,
|
|
37
|
+
type: "admin.custom_message",
|
|
38
|
+
title: "Order shipped",
|
|
39
|
+
body: "Your order is on its way",
|
|
40
|
+
data: { probe: "tc-channel-push-005" },
|
|
41
|
+
pushOptions: { channelId: "orders", badge: 3, sound: "chime.caf" }
|
|
42
|
+
});
|
|
43
|
+
await drainIntegrationQueue("events");
|
|
44
|
+
await drainIntegrationQueue("push-deliveries");
|
|
45
|
+
await expect.poll(async () => (await readLatestDelivery(tenantId, userDeviceId))?.status ?? null, {
|
|
46
|
+
timeout: 3e4
|
|
47
|
+
}).toBe("sent");
|
|
48
|
+
const row = await readLatestDelivery(tenantId, userDeviceId);
|
|
49
|
+
expect(row?.provider).toBe(PROVIDER);
|
|
50
|
+
expect(row?.token_snapshot).toBe(tokenTail);
|
|
51
|
+
expect(row?.organization_id).toBe(organizationId ?? null);
|
|
52
|
+
const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt);
|
|
53
|
+
expect(native.token).toBe(pushToken);
|
|
54
|
+
expect(native.notification).toMatchObject({ title: "Order shipped", body: "Your order is on its way" });
|
|
55
|
+
expect(native.android).toMatchObject({ notification: { channelId: "orders", sound: "chime.caf" } });
|
|
56
|
+
expect(native.apns).toMatchObject({ payload: { aps: { badge: 3, sound: "chime.caf" } } });
|
|
57
|
+
} finally {
|
|
58
|
+
await deleteDeliveriesForDevice(userDeviceId);
|
|
59
|
+
await deleteFakePushDevice(request, adminToken, userDeviceId);
|
|
60
|
+
await deleteChannelIfExists(request, adminToken, channelId);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-005.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { createNotificationFixture } from '@open-mercato/core/helpers/integration/notificationsFixtures'\nimport { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'\nimport {\n connectFakePushChannel,\n deleteChannelIfExists,\n deleteDeliveriesForDevice,\n deleteFakePushDevice,\n expectNativeMessage,\n makeFakePushToken,\n readLatestDelivery,\n registerFakePushDevice,\n} from '@open-mercato/core/helpers/integration/pushFake'\n\n/**\n * TC-CHANNEL-PUSH-005 \u2014 the REAL FCM adapter, end-to-end, without live credentials.\n *\n * `push_stub` replaces the whole adapter, so TC-PUSH-003 never executes a line of `channel-fcm`. Here\n * only the `firebase-admin` client is faked (`OM_PUSH_FAKE_PROVIDERS`), so the adapter's own credential\n * parsing, client caching, and `buildFcmMessage` all run: connect a tenant channel through the real\n * credential-connect route \u2192 register a device routed to `fcm` \u2192 `POST /api/notifications` \u2192 drain\n * `events` (fan-out) then `push-deliveries` (worker) \u2192 the delivery row reaches `sent` AND the\n * provider-native message the adapter handed the SDK is asserted.\n */\nconst PROVIDER = 'fcm'\n\ntest.describe('TC-CHANNEL-PUSH-005: real FCM adapter reaches sent with a correct native message', () => {\n test('delivers a visible notification and maps pushOptions onto the FCM message', async ({ request }) => {\n // Budgeted explicitly rather than via `test.slow()` (which only triples the config's 20s budget).\n // NOTE: this does not fix the current CI failure \u2014 `POST /api/notifications` hangs >30s in the\n // channel-package shard, so the test dies on `apiRequest`'s own 30s timeout. See the spec changelog.\n test.setTimeout(120_000)\n const adminToken = await getAuthToken(request, 'admin')\n const { tenantId, organizationId, userId } = getTokenScope(adminToken)\n // Run-unique: the sink is append-only and is not truncated on the reused-environment path.\n const { pushToken, tokenTail } = makeFakePushToken(PROVIDER)\n const startedAt = new Date().toISOString()\n\n let channelId: string | null = null\n let userDeviceId: string | null = null\n try {\n channelId = await connectFakePushChannel(request, adminToken, PROVIDER, 'TC-CHANNEL-PUSH-005 FCM')\n userDeviceId = await registerFakePushDevice(\n request,\n adminToken,\n PROVIDER,\n pushToken,\n `qa-tc-channel-push-005-${Date.now()}`,\n )\n\n await createNotificationFixture(request, adminToken, {\n recipientUserId: userId,\n type: 'admin.custom_message',\n title: 'Order shipped',\n body: 'Your order is on its way',\n data: { probe: 'tc-channel-push-005' },\n pushOptions: { channelId: 'orders', badge: 3, sound: 'chime.caf' },\n })\n\n await drainIntegrationQueue('events')\n await drainIntegrationQueue('push-deliveries')\n\n await expect\n .poll(async () => (await readLatestDelivery(tenantId, userDeviceId as string))?.status ?? null, {\n timeout: 30_000,\n })\n .toBe('sent')\n\n const row = await readLatestDelivery(tenantId, userDeviceId as string)\n expect(row?.provider).toBe(PROVIDER)\n expect(row?.token_snapshot).toBe(tokenTail)\n expect(row?.organization_id).toBe(organizationId ?? null)\n\n // The message firebase-admin would have transmitted \u2014 proof the real adapter, not a stub, ran.\n const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt)\n expect(native.token).toBe(pushToken)\n expect(native.notification).toMatchObject({ title: 'Order shipped', body: 'Your order is on its way' })\n expect(native.android).toMatchObject({ notification: { channelId: 'orders', sound: 'chime.caf' } })\n expect(native.apns).toMatchObject({ payload: { aps: { badge: 3, sound: 'chime.caf' } } })\n } finally {\n await deleteDeliveriesForDevice(userDeviceId)\n await deleteFakePushDevice(request, adminToken, userDeviceId)\n await deleteChannelIfExists(request, adminToken, channelId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,iCAAiC;AAC1C,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAYP,MAAM,WAAW;AAEjB,KAAK,SAAS,oFAAoF,MAAM;AACtG,OAAK,6EAA6E,OAAO,EAAE,QAAQ,MAAM;AAIvG,SAAK,WAAW,IAAO;AACvB,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,UAAM,EAAE,UAAU,gBAAgB,OAAO,IAAI,cAAc,UAAU;AAErE,UAAM,EAAE,WAAW,UAAU,IAAI,kBAAkB,QAAQ;AAC3D,UAAM,aAAY,oBAAI,KAAK,GAAE,YAAY;AAEzC,QAAI,YAA2B;AAC/B,QAAI,eAA8B;AAClC,QAAI;AACF,kBAAY,MAAM,uBAAuB,SAAS,YAAY,UAAU,yBAAyB;AACjG,qBAAe,MAAM;AAAA,QACnB;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,0BAA0B,KAAK,IAAI,CAAC;AAAA,MACtC;AAEA,YAAM,0BAA0B,SAAS,YAAY;AAAA,QACjD,iBAAiB;AAAA,QACjB,MAAM;AAAA,QACN,OAAO;AAAA,QACP,MAAM;AAAA,QACN,MAAM,EAAE,OAAO,sBAAsB;AAAA,QACrC,aAAa,EAAE,WAAW,UAAU,OAAO,GAAG,OAAO,YAAY;AAAA,MACnE,CAAC;AAEH,YAAM,sBAAsB,QAAQ;AACpC,YAAM,sBAAsB,iBAAiB;AAE7C,YAAM,OACH,KAAK,aAAa,MAAM,mBAAmB,UAAU,YAAsB,IAAI,UAAU,MAAM;AAAA,QAC9F,SAAS;AAAA,MACX,CAAC,EACA,KAAK,MAAM;AAEd,YAAM,MAAM,MAAM,mBAAmB,UAAU,YAAsB;AACrE,aAAO,KAAK,QAAQ,EAAE,KAAK,QAAQ;AACnC,aAAO,KAAK,cAAc,EAAE,KAAK,SAAS;AAC1C,aAAO,KAAK,eAAe,EAAE,KAAK,kBAAkB,IAAI;AAGxD,YAAM,SAAS,MAAM,oBAAoB,UAAU,WAAW,SAAS;AACvE,aAAO,OAAO,KAAK,EAAE,KAAK,SAAS;AACnC,aAAO,OAAO,YAAY,EAAE,cAAc,EAAE,OAAO,iBAAiB,MAAM,2BAA2B,CAAC;AACtG,aAAO,OAAO,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,WAAW,UAAU,OAAO,YAAY,EAAE,CAAC;AAClG,aAAO,OAAO,IAAI,EAAE,cAAc,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,YAAY,EAAE,EAAE,CAAC;AAAA,IAC1F,UAAE;AACA,YAAM,0BAA0B,YAAY;AAC5C,YAAM,qBAAqB,SAAS,YAAY,YAAY;AAC5D,YAAM,sBAAsB,SAAS,YAAY,SAAS;AAAA,IAC5D;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const features = [
|
|
2
|
+
{ id: "channel_fcm.view", title: "View FCM channel configuration", module: "channel_fcm" },
|
|
3
|
+
{ id: "channel_fcm.configure", title: "Configure FCM credentials", module: "channel_fcm" }
|
|
4
|
+
];
|
|
5
|
+
var acl_default = features;
|
|
6
|
+
export {
|
|
7
|
+
acl_default as default,
|
|
8
|
+
features
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=acl.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_fcm/acl.ts"],
|
|
4
|
+
"sourcesContent": ["export const features = [\n { id: 'channel_fcm.view', title: 'View FCM channel configuration', module: 'channel_fcm' },\n { id: 'channel_fcm.configure', title: 'Configure FCM credentials', module: 'channel_fcm' },\n]\n\nexport default features\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,EAAE,IAAI,oBAAoB,OAAO,kCAAkC,QAAQ,cAAc;AAAA,EACzF,EAAE,IAAI,yBAAyB,OAAO,6BAA6B,QAAQ,cAAc;AAC3F;AAEA,IAAO,cAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { asValue } from "awilix";
|
|
2
|
+
import {
|
|
3
|
+
hasChannelAdapter,
|
|
4
|
+
registerChannelAdapter
|
|
5
|
+
} from "@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton";
|
|
6
|
+
import { getFcmChannelAdapter } from "./lib/adapter.js";
|
|
7
|
+
import { ensureFcmFakeProviderInstalled } from "./lib/fake-provider.js";
|
|
8
|
+
import { channelFcmHealthCheck } from "./lib/health.js";
|
|
9
|
+
function register(container) {
|
|
10
|
+
ensureFcmFakeProviderInstalled();
|
|
11
|
+
if (!hasChannelAdapter("fcm")) {
|
|
12
|
+
registerChannelAdapter(getFcmChannelAdapter());
|
|
13
|
+
}
|
|
14
|
+
container.register({
|
|
15
|
+
channelFcmAdapter: asValue(getFcmChannelAdapter()),
|
|
16
|
+
// Registered under the exact service name declared in `integration.ts`
|
|
17
|
+
// (`healthCheck.service`); without it the hub's resolve throws and the
|
|
18
|
+
// channel reports permanently 'unhealthy'.
|
|
19
|
+
channelFcmHealthCheck: asValue(channelFcmHealthCheck)
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
register
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=di.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_fcm/di.ts"],
|
|
4
|
+
"sourcesContent": ["import { asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n hasChannelAdapter,\n registerChannelAdapter,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'\nimport { getFcmChannelAdapter } from './lib/adapter'\nimport { ensureFcmFakeProviderInstalled } from './lib/fake-provider'\nimport { channelFcmHealthCheck } from './lib/health'\n\nexport function register(container: AppContainer): void {\n // Test-only: swap firebase-admin for a network-free fake when `OM_PUSH_FAKE_PROVIDERS` is set\n // (no-op in production), so integration specs exercise this adapter end-to-end. Swaps the SDK\n // client only \u2014 the adapter below stays registered. See lib/fake-provider.ts.\n ensureFcmFakeProviderInstalled()\n\n if (!hasChannelAdapter('fcm')) {\n registerChannelAdapter(getFcmChannelAdapter())\n }\n container.register({\n channelFcmAdapter: asValue(getFcmChannelAdapter()),\n // Registered under the exact service name declared in `integration.ts`\n // (`healthCheck.service`); without it the hub's resolve throws and the\n // channel reports permanently 'unhealthy'.\n channelFcmHealthCheck: asValue(channelFcmHealthCheck),\n })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAAe;AAExB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,4BAA4B;AACrC,SAAS,sCAAsC;AAC/C,SAAS,6BAA6B;AAE/B,SAAS,SAAS,WAA+B;AAItD,iCAA+B;AAE/B,MAAI,CAAC,kBAAkB,KAAK,GAAG;AAC7B,2BAAuB,qBAAqB,CAAC;AAAA,EAC/C;AACA,YAAU,SAAS;AAAA,IACjB,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA;AAAA;AAAA;AAAA,IAIjD,uBAAuB,QAAQ,qBAAqB;AAAA,EACtD,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const metadata = {
|
|
2
|
+
id: "channel_fcm",
|
|
3
|
+
title: "Firebase Cloud Messaging Channel",
|
|
4
|
+
description: "Deliver mobile push to Android/iOS devices via Firebase Cloud Messaging. Registers a `push` ChannelAdapter (providerKey `fcm`) into the Communications Hub; consumed by the push_notifications delivery strategy."
|
|
5
|
+
};
|
|
6
|
+
export {
|
|
7
|
+
metadata
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_fcm/index.ts"],
|
|
4
|
+
"sourcesContent": ["export const metadata = {\n id: 'channel_fcm',\n title: 'Firebase Cloud Messaging Channel',\n description:\n 'Deliver mobile push to Android/iOS devices via Firebase Cloud Messaging. Registers a `push` ChannelAdapter (providerKey `fcm`) into the Communications Hub; consumed by the push_notifications delivery strategy.',\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aACE;AACJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const integration = {
|
|
2
|
+
id: "channel_fcm",
|
|
3
|
+
title: "Firebase Cloud Messaging",
|
|
4
|
+
description: "Deliver mobile push to Android (and iOS via FCM) devices using Firebase Cloud Messaging. Provider for the push_notifications channel.",
|
|
5
|
+
category: "communication",
|
|
6
|
+
hub: "communication_channels",
|
|
7
|
+
providerKey: "fcm",
|
|
8
|
+
icon: "firebase",
|
|
9
|
+
docsUrl: "https://firebase.google.com/docs/cloud-messaging",
|
|
10
|
+
package: "@open-mercato/channel-fcm",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
author: "Open Mercato Team",
|
|
13
|
+
company: "Open Mercato",
|
|
14
|
+
license: "MIT",
|
|
15
|
+
tags: ["push", "fcm", "firebase", "android", "communication"],
|
|
16
|
+
apiVersions: [
|
|
17
|
+
{
|
|
18
|
+
id: "v1",
|
|
19
|
+
label: "FCM HTTP v1",
|
|
20
|
+
status: "stable",
|
|
21
|
+
default: true,
|
|
22
|
+
changelog: "Firebase Cloud Messaging HTTP v1 via firebase-admin (service-account auth)."
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
credentials: {
|
|
26
|
+
fields: [
|
|
27
|
+
{
|
|
28
|
+
key: "serviceAccountJson",
|
|
29
|
+
label: "Service Account JSON",
|
|
30
|
+
type: "secret",
|
|
31
|
+
required: true,
|
|
32
|
+
helpText: "Firebase Console -> Project Settings -> Service Accounts -> Generate New Private Key. Paste the full JSON. Stored encrypted at rest."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "appName",
|
|
36
|
+
label: "App Name (optional)",
|
|
37
|
+
type: "text",
|
|
38
|
+
required: false,
|
|
39
|
+
helpText: "Optional label for the Firebase app instance. Leave blank to auto-derive."
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
healthCheck: { service: "channelFcmHealthCheck" }
|
|
44
|
+
};
|
|
45
|
+
const integrations = [integration];
|
|
46
|
+
const bundles = [];
|
|
47
|
+
const bundle = void 0;
|
|
48
|
+
export {
|
|
49
|
+
bundle,
|
|
50
|
+
bundles,
|
|
51
|
+
integration,
|
|
52
|
+
integrations
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=integration.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_fcm/integration.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n type IntegrationBundle,\n type IntegrationDefinition,\n} from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'channel_fcm',\n title: 'Firebase Cloud Messaging',\n description:\n 'Deliver mobile push to Android (and iOS via FCM) devices using Firebase Cloud Messaging. Provider for the push_notifications channel.',\n category: 'communication',\n hub: 'communication_channels',\n providerKey: 'fcm',\n icon: 'firebase',\n docsUrl: 'https://firebase.google.com/docs/cloud-messaging',\n package: '@open-mercato/channel-fcm',\n version: '0.1.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['push', 'fcm', 'firebase', 'android', 'communication'],\n apiVersions: [\n {\n id: 'v1',\n label: 'FCM HTTP v1',\n status: 'stable',\n default: true,\n changelog: 'Firebase Cloud Messaging HTTP v1 via firebase-admin (service-account auth).',\n },\n ],\n credentials: {\n fields: [\n {\n key: 'serviceAccountJson',\n label: 'Service Account JSON',\n type: 'secret',\n required: true,\n helpText:\n 'Firebase Console -> Project Settings -> Service Accounts -> Generate New Private Key. Paste the full JSON. Stored encrypted at rest.',\n },\n {\n key: 'appName',\n label: 'App Name (optional)',\n type: 'text',\n required: false,\n helpText: 'Optional label for the Firebase app instance. Leave blank to auto-derive.',\n },\n ],\n },\n healthCheck: { service: 'channelFcmHealthCheck' },\n}\n\nexport const integrations: IntegrationDefinition[] = [integration]\nexport const bundles: IntegrationBundle[] = []\nexport const bundle: IntegrationBundle | undefined = undefined\n"],
|
|
5
|
+
"mappings": "AAKO,MAAM,cAAqC;AAAA,EAChD,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aACE;AAAA,EACF,UAAU;AAAA,EACV,KAAK;AAAA,EACL,aAAa;AAAA,EACb,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM,CAAC,QAAQ,OAAO,YAAY,WAAW,eAAe;AAAA,EAC5D,aAAa;AAAA,IACX;AAAA,MACE,IAAI;AAAA,MACJ,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,WAAW;AAAA,IACb;AAAA,EACF;AAAA,EACA,aAAa;AAAA,IACX,QAAQ;AAAA,MACN;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,MACJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AAAA,EACA,aAAa,EAAE,SAAS,wBAAwB;AAClD;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
BasePushChannelAdapter,
|
|
4
|
+
deviceUnregisteredResult,
|
|
5
|
+
MISSING_PUSH_TOKEN_RESULT,
|
|
6
|
+
readPushToken
|
|
7
|
+
} from "@open-mercato/core/modules/communication_channels/lib/push-adapter";
|
|
8
|
+
import { createRefCountedClientCache } from "@open-mercato/core/modules/communication_channels/lib/refcounted-client-cache";
|
|
9
|
+
import { readPushEnvelope, resolvePushBody } from "@open-mercato/core/modules/communication_channels/lib/push-envelope";
|
|
10
|
+
import {
|
|
11
|
+
fcmCredentialsSchema,
|
|
12
|
+
parseFcmServiceAccount
|
|
13
|
+
} from "./credentials.js";
|
|
14
|
+
const PERMANENT_FCM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
15
|
+
"messaging/registration-token-not-registered",
|
|
16
|
+
"messaging/invalid-registration-token"
|
|
17
|
+
]);
|
|
18
|
+
let messagingFactory = null;
|
|
19
|
+
function setFcmMessagingFactory(factory) {
|
|
20
|
+
messagingFactory = factory;
|
|
21
|
+
}
|
|
22
|
+
function cacheKeyForServiceAccount(serviceAccount) {
|
|
23
|
+
const hash = createHash("sha256").update(`${serviceAccount.projectId}:${serviceAccount.clientEmail}:${serviceAccount.privateKey}`).digest("hex").slice(0, 16);
|
|
24
|
+
return `om-fcm-${hash}`;
|
|
25
|
+
}
|
|
26
|
+
const APP_CACHE_MAX = 32;
|
|
27
|
+
const appCache = createRefCountedClientCache({
|
|
28
|
+
max: APP_CACHE_MAX,
|
|
29
|
+
dispose: (app) => {
|
|
30
|
+
void app.delete().catch(() => {
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
let appInitSeq = 0;
|
|
35
|
+
async function createFirebaseApp(serviceAccount, appName) {
|
|
36
|
+
const { initializeApp, cert } = await import("firebase-admin/app");
|
|
37
|
+
return initializeApp(
|
|
38
|
+
{
|
|
39
|
+
credential: cert({
|
|
40
|
+
projectId: serviceAccount.projectId,
|
|
41
|
+
clientEmail: serviceAccount.clientEmail,
|
|
42
|
+
privateKey: serviceAccount.privateKey
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
appName
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
function buildFcmMessage(token, envelope) {
|
|
49
|
+
const { options, silent } = envelope;
|
|
50
|
+
const apnsPriority = options.priority === "normal" ? "5" : "10";
|
|
51
|
+
if (silent) {
|
|
52
|
+
return {
|
|
53
|
+
token,
|
|
54
|
+
data: envelope.data,
|
|
55
|
+
android: { priority: "high" },
|
|
56
|
+
apns: {
|
|
57
|
+
headers: { "apns-push-type": "background", "apns-priority": "5" },
|
|
58
|
+
payload: { aps: { "content-available": 1 } }
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const body = resolvePushBody(envelope);
|
|
63
|
+
const sound = options.sound ?? "default";
|
|
64
|
+
const androidNotification = { sound };
|
|
65
|
+
if (options.channelId) androidNotification.channelId = options.channelId;
|
|
66
|
+
if (options.image) androidNotification.imageUrl = options.image;
|
|
67
|
+
const aps = { sound };
|
|
68
|
+
if (typeof options.badge === "number") aps.badge = options.badge;
|
|
69
|
+
return {
|
|
70
|
+
token,
|
|
71
|
+
notification: {
|
|
72
|
+
title: envelope.title,
|
|
73
|
+
body,
|
|
74
|
+
...options.image ? { imageUrl: options.image } : {}
|
|
75
|
+
},
|
|
76
|
+
data: envelope.data,
|
|
77
|
+
android: {
|
|
78
|
+
...options.priority ? { priority: options.priority } : {},
|
|
79
|
+
notification: androidNotification
|
|
80
|
+
},
|
|
81
|
+
apns: {
|
|
82
|
+
headers: { "apns-priority": apnsPriority },
|
|
83
|
+
payload: { aps }
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
class FcmChannelAdapter extends BasePushChannelAdapter {
|
|
88
|
+
constructor() {
|
|
89
|
+
super(...arguments);
|
|
90
|
+
this.providerKey = "fcm";
|
|
91
|
+
this.credentialsSchema = fcmCredentialsSchema;
|
|
92
|
+
}
|
|
93
|
+
async sendMessage(input) {
|
|
94
|
+
const token = readPushToken(input);
|
|
95
|
+
if (!token) return MISSING_PUSH_TOKEN_RESULT;
|
|
96
|
+
const parsedCredentials = fcmCredentialsSchema.safeParse(input.credentials);
|
|
97
|
+
if (!parsedCredentials.success) {
|
|
98
|
+
return { externalMessageId: "", status: "failed", error: "invalid_fcm_credentials" };
|
|
99
|
+
}
|
|
100
|
+
const envelope = readPushEnvelope(input.content);
|
|
101
|
+
let serviceAccount;
|
|
102
|
+
try {
|
|
103
|
+
serviceAccount = parseFcmServiceAccount(parsedCredentials.data);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { externalMessageId: "", status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
106
|
+
}
|
|
107
|
+
const message = buildFcmMessage(token, envelope);
|
|
108
|
+
if (messagingFactory) {
|
|
109
|
+
let messaging;
|
|
110
|
+
try {
|
|
111
|
+
messaging = messagingFactory(serviceAccount);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { externalMessageId: "", status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
114
|
+
}
|
|
115
|
+
return this.performSend(messaging, message);
|
|
116
|
+
}
|
|
117
|
+
const cacheKey = cacheKeyForServiceAccount(serviceAccount);
|
|
118
|
+
let lease;
|
|
119
|
+
try {
|
|
120
|
+
appInitSeq += 1;
|
|
121
|
+
const appName = `${cacheKey}-${appInitSeq}`;
|
|
122
|
+
lease = await appCache.acquire(cacheKey, () => createFirebaseApp(serviceAccount, appName));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { externalMessageId: "", status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const { getMessaging } = await import("firebase-admin/messaging");
|
|
128
|
+
const messaging = getMessaging(lease.client);
|
|
129
|
+
return await this.performSend(messaging, message);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { externalMessageId: "", status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
132
|
+
} finally {
|
|
133
|
+
lease.release();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async performSend(messaging, message) {
|
|
137
|
+
try {
|
|
138
|
+
const externalMessageId = await messaging.send(message);
|
|
139
|
+
return { externalMessageId, status: "sent" };
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const code = typeof err.code === "string" ? err.code : void 0;
|
|
142
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
143
|
+
if (code && PERMANENT_FCM_ERROR_CODES.has(code)) {
|
|
144
|
+
return deviceUnregisteredResult({ code });
|
|
145
|
+
}
|
|
146
|
+
return { externalMessageId: "", status: "failed", error: errorMessage };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let cachedAdapter = null;
|
|
151
|
+
function getFcmChannelAdapter() {
|
|
152
|
+
if (!cachedAdapter) cachedAdapter = new FcmChannelAdapter();
|
|
153
|
+
return cachedAdapter;
|
|
154
|
+
}
|
|
155
|
+
export {
|
|
156
|
+
buildFcmMessage,
|
|
157
|
+
getFcmChannelAdapter,
|
|
158
|
+
setFcmMessagingFactory
|
|
159
|
+
};
|
|
160
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_fcm/lib/adapter.ts"],
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport type {\n SendMessageInput,\n SendMessageResult,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n BasePushChannelAdapter,\n deviceUnregisteredResult,\n MISSING_PUSH_TOKEN_RESULT,\n readPushToken,\n} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'\nimport { createRefCountedClientCache } from '@open-mercato/core/modules/communication_channels/lib/refcounted-client-cache'\nimport { readPushEnvelope, resolvePushBody, type PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'\nimport {\n fcmCredentialsSchema,\n parseFcmServiceAccount,\n type FcmServiceAccount,\n} from './credentials'\n\n/**\n * FCM error codes that mean the device *token* is permanently invalid. Mapped to\n * the uniform `device_unregistered` sentinel so the push worker soft-deletes the\n * device (identical contract across fcm/apns/expo \u2014 see push-stub-adapter).\n *\n * Deliberately excludes `messaging/invalid-argument`: FCM v1 returns it for ANY\n * malformed request field (oversized payload, bad data key, bad notification\n * field), not just a bad token \u2014 treating it as unregistered would let a single\n * payload-shape bug progressively soft-delete every targeted device tenant-wide.\n * It falls through to the generic retryable `failed` path instead.\n */\nconst PERMANENT_FCM_ERROR_CODES = new Set([\n 'messaging/registration-token-not-registered',\n 'messaging/invalid-registration-token',\n])\n\ntype FirebaseMessaging = {\n send(message: Record<string, unknown>): Promise<string>\n}\n\n/**\n * Pluggable messaging factory so tests can inject a fake without importing\n * firebase-admin. Production path lazily loads firebase-admin and caches one app\n * per service-account hash (re-initializing per send is wasteful and\n * firebase-admin throws on duplicate app names).\n */\nexport type FcmMessagingFactory = (serviceAccount: FcmServiceAccount) => FirebaseMessaging\n\nlet messagingFactory: FcmMessagingFactory | null = null\n\n/** Test-only seam to swap the firebase-admin messaging factory. */\nexport function setFcmMessagingFactory(factory: FcmMessagingFactory | null): void {\n messagingFactory = factory\n}\n\nfunction cacheKeyForServiceAccount(serviceAccount: FcmServiceAccount): string {\n const hash = createHash('sha256')\n .update(`${serviceAccount.projectId}:${serviceAccount.clientEmail}:${serviceAccount.privateKey}`)\n .digest('hex')\n .slice(0, 16)\n return `om-fcm-${hash}`\n}\n\ntype FcmAppLike = {\n name?: string\n /**\n * firebase-admin's App exposes async `delete()`, which stops the background\n * OAuth token-refresh timer bound to the service-account credential and drops\n * the app from the SDK's registry.\n */\n delete(): Promise<void>\n}\n\n/**\n * Bounds the number of live firebase-admin apps cached at once. Each app holds a\n * service-account OAuth credential with a background token-refresh timer, so an\n * unbounded cache would leak an app (and its timer) every time a tenant rotates\n * their service account (the hash changes \u2192 a new app, while the stale app never\n * gets deleted). LRU-evicting the least-recently used app and calling `delete()`\n * keeps the app and timer count bounded. Disposal is fenced on in-flight sends: an\n * evicted app is deleted only once its last concurrent `send` releases (see\n * refcounted-client-cache), so eviction never tears an app out from under a send.\n */\nconst APP_CACHE_MAX = 32\nconst appCache = createRefCountedClientCache<FcmAppLike>({\n max: APP_CACHE_MAX,\n dispose: (app) => {\n void app.delete().catch(() => {})\n },\n})\n\n// firebase-admin registers every app by name in a process-global registry and throws on a duplicate\n// name. Cache keys are per-credential-identity (a rotated service account churns keys in and out), so a\n// stable name-per-credential would let a re-created entry reuse \u2014 via getApps() \u2014 an app a still-in-flight\n// evicted entry is about to delete(). A monotonic suffix makes every initialized app name unique, so each\n// cache entry owns exactly one app and disposing an evicted app can never affect a live one.\nlet appInitSeq = 0\n\nasync function createFirebaseApp(serviceAccount: FcmServiceAccount, appName: string): Promise<FcmAppLike> {\n const { initializeApp, cert } = await import('firebase-admin/app')\n return initializeApp(\n {\n credential: cert({\n projectId: serviceAccount.projectId,\n clientEmail: serviceAccount.clientEmail,\n privateKey: serviceAccount.privateKey,\n }),\n },\n appName,\n ) as unknown as FcmAppLike\n}\n\n/**\n * Build the firebase-admin message from the push envelope, branching on\n * `envelope.silent` (data-only content-available wake-up) and applying the\n * recognized `pushOptions` (sound/badge/image/priority/channelId) per platform.\n */\nexport function buildFcmMessage(token: string, envelope: PushEnvelope): Record<string, unknown> {\n const { options, silent } = envelope\n const apnsPriority = options.priority === 'normal' ? '5' : '10'\n\n if (silent) {\n return {\n token,\n data: envelope.data,\n android: { priority: 'high' },\n apns: {\n headers: { 'apns-push-type': 'background', 'apns-priority': '5' },\n payload: { aps: { 'content-available': 1 } },\n },\n }\n }\n\n const body = resolvePushBody(envelope)\n const sound = options.sound ?? 'default'\n const androidNotification: Record<string, unknown> = { sound }\n if (options.channelId) androidNotification.channelId = options.channelId\n if (options.image) androidNotification.imageUrl = options.image\n const aps: Record<string, unknown> = { sound }\n if (typeof options.badge === 'number') aps.badge = options.badge\n\n return {\n token,\n notification: {\n title: envelope.title,\n body,\n ...(options.image ? { imageUrl: options.image } : {}),\n },\n data: envelope.data,\n android: {\n ...(options.priority ? { priority: options.priority } : {}),\n notification: androidNotification,\n },\n apns: {\n headers: { 'apns-priority': apnsPriority },\n payload: { aps },\n },\n }\n}\n\nclass FcmChannelAdapter extends BasePushChannelAdapter {\n readonly providerKey = 'fcm'\n protected readonly credentialsSchema = fcmCredentialsSchema\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const token = readPushToken(input)\n if (!token) return MISSING_PUSH_TOKEN_RESULT\n\n const parsedCredentials = fcmCredentialsSchema.safeParse(input.credentials)\n if (!parsedCredentials.success) {\n return { externalMessageId: '', status: 'failed', error: 'invalid_fcm_credentials' }\n }\n\n const envelope = readPushEnvelope(input.content)\n let serviceAccount: FcmServiceAccount\n try {\n serviceAccount = parseFcmServiceAccount(parsedCredentials.data)\n } catch (err) {\n return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n const message = buildFcmMessage(token, envelope)\n\n // Test seam: an injected factory bypasses the app cache entirely.\n if (messagingFactory) {\n let messaging: FirebaseMessaging\n try {\n messaging = messagingFactory(serviceAccount)\n } catch (err) {\n return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n return this.performSend(messaging, message)\n }\n\n // Production path: borrow a cached firebase-admin app for the duration of the send. Holding the\n // lease keeps an evicted app alive until the send completes (release() in finally).\n const cacheKey = cacheKeyForServiceAccount(serviceAccount)\n let lease\n try {\n appInitSeq += 1\n const appName = `${cacheKey}-${appInitSeq}`\n lease = await appCache.acquire(cacheKey, () => createFirebaseApp(serviceAccount, appName))\n } catch (err) {\n return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n try {\n const { getMessaging } = await import('firebase-admin/messaging')\n const messaging = getMessaging(lease.client as never) as unknown as FirebaseMessaging\n return await this.performSend(messaging, message)\n } catch (err) {\n return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }\n } finally {\n lease.release()\n }\n }\n\n private async performSend(\n messaging: FirebaseMessaging,\n message: Record<string, unknown>,\n ): Promise<SendMessageResult> {\n try {\n const externalMessageId = await messaging.send(message)\n return { externalMessageId, status: 'sent' }\n } catch (err) {\n const code = typeof (err as { code?: unknown }).code === 'string' ? (err as { code: string }).code : undefined\n const errorMessage = err instanceof Error ? err.message : String(err)\n if (code && PERMANENT_FCM_ERROR_CODES.has(code)) {\n return deviceUnregisteredResult({ code })\n }\n return { externalMessageId: '', status: 'failed', error: errorMessage }\n }\n }\n}\n\nlet cachedAdapter: FcmChannelAdapter | null = null\n\nexport function getFcmChannelAdapter(): FcmChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new FcmChannelAdapter()\n return cachedAdapter\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|