@open-mercato/channel-expo 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 (60) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +26 -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_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js +56 -0
  7. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js.map +7 -0
  8. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js +7 -0
  9. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js.map +7 -0
  10. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js +65 -0
  11. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js.map +7 -0
  12. package/dist/modules/channel_expo/acl.js +10 -0
  13. package/dist/modules/channel_expo/acl.js.map +7 -0
  14. package/dist/modules/channel_expo/di.js +24 -0
  15. package/dist/modules/channel_expo/di.js.map +7 -0
  16. package/dist/modules/channel_expo/index.js +9 -0
  17. package/dist/modules/channel_expo/index.js.map +7 -0
  18. package/dist/modules/channel_expo/integration.js +47 -0
  19. package/dist/modules/channel_expo/integration.js.map +7 -0
  20. package/dist/modules/channel_expo/lib/adapter.js +154 -0
  21. package/dist/modules/channel_expo/lib/adapter.js.map +7 -0
  22. package/dist/modules/channel_expo/lib/credentials.js +8 -0
  23. package/dist/modules/channel_expo/lib/credentials.js.map +7 -0
  24. package/dist/modules/channel_expo/lib/fake-provider.js +40 -0
  25. package/dist/modules/channel_expo/lib/fake-provider.js.map +7 -0
  26. package/dist/modules/channel_expo/lib/health.js +10 -0
  27. package/dist/modules/channel_expo/lib/health.js.map +7 -0
  28. package/dist/modules/channel_expo/setup.js +25 -0
  29. package/dist/modules/channel_expo/setup.js.map +7 -0
  30. package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js +157 -0
  31. package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js.map +7 -0
  32. package/dist/modules/channel_expo/widgets/injection/connect/widget.js +17 -0
  33. package/dist/modules/channel_expo/widgets/injection/connect/widget.js.map +7 -0
  34. package/dist/modules/channel_expo/widgets/injection-table.js +15 -0
  35. package/dist/modules/channel_expo/widgets/injection-table.js.map +7 -0
  36. package/jest.config.cjs +34 -0
  37. package/package.json +96 -0
  38. package/src/index.ts +1 -0
  39. package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.ts +76 -0
  40. package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.ts +3 -0
  41. package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.ts +83 -0
  42. package/src/modules/channel_expo/acl.ts +6 -0
  43. package/src/modules/channel_expo/di.ts +26 -0
  44. package/src/modules/channel_expo/index.ts +6 -0
  45. package/src/modules/channel_expo/integration.ts +48 -0
  46. package/src/modules/channel_expo/lib/__tests__/adapter-instance-cache.test.ts +65 -0
  47. package/src/modules/channel_expo/lib/__tests__/adapter.test.ts +211 -0
  48. package/src/modules/channel_expo/lib/__tests__/credentials.test.ts +18 -0
  49. package/src/modules/channel_expo/lib/__tests__/fake-provider.test.ts +94 -0
  50. package/src/modules/channel_expo/lib/__tests__/message-golden.test.ts +77 -0
  51. package/src/modules/channel_expo/lib/adapter.ts +256 -0
  52. package/src/modules/channel_expo/lib/credentials.ts +14 -0
  53. package/src/modules/channel_expo/lib/fake-provider.ts +64 -0
  54. package/src/modules/channel_expo/lib/health.ts +13 -0
  55. package/src/modules/channel_expo/setup.ts +31 -0
  56. package/src/modules/channel_expo/widgets/injection/connect/widget.client.tsx +193 -0
  57. package/src/modules/channel_expo/widgets/injection/connect/widget.ts +16 -0
  58. package/src/modules/channel_expo/widgets/injection-table.ts +13 -0
  59. package/tsconfig.json +9 -0
  60. package/watch.mjs +7 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_expo/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 {\n setExpoClientFactory,\n type ExpoPushMessage,\n type ExpoPushReceipt,\n type ExpoPushTicket,\n} from './adapter'\n\n/**\n * Network-free `expo-server-sdk` client used ONLY by integration tests.\n *\n * Swaps the SDK client behind the adapter's existing seam, so the real adapter still runs its token\n * validation, chunking, and `DeviceNotRegistered` \u2192 `device_unregistered` mapping. The adapter itself is\n * never replaced or re-registered.\n *\n * `isExpoPushToken` accepts every token: the sentinels below are not real `ExponentPushToken[...]`\n * values, and rejecting them would short-circuit the adapter before it ever reaches the ticket path.\n *\n * Token sentinels match `push_stub`'s convention (see push-stub-adapter.ts):\n * - token containing `unregistered` \u2192 an accepted ticket whose *receipt* later reports\n * `DeviceNotRegistered`, reproducing Expo's real two-phase behavior for an uninstalled app\n * - token containing `fail` \u2192 a rejected ticket (retryable)\n * - otherwise \u2192 success\n *\n * The sentinel is encoded into the ticket id because `getReceipts` is polled by the reaper in a\n * *different* process than the send: the fake keeps no cross-process state.\n *\n * Production safety: never installed at module import; no-op unless `OM_PUSH_FAKE_PROVIDERS` is set.\n */\nconst UNREGISTERED_TICKET_MARKER = 'unreg'\n\nexport function ensureExpoFakeProviderInstalled(): void {\n if (!isPushFakeProvidersEnabled()) return\n warnPushFakeProvidersActive('expo')\n setExpoClientFactory(() => ({\n isExpoPushToken(): boolean {\n return true\n },\n async send(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]> {\n return messages.map((message) => {\n const token = message.to\n recordFakePush('expo', token, message as unknown as Record<string, unknown>)\n if (token.includes('fail')) {\n return { status: 'error', message: 'fake expo transient failure' }\n }\n const marker = token.includes('unregistered') ? `${UNREGISTERED_TICKET_MARKER}-` : ''\n return { status: 'ok', id: `expo-fake-${marker}${token.slice(-8) || 'token'}` }\n })\n },\n async getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>> {\n const receipts: Record<string, ExpoPushReceipt> = {}\n for (const ticketId of ticketIds) {\n receipts[ticketId] = ticketId.includes(`-${UNREGISTERED_TICKET_MARKER}-`)\n ? { status: 'error', details: { error: 'DeviceNotRegistered' } }\n : { status: 'ok' }\n }\n return receipts\n },\n }))\n}\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,OAIK;AAuBP,MAAM,6BAA6B;AAE5B,SAAS,kCAAwC;AACtD,MAAI,CAAC,2BAA2B,EAAG;AACnC,8BAA4B,MAAM;AAClC,uBAAqB,OAAO;AAAA,IAC1B,kBAA2B;AACzB,aAAO;AAAA,IACT;AAAA,IACA,MAAM,KAAK,UAAwD;AACjE,aAAO,SAAS,IAAI,CAAC,YAAY;AAC/B,cAAM,QAAQ,QAAQ;AACtB,uBAAe,QAAQ,OAAO,OAA6C;AAC3E,YAAI,MAAM,SAAS,MAAM,GAAG;AAC1B,iBAAO,EAAE,QAAQ,SAAS,SAAS,8BAA8B;AAAA,QACnE;AACA,cAAM,SAAS,MAAM,SAAS,cAAc,IAAI,GAAG,0BAA0B,MAAM;AACnF,eAAO,EAAE,QAAQ,MAAM,IAAI,aAAa,MAAM,GAAG,MAAM,MAAM,EAAE,KAAK,OAAO,GAAG;AAAA,MAChF,CAAC;AAAA,IACH;AAAA,IACA,MAAM,YAAY,WAA+D;AAC/E,YAAM,WAA4C,CAAC;AACnD,iBAAW,YAAY,WAAW;AAChC,iBAAS,QAAQ,IAAI,SAAS,SAAS,IAAI,0BAA0B,GAAG,IACpE,EAAE,QAAQ,SAAS,SAAS,EAAE,OAAO,sBAAsB,EAAE,IAC7D,EAAE,QAAQ,KAAK;AAAA,MACrB;AACA,aAAO;AAAA,IACT;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 { expoCredentialsSchema } from "./credentials.js";
3
+ const channelExpoHealthCheck = makePushClientConfigHealthCheck({
4
+ schema: expoCredentialsSchema,
5
+ providerLabel: "Expo"
6
+ });
7
+ export {
8
+ channelExpoHealthCheck
9
+ };
10
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_expo/lib/health.ts"],
4
+ "sourcesContent": ["import { makePushClientConfigHealthCheck } from '@open-mercato/core/modules/push_notifications/lib/push-health'\nimport { expoCredentialsSchema } from './credentials'\n\n/**\n * Liveness probe for the Expo integration. Expo credentials are minimal (an\n * optional access token), so the probe just confirms the stored config is\n * well-formed \u2014 no network call. Per-device token validity surfaces on delivery\n * (`device_unregistered` soft-deletes).\n */\nexport const channelExpoHealthCheck = makePushClientConfigHealthCheck({\n schema: expoCredentialsSchema,\n providerLabel: 'Expo',\n})\n"],
5
+ "mappings": "AAAA,SAAS,uCAAuC;AAChD,SAAS,6BAA6B;AAQ/B,MAAM,yBAAyB,gCAAgC;AAAA,EACpE,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 { getExpoChannelAdapter } from "./lib/adapter.js";
6
+ function ensureExpoAdapterRegistered() {
7
+ if (hasChannelAdapter("expo")) return;
8
+ registerChannelAdapter(getExpoChannelAdapter());
9
+ }
10
+ ensureExpoAdapterRegistered();
11
+ const setup = {
12
+ defaultRoleFeatures: {
13
+ superadmin: ["channel_expo.view", "channel_expo.configure"],
14
+ admin: ["channel_expo.view", "channel_expo.configure"]
15
+ },
16
+ async onTenantCreated() {
17
+ ensureExpoAdapterRegistered();
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_expo/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 { getExpoChannelAdapter } from './lib/adapter'\n\n/**\n * Register the Expo `ChannelAdapter` once per process at import time. Guarded with\n * `hasChannelAdapter` to silence the duplicate error on dev-mode HMR + repeated\n * test imports. Provider credentials (optional access token) are persisted per\n * tenant via the standard `IntegrationCredentials` flow for `channel_expo`.\n */\nfunction ensureExpoAdapterRegistered(): void {\n if (hasChannelAdapter('expo')) return\n registerChannelAdapter(getExpoChannelAdapter())\n}\n\nensureExpoAdapterRegistered()\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['channel_expo.view', 'channel_expo.configure'],\n admin: ['channel_expo.view', 'channel_expo.configure'],\n },\n async onTenantCreated() {\n ensureExpoAdapterRegistered()\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,6BAA6B;AAQtC,SAAS,8BAAoC;AAC3C,MAAI,kBAAkB,MAAM,EAAG;AAC/B,yBAAuB,sBAAsB,CAAC;AAChD;AAEA,4BAA4B;AAErB,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,YAAY,CAAC,qBAAqB,wBAAwB;AAAA,IAC1D,OAAO,CAAC,qBAAqB,wBAAwB;AAAA,EACvD;AAAA,EACA,MAAM,kBAAkB;AACtB,gCAA4B;AAAA,EAC9B;AACF;AAEA,IAAO,gBAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,157 @@
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 { PasswordInput } from "@open-mercato/ui/primitives/password-input";
23
+ const INITIAL_FORM = {
24
+ displayName: "Expo Push",
25
+ accessToken: ""
26
+ };
27
+ function ConnectExpoWidget({
28
+ context
29
+ }) {
30
+ const t = useT();
31
+ const widgetContext = context;
32
+ const [open, setOpen] = React.useState(false);
33
+ const [pending, setPending] = React.useState(false);
34
+ const [form, setForm] = React.useState(INITIAL_FORM);
35
+ const [fieldErrors, setFieldErrors] = React.useState({});
36
+ const { runMutation, retryLastMutation } = useGuardedMutation({
37
+ contextId: "channel-expo-connect",
38
+ blockedMessage: t("communication_channels.push.connect.blocked", "Connection blocked by validation")
39
+ });
40
+ const mutationContext = React.useMemo(
41
+ () => ({ providerKey: "expo", retryLastMutation }),
42
+ [retryLastMutation]
43
+ );
44
+ const update = React.useCallback(
45
+ (key, value) => {
46
+ setForm((current) => ({ ...current, [key]: value }));
47
+ setFieldErrors((current) => {
48
+ if (!current[key]) return current;
49
+ const next = { ...current };
50
+ delete next[key];
51
+ return next;
52
+ });
53
+ },
54
+ []
55
+ );
56
+ const submit = React.useCallback(async () => {
57
+ if (pending) return;
58
+ setPending(true);
59
+ setFieldErrors({});
60
+ const displayName = form.displayName.trim() || "Expo Push";
61
+ try {
62
+ const response = await runMutation({
63
+ context: mutationContext,
64
+ mutationPayload: { providerKey: "expo", displayName },
65
+ operation: () => apiCall("/api/communication_channels/channels/connect/tenant-credentials", {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify({
69
+ providerKey: "expo",
70
+ displayName,
71
+ credentials: {
72
+ ...form.accessToken.trim() ? { accessToken: form.accessToken.trim() } : {}
73
+ }
74
+ })
75
+ })
76
+ });
77
+ const body = response.result;
78
+ if (!response.ok) {
79
+ setFieldErrors(resolvePushConnectFieldErrors(t, body));
80
+ flash(resolvePushConnectErrorMessage(t, body), "error");
81
+ return;
82
+ }
83
+ flash(t("communication_channels.push.connect.connected", "Push provider connected."), "success");
84
+ setOpen(false);
85
+ setForm(INITIAL_FORM);
86
+ widgetContext?.reload?.();
87
+ } finally {
88
+ setPending(false);
89
+ }
90
+ }, [form, mutationContext, pending, runMutation, t, widgetContext]);
91
+ const onDialogKeyDown = React.useCallback(
92
+ (event) => {
93
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
94
+ event.preventDefault();
95
+ void submit();
96
+ }
97
+ },
98
+ [submit]
99
+ );
100
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
101
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => setOpen(true), children: t("communication_channels.push.connect.button.expo", "Connect Expo") }),
102
+ /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { onKeyDown: onDialogKeyDown, children: [
103
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: t("communication_channels.push.connect.title.expo", "Connect Expo Push") }) }),
104
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 py-2", children: [
105
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t(
106
+ "communication_channels.push.connect.description",
107
+ "This is a shared, tenant-wide channel \u2014 every user's devices in this workspace are served by it."
108
+ ) }),
109
+ /* @__PURE__ */ jsx(
110
+ Field,
111
+ {
112
+ label: t("communication_channels.push.connect.displayName", "Display name"),
113
+ error: fieldErrors.displayName,
114
+ children: /* @__PURE__ */ jsx(
115
+ Input,
116
+ {
117
+ value: form.displayName,
118
+ onChange: (event) => update("displayName", event.target.value),
119
+ "aria-invalid": Boolean(fieldErrors.displayName)
120
+ }
121
+ )
122
+ }
123
+ ),
124
+ /* @__PURE__ */ jsx(
125
+ Field,
126
+ {
127
+ label: t("communication_channels.push.connect.fields.expo.accessToken", "Access token (optional)"),
128
+ error: fieldErrors.accessToken,
129
+ children: /* @__PURE__ */ jsx(
130
+ PasswordInput,
131
+ {
132
+ value: form.accessToken,
133
+ onChange: (event) => update("accessToken", event.target.value),
134
+ "aria-invalid": Boolean(fieldErrors.accessToken)
135
+ }
136
+ )
137
+ }
138
+ )
139
+ ] }),
140
+ /* @__PURE__ */ jsxs(DialogFooter, { children: [
141
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => setOpen(false), disabled: pending, children: t("communication_channels.push.connect.cancel", "Cancel") }),
142
+ /* @__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") })
143
+ ] })
144
+ ] }) })
145
+ ] });
146
+ }
147
+ function Field(props) {
148
+ return /* @__PURE__ */ jsxs("label", { className: "grid gap-1.5", children: [
149
+ /* @__PURE__ */ jsx(Label, { asChild: true, children: /* @__PURE__ */ jsx("span", { children: props.label }) }),
150
+ props.children,
151
+ props.error ? /* @__PURE__ */ jsx("span", { className: "text-xs text-destructive", children: props.error }) : null
152
+ ] });
153
+ }
154
+ export {
155
+ ConnectExpoWidget as default
156
+ };
157
+ //# sourceMappingURL=widget.client.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/channel_expo/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 { PasswordInput } from '@open-mercato/ui/primitives/password-input'\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 accessToken: string\n}\n\nconst INITIAL_FORM: FormState = {\n displayName: 'Expo Push',\n accessToken: '',\n}\n\nexport default function ConnectExpoWidget({\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-expo-connect',\n blockedMessage: t('communication_channels.push.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey: 'expo', 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() || 'Expo Push'\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey: 'expo', 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: 'expo',\n displayName,\n credentials: {\n ...(form.accessToken.trim() ? { accessToken: form.accessToken.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.expo', 'Connect Expo')}\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent onKeyDown={onDialogKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.push.connect.title.expo', 'Connect Expo Push')}\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.expo.accessToken', 'Access token (optional)')}\n error={fieldErrors.accessToken}\n >\n <PasswordInput\n value={form.accessToken}\n onChange={(event) => update('accessToken', event.target.value)}\n aria-invalid={Boolean(fieldErrors.accessToken)}\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 ConnectExpoWidget from "./widget.client.js";
2
+ const widget = {
3
+ metadata: {
4
+ id: "channel_expo.injection.connect",
5
+ title: "Connect Expo",
6
+ description: "Connects a tenant-wide Expo push channel.",
7
+ features: ["communication_channels.connect_tenant_channel"],
8
+ priority: 80,
9
+ enabled: true
10
+ },
11
+ Widget: ConnectExpoWidget
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_expo/widgets/injection/connect/widget.ts"],
4
+ "sourcesContent": ["import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport ConnectExpoWidget from './widget.client'\n\nconst widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {\n metadata: {\n id: 'channel_expo.injection.connect',\n title: 'Connect Expo',\n description: 'Connects a tenant-wide Expo push channel.',\n features: ['communication_channels.connect_tenant_channel'],\n priority: 80,\n enabled: true,\n },\n Widget: ConnectExpoWidget,\n}\n\nexport default widget\n"],
5
+ "mappings": "AACA,OAAO,uBAAuB;AAE9B,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_expo.injection.connect",
6
+ priority: 80
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_expo/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_expo.injection.connect',\n priority: 80,\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-expo/(.*)$': '<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-expo",
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
+ "expo-server-sdk": "^3.15.0"
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-expo"
94
+ },
95
+ "stableVersion": "0.6.7"
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { metadata } from './modules/channel_expo/index'
@@ -0,0 +1,76 @@
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-004 — Expo is registered AND tenant-scoped.
8
+ *
9
+ * `@open-mercato/channel-expo` registers the `expo` push `ChannelAdapter` at import with
10
+ * `channelScope: 'tenant'`. Push providers have no OAuth/webhook surface of their own, so
11
+ * the credential-connect routes are where hub registration is observable:
12
+ *
13
+ * - the **per-user** route short-circuits tenant-scoped providers with 403
14
+ * (`provider_is_tenant_scoped`) before touching credentials — 403 rather than 404
15
+ * (`no_adapter`) proves the adapter is registered, and it guards against a non-admin
16
+ * minting a tenant-wide channel;
17
+ * - the **tenant** route runs the adapter's own `validateCredentials`, which returns 422.
18
+ *
19
+ * Expo credentials are all-optional (`accessToken?`), so an empty object would VALIDATE
20
+ * and create a channel on the tenant route — we send a wrong-typed `accessToken` instead,
21
+ * so `validateCredentials` rejects it and the flow stays side-effect-free.
22
+ */
23
+ test.describe('TC-CHANNEL-PUSH-004: Expo provider registration', () => {
24
+ test('POST per-user connect/credentials with providerKey=expo is refused (403, tenant-scoped)', async ({
25
+ request,
26
+ }) => {
27
+ const token = await getAuthToken(request)
28
+ const response = await apiRequest(
29
+ request,
30
+ 'POST',
31
+ '/api/communication_channels/channels/connect/credentials',
32
+ {
33
+ token,
34
+ data: {
35
+ providerKey: 'expo',
36
+ displayName: 'Expo — per-user (should be refused)',
37
+ credentials: { accessToken: 12345 },
38
+ },
39
+ },
40
+ )
41
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
42
+ expect(response.status(), 'Expo provider should be registered (never 404 no_adapter)').not.toBe(404)
43
+ expect(response.status(), 'authenticated request should not 401').not.toBe(401)
44
+ expect(response.status()).toBe(403)
45
+ const body = await readJsonSafe<{ code?: string }>(response)
46
+ expect(body?.code).toBe('provider_is_tenant_scoped')
47
+ })
48
+
49
+ test('POST tenant connect/credentials with providerKey=expo reaches validateCredentials (422)', async ({
50
+ request,
51
+ }) => {
52
+ const token = await getAuthToken(request)
53
+ const response = await apiRequest(
54
+ request,
55
+ 'POST',
56
+ '/api/communication_channels/channels/connect/tenant-credentials',
57
+ {
58
+ token,
59
+ // Wrong-typed accessToken (not an empty object, which would validate and create a
60
+ // channel) → validateCredentials rejects it, so the flow stays side-effect-free.
61
+ data: {
62
+ providerKey: 'expo',
63
+ displayName: 'Expo — tenant connect',
64
+ credentials: { accessToken: 12345 },
65
+ },
66
+ },
67
+ )
68
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
69
+ // A 422 proves the request reached the registered adapter and ran its validateCredentials
70
+ // — not just that auth short-circuited. 401 would satisfy "not 404" without proving it.
71
+ expect(response.status(), 'authenticated request should not 401').not.toBe(401)
72
+ expect(response.status(), 'wrong-typed credentials should reach the adapter and 422').toBe(422)
73
+ const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)
74
+ expect(body?.fieldErrors ?? body?.error).toBeTruthy()
75
+ })
76
+ })
@@ -0,0 +1,3 @@
1
+ export const integrationMeta = {
2
+ requiredEnvVars: ['OM_PUSH_FAKE_PROVIDERS'],
3
+ }
@@ -0,0 +1,83 @@
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
+
17
+ /**
18
+ * TC-CHANNEL-PUSH-007 — the REAL Expo adapter, end-to-end, without live credentials.
19
+ *
20
+ * Only the `expo-server-sdk` client is faked (`OM_PUSH_FAKE_PROVIDERS`), so the adapter's token
21
+ * validation, chunking, and `buildExpoMessage` run for real.
22
+ */
23
+ const PROVIDER = 'expo'
24
+
25
+ test.describe('TC-CHANNEL-PUSH-007: real Expo adapter reaches sent with a correct native message', () => {
26
+ test('maps sound and priority onto the Expo message', async ({ request }) => {
27
+ // Budgeted explicitly rather than via `test.slow()` (which only triples the config's 20s budget).
28
+ // NOTE: this does not fix the current CI failure — `POST /api/notifications` hangs >30s in the
29
+ // channel-package shard, so the test dies on `apiRequest`'s own 30s timeout. See the spec changelog.
30
+ test.setTimeout(120_000)
31
+ const adminToken = await getAuthToken(request, 'admin')
32
+ const { tenantId, userId } = getTokenScope(adminToken)
33
+ const { pushToken, tokenTail } = makeFakePushToken(PROVIDER)
34
+ const startedAt = new Date().toISOString()
35
+
36
+ let channelId: string | null = null
37
+ let userDeviceId: string | null = null
38
+ try {
39
+ channelId = await connectFakePushChannel(request, adminToken, PROVIDER, 'TC-CHANNEL-PUSH-007 Expo')
40
+ userDeviceId = await registerFakePushDevice(
41
+ request,
42
+ adminToken,
43
+ PROVIDER,
44
+ pushToken,
45
+ `qa-tc-channel-push-007-${Date.now()}`,
46
+ )
47
+
48
+ await createNotificationFixture(request, adminToken, {
49
+ recipientUserId: userId,
50
+ type: 'admin.custom_message',
51
+ title: 'Order shipped',
52
+ body: 'Your order is on its way',
53
+ data: { probe: 'tc-channel-push-007' },
54
+ pushOptions: { sound: 'chime.caf', priority: 'high', channelId: 'orders' },
55
+ })
56
+
57
+ await drainIntegrationQueue('events')
58
+ await drainIntegrationQueue('push-deliveries')
59
+
60
+ await expect
61
+ .poll(async () => (await readLatestDelivery(tenantId, userDeviceId as string))?.status ?? null, {
62
+ timeout: 30_000,
63
+ })
64
+ .toBe('sent')
65
+
66
+ const row = await readLatestDelivery(tenantId, userDeviceId as string)
67
+ expect(row?.provider).toBe(PROVIDER)
68
+ expect(row?.token_snapshot).toBe(tokenTail)
69
+
70
+ const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt)
71
+ expect(native.to).toBe(pushToken)
72
+ expect(native.title).toBe('Order shipped')
73
+ expect(native.body).toBe('Your order is on its way')
74
+ expect(native.sound).toBe('chime.caf')
75
+ expect(native.priority).toBe('high')
76
+ expect(native.channelId).toBe('orders')
77
+ } finally {
78
+ await deleteDeliveriesForDevice(userDeviceId)
79
+ await deleteFakePushDevice(request, adminToken, userDeviceId)
80
+ await deleteChannelIfExists(request, adminToken, channelId)
81
+ }
82
+ })
83
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'channel_expo.view', title: 'View Expo push channel configuration', module: 'channel_expo' },
3
+ { id: 'channel_expo.configure', title: 'Configure Expo push credentials', module: 'channel_expo' },
4
+ ]
5
+
6
+ export default features
@@ -0,0 +1,26 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import {
4
+ hasChannelAdapter,
5
+ registerChannelAdapter,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
7
+ import { getExpoChannelAdapter } from './lib/adapter'
8
+ import { ensureExpoFakeProviderInstalled } from './lib/fake-provider'
9
+ import { channelExpoHealthCheck } from './lib/health'
10
+
11
+ export function register(container: AppContainer): void {
12
+ // Test-only: swap expo-server-sdk for a network-free fake when `OM_PUSH_FAKE_PROVIDERS` is set
13
+ // (no-op in production), so integration specs exercise this adapter end-to-end. Swaps the SDK
14
+ // client only — the adapter below stays registered. See lib/fake-provider.ts.
15
+ ensureExpoFakeProviderInstalled()
16
+
17
+ if (!hasChannelAdapter('expo')) {
18
+ registerChannelAdapter(getExpoChannelAdapter())
19
+ }
20
+ container.register({
21
+ channelExpoAdapter: asValue(getExpoChannelAdapter()),
22
+ // Registered under the exact service name declared in `integration.ts`
23
+ // (`healthCheck.service`); without it the hub's resolve throws.
24
+ channelExpoHealthCheck: asValue(channelExpoHealthCheck),
25
+ })
26
+ }