@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,56 @@
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
+ const fcmServiceAccountSchema = z.object({
8
+ projectId: z.string().min(1, "project_id missing"),
9
+ clientEmail: z.string().min(1, "client_email missing"),
10
+ privateKey: z.string().min(1, "private_key missing")
11
+ }).passthrough();
12
+ function normalizeServiceAccount(raw) {
13
+ return fcmServiceAccountSchema.parse({
14
+ projectId: raw.projectId ?? raw.project_id,
15
+ clientEmail: raw.clientEmail ?? raw.client_email,
16
+ privateKey: raw.privateKey ?? raw.private_key
17
+ });
18
+ }
19
+ const fcmCredentialsSchema = z.object({
20
+ serviceAccountJson: z.string().min(1, PUSH_CREDENTIAL_ERROR_REQUIRED),
21
+ appName: z.string().optional()
22
+ }).passthrough().superRefine((value, ctx) => {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(value.serviceAccountJson);
26
+ } catch {
27
+ ctx.addIssue({
28
+ code: z.ZodIssueCode.custom,
29
+ path: ["serviceAccountJson"],
30
+ message: PUSH_CREDENTIAL_ERROR_INVALID_JSON
31
+ });
32
+ return;
33
+ }
34
+ const account = fcmServiceAccountSchema.safeParse({
35
+ projectId: parsed.projectId ?? parsed.project_id,
36
+ clientEmail: parsed.clientEmail ?? parsed.client_email,
37
+ privateKey: parsed.privateKey ?? parsed.private_key
38
+ });
39
+ if (!account.success) {
40
+ ctx.addIssue({
41
+ code: z.ZodIssueCode.custom,
42
+ path: ["serviceAccountJson"],
43
+ message: PUSH_CREDENTIAL_ERROR_MISSING_FIELDS
44
+ });
45
+ }
46
+ });
47
+ function parseFcmServiceAccount(credentials) {
48
+ const parsed = JSON.parse(credentials.serviceAccountJson);
49
+ return normalizeServiceAccount(parsed);
50
+ }
51
+ export {
52
+ fcmCredentialsSchema,
53
+ fcmServiceAccountSchema,
54
+ parseFcmServiceAccount
55
+ };
56
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_fcm/lib/credentials.ts"],
4
+ "sourcesContent": ["import { z } from 'zod'\nimport {\n PUSH_CREDENTIAL_ERROR_INVALID_JSON,\n PUSH_CREDENTIAL_ERROR_MISSING_FIELDS,\n PUSH_CREDENTIAL_ERROR_REQUIRED,\n} from '@open-mercato/core/modules/communication_channels/lib/push-credential-errors'\n\n/**\n * Firebase service account shape (camelCase) used to mint FCM credentials.\n * Source JSON from the Firebase console uses snake_case keys, normalized by\n * {@link parseFcmServiceAccount}.\n */\nexport const fcmServiceAccountSchema = z\n .object({\n projectId: z.string().min(1, 'project_id missing'),\n clientEmail: z.string().min(1, 'client_email missing'),\n privateKey: z.string().min(1, 'private_key missing'),\n })\n .passthrough()\n\nexport type FcmServiceAccount = z.infer<typeof fcmServiceAccountSchema>\n\nfunction normalizeServiceAccount(raw: Record<string, unknown>): FcmServiceAccount {\n return fcmServiceAccountSchema.parse({\n projectId: raw.projectId ?? raw.project_id,\n clientEmail: raw.clientEmail ?? raw.client_email,\n privateKey: raw.privateKey ?? raw.private_key,\n })\n}\n\n/**\n * Tenant-level FCM credentials persisted on `IntegrationCredentials` for provider\n * `channel_fcm`. `serviceAccountJson` is the full Firebase service-account JSON\n * (stored encrypted at rest); `appName` is an optional label for the cached\n * firebase-admin app.\n */\nexport const fcmCredentialsSchema = z\n .object({\n serviceAccountJson: z.string().min(1, PUSH_CREDENTIAL_ERROR_REQUIRED),\n appName: z.string().optional(),\n })\n .passthrough()\n .superRefine((value, ctx) => {\n // Two distinct failures, two distinct codes. Never interpolate the caught\n // error: a `normalizeServiceAccount` rejection is a ZodError whose\n // `.message` is a JSON dump of the issue array, which used to reach the\n // operator verbatim through `fieldErrors`.\n let parsed: Record<string, unknown>\n try {\n parsed = JSON.parse(value.serviceAccountJson) as Record<string, unknown>\n } catch {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['serviceAccountJson'],\n message: PUSH_CREDENTIAL_ERROR_INVALID_JSON,\n })\n return\n }\n const account = fcmServiceAccountSchema.safeParse({\n projectId: parsed.projectId ?? parsed.project_id,\n clientEmail: parsed.clientEmail ?? parsed.client_email,\n privateKey: parsed.privateKey ?? parsed.private_key,\n })\n if (!account.success) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['serviceAccountJson'],\n message: PUSH_CREDENTIAL_ERROR_MISSING_FIELDS,\n })\n }\n })\n\nexport type FcmCredentials = z.infer<typeof fcmCredentialsSchema>\n\n/** Parse and normalize the service account out of validated credentials. Throws on malformed JSON. */\nexport function parseFcmServiceAccount(credentials: FcmCredentials): FcmServiceAccount {\n const parsed = JSON.parse(credentials.serviceAccountJson) as Record<string, unknown>\n return normalizeServiceAccount(parsed)\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAClB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAOA,MAAM,0BAA0B,EACpC,OAAO;AAAA,EACN,WAAW,EAAE,OAAO,EAAE,IAAI,GAAG,oBAAoB;AAAA,EACjD,aAAa,EAAE,OAAO,EAAE,IAAI,GAAG,sBAAsB;AAAA,EACrD,YAAY,EAAE,OAAO,EAAE,IAAI,GAAG,qBAAqB;AACrD,CAAC,EACA,YAAY;AAIf,SAAS,wBAAwB,KAAiD;AAChF,SAAO,wBAAwB,MAAM;AAAA,IACnC,WAAW,IAAI,aAAa,IAAI;AAAA,IAChC,aAAa,IAAI,eAAe,IAAI;AAAA,IACpC,YAAY,IAAI,cAAc,IAAI;AAAA,EACpC,CAAC;AACH;AAQO,MAAM,uBAAuB,EACjC,OAAO;AAAA,EACN,oBAAoB,EAAE,OAAO,EAAE,IAAI,GAAG,8BAA8B;AAAA,EACpE,SAAS,EAAE,OAAO,EAAE,SAAS;AAC/B,CAAC,EACA,YAAY,EACZ,YAAY,CAAC,OAAO,QAAQ;AAK3B,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,MAAM,kBAAkB;AAAA,EAC9C,QAAQ;AACN,QAAI,SAAS;AAAA,MACX,MAAM,EAAE,aAAa;AAAA,MACrB,MAAM,CAAC,oBAAoB;AAAA,MAC3B,SAAS;AAAA,IACX,CAAC;AACD;AAAA,EACF;AACA,QAAM,UAAU,wBAAwB,UAAU;AAAA,IAChD,WAAW,OAAO,aAAa,OAAO;AAAA,IACtC,aAAa,OAAO,eAAe,OAAO;AAAA,IAC1C,YAAY,OAAO,cAAc,OAAO;AAAA,EAC1C,CAAC;AACD,MAAI,CAAC,QAAQ,SAAS;AACpB,QAAI,SAAS;AAAA,MACX,MAAM,EAAE,aAAa;AAAA,MACrB,MAAM,CAAC,oBAAoB;AAAA,MAC3B,SAAS;AAAA,IACX,CAAC;AAAA,EACH;AACF,CAAC;AAKI,SAAS,uBAAuB,aAAgD;AACrF,QAAM,SAAS,KAAK,MAAM,YAAY,kBAAkB;AACxD,SAAO,wBAAwB,MAAM;AACvC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,32 @@
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.js";
7
+ function fakeSendError(message, code) {
8
+ const error = new Error(message);
9
+ if (code) Object.assign(error, { code });
10
+ return error;
11
+ }
12
+ function ensureFcmFakeProviderInstalled() {
13
+ if (!isPushFakeProvidersEnabled()) return;
14
+ warnPushFakeProvidersActive("fcm");
15
+ setFcmMessagingFactory(() => ({
16
+ async send(message) {
17
+ const token = typeof message.token === "string" ? message.token : "";
18
+ recordFakePush("fcm", token, message);
19
+ if (token.includes("unregistered")) {
20
+ throw fakeSendError("fake fcm token not registered", "messaging/registration-token-not-registered");
21
+ }
22
+ if (token.includes("fail")) {
23
+ throw fakeSendError("fake fcm transient failure");
24
+ }
25
+ return `fcm-fake-${token.slice(-8) || "token"}`;
26
+ }
27
+ }));
28
+ }
29
+ export {
30
+ ensureFcmFakeProviderInstalled
31
+ };
32
+ //# sourceMappingURL=fake-provider.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_fcm/lib/fake-provider.ts"],
4
+ "sourcesContent": ["import {\n isPushFakeProvidersEnabled,\n recordFakePush,\n warnPushFakeProvidersActive,\n} from '@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder'\nimport { setFcmMessagingFactory } from './adapter'\n\n/**\n * Network-free `firebase-admin` messaging client used ONLY by integration tests.\n *\n * Swaps the SDK client behind the adapter's existing seam, so every line of the real adapter still\n * runs \u2014 message construction, credential parsing, client caching, and the\n * `messaging/registration-token-not-registered` \u2192 `device_unregistered` mapping. The adapter itself is\n * never replaced (unlike `push_stub`), and is never re-registered: `registerChannelAdapter` throws on a\n * duplicate provider key.\n *\n * Token sentinels match `push_stub`'s convention (see push-stub-adapter.ts):\n * - token containing `unregistered` \u2192 FCM's native permanent-token error code\n * - token containing `fail` \u2192 a retryable error (no `code`, so the adapter retries)\n * - otherwise \u2192 success\n *\n * Production safety: never installed at module import; no-op unless `OM_PUSH_FAKE_PROVIDERS` is set.\n */\nfunction fakeSendError(message: string, code?: string): Error {\n const error = new Error(message)\n if (code) Object.assign(error, { code })\n return error\n}\n\nexport function ensureFcmFakeProviderInstalled(): void {\n if (!isPushFakeProvidersEnabled()) return\n warnPushFakeProvidersActive('fcm')\n setFcmMessagingFactory(() => ({\n async send(message: Record<string, unknown>): Promise<string> {\n const token = typeof message.token === 'string' ? message.token : ''\n recordFakePush('fcm', token, message)\n if (token.includes('unregistered')) {\n throw fakeSendError('fake fcm token not registered', 'messaging/registration-token-not-registered')\n }\n if (token.includes('fail')) {\n throw fakeSendError('fake fcm transient failure')\n }\n return `fcm-fake-${token.slice(-8) || 'token'}`\n },\n }))\n}\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,8BAA8B;AAkBvC,SAAS,cAAc,SAAiB,MAAsB;AAC5D,QAAM,QAAQ,IAAI,MAAM,OAAO;AAC/B,MAAI,KAAM,QAAO,OAAO,OAAO,EAAE,KAAK,CAAC;AACvC,SAAO;AACT;AAEO,SAAS,iCAAuC;AACrD,MAAI,CAAC,2BAA2B,EAAG;AACnC,8BAA4B,KAAK;AACjC,yBAAuB,OAAO;AAAA,IAC5B,MAAM,KAAK,SAAmD;AAC5D,YAAM,QAAQ,OAAO,QAAQ,UAAU,WAAW,QAAQ,QAAQ;AAClE,qBAAe,OAAO,OAAO,OAAO;AACpC,UAAI,MAAM,SAAS,cAAc,GAAG;AAClC,cAAM,cAAc,iCAAiC,6CAA6C;AAAA,MACpG;AACA,UAAI,MAAM,SAAS,MAAM,GAAG;AAC1B,cAAM,cAAc,4BAA4B;AAAA,MAClD;AACA,aAAO,YAAY,MAAM,MAAM,EAAE,KAAK,OAAO;AAAA,IAC/C;AAAA,EACF,EAAE;AACJ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,10 @@
1
+ import { makePushClientConfigHealthCheck } from "@open-mercato/core/modules/push_notifications/lib/push-health";
2
+ import { fcmCredentialsSchema } from "./credentials.js";
3
+ const channelFcmHealthCheck = makePushClientConfigHealthCheck({
4
+ schema: fcmCredentialsSchema,
5
+ providerLabel: "FCM"
6
+ });
7
+ export {
8
+ channelFcmHealthCheck
9
+ };
10
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_fcm/lib/health.ts"],
4
+ "sourcesContent": ["import { makePushClientConfigHealthCheck } from '@open-mercato/core/modules/push_notifications/lib/push-health'\nimport { fcmCredentialsSchema } from './credentials'\n\n/**\n * Liveness probe for the FCM integration. The hub passes the tenant-scoped\n * credentials (the service-account JSON), so the probe just confirms they are\n * present and parse into a valid service account \u2014 no network call. Per-device\n * token validity surfaces on delivery (`device_unregistered` soft-deletes).\n */\nexport const channelFcmHealthCheck = makePushClientConfigHealthCheck({\n schema: fcmCredentialsSchema,\n providerLabel: 'FCM',\n})\n"],
5
+ "mappings": "AAAA,SAAS,uCAAuC;AAChD,SAAS,4BAA4B;AAQ9B,MAAM,wBAAwB,gCAAgC;AAAA,EACnE,QAAQ;AAAA,EACR,eAAe;AACjB,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,25 @@
1
+ import {
2
+ hasChannelAdapter,
3
+ registerChannelAdapter
4
+ } from "@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton";
5
+ import { getFcmChannelAdapter } from "./lib/adapter.js";
6
+ function ensureFcmAdapterRegistered() {
7
+ if (hasChannelAdapter("fcm")) return;
8
+ registerChannelAdapter(getFcmChannelAdapter());
9
+ }
10
+ ensureFcmAdapterRegistered();
11
+ const setup = {
12
+ defaultRoleFeatures: {
13
+ superadmin: ["channel_fcm.view", "channel_fcm.configure"],
14
+ admin: ["channel_fcm.view", "channel_fcm.configure"]
15
+ },
16
+ async onTenantCreated() {
17
+ ensureFcmAdapterRegistered();
18
+ }
19
+ };
20
+ var setup_default = setup;
21
+ export {
22
+ setup_default as default,
23
+ setup
24
+ };
25
+ //# sourceMappingURL=setup.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/channel_fcm/setup.ts"],
4
+ "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport {\n hasChannelAdapter,\n registerChannelAdapter,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'\nimport { getFcmChannelAdapter } from './lib/adapter'\n\n/**\n * Register the FCM `ChannelAdapter` once per process at import time. The registry\n * is process-wide; we guard with `hasChannelAdapter` to silence the duplicate\n * error on dev-mode HMR + repeated test imports.\n *\n * Provider credentials (the Firebase service account) are persisted per tenant via\n * the standard `IntegrationCredentials` flow for the `channel_fcm` provider; this\n * module never preconfigures credentials from env.\n */\nfunction ensureFcmAdapterRegistered(): void {\n if (hasChannelAdapter('fcm')) return\n registerChannelAdapter(getFcmChannelAdapter())\n}\n\nensureFcmAdapterRegistered()\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['channel_fcm.view', 'channel_fcm.configure'],\n admin: ['channel_fcm.view', 'channel_fcm.configure'],\n },\n async onTenantCreated() {\n ensureFcmAdapterRegistered()\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,4BAA4B;AAWrC,SAAS,6BAAmC;AAC1C,MAAI,kBAAkB,KAAK,EAAG;AAC9B,yBAAuB,qBAAqB,CAAC;AAC/C;AAEA,2BAA2B;AAEpB,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,YAAY,CAAC,oBAAoB,uBAAuB;AAAA,IACxD,OAAO,CAAC,oBAAoB,uBAAuB;AAAA,EACrD;AAAA,EACA,MAAM,kBAAkB;AACtB,+BAA2B;AAAA,EAC7B;AACF;AAEA,IAAO,gBAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
5
+ import { flash } from "@open-mercato/ui/backend/FlashMessages";
6
+ import { useGuardedMutation } from "@open-mercato/ui/backend/injection/useGuardedMutation";
7
+ import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
8
+ import {
9
+ resolvePushConnectErrorMessage,
10
+ resolvePushConnectFieldErrors
11
+ } from "@open-mercato/core/modules/communication_channels/lib/push-connect-error";
12
+ import { Button } from "@open-mercato/ui/primitives/button";
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle
19
+ } from "@open-mercato/ui/primitives/dialog";
20
+ import { Input } from "@open-mercato/ui/primitives/input";
21
+ import { Label } from "@open-mercato/ui/primitives/label";
22
+ import { Textarea } from "@open-mercato/ui/primitives/textarea";
23
+ const INITIAL_FORM = {
24
+ displayName: "Firebase Cloud Messaging",
25
+ serviceAccountJson: "",
26
+ appName: ""
27
+ };
28
+ function ConnectFcmWidget({
29
+ context
30
+ }) {
31
+ const t = useT();
32
+ const widgetContext = context;
33
+ const [open, setOpen] = React.useState(false);
34
+ const [pending, setPending] = React.useState(false);
35
+ const [form, setForm] = React.useState(INITIAL_FORM);
36
+ const [fieldErrors, setFieldErrors] = React.useState({});
37
+ const { runMutation, retryLastMutation } = useGuardedMutation({
38
+ contextId: "channel-fcm-connect",
39
+ blockedMessage: t("communication_channels.push.connect.blocked", "Connection blocked by validation")
40
+ });
41
+ const mutationContext = React.useMemo(
42
+ () => ({ providerKey: "fcm", retryLastMutation }),
43
+ [retryLastMutation]
44
+ );
45
+ const update = React.useCallback(
46
+ (key, value) => {
47
+ setForm((current) => ({ ...current, [key]: value }));
48
+ setFieldErrors((current) => {
49
+ if (!current[key]) return current;
50
+ const next = { ...current };
51
+ delete next[key];
52
+ return next;
53
+ });
54
+ },
55
+ []
56
+ );
57
+ const submit = React.useCallback(async () => {
58
+ if (pending) return;
59
+ setPending(true);
60
+ setFieldErrors({});
61
+ const displayName = form.displayName.trim() || "Firebase Cloud Messaging";
62
+ try {
63
+ const response = await runMutation({
64
+ context: mutationContext,
65
+ mutationPayload: { providerKey: "fcm", displayName },
66
+ operation: () => apiCall("/api/communication_channels/channels/connect/tenant-credentials", {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({
70
+ providerKey: "fcm",
71
+ displayName,
72
+ credentials: {
73
+ serviceAccountJson: form.serviceAccountJson.trim(),
74
+ ...form.appName.trim() ? { appName: form.appName.trim() } : {}
75
+ }
76
+ })
77
+ })
78
+ });
79
+ const body = response.result;
80
+ if (!response.ok) {
81
+ setFieldErrors(resolvePushConnectFieldErrors(t, body));
82
+ flash(resolvePushConnectErrorMessage(t, body), "error");
83
+ return;
84
+ }
85
+ flash(t("communication_channels.push.connect.connected", "Push provider connected."), "success");
86
+ setOpen(false);
87
+ setForm(INITIAL_FORM);
88
+ widgetContext?.reload?.();
89
+ } finally {
90
+ setPending(false);
91
+ }
92
+ }, [form, mutationContext, pending, runMutation, t, widgetContext]);
93
+ const onDialogKeyDown = React.useCallback(
94
+ (event) => {
95
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
96
+ event.preventDefault();
97
+ void submit();
98
+ }
99
+ },
100
+ [submit]
101
+ );
102
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
103
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => setOpen(true), children: t("communication_channels.push.connect.button.fcm", "Connect FCM") }),
104
+ /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { onKeyDown: onDialogKeyDown, children: [
105
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: t("communication_channels.push.connect.title.fcm", "Connect Firebase Cloud Messaging") }) }),
106
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 py-2", children: [
107
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t(
108
+ "communication_channels.push.connect.description",
109
+ "This is a shared, tenant-wide channel \u2014 every user's devices in this workspace are served by it."
110
+ ) }),
111
+ /* @__PURE__ */ jsx(
112
+ Field,
113
+ {
114
+ label: t("communication_channels.push.connect.displayName", "Display name"),
115
+ error: fieldErrors.displayName,
116
+ children: /* @__PURE__ */ jsx(
117
+ Input,
118
+ {
119
+ value: form.displayName,
120
+ onChange: (event) => update("displayName", event.target.value),
121
+ "aria-invalid": Boolean(fieldErrors.displayName)
122
+ }
123
+ )
124
+ }
125
+ ),
126
+ /* @__PURE__ */ jsx(
127
+ Field,
128
+ {
129
+ label: t("communication_channels.push.connect.fields.fcm.serviceAccountJson", "Service Account JSON"),
130
+ error: fieldErrors.serviceAccountJson,
131
+ children: /* @__PURE__ */ jsx(
132
+ Textarea,
133
+ {
134
+ rows: 8,
135
+ value: form.serviceAccountJson,
136
+ onChange: (event) => update("serviceAccountJson", event.target.value),
137
+ "aria-invalid": Boolean(fieldErrors.serviceAccountJson),
138
+ className: "font-mono text-xs"
139
+ }
140
+ )
141
+ }
142
+ ),
143
+ /* @__PURE__ */ jsx(
144
+ Field,
145
+ {
146
+ label: t("communication_channels.push.connect.fields.fcm.appName", "App name (optional)"),
147
+ error: fieldErrors.appName,
148
+ children: /* @__PURE__ */ jsx(
149
+ Input,
150
+ {
151
+ value: form.appName,
152
+ onChange: (event) => update("appName", event.target.value),
153
+ "aria-invalid": Boolean(fieldErrors.appName)
154
+ }
155
+ )
156
+ }
157
+ )
158
+ ] }),
159
+ /* @__PURE__ */ jsxs(DialogFooter, { children: [
160
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => setOpen(false), disabled: pending, children: t("communication_channels.push.connect.cancel", "Cancel") }),
161
+ /* @__PURE__ */ jsx(Button, { type: "button", onClick: () => void submit(), disabled: pending, children: pending ? t("communication_channels.push.connect.connecting", "Connecting\u2026") : t("communication_channels.push.connect.save", "Connect") })
162
+ ] })
163
+ ] }) })
164
+ ] });
165
+ }
166
+ function Field(props) {
167
+ return /* @__PURE__ */ jsxs("label", { className: "grid gap-1.5", children: [
168
+ /* @__PURE__ */ jsx(Label, { asChild: true, children: /* @__PURE__ */ jsx("span", { children: props.label }) }),
169
+ props.children,
170
+ props.error ? /* @__PURE__ */ jsx("span", { className: "text-xs text-destructive", children: props.error }) : null
171
+ ] });
172
+ }
173
+ export {
174
+ ConnectFcmWidget as default
175
+ };
176
+ //# sourceMappingURL=widget.client.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/channel_fcm/widgets/injection/connect/widget.client.tsx"],
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport {\n resolvePushConnectErrorMessage,\n resolvePushConnectFieldErrors,\n} from '@open-mercato/core/modules/communication_channels/lib/push-connect-error'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Dialog,\n DialogContent,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\n\ntype WidgetContext = Record<string, unknown> & {\n reload?: () => void\n}\n\ntype ConnectResponse = {\n channelId?: string\n error?: string\n code?: string\n fieldErrors?: Record<string, string>\n fieldErrorCodes?: Record<string, string>\n}\n\ntype FormState = {\n displayName: string\n serviceAccountJson: string\n appName: string\n}\n\nconst INITIAL_FORM: FormState = {\n displayName: 'Firebase Cloud Messaging',\n serviceAccountJson: '',\n appName: '',\n}\n\nexport default function ConnectFcmWidget({\n context,\n}: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>) {\n const t = useT()\n const widgetContext = context as WidgetContext | undefined\n const [open, setOpen] = React.useState(false)\n const [pending, setPending] = React.useState(false)\n const [form, setForm] = React.useState<FormState>(INITIAL_FORM)\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: 'channel-fcm-connect',\n blockedMessage: t('communication_channels.push.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey: 'fcm', retryLastMutation }),\n [retryLastMutation],\n )\n\n const update = React.useCallback(\n <K extends keyof FormState>(key: K, value: FormState[K]) => {\n setForm((current) => ({ ...current, [key]: value }))\n setFieldErrors((current) => {\n if (!current[key]) return current\n const next = { ...current }\n delete next[key]\n return next\n })\n },\n [],\n )\n\n const submit = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n setFieldErrors({})\n const displayName = form.displayName.trim() || 'Firebase Cloud Messaging'\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey: 'fcm', displayName },\n operation: () =>\n apiCall<ConnectResponse>('/api/communication_channels/channels/connect/tenant-credentials', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n providerKey: 'fcm',\n displayName,\n credentials: {\n serviceAccountJson: form.serviceAccountJson.trim(),\n ...(form.appName.trim() ? { appName: form.appName.trim() } : {}),\n },\n }),\n }),\n })\n const body = response.result as ConnectResponse | undefined\n if (!response.ok) {\n setFieldErrors(resolvePushConnectFieldErrors(t, body))\n flash(resolvePushConnectErrorMessage(t, body), 'error')\n return\n }\n flash(t('communication_channels.push.connect.connected', 'Push provider connected.'), 'success')\n setOpen(false)\n setForm(INITIAL_FORM)\n widgetContext?.reload?.()\n } finally {\n setPending(false)\n }\n }, [form, mutationContext, pending, runMutation, t, widgetContext])\n\n const onDialogKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n void submit()\n }\n },\n [submit],\n )\n\n return (\n <>\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(true)}>\n {t('communication_channels.push.connect.button.fcm', 'Connect FCM')}\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent onKeyDown={onDialogKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.push.connect.title.fcm', 'Connect Firebase Cloud Messaging')}\n </DialogTitle>\n </DialogHeader>\n\n <div className=\"grid gap-4 py-2\">\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'communication_channels.push.connect.description',\n \"This is a shared, tenant-wide channel \u2014 every user's devices in this workspace are served by it.\",\n )}\n </p>\n <Field\n label={t('communication_channels.push.connect.displayName', 'Display name')}\n error={fieldErrors.displayName}\n >\n <Input\n value={form.displayName}\n onChange={(event) => update('displayName', event.target.value)}\n aria-invalid={Boolean(fieldErrors.displayName)}\n />\n </Field>\n <Field\n label={t('communication_channels.push.connect.fields.fcm.serviceAccountJson', 'Service Account JSON')}\n error={fieldErrors.serviceAccountJson}\n >\n <Textarea\n rows={8}\n value={form.serviceAccountJson}\n onChange={(event) => update('serviceAccountJson', event.target.value)}\n aria-invalid={Boolean(fieldErrors.serviceAccountJson)}\n className=\"font-mono text-xs\"\n />\n </Field>\n <Field\n label={t('communication_channels.push.connect.fields.fcm.appName', 'App name (optional)')}\n error={fieldErrors.appName}\n >\n <Input\n value={form.appName}\n onChange={(event) => update('appName', event.target.value)}\n aria-invalid={Boolean(fieldErrors.appName)}\n />\n </Field>\n </div>\n\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(false)} disabled={pending}>\n {t('communication_channels.push.connect.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={() => void submit()} disabled={pending}>\n {pending\n ? t('communication_channels.push.connect.connecting', 'Connecting\u2026')\n : t('communication_channels.push.connect.save', 'Connect')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\nfunction Field(props: { label: string; error?: string; children: React.ReactNode }) {\n return (\n <label className=\"grid gap-1.5\">\n <Label asChild>\n <span>{props.label}</span>\n </Label>\n {props.children}\n {props.error ? <span className=\"text-xs text-destructive\">{props.error}</span> : null}\n </label>\n )\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,17 @@
1
+ import ConnectFcmWidget from "./widget.client.js";
2
+ const widget = {
3
+ metadata: {
4
+ id: "channel_fcm.injection.connect",
5
+ title: "Connect FCM",
6
+ description: "Connects a tenant-wide Firebase Cloud Messaging push channel.",
7
+ features: ["communication_channels.connect_tenant_channel"],
8
+ priority: 100,
9
+ enabled: true
10
+ },
11
+ Widget: ConnectFcmWidget
12
+ };
13
+ var widget_default = widget;
14
+ export {
15
+ widget_default as default
16
+ };
17
+ //# sourceMappingURL=widget.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/channel_fcm/widgets/injection/connect/widget.ts"],
4
+ "sourcesContent": ["import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport ConnectFcmWidget from './widget.client'\n\nconst widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {\n metadata: {\n id: 'channel_fcm.injection.connect',\n title: 'Connect FCM',\n description: 'Connects a tenant-wide Firebase Cloud Messaging push channel.',\n features: ['communication_channels.connect_tenant_channel'],\n priority: 100,\n enabled: true,\n },\n Widget: ConnectFcmWidget,\n}\n\nexport default widget\n"],
5
+ "mappings": "AACA,OAAO,sBAAsB;AAE7B,MAAM,SAAkF;AAAA,EACtF,UAAU;AAAA,IACR,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU,CAAC,+CAA+C;AAAA,IAC1D,UAAU;AAAA,IACV,SAAS;AAAA,EACX;AAAA,EACA,QAAQ;AACV;AAEA,IAAO,iBAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,15 @@
1
+ const injectionTable = {
2
+ // Tenant-wide connect entry on the shared channels admin DataTable toolbar.
3
+ "data-table:communication_channels.channels:toolbar": [
4
+ {
5
+ widgetId: "channel_fcm.injection.connect",
6
+ priority: 100
7
+ }
8
+ ]
9
+ };
10
+ var injection_table_default = injectionTable;
11
+ export {
12
+ injection_table_default as default,
13
+ injectionTable
14
+ };
15
+ //# sourceMappingURL=injection-table.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_fcm/widgets/injection-table.ts"],
4
+ "sourcesContent": ["import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'\n\nexport const injectionTable: ModuleInjectionTable = {\n // Tenant-wide connect entry on the shared channels admin DataTable toolbar.\n 'data-table:communication_channels.channels:toolbar': [\n {\n widgetId: 'channel_fcm.injection.connect',\n priority: 100,\n },\n ],\n}\n\nexport default injectionTable\n"],
5
+ "mappings": "AAEO,MAAM,iBAAuC;AAAA;AAAA,EAElD,sDAAsD;AAAA,IACpD;AAAA,MACE,UAAU;AAAA,MACV,UAAU;AAAA,IACZ;AAAA,EACF;AACF;AAEA,IAAO,0BAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,34 @@
1
+ /** @type {import('jest').Config} */
2
+ const base = require('../../jest.config.base.cjs')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ testEnvironment: 'node',
7
+ watchman: false,
8
+ rootDir: '.',
9
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
10
+ moduleNameMapper: {
11
+ '^@open-mercato/channel-fcm/(.*)$': '<rootDir>/src/$1',
12
+ '^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
13
+ '^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
14
+ '^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
15
+ '^@open-mercato/ui/(.*)$': '<rootDir>/../ui/src/$1',
16
+ },
17
+ transform: {
18
+ '^.+\\.(t|j)sx?$': [
19
+ '<rootDir>/../../scripts/jest-mikroorm-transformer.cjs',
20
+ {
21
+ tsconfig: {
22
+ jsx: 'react-jsx',
23
+ rootDir: '.',
24
+ ignoreDeprecations: '6.0',
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ transformIgnorePatterns: [
30
+ 'node_modules/(?!(@mikro-orm|kysely)/)',
31
+ ],
32
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
33
+ passWithNoTests: true,
34
+ }
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "@open-mercato/channel-fcm",
3
+ "version": "0.6.8-develop.6985.1.fb93574faa",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "scripts": {
8
+ "build": "node build.mjs",
9
+ "watch": "node watch.mjs",
10
+ "test": "jest --config jest.config.cjs",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "exports": {
14
+ ".": "./dist/index.js",
15
+ "./*.ts": {
16
+ "types": "./src/*.ts",
17
+ "default": "./dist/*.js"
18
+ },
19
+ "./*.tsx": {
20
+ "types": "./src/*.tsx",
21
+ "default": "./dist/*.js"
22
+ },
23
+ "./*.json": "./src/*.json",
24
+ "./*": {
25
+ "types": [
26
+ "./src/*.ts",
27
+ "./src/*.tsx"
28
+ ],
29
+ "default": "./dist/*.js"
30
+ },
31
+ "./*/*.json": "./src/*/*.json",
32
+ "./*/*": {
33
+ "types": [
34
+ "./src/*/*.ts",
35
+ "./src/*/*.tsx"
36
+ ],
37
+ "default": "./dist/*/*.js"
38
+ },
39
+ "./*/*/*.json": "./src/*/*/*.json",
40
+ "./*/*/*": {
41
+ "types": [
42
+ "./src/*/*/*.ts",
43
+ "./src/*/*/*.tsx"
44
+ ],
45
+ "default": "./dist/*/*/*.js"
46
+ },
47
+ "./*/*/*/*.json": "./src/*/*/*/*.json",
48
+ "./*/*/*/*": {
49
+ "types": [
50
+ "./src/*/*/*/*.ts",
51
+ "./src/*/*/*/*.tsx"
52
+ ],
53
+ "default": "./dist/*/*/*/*.js"
54
+ },
55
+ "./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
56
+ "./*/*/*/*/*": {
57
+ "types": [
58
+ "./src/*/*/*/*/*.ts",
59
+ "./src/*/*/*/*/*.tsx"
60
+ ],
61
+ "default": "./dist/*/*/*/*/*.js"
62
+ }
63
+ },
64
+ "dependencies": {
65
+ "@open-mercato/core": "0.6.8-develop.6985.1.fb93574faa",
66
+ "@open-mercato/ui": "0.6.8-develop.6985.1.fb93574faa",
67
+ "firebase-admin": "^13.0.2"
68
+ },
69
+ "peerDependencies": {
70
+ "@mikro-orm/postgresql": "^7.0.14",
71
+ "@open-mercato/shared": "0.6.8-develop.6985.1.fb93574faa",
72
+ "react": "^19.0.0",
73
+ "react-dom": "^19.0.0"
74
+ },
75
+ "devDependencies": {
76
+ "@open-mercato/shared": "0.6.8-develop.6985.1.fb93574faa",
77
+ "@types/jest": "^30.0.0",
78
+ "@types/react": "^19.2.17",
79
+ "@types/react-dom": "^19.2.3",
80
+ "esbuild": "^0.28.1",
81
+ "glob": "^13.0.6",
82
+ "jest": "^30.4.2",
83
+ "react": "19.2.7",
84
+ "react-dom": "19.2.7",
85
+ "ts-jest": "^29.4.11"
86
+ },
87
+ "publishConfig": {
88
+ "access": "public"
89
+ },
90
+ "repository": {
91
+ "type": "git",
92
+ "url": "https://github.com/open-mercato/open-mercato",
93
+ "directory": "packages/channel-fcm"
94
+ },
95
+ "stableVersion": "0.6.7"
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { metadata } from './modules/channel_fcm/index'
@@ -0,0 +1,39 @@
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
+
6
+ /**
7
+ * TC-CHANNEL-PUSH-001 — FCM is registered AND tenant-scoped, so the per-user
8
+ * connect route refuses it.
9
+ *
10
+ * `@open-mercato/channel-fcm` registers the `fcm` push `ChannelAdapter` at import
11
+ * with `channelScope: 'tenant'`. Push providers are connected tenant-wide by an
12
+ * admin through `POST /channels/connect/tenant-credentials` (see
13
+ * TC-CHANNEL-PUSH-002), NOT the per-user route. The per-user route
14
+ * `POST /channels/connect/credentials` now short-circuits tenant-scoped providers
15
+ * with 403 (`provider_is_tenant_scoped`) before touching credentials — this both
16
+ * proves the adapter is registered (an UNregistered provider would fall through to
17
+ * 404 `no_adapter`) and guards against a non-admin minting a tenant-wide channel.
18
+ * No channel is created, so there is nothing to clean up.
19
+ */
20
+ test.describe('TC-CHANNEL-PUSH-001: FCM registered + rejected on the per-user route', () => {
21
+ test('POST per-user connect/credentials with providerKey=fcm is refused (403, tenant-scoped)', async ({ request }) => {
22
+ const token = await getAuthToken(request)
23
+ const response = await apiRequest(
24
+ request,
25
+ 'POST',
26
+ '/api/communication_channels/channels/connect/credentials',
27
+ {
28
+ token,
29
+ data: { providerKey: 'fcm', displayName: 'FCM — per-user (should be refused)', credentials: {} },
30
+ },
31
+ )
32
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
33
+ // 403 (not 404) proves the FCM adapter is registered and declares tenant scope,
34
+ // and that the per-user route refuses it before any credential/channel work.
35
+ expect(response.status()).toBe(403)
36
+ const body = await readJsonSafe<{ code?: string }>(response)
37
+ expect(body?.code).toBe('provider_is_tenant_scoped')
38
+ })
39
+ })