@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +26 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js +56 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js +65 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js.map +7 -0
- package/dist/modules/channel_expo/acl.js +10 -0
- package/dist/modules/channel_expo/acl.js.map +7 -0
- package/dist/modules/channel_expo/di.js +24 -0
- package/dist/modules/channel_expo/di.js.map +7 -0
- package/dist/modules/channel_expo/index.js +9 -0
- package/dist/modules/channel_expo/index.js.map +7 -0
- package/dist/modules/channel_expo/integration.js +47 -0
- package/dist/modules/channel_expo/integration.js.map +7 -0
- package/dist/modules/channel_expo/lib/adapter.js +154 -0
- package/dist/modules/channel_expo/lib/adapter.js.map +7 -0
- package/dist/modules/channel_expo/lib/credentials.js +8 -0
- package/dist/modules/channel_expo/lib/credentials.js.map +7 -0
- package/dist/modules/channel_expo/lib/fake-provider.js +40 -0
- package/dist/modules/channel_expo/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_expo/lib/health.js +10 -0
- package/dist/modules/channel_expo/lib/health.js.map +7 -0
- package/dist/modules/channel_expo/setup.js +25 -0
- package/dist/modules/channel_expo/setup.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js +157 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection-table.js +15 -0
- package/dist/modules/channel_expo/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_expo/__integration__/TC-CHANNEL-PUSH-004.spec.ts +76 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.ts +3 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.ts +83 -0
- package/src/modules/channel_expo/acl.ts +6 -0
- package/src/modules/channel_expo/di.ts +26 -0
- package/src/modules/channel_expo/index.ts +6 -0
- package/src/modules/channel_expo/integration.ts +48 -0
- package/src/modules/channel_expo/lib/__tests__/adapter-instance-cache.test.ts +65 -0
- package/src/modules/channel_expo/lib/__tests__/adapter.test.ts +211 -0
- package/src/modules/channel_expo/lib/__tests__/credentials.test.ts +18 -0
- package/src/modules/channel_expo/lib/__tests__/fake-provider.test.ts +94 -0
- package/src/modules/channel_expo/lib/__tests__/message-golden.test.ts +77 -0
- package/src/modules/channel_expo/lib/adapter.ts +256 -0
- package/src/modules/channel_expo/lib/credentials.ts +14 -0
- package/src/modules/channel_expo/lib/fake-provider.ts +64 -0
- package/src/modules/channel_expo/lib/health.ts +13 -0
- package/src/modules/channel_expo/setup.ts +31 -0
- package/src/modules/channel_expo/widgets/injection/connect/widget.client.tsx +193 -0
- package/src/modules/channel_expo/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_expo/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# `@open-mercato/channel-expo` — Agent Guidelines
|
|
2
|
+
|
|
3
|
+
Expo push provider for the Communications Hub (`communication_channels`). Registers a `push` `ChannelAdapter` (providerKey `expo`) consumed by the `push_notifications` delivery strategy/worker.
|
|
4
|
+
|
|
5
|
+
- **Package**: `@open-mercato/channel-expo` ⇒ **module id**: `channel_expo`
|
|
6
|
+
- **Provider key**: `expo` · **channelType**: `push`
|
|
7
|
+
- Transport: `expo-server-sdk` (Expo push service) — keep all Expo-specific logic here.
|
|
8
|
+
|
|
9
|
+
## Key Files (`src/modules/channel_expo/`)
|
|
10
|
+
|
|
11
|
+
| File | Purpose |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| `integration.ts` | `IntegrationDefinition` (credentials field, `healthCheck.service`) |
|
|
14
|
+
| `di.ts` / `setup.ts` | Register the adapter + `channelExpoHealthCheck`; `defaultRoleFeatures` |
|
|
15
|
+
| `acl.ts` | `channel_expo.view`, `channel_expo.configure` |
|
|
16
|
+
| `lib/adapter.ts` | `ExpoChannelAdapter`; `setExpoClientFactory` test seam isolates expo-server-sdk |
|
|
17
|
+
| `lib/credentials.ts` | Zod schema: `{ accessToken? }` |
|
|
18
|
+
| `lib/health.ts` | `channelExpoHealthCheck` liveness probe |
|
|
19
|
+
|
|
20
|
+
## Adapter Contract
|
|
21
|
+
|
|
22
|
+
Implements the shared push-adapter contract (see `channel-fcm` AGENTS.md). A malformed Expo token (`!Expo.isExpoPushToken`) and a `DeviceNotRegistered` ticket both map to the uniform `device_unregistered` sentinel so the worker soft-deletes the device. Receipt-polling for delayed `DeviceNotRegistered` is out of scope — classification is on the send ticket.
|
|
23
|
+
|
|
24
|
+
## Credentials
|
|
25
|
+
|
|
26
|
+
Tenant-level credentials on `IntegrationCredentials` for provider `channel_expo`: `{ accessToken? }` (only needed with Expo enhanced push security). Connect via the existing `communication_channels` credentials-connect flow. Device tokens (`ExponentPushToken[...]`) live in `devices.UserDevice.push_token`, never here.
|
package/build.mjs
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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-004: Expo provider registration", () => {
|
|
6
|
+
test("POST per-user connect/credentials with providerKey=expo is refused (403, tenant-scoped)", async ({
|
|
7
|
+
request
|
|
8
|
+
}) => {
|
|
9
|
+
const token = await getAuthToken(request);
|
|
10
|
+
const response = await apiRequest(
|
|
11
|
+
request,
|
|
12
|
+
"POST",
|
|
13
|
+
"/api/communication_channels/channels/connect/credentials",
|
|
14
|
+
{
|
|
15
|
+
token,
|
|
16
|
+
data: {
|
|
17
|
+
providerKey: "expo",
|
|
18
|
+
displayName: "Expo \u2014 per-user (should be refused)",
|
|
19
|
+
credentials: { accessToken: 12345 }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
24
|
+
expect(response.status(), "Expo provider should be registered (never 404 no_adapter)").not.toBe(404);
|
|
25
|
+
expect(response.status(), "authenticated request should not 401").not.toBe(401);
|
|
26
|
+
expect(response.status()).toBe(403);
|
|
27
|
+
const body = await readJsonSafe(response);
|
|
28
|
+
expect(body?.code).toBe("provider_is_tenant_scoped");
|
|
29
|
+
});
|
|
30
|
+
test("POST tenant connect/credentials with providerKey=expo reaches validateCredentials (422)", async ({
|
|
31
|
+
request
|
|
32
|
+
}) => {
|
|
33
|
+
const token = await getAuthToken(request);
|
|
34
|
+
const response = await apiRequest(
|
|
35
|
+
request,
|
|
36
|
+
"POST",
|
|
37
|
+
"/api/communication_channels/channels/connect/tenant-credentials",
|
|
38
|
+
{
|
|
39
|
+
token,
|
|
40
|
+
// Wrong-typed accessToken (not an empty object, which would validate and create a
|
|
41
|
+
// channel) → validateCredentials rejects it, so the flow stays side-effect-free.
|
|
42
|
+
data: {
|
|
43
|
+
providerKey: "expo",
|
|
44
|
+
displayName: "Expo \u2014 tenant connect",
|
|
45
|
+
credentials: { accessToken: 12345 }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
50
|
+
expect(response.status(), "authenticated request should not 401").not.toBe(401);
|
|
51
|
+
expect(response.status(), "wrong-typed credentials should reach the adapter and 422").toBe(422);
|
|
52
|
+
const body = await readJsonSafe(response);
|
|
53
|
+
expect(body?.fieldErrors ?? body?.error).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-004.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.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-004 \u2014 Expo is registered AND tenant-scoped.\n *\n * `@open-mercato/channel-expo` registers the `expo` push `ChannelAdapter` at import with\n * `channelScope: 'tenant'`. Push providers have no OAuth/webhook surface of their own, so\n * the credential-connect routes are where hub registration is observable:\n *\n * - the **per-user** route short-circuits tenant-scoped providers with 403\n * (`provider_is_tenant_scoped`) before touching credentials \u2014 403 rather than 404\n * (`no_adapter`) proves the adapter is registered, and it guards against a non-admin\n * minting a tenant-wide channel;\n * - the **tenant** route runs the adapter's own `validateCredentials`, which returns 422.\n *\n * Expo credentials are all-optional (`accessToken?`), so an empty object would VALIDATE\n * and create a channel on the tenant route \u2014 we send a wrong-typed `accessToken` instead,\n * so `validateCredentials` rejects it and the flow stays side-effect-free.\n */\ntest.describe('TC-CHANNEL-PUSH-004: Expo provider registration', () => {\n test('POST per-user connect/credentials with providerKey=expo is refused (403, tenant-scoped)', async ({\n request,\n }) => {\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: {\n providerKey: 'expo',\n displayName: 'Expo \u2014 per-user (should be refused)',\n credentials: { accessToken: 12345 },\n },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n expect(response.status(), 'Expo provider should be registered (never 404 no_adapter)').not.toBe(404)\n expect(response.status(), 'authenticated request should not 401').not.toBe(401)\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 test('POST tenant connect/credentials with providerKey=expo reaches validateCredentials (422)', async ({\n request,\n }) => {\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 // Wrong-typed accessToken (not an empty object, which would validate and create a\n // channel) \u2192 validateCredentials rejects it, so the flow stays side-effect-free.\n data: {\n providerKey: 'expo',\n displayName: 'Expo \u2014 tenant connect',\n credentials: { accessToken: 12345 },\n },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n // A 422 proves the request reached the registered adapter and ran its validateCredentials\n // \u2014 not just that auth short-circuited. 401 would satisfy \"not 404\" without proving it.\n expect(response.status(), 'authenticated request should not 401').not.toBe(401)\n expect(response.status(), 'wrong-typed credentials should reach the adapter and 422').toBe(422)\n const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)\n expect(body?.fieldErrors ?? body?.error).toBeTruthy()\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAmB7B,KAAK,SAAS,mDAAmD,MAAM;AACrE,OAAK,2FAA2F,OAAO;AAAA,IACrG;AAAA,EACF,MAAM;AACJ,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa;AAAA,UACb,aAAa,EAAE,aAAa,MAAM;AAAA,QACpC;AAAA,MACF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAClE,WAAO,SAAS,OAAO,GAAG,2DAA2D,EAAE,IAAI,KAAK,GAAG;AACnG,WAAO,SAAS,OAAO,GAAG,sCAAsC,EAAE,IAAI,KAAK,GAAG;AAC9E,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,UAAM,OAAO,MAAM,aAAgC,QAAQ;AAC3D,WAAO,MAAM,IAAI,EAAE,KAAK,2BAA2B;AAAA,EACrD,CAAC;AAED,OAAK,2FAA2F,OAAO;AAAA,IACrG;AAAA,EACF,MAAM;AACJ,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA;AAAA;AAAA,QAGA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa;AAAA,UACb,aAAa,EAAE,aAAa,MAAM;AAAA,QACpC;AAAA,MACF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAGlE,WAAO,SAAS,OAAO,GAAG,sCAAsC,EAAE,IAAI,KAAK,GAAG;AAC9E,WAAO,SAAS,OAAO,GAAG,0DAA0D,EAAE,KAAK,GAAG;AAC9F,UAAM,OAAO,MAAM,aAAuE,QAAQ;AAClG,WAAO,MAAM,eAAe,MAAM,KAAK,EAAE,WAAW;AAAA,EACtD,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.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,65 @@
|
|
|
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 = "expo";
|
|
17
|
+
test.describe("TC-CHANNEL-PUSH-007: real Expo adapter reaches sent with a correct native message", () => {
|
|
18
|
+
test("maps sound and priority onto the Expo message", async ({ request }) => {
|
|
19
|
+
test.setTimeout(12e4);
|
|
20
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
21
|
+
const { tenantId, 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-007 Expo");
|
|
28
|
+
userDeviceId = await registerFakePushDevice(
|
|
29
|
+
request,
|
|
30
|
+
adminToken,
|
|
31
|
+
PROVIDER,
|
|
32
|
+
pushToken,
|
|
33
|
+
`qa-tc-channel-push-007-${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-007" },
|
|
41
|
+
pushOptions: { sound: "chime.caf", priority: "high", channelId: "orders" }
|
|
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
|
+
const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt);
|
|
52
|
+
expect(native.to).toBe(pushToken);
|
|
53
|
+
expect(native.title).toBe("Order shipped");
|
|
54
|
+
expect(native.body).toBe("Your order is on its way");
|
|
55
|
+
expect(native.sound).toBe("chime.caf");
|
|
56
|
+
expect(native.priority).toBe("high");
|
|
57
|
+
expect(native.channelId).toBe("orders");
|
|
58
|
+
} finally {
|
|
59
|
+
await deleteDeliveriesForDevice(userDeviceId);
|
|
60
|
+
await deleteFakePushDevice(request, adminToken, userDeviceId);
|
|
61
|
+
await deleteChannelIfExists(request, adminToken, channelId);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-007.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.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-007 \u2014 the REAL Expo adapter, end-to-end, without live credentials.\n *\n * Only the `expo-server-sdk` client is faked (`OM_PUSH_FAKE_PROVIDERS`), so the adapter's token\n * validation, chunking, and `buildExpoMessage` run for real.\n */\nconst PROVIDER = 'expo'\n\ntest.describe('TC-CHANNEL-PUSH-007: real Expo adapter reaches sent with a correct native message', () => {\n test('maps sound and priority onto the Expo 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, userId } = getTokenScope(adminToken)\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-007 Expo')\n userDeviceId = await registerFakePushDevice(\n request,\n adminToken,\n PROVIDER,\n pushToken,\n `qa-tc-channel-push-007-${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-007' },\n pushOptions: { sound: 'chime.caf', priority: 'high', channelId: 'orders' },\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\n const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt)\n expect(native.to).toBe(pushToken)\n expect(native.title).toBe('Order shipped')\n expect(native.body).toBe('Your order is on its way')\n expect(native.sound).toBe('chime.caf')\n expect(native.priority).toBe('high')\n expect(native.channelId).toBe('orders')\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;AAQP,MAAM,WAAW;AAEjB,KAAK,SAAS,qFAAqF,MAAM;AACvG,OAAK,iDAAiD,OAAO,EAAE,QAAQ,MAAM;AAI3E,SAAK,WAAW,IAAO;AACvB,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,UAAM,EAAE,UAAU,OAAO,IAAI,cAAc,UAAU;AACrD,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,0BAA0B;AAClG,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,OAAO,aAAa,UAAU,QAAQ,WAAW,SAAS;AAAA,MAC3E,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;AAE1C,YAAM,SAAS,MAAM,oBAAoB,UAAU,WAAW,SAAS;AACvE,aAAO,OAAO,EAAE,EAAE,KAAK,SAAS;AAChC,aAAO,OAAO,KAAK,EAAE,KAAK,eAAe;AACzC,aAAO,OAAO,IAAI,EAAE,KAAK,0BAA0B;AACnD,aAAO,OAAO,KAAK,EAAE,KAAK,WAAW;AACrC,aAAO,OAAO,QAAQ,EAAE,KAAK,MAAM;AACnC,aAAO,OAAO,SAAS,EAAE,KAAK,QAAQ;AAAA,IACxC,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_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
|
+
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_expo/acl.ts"],
|
|
4
|
+
"sourcesContent": ["export const features = [\n { id: 'channel_expo.view', title: 'View Expo push channel configuration', module: 'channel_expo' },\n { id: 'channel_expo.configure', title: 'Configure Expo push credentials', module: 'channel_expo' },\n]\n\nexport default features\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,EAAE,IAAI,qBAAqB,OAAO,wCAAwC,QAAQ,eAAe;AAAA,EACjG,EAAE,IAAI,0BAA0B,OAAO,mCAAmC,QAAQ,eAAe;AACnG;AAEA,IAAO,cAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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 { getExpoChannelAdapter } from "./lib/adapter.js";
|
|
7
|
+
import { ensureExpoFakeProviderInstalled } from "./lib/fake-provider.js";
|
|
8
|
+
import { channelExpoHealthCheck } from "./lib/health.js";
|
|
9
|
+
function register(container) {
|
|
10
|
+
ensureExpoFakeProviderInstalled();
|
|
11
|
+
if (!hasChannelAdapter("expo")) {
|
|
12
|
+
registerChannelAdapter(getExpoChannelAdapter());
|
|
13
|
+
}
|
|
14
|
+
container.register({
|
|
15
|
+
channelExpoAdapter: asValue(getExpoChannelAdapter()),
|
|
16
|
+
// Registered under the exact service name declared in `integration.ts`
|
|
17
|
+
// (`healthCheck.service`); without it the hub's resolve throws.
|
|
18
|
+
channelExpoHealthCheck: asValue(channelExpoHealthCheck)
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
register
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=di.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_expo/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 { getExpoChannelAdapter } from './lib/adapter'\nimport { ensureExpoFakeProviderInstalled } from './lib/fake-provider'\nimport { channelExpoHealthCheck } from './lib/health'\n\nexport function register(container: AppContainer): void {\n // Test-only: swap expo-server-sdk 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 ensureExpoFakeProviderInstalled()\n\n if (!hasChannelAdapter('expo')) {\n registerChannelAdapter(getExpoChannelAdapter())\n }\n container.register({\n channelExpoAdapter: asValue(getExpoChannelAdapter()),\n // Registered under the exact service name declared in `integration.ts`\n // (`healthCheck.service`); without it the hub's resolve throws.\n channelExpoHealthCheck: asValue(channelExpoHealthCheck),\n })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAAe;AAExB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,6BAA6B;AACtC,SAAS,uCAAuC;AAChD,SAAS,8BAA8B;AAEhC,SAAS,SAAS,WAA+B;AAItD,kCAAgC;AAEhC,MAAI,CAAC,kBAAkB,MAAM,GAAG;AAC9B,2BAAuB,sBAAsB,CAAC;AAAA,EAChD;AACA,YAAU,SAAS;AAAA,IACjB,oBAAoB,QAAQ,sBAAsB,CAAC;AAAA;AAAA;AAAA,IAGnD,wBAAwB,QAAQ,sBAAsB;AAAA,EACxD,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const metadata = {
|
|
2
|
+
id: "channel_expo",
|
|
3
|
+
title: "Expo Push Channel",
|
|
4
|
+
description: "Deliver mobile push to Expo apps via the Expo push service. Registers a `push` ChannelAdapter (providerKey `expo`) 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_expo/index.ts"],
|
|
4
|
+
"sourcesContent": ["export const metadata = {\n id: 'channel_expo',\n title: 'Expo Push Channel',\n description:\n 'Deliver mobile push to Expo apps via the Expo push service. Registers a `push` ChannelAdapter (providerKey `expo`) 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,47 @@
|
|
|
1
|
+
const integration = {
|
|
2
|
+
id: "channel_expo",
|
|
3
|
+
title: "Expo Push",
|
|
4
|
+
description: "Deliver mobile push to Expo apps via the Expo push service (ExponentPushToken). Provider for the push_notifications channel.",
|
|
5
|
+
category: "communication",
|
|
6
|
+
hub: "communication_channels",
|
|
7
|
+
providerKey: "expo",
|
|
8
|
+
icon: "expo",
|
|
9
|
+
docsUrl: "https://docs.expo.dev/push-notifications/sending-notifications/",
|
|
10
|
+
package: "@open-mercato/channel-expo",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
author: "Open Mercato Team",
|
|
13
|
+
company: "Open Mercato",
|
|
14
|
+
license: "MIT",
|
|
15
|
+
tags: ["push", "expo", "react-native", "communication"],
|
|
16
|
+
apiVersions: [
|
|
17
|
+
{
|
|
18
|
+
id: "v1",
|
|
19
|
+
label: "Expo Push API",
|
|
20
|
+
status: "stable",
|
|
21
|
+
default: true,
|
|
22
|
+
changelog: "Expo push service via expo-server-sdk (ExponentPushToken delivery)."
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
credentials: {
|
|
26
|
+
fields: [
|
|
27
|
+
{
|
|
28
|
+
key: "accessToken",
|
|
29
|
+
label: "Expo Access Token (optional)",
|
|
30
|
+
type: "secret",
|
|
31
|
+
required: false,
|
|
32
|
+
helpText: "Only required when Expo enhanced push security is enabled for the project. Expo dashboard -> Account -> Access Tokens. Stored encrypted at rest."
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
healthCheck: { service: "channelExpoHealthCheck" }
|
|
37
|
+
};
|
|
38
|
+
const integrations = [integration];
|
|
39
|
+
const bundles = [];
|
|
40
|
+
const bundle = void 0;
|
|
41
|
+
export {
|
|
42
|
+
bundle,
|
|
43
|
+
bundles,
|
|
44
|
+
integration,
|
|
45
|
+
integrations
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=integration.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_expo/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_expo',\n title: 'Expo Push',\n description:\n 'Deliver mobile push to Expo apps via the Expo push service (ExponentPushToken). Provider for the push_notifications channel.',\n category: 'communication',\n hub: 'communication_channels',\n providerKey: 'expo',\n icon: 'expo',\n docsUrl: 'https://docs.expo.dev/push-notifications/sending-notifications/',\n package: '@open-mercato/channel-expo',\n version: '0.1.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['push', 'expo', 'react-native', 'communication'],\n apiVersions: [\n {\n id: 'v1',\n label: 'Expo Push API',\n status: 'stable',\n default: true,\n changelog: 'Expo push service via expo-server-sdk (ExponentPushToken delivery).',\n },\n ],\n credentials: {\n fields: [\n {\n key: 'accessToken',\n label: 'Expo Access Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Only required when Expo enhanced push security is enabled for the project. Expo dashboard -> Account -> Access Tokens. Stored encrypted at rest.',\n },\n ],\n },\n healthCheck: { service: 'channelExpoHealthCheck' },\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,QAAQ,gBAAgB,eAAe;AAAA,EACtD,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,IACF;AAAA,EACF;AAAA,EACA,aAAa,EAAE,SAAS,yBAAyB;AACnD;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { readPushEnvelope, resolvePushBody } from "@open-mercato/core/modules/communication_channels/lib/push-envelope";
|
|
9
|
+
import { expoCredentialsSchema } from "./credentials.js";
|
|
10
|
+
const PERMANENT_EXPO_RECEIPT_ERRORS = /* @__PURE__ */ new Set(["DeviceNotRegistered"]);
|
|
11
|
+
function buildExpoMessage(token, envelope) {
|
|
12
|
+
const { options, silent } = envelope;
|
|
13
|
+
if (silent) {
|
|
14
|
+
return { to: token, data: envelope.data, _contentAvailable: true };
|
|
15
|
+
}
|
|
16
|
+
const message = {
|
|
17
|
+
to: token,
|
|
18
|
+
title: envelope.title,
|
|
19
|
+
body: resolvePushBody(envelope),
|
|
20
|
+
data: envelope.data,
|
|
21
|
+
sound: options.sound ?? "default"
|
|
22
|
+
};
|
|
23
|
+
if (typeof options.badge === "number") message.badge = options.badge;
|
|
24
|
+
if (options.priority) message.priority = options.priority;
|
|
25
|
+
if (options.channelId) message.channelId = options.channelId;
|
|
26
|
+
if (options.image) message.richContent = { image: options.image };
|
|
27
|
+
return message;
|
|
28
|
+
}
|
|
29
|
+
let clientFactory = null;
|
|
30
|
+
function setExpoClientFactory(factory) {
|
|
31
|
+
clientFactory = factory;
|
|
32
|
+
}
|
|
33
|
+
const INSTANCE_CACHE_MAX = 32;
|
|
34
|
+
const expoInstanceCache = /* @__PURE__ */ new Map();
|
|
35
|
+
let expoModulePromise = null;
|
|
36
|
+
function loadExpoModule() {
|
|
37
|
+
if (!expoModulePromise) {
|
|
38
|
+
const loading = import("expo-server-sdk").then((mod) => {
|
|
39
|
+
const candidate = mod;
|
|
40
|
+
return candidate.default ?? candidate;
|
|
41
|
+
});
|
|
42
|
+
loading.catch(() => {
|
|
43
|
+
if (expoModulePromise === loading) expoModulePromise = null;
|
|
44
|
+
});
|
|
45
|
+
expoModulePromise = loading;
|
|
46
|
+
}
|
|
47
|
+
return expoModulePromise;
|
|
48
|
+
}
|
|
49
|
+
function getExpoInstance(accessToken) {
|
|
50
|
+
const cacheKey = accessToken ? createHash("sha256").update(accessToken).digest("hex").slice(0, 16) : "no-access-token";
|
|
51
|
+
const existing = expoInstanceCache.get(cacheKey);
|
|
52
|
+
if (existing) {
|
|
53
|
+
expoInstanceCache.delete(cacheKey);
|
|
54
|
+
expoInstanceCache.set(cacheKey, existing);
|
|
55
|
+
return existing;
|
|
56
|
+
}
|
|
57
|
+
const pending = loadExpoModule().then(({ Expo }) => new Expo({ accessToken }));
|
|
58
|
+
pending.catch(() => {
|
|
59
|
+
if (expoInstanceCache.get(cacheKey) === pending) expoInstanceCache.delete(cacheKey);
|
|
60
|
+
});
|
|
61
|
+
expoInstanceCache.set(cacheKey, pending);
|
|
62
|
+
if (expoInstanceCache.size > INSTANCE_CACHE_MAX) {
|
|
63
|
+
const oldestKey = expoInstanceCache.keys().next().value;
|
|
64
|
+
if (oldestKey != null) expoInstanceCache.delete(oldestKey);
|
|
65
|
+
}
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
function defaultClientFactory(credentials) {
|
|
69
|
+
return {
|
|
70
|
+
async isExpoPushToken(token) {
|
|
71
|
+
const { Expo } = await loadExpoModule();
|
|
72
|
+
return Expo.isExpoPushToken(token);
|
|
73
|
+
},
|
|
74
|
+
async send(messages) {
|
|
75
|
+
const expo = await getExpoInstance(credentials.accessToken);
|
|
76
|
+
return expo.sendPushNotificationsAsync(messages);
|
|
77
|
+
},
|
|
78
|
+
async getReceipts(ticketIds) {
|
|
79
|
+
const expo = await getExpoInstance(credentials.accessToken);
|
|
80
|
+
const receipts = {};
|
|
81
|
+
for (const chunk of expo.chunkPushNotificationReceiptIds(ticketIds)) {
|
|
82
|
+
Object.assign(receipts, await expo.getPushNotificationReceiptsAsync(chunk));
|
|
83
|
+
}
|
|
84
|
+
return receipts;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
class ExpoChannelAdapter extends BasePushChannelAdapter {
|
|
89
|
+
constructor() {
|
|
90
|
+
super(...arguments);
|
|
91
|
+
this.providerKey = "expo";
|
|
92
|
+
this.credentialsSchema = expoCredentialsSchema;
|
|
93
|
+
}
|
|
94
|
+
async sendMessage(input) {
|
|
95
|
+
const token = readPushToken(input);
|
|
96
|
+
if (!token) return MISSING_PUSH_TOKEN_RESULT;
|
|
97
|
+
const parsedCredentials = expoCredentialsSchema.safeParse(input.credentials);
|
|
98
|
+
if (!parsedCredentials.success) {
|
|
99
|
+
return { externalMessageId: "", status: "failed", error: "invalid_expo_credentials" };
|
|
100
|
+
}
|
|
101
|
+
const client = (clientFactory ?? defaultClientFactory)(parsedCredentials.data);
|
|
102
|
+
if (!await client.isExpoPushToken(token)) {
|
|
103
|
+
return deviceUnregisteredResult({ reason: "invalid_expo_push_token" });
|
|
104
|
+
}
|
|
105
|
+
const envelope = readPushEnvelope(input.content);
|
|
106
|
+
try {
|
|
107
|
+
const tickets = await client.send([buildExpoMessage(token, envelope)]);
|
|
108
|
+
const ticket = tickets[0];
|
|
109
|
+
if (!ticket) return { externalMessageId: "", status: "failed", error: "no_response" };
|
|
110
|
+
if (ticket.status === "ok") {
|
|
111
|
+
return { externalMessageId: ticket.id ?? "", status: "sent" };
|
|
112
|
+
}
|
|
113
|
+
if (ticket.details?.error === "DeviceNotRegistered") {
|
|
114
|
+
return deviceUnregisteredResult({ reason: "DeviceNotRegistered" });
|
|
115
|
+
}
|
|
116
|
+
return { externalMessageId: "", status: "failed", error: ticket.message ?? "expo_send_failed" };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { externalMessageId: "", status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Poll async delivery receipts for previously-accepted tickets and report which map to a
|
|
123
|
+
* permanently-unregistered device token. Returns an entry ONLY for tickets whose receipt is ready
|
|
124
|
+
* (present in the SDK response); tickets with no receipt yet are omitted so the reaper re-checks them
|
|
125
|
+
* on a later sweep. `MessageRateExceeded` (and any other non-`DeviceNotRegistered` error) is treated
|
|
126
|
+
* as transient — the receipt resolves but the token is NOT killed.
|
|
127
|
+
*/
|
|
128
|
+
async checkReceipts(ticketIds, credentials) {
|
|
129
|
+
const ids = ticketIds.filter((id) => typeof id === "string" && id.length > 0);
|
|
130
|
+
if (ids.length === 0) return [];
|
|
131
|
+
const parsedCredentials = expoCredentialsSchema.safeParse(credentials ?? {});
|
|
132
|
+
const client = (clientFactory ?? defaultClientFactory)(parsedCredentials.success ? parsedCredentials.data : {});
|
|
133
|
+
const receipts = await client.getReceipts(ids);
|
|
134
|
+
const outcomes = [];
|
|
135
|
+
for (const ticketId of ids) {
|
|
136
|
+
const receipt = receipts[ticketId];
|
|
137
|
+
if (!receipt) continue;
|
|
138
|
+
const unregistered = receipt.status === "error" && !!receipt.details?.error && PERMANENT_EXPO_RECEIPT_ERRORS.has(receipt.details.error);
|
|
139
|
+
outcomes.push({ ticketId, unregistered });
|
|
140
|
+
}
|
|
141
|
+
return outcomes;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let cachedAdapter = null;
|
|
145
|
+
function getExpoChannelAdapter() {
|
|
146
|
+
if (!cachedAdapter) cachedAdapter = new ExpoChannelAdapter();
|
|
147
|
+
return cachedAdapter;
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
buildExpoMessage,
|
|
151
|
+
getExpoChannelAdapter,
|
|
152
|
+
setExpoClientFactory
|
|
153
|
+
};
|
|
154
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_expo/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 type PushReceiptChecker,\n type PushReceiptOutcome,\n} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'\nimport { readPushEnvelope, resolvePushBody, type PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'\nimport { expoCredentialsSchema, type ExpoCredentials } from './credentials'\n\nexport interface ExpoPushTicket {\n status: 'ok' | 'error'\n id?: string\n message?: string\n details?: { error?: string }\n}\n\n/**\n * Async delivery receipt fetched later by ticket id via `getPushNotificationReceiptsAsync`. This is\n * where the common \"app uninstalled\" case surfaces for Expo (`details.error === 'DeviceNotRegistered'`),\n * NOT the synchronous send ticket.\n */\nexport interface ExpoPushReceipt {\n status: 'ok' | 'error'\n message?: string\n details?: { error?: string }\n}\n\n/**\n * Expo receipt-error codes that mean the device token is permanently invalid \u21D2 soft-delete the device\n * (uniform `device_unregistered` contract). Everything else (notably `MessageRateExceeded`) is transient\n * and must NOT kill the token.\n */\nconst PERMANENT_EXPO_RECEIPT_ERRORS = new Set(['DeviceNotRegistered'])\n\nexport interface ExpoPushMessage {\n to: string\n title?: string\n body?: string\n data?: Record<string, string>\n sound?: string\n badge?: number\n priority?: 'default' | 'normal' | 'high'\n channelId?: string\n richContent?: { image?: string }\n /** Data-only / silent delivery (iOS background wake-up). */\n _contentAvailable?: boolean\n}\n\n/** Build the Expo message, branching on silent and applying recognized push options. */\nexport function buildExpoMessage(token: string, envelope: PushEnvelope): ExpoPushMessage {\n const { options, silent } = envelope\n if (silent) {\n return { to: token, data: envelope.data, _contentAvailable: true }\n }\n const message: ExpoPushMessage = {\n to: token,\n title: envelope.title,\n body: resolvePushBody(envelope),\n data: envelope.data,\n sound: options.sound ?? 'default',\n }\n if (typeof options.badge === 'number') message.badge = options.badge\n if (options.priority) message.priority = options.priority\n if (options.channelId) message.channelId = options.channelId\n if (options.image) message.richContent = { image: options.image }\n return message\n}\n\n/**\n * Seam over `expo-server-sdk` so the SDK (and its network client) stays out of\n * the adapter control flow and unit tests.\n */\nexport interface ExpoClientLike {\n isExpoPushToken(token: string): boolean | Promise<boolean>\n send(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]>\n /** Fetch async delivery receipts keyed by ticket id (chunked internally by the SDK). */\n getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>>\n}\n\ntype ExpoModule = {\n Expo: {\n isExpoPushToken(token: string): boolean\n new (options: { accessToken?: string }): {\n sendPushNotificationsAsync(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]>\n chunkPushNotificationReceiptIds(receiptIds: string[]): string[][]\n getPushNotificationReceiptsAsync(receiptIds: string[]): Promise<Record<string, ExpoPushReceipt>>\n }\n }\n}\n\nexport type ExpoClientFactory = (credentials: ExpoCredentials) => ExpoClientLike\n\nlet clientFactory: ExpoClientFactory | null = null\n\n/** Test-only seam to swap the Expo client factory. */\nexport function setExpoClientFactory(factory: ExpoClientFactory | null): void {\n clientFactory = factory\n}\n\ntype ExpoInstance = InstanceType<ExpoModule['Expo']>\n\n// Bound the Expo client cache like the FCM/APNs adapters. An Expo instance is a plain SDK client (no\n// background timer or teardown), so eviction just drops the map entry \u2014 but leaving it unbounded would\n// grow one entry per distinct tenant access token.\nconst INSTANCE_CACHE_MAX = 32\nconst expoInstanceCache = new Map<string, Promise<ExpoInstance>>()\n\nlet expoModulePromise: Promise<ExpoModule> | null = null\n\nfunction loadExpoModule(): Promise<ExpoModule> {\n if (!expoModulePromise) {\n const loading = import('expo-server-sdk').then((mod) => {\n const candidate = mod as unknown as { default?: ExpoModule } & ExpoModule\n return candidate.default ?? candidate\n })\n // Drop a failed import so a later call can retry instead of returning the cached rejection forever.\n loading.catch(() => {\n if (expoModulePromise === loading) expoModulePromise = null\n })\n expoModulePromise = loading\n }\n return expoModulePromise\n}\n\n// Key the cache on a hash of the access token, never the raw secret (mirrors the FCM/APNs adapters):\n// a Map key is trivially inspectable in a heap dump, and hashing keeps tenant credential material out\n// of the cache's key space. The empty-token case (Expo allows an unauthenticated client) keeps a stable\n// sentinel so those callers still share one instance.\nfunction getExpoInstance(accessToken: string | undefined): Promise<ExpoInstance> {\n const cacheKey = accessToken\n ? createHash('sha256').update(accessToken).digest('hex').slice(0, 16)\n : 'no-access-token'\n const existing = expoInstanceCache.get(cacheKey)\n if (existing) {\n // Refresh recency: delete + re-insert moves the key to the newest position.\n expoInstanceCache.delete(cacheKey)\n expoInstanceCache.set(cacheKey, existing)\n return existing\n }\n const pending = loadExpoModule().then(({ Expo }) => new Expo({ accessToken }))\n // Drop a failed init so a later call can retry instead of returning the cached rejection forever.\n pending.catch(() => {\n if (expoInstanceCache.get(cacheKey) === pending) expoInstanceCache.delete(cacheKey)\n })\n expoInstanceCache.set(cacheKey, pending)\n if (expoInstanceCache.size > INSTANCE_CACHE_MAX) {\n const oldestKey = expoInstanceCache.keys().next().value as string | undefined\n if (oldestKey != null) expoInstanceCache.delete(oldestKey)\n }\n return pending\n}\n\nfunction defaultClientFactory(credentials: ExpoCredentials): ExpoClientLike {\n return {\n async isExpoPushToken(token: string): Promise<boolean> {\n const { Expo } = await loadExpoModule()\n return Expo.isExpoPushToken(token)\n },\n async send(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]> {\n const expo = await getExpoInstance(credentials.accessToken)\n return expo.sendPushNotificationsAsync(messages)\n },\n async getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>> {\n const expo = await getExpoInstance(credentials.accessToken)\n const receipts: Record<string, ExpoPushReceipt> = {}\n for (const chunk of expo.chunkPushNotificationReceiptIds(ticketIds)) {\n Object.assign(receipts, await expo.getPushNotificationReceiptsAsync(chunk))\n }\n return receipts\n },\n }\n}\n\nclass ExpoChannelAdapter extends BasePushChannelAdapter implements PushReceiptChecker {\n readonly providerKey = 'expo'\n protected readonly credentialsSchema = expoCredentialsSchema\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 = expoCredentialsSchema.safeParse(input.credentials)\n if (!parsedCredentials.success) {\n return { externalMessageId: '', status: 'failed', error: 'invalid_expo_credentials' }\n }\n\n const client = (clientFactory ?? defaultClientFactory)(parsedCredentials.data)\n\n // A malformed Expo token can never deliver \u2014 treat it as unregistered so the\n // worker soft-deletes the device (uniform sentinel across providers).\n if (!(await client.isExpoPushToken(token))) {\n return deviceUnregisteredResult({ reason: 'invalid_expo_push_token' })\n }\n\n const envelope = readPushEnvelope(input.content)\n\n try {\n const tickets = await client.send([buildExpoMessage(token, envelope)])\n const ticket = tickets[0]\n if (!ticket) return { externalMessageId: '', status: 'failed', error: 'no_response' }\n if (ticket.status === 'ok') {\n return { externalMessageId: ticket.id ?? '', status: 'sent' }\n }\n // Expo delivery is two-phase. A `status: 'ok'` ticket only means Expo *accepted* the message \u2014 it\n // does NOT confirm the token is valid. This ticket-level check catches the narrow cases Expo\n // rejects synchronously; the common \"app uninstalled\" case (`DeviceNotRegistered` for a\n // well-formed-but-stale token) surfaces later in the RECEIPT phase and is pruned by `checkReceipts`\n // below, polled by the push_notifications receipt reaper.\n if (ticket.details?.error === 'DeviceNotRegistered') {\n return deviceUnregisteredResult({ reason: 'DeviceNotRegistered' })\n }\n return { externalMessageId: '', status: 'failed', error: ticket.message ?? 'expo_send_failed' }\n } catch (err) {\n return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n }\n\n /**\n * Poll async delivery receipts for previously-accepted tickets and report which map to a\n * permanently-unregistered device token. Returns an entry ONLY for tickets whose receipt is ready\n * (present in the SDK response); tickets with no receipt yet are omitted so the reaper re-checks them\n * on a later sweep. `MessageRateExceeded` (and any other non-`DeviceNotRegistered` error) is treated\n * as transient \u2014 the receipt resolves but the token is NOT killed.\n */\n async checkReceipts(ticketIds: string[], credentials: unknown): Promise<PushReceiptOutcome[]> {\n const ids = ticketIds.filter((id) => typeof id === 'string' && id.length > 0)\n if (ids.length === 0) return []\n\n const parsedCredentials = expoCredentialsSchema.safeParse(credentials ?? {})\n const client = (clientFactory ?? defaultClientFactory)(parsedCredentials.success ? parsedCredentials.data : {})\n\n const receipts = await client.getReceipts(ids)\n const outcomes: PushReceiptOutcome[] = []\n for (const ticketId of ids) {\n const receipt = receipts[ticketId]\n if (!receipt) continue // receipt not ready yet \u2014 leave it for a later sweep\n const unregistered = receipt.status === 'error' && !!receipt.details?.error && PERMANENT_EXPO_RECEIPT_ERRORS.has(receipt.details.error)\n outcomes.push({ ticketId, unregistered })\n }\n return outcomes\n }\n}\n\nlet cachedAdapter: ExpoChannelAdapter | null = null\n\nexport function getExpoChannelAdapter(): ExpoChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new ExpoChannelAdapter()\n return cachedAdapter\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,kBAAkB;AAK3B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP,SAAS,kBAAkB,uBAA0C;AACrE,SAAS,6BAAmD;AAyB5D,MAAM,gCAAgC,oBAAI,IAAI,CAAC,qBAAqB,CAAC;AAiB9D,SAAS,iBAAiB,OAAe,UAAyC;AACvF,QAAM,EAAE,SAAS,OAAO,IAAI;AAC5B,MAAI,QAAQ;AACV,WAAO,EAAE,IAAI,OAAO,MAAM,SAAS,MAAM,mBAAmB,KAAK;AAAA,EACnE;AACA,QAAM,UAA2B;AAAA,IAC/B,IAAI;AAAA,IACJ,OAAO,SAAS;AAAA,IAChB,MAAM,gBAAgB,QAAQ;AAAA,IAC9B,MAAM,SAAS;AAAA,IACf,OAAO,QAAQ,SAAS;AAAA,EAC1B;AACA,MAAI,OAAO,QAAQ,UAAU,SAAU,SAAQ,QAAQ,QAAQ;AAC/D,MAAI,QAAQ,SAAU,SAAQ,WAAW,QAAQ;AACjD,MAAI,QAAQ,UAAW,SAAQ,YAAY,QAAQ;AACnD,MAAI,QAAQ,MAAO,SAAQ,cAAc,EAAE,OAAO,QAAQ,MAAM;AAChE,SAAO;AACT;AA0BA,IAAI,gBAA0C;AAGvC,SAAS,qBAAqB,SAAyC;AAC5E,kBAAgB;AAClB;AAOA,MAAM,qBAAqB;AAC3B,MAAM,oBAAoB,oBAAI,IAAmC;AAEjE,IAAI,oBAAgD;AAEpD,SAAS,iBAAsC;AAC7C,MAAI,CAAC,mBAAmB;AACtB,UAAM,UAAU,OAAO,iBAAiB,EAAE,KAAK,CAAC,QAAQ;AACtD,YAAM,YAAY;AAClB,aAAO,UAAU,WAAW;AAAA,IAC9B,CAAC;AAED,YAAQ,MAAM,MAAM;AAClB,UAAI,sBAAsB,QAAS,qBAAoB;AAAA,IACzD,CAAC;AACD,wBAAoB;AAAA,EACtB;AACA,SAAO;AACT;AAMA,SAAS,gBAAgB,aAAwD;AAC/E,QAAM,WAAW,cACb,WAAW,QAAQ,EAAE,OAAO,WAAW,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE,IAClE;AACJ,QAAM,WAAW,kBAAkB,IAAI,QAAQ;AAC/C,MAAI,UAAU;AAEZ,sBAAkB,OAAO,QAAQ;AACjC,sBAAkB,IAAI,UAAU,QAAQ;AACxC,WAAO;AAAA,EACT;AACA,QAAM,UAAU,eAAe,EAAE,KAAK,CAAC,EAAE,KAAK,MAAM,IAAI,KAAK,EAAE,YAAY,CAAC,CAAC;AAE7E,UAAQ,MAAM,MAAM;AAClB,QAAI,kBAAkB,IAAI,QAAQ,MAAM,QAAS,mBAAkB,OAAO,QAAQ;AAAA,EACpF,CAAC;AACD,oBAAkB,IAAI,UAAU,OAAO;AACvC,MAAI,kBAAkB,OAAO,oBAAoB;AAC/C,UAAM,YAAY,kBAAkB,KAAK,EAAE,KAAK,EAAE;AAClD,QAAI,aAAa,KAAM,mBAAkB,OAAO,SAAS;AAAA,EAC3D;AACA,SAAO;AACT;AAEA,SAAS,qBAAqB,aAA8C;AAC1E,SAAO;AAAA,IACL,MAAM,gBAAgB,OAAiC;AACrD,YAAM,EAAE,KAAK,IAAI,MAAM,eAAe;AACtC,aAAO,KAAK,gBAAgB,KAAK;AAAA,IACnC;AAAA,IACA,MAAM,KAAK,UAAwD;AACjE,YAAM,OAAO,MAAM,gBAAgB,YAAY,WAAW;AAC1D,aAAO,KAAK,2BAA2B,QAAQ;AAAA,IACjD;AAAA,IACA,MAAM,YAAY,WAA+D;AAC/E,YAAM,OAAO,MAAM,gBAAgB,YAAY,WAAW;AAC1D,YAAM,WAA4C,CAAC;AACnD,iBAAW,SAAS,KAAK,gCAAgC,SAAS,GAAG;AACnE,eAAO,OAAO,UAAU,MAAM,KAAK,iCAAiC,KAAK,CAAC;AAAA,MAC5E;AACA,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAEA,MAAM,2BAA2B,uBAAqD;AAAA,EAAtF;AAAA;AACE,SAAS,cAAc;AACvB,SAAmB,oBAAoB;AAAA;AAAA,EAEvC,MAAM,YAAY,OAAqD;AACrE,UAAM,QAAQ,cAAc,KAAK;AACjC,QAAI,CAAC,MAAO,QAAO;AAEnB,UAAM,oBAAoB,sBAAsB,UAAU,MAAM,WAAW;AAC3E,QAAI,CAAC,kBAAkB,SAAS;AAC9B,aAAO,EAAE,mBAAmB,IAAI,QAAQ,UAAU,OAAO,2BAA2B;AAAA,IACtF;AAEA,UAAM,UAAU,iBAAiB,sBAAsB,kBAAkB,IAAI;AAI7E,QAAI,CAAE,MAAM,OAAO,gBAAgB,KAAK,GAAI;AAC1C,aAAO,yBAAyB,EAAE,QAAQ,0BAA0B,CAAC;AAAA,IACvE;AAEA,UAAM,WAAW,iBAAiB,MAAM,OAAO;AAE/C,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,KAAK,CAAC,iBAAiB,OAAO,QAAQ,CAAC,CAAC;AACrE,YAAM,SAAS,QAAQ,CAAC;AACxB,UAAI,CAAC,OAAQ,QAAO,EAAE,mBAAmB,IAAI,QAAQ,UAAU,OAAO,cAAc;AACpF,UAAI,OAAO,WAAW,MAAM;AAC1B,eAAO,EAAE,mBAAmB,OAAO,MAAM,IAAI,QAAQ,OAAO;AAAA,MAC9D;AAMA,UAAI,OAAO,SAAS,UAAU,uBAAuB;AACnD,eAAO,yBAAyB,EAAE,QAAQ,sBAAsB,CAAC;AAAA,MACnE;AACA,aAAO,EAAE,mBAAmB,IAAI,QAAQ,UAAU,OAAO,OAAO,WAAW,mBAAmB;AAAA,IAChG,SAAS,KAAK;AACZ,aAAO,EAAE,mBAAmB,IAAI,QAAQ,UAAU,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE;AAAA,IAC5G;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,cAAc,WAAqB,aAAqD;AAC5F,UAAM,MAAM,UAAU,OAAO,CAAC,OAAO,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC;AAC5E,QAAI,IAAI,WAAW,EAAG,QAAO,CAAC;AAE9B,UAAM,oBAAoB,sBAAsB,UAAU,eAAe,CAAC,CAAC;AAC3E,UAAM,UAAU,iBAAiB,sBAAsB,kBAAkB,UAAU,kBAAkB,OAAO,CAAC,CAAC;AAE9G,UAAM,WAAW,MAAM,OAAO,YAAY,GAAG;AAC7C,UAAM,WAAiC,CAAC;AACxC,eAAW,YAAY,KAAK;AAC1B,YAAM,UAAU,SAAS,QAAQ;AACjC,UAAI,CAAC,QAAS;AACd,YAAM,eAAe,QAAQ,WAAW,WAAW,CAAC,CAAC,QAAQ,SAAS,SAAS,8BAA8B,IAAI,QAAQ,QAAQ,KAAK;AACtI,eAAS,KAAK,EAAE,UAAU,aAAa,CAAC;AAAA,IAC1C;AACA,WAAO;AAAA,EACT;AACF;AAEA,IAAI,gBAA2C;AAExC,SAAS,wBAA4C;AAC1D,MAAI,CAAC,cAAe,iBAAgB,IAAI,mBAAmB;AAC3D,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_expo/lib/credentials.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\n\n/**\n * Tenant-level Expo credentials persisted on `IntegrationCredentials` for provider\n * `channel_expo`. The access token is optional (only required when Expo \"enhanced\n * push security\" is enabled for the project); stored encrypted at rest.\n */\nexport const expoCredentialsSchema = z\n .object({\n accessToken: z.string().optional(),\n })\n .passthrough()\n\nexport type ExpoCredentials = z.infer<typeof expoCredentialsSchema>\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAOX,MAAM,wBAAwB,EAClC,OAAO;AAAA,EACN,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC,EACA,YAAY;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPushFakeProvidersEnabled,
|
|
3
|
+
recordFakePush,
|
|
4
|
+
warnPushFakeProvidersActive
|
|
5
|
+
} from "@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder";
|
|
6
|
+
import {
|
|
7
|
+
setExpoClientFactory
|
|
8
|
+
} from "./adapter.js";
|
|
9
|
+
const UNREGISTERED_TICKET_MARKER = "unreg";
|
|
10
|
+
function ensureExpoFakeProviderInstalled() {
|
|
11
|
+
if (!isPushFakeProvidersEnabled()) return;
|
|
12
|
+
warnPushFakeProvidersActive("expo");
|
|
13
|
+
setExpoClientFactory(() => ({
|
|
14
|
+
isExpoPushToken() {
|
|
15
|
+
return true;
|
|
16
|
+
},
|
|
17
|
+
async send(messages) {
|
|
18
|
+
return messages.map((message) => {
|
|
19
|
+
const token = message.to;
|
|
20
|
+
recordFakePush("expo", token, message);
|
|
21
|
+
if (token.includes("fail")) {
|
|
22
|
+
return { status: "error", message: "fake expo transient failure" };
|
|
23
|
+
}
|
|
24
|
+
const marker = token.includes("unregistered") ? `${UNREGISTERED_TICKET_MARKER}-` : "";
|
|
25
|
+
return { status: "ok", id: `expo-fake-${marker}${token.slice(-8) || "token"}` };
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
async getReceipts(ticketIds) {
|
|
29
|
+
const receipts = {};
|
|
30
|
+
for (const ticketId of ticketIds) {
|
|
31
|
+
receipts[ticketId] = ticketId.includes(`-${UNREGISTERED_TICKET_MARKER}-`) ? { status: "error", details: { error: "DeviceNotRegistered" } } : { status: "ok" };
|
|
32
|
+
}
|
|
33
|
+
return receipts;
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
ensureExpoFakeProviderInstalled
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=fake-provider.js.map
|