@open-mercato/channel-apns 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 +32 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js +47 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js.map +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js.map +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js +104 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js.map +7 -0
- package/dist/modules/channel_apns/acl.js +10 -0
- package/dist/modules/channel_apns/acl.js.map +7 -0
- package/dist/modules/channel_apns/di.js +24 -0
- package/dist/modules/channel_apns/di.js.map +7 -0
- package/dist/modules/channel_apns/index.js +9 -0
- package/dist/modules/channel_apns/index.js.map +7 -0
- package/dist/modules/channel_apns/integration.js +76 -0
- package/dist/modules/channel_apns/integration.js.map +7 -0
- package/dist/modules/channel_apns/lib/adapter.js +117 -0
- package/dist/modules/channel_apns/lib/adapter.js.map +7 -0
- package/dist/modules/channel_apns/lib/credentials.js +42 -0
- package/dist/modules/channel_apns/lib/credentials.js.map +7 -0
- package/dist/modules/channel_apns/lib/fake-provider.js +30 -0
- package/dist/modules/channel_apns/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_apns/lib/health.js +10 -0
- package/dist/modules/channel_apns/lib/health.js.map +7 -0
- package/dist/modules/channel_apns/setup.js +25 -0
- package/dist/modules/channel_apns/setup.js.map +7 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js +225 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_apns/widgets/injection-table.js +15 -0
- package/dist/modules/channel_apns/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_apns/__integration__/TC-CHANNEL-PUSH-003.spec.ts +67 -0
- package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.ts +3 -0
- package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.ts +145 -0
- package/src/modules/channel_apns/acl.ts +6 -0
- package/src/modules/channel_apns/di.ts +26 -0
- package/src/modules/channel_apns/index.ts +6 -0
- package/src/modules/channel_apns/integration.ts +77 -0
- package/src/modules/channel_apns/lib/__tests__/adapter.test.ts +158 -0
- package/src/modules/channel_apns/lib/__tests__/apnsTestKey.ts +21 -0
- package/src/modules/channel_apns/lib/__tests__/credentials.test.ts +98 -0
- package/src/modules/channel_apns/lib/__tests__/fake-provider.test.ts +93 -0
- package/src/modules/channel_apns/lib/__tests__/message-golden.test.ts +101 -0
- package/src/modules/channel_apns/lib/adapter.ts +199 -0
- package/src/modules/channel_apns/lib/credentials.ts +88 -0
- package/src/modules/channel_apns/lib/fake-provider.ts +54 -0
- package/src/modules/channel_apns/lib/health.ts +13 -0
- package/src/modules/channel_apns/setup.ts +31 -0
- package/src/modules/channel_apns/widgets/injection/connect/widget.client.tsx +251 -0
- package/src/modules/channel_apns/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_apns/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# `@open-mercato/channel-apns` — Agent Guidelines
|
|
2
|
+
|
|
3
|
+
Apple Push Notification service provider for the Communications Hub (`communication_channels`). Registers a `push` `ChannelAdapter` (providerKey `apns`) consumed by the `push_notifications` delivery strategy/worker.
|
|
4
|
+
|
|
5
|
+
- **Package**: `@open-mercato/channel-apns` ⇒ **module id**: `channel_apns`
|
|
6
|
+
- **Provider key**: `apns` · **channelType**: `push`
|
|
7
|
+
- Transport: `@parse/node-apn` (native HTTP/2, token-based `.p8` auth) — keep all APNs-specific logic here.
|
|
8
|
+
|
|
9
|
+
## Transitive `node-forge` pin
|
|
10
|
+
|
|
11
|
+
`@parse/node-apn@6.5.0` declares `node-forge` as an **exact** version (`node-forge: "npm:1.3.1"`), not a range, so a normal install can never pick up a patched `node-forge` — even one published to fix a vulnerability. The root `package.json` therefore carries a `resolutions` entry lifting the whole monorepo to `node-forge@1.4.0`, alongside the other security-hygiene pins in that block.
|
|
12
|
+
|
|
13
|
+
Do not remove the pin while this package depends on `@parse/node-apn`: dropping it silently reverts every workspace to the exact 1.3.1 the SDK hard-codes. It can be dropped once `@parse/node-apn` relaxes the constraint to a caret range, or once this package moves off that transport. `yarn why node-forge` confirms the pin is applied.
|
|
14
|
+
|
|
15
|
+
## Key Files (`src/modules/channel_apns/`)
|
|
16
|
+
|
|
17
|
+
| File | Purpose |
|
|
18
|
+
|------|---------|
|
|
19
|
+
| `integration.ts` | `IntegrationDefinition` (credentials fields, `healthCheck.service`) |
|
|
20
|
+
| `di.ts` / `setup.ts` | Register the adapter + `channelApnsHealthCheck`; `defaultRoleFeatures` |
|
|
21
|
+
| `acl.ts` | `channel_apns.view`, `channel_apns.configure` |
|
|
22
|
+
| `lib/adapter.ts` | `ApnsChannelAdapter`; `setApnsSenderFactory` test seam isolates `@parse/node-apn` |
|
|
23
|
+
| `lib/credentials.ts` | Zod schema: `{ p8Key, keyId, teamId, bundleId, production? }` |
|
|
24
|
+
| `lib/health.ts` | `channelApnsHealthCheck` liveness probe |
|
|
25
|
+
|
|
26
|
+
## Adapter Contract
|
|
27
|
+
|
|
28
|
+
Implements the shared push-adapter contract (see `channel-fcm` AGENTS.md). Permanent-token reasons mapped to the uniform `device_unregistered` sentinel: `Unregistered` (410), `BadDeviceToken` (400). One HTTP/2 `apn.Provider` is cached per credentials hash. The `ApnsSender` seam keeps the node-apn provider out of the control flow and out of unit tests.
|
|
29
|
+
|
|
30
|
+
## Credentials
|
|
31
|
+
|
|
32
|
+
Tenant-level credentials on `IntegrationCredentials` for provider `channel_apns`: `{ p8Key (PEM contents), keyId, teamId, bundleId (APNs topic), production }`. Connect via the existing `communication_channels` credentials-connect flow. Device tokens live in `devices.UserDevice.push_token`, never here.
|
package/build.mjs
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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-003: APNs provider registration", () => {
|
|
6
|
+
test("POST per-user connect/credentials with providerKey=apns 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: { providerKey: "apns", displayName: "APNs \u2014 per-user (should be refused)", credentials: {} }
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
20
|
+
expect(response.status(), "authenticated request should not 401").not.toBe(401);
|
|
21
|
+
expect(response.status()).toBe(403);
|
|
22
|
+
const body = await readJsonSafe(response);
|
|
23
|
+
expect(body?.code).toBe("provider_is_tenant_scoped");
|
|
24
|
+
});
|
|
25
|
+
test("POST tenant connect/credentials with providerKey=apns reaches validateCredentials (422)", async ({
|
|
26
|
+
request
|
|
27
|
+
}) => {
|
|
28
|
+
const token = await getAuthToken(request);
|
|
29
|
+
const response = await apiRequest(
|
|
30
|
+
request,
|
|
31
|
+
"POST",
|
|
32
|
+
"/api/communication_channels/channels/connect/tenant-credentials",
|
|
33
|
+
{
|
|
34
|
+
token,
|
|
35
|
+
// Missing p8Key/keyId/teamId/bundleId → validateCredentials rejects it, so the
|
|
36
|
+
// flow never creates a channel (no side effect / no cleanup needed).
|
|
37
|
+
data: { providerKey: "apns", displayName: "APNs \u2014 tenant connect", credentials: {} }
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
expect(response.status(), "route should not 5xx").toBeLessThan(500);
|
|
41
|
+
expect(response.status(), "authenticated request should not 401").not.toBe(401);
|
|
42
|
+
expect(response.status(), "registered adapter should reject empty credentials with 422").toBe(422);
|
|
43
|
+
const body = await readJsonSafe(response);
|
|
44
|
+
expect(body?.fieldErrors ?? body?.error).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-003.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.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-003 \u2014 APNs is registered AND tenant-scoped.\n *\n * `@open-mercato/channel-apns` registers the `apns` push `ChannelAdapter` at import\n * with `channelScope: 'tenant'`. Push providers have no OAuth/webhook surface of their\n * own, so 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 rejects the\n * empty payload with 422 \u2014 proving the adapter is not merely registered but reachable.\n *\n * Neither call creates a channel, so there is nothing to clean up. Real send paths are\n * covered network-free by `lib/__tests__/adapter.test.ts`.\n */\ntest.describe('TC-CHANNEL-PUSH-003: APNs provider registration', () => {\n test('POST per-user connect/credentials with providerKey=apns 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: { providerKey: 'apns', displayName: 'APNs \u2014 per-user (should be refused)', credentials: {} },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\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=apns 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 // Missing p8Key/keyId/teamId/bundleId \u2192 validateCredentials rejects it, so the\n // flow never creates a channel (no side effect / no cleanup needed).\n data: { providerKey: 'apns', displayName: 'APNs \u2014 tenant connect', credentials: {} },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n // Asserting 422 (not merely \"not 404\") proves the request authenticated AND reached the\n // adapter \u2014 a 401 auth misconfig would otherwise let the spec pass vacuously.\n expect(response.status(), 'authenticated request should not 401').not.toBe(401)\n expect(response.status(), 'registered adapter should reject empty credentials with 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,EAAE,aAAa,QAAQ,aAAa,4CAAuC,aAAa,CAAC,EAAE;AAAA,MACnG;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAClE,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,EAAE,aAAa,QAAQ,aAAa,8BAAyB,aAAa,CAAC,EAAE;AAAA,MACrF;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,6DAA6D,EAAE,KAAK,GAAG;AACjG,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_apns/__integration__/TC-CHANNEL-PUSH-006.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,104 @@
|
|
|
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 = "apns";
|
|
17
|
+
test.describe("TC-CHANNEL-PUSH-006: real APNs adapter reaches sent with a correct native notification", () => {
|
|
18
|
+
test("maps pushOptions onto the aps payload for a visible notification", 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-006 APNs");
|
|
28
|
+
userDeviceId = await registerFakePushDevice(
|
|
29
|
+
request,
|
|
30
|
+
adminToken,
|
|
31
|
+
PROVIDER,
|
|
32
|
+
pushToken,
|
|
33
|
+
`qa-tc-channel-push-006-${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
|
+
pushOptions: { badge: 5, sound: "chime.caf" }
|
|
41
|
+
});
|
|
42
|
+
await drainIntegrationQueue("events");
|
|
43
|
+
await drainIntegrationQueue("push-deliveries");
|
|
44
|
+
await expect.poll(async () => (await readLatestDelivery(tenantId, userDeviceId))?.status ?? null, {
|
|
45
|
+
timeout: 3e4
|
|
46
|
+
}).toBe("sent");
|
|
47
|
+
const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt);
|
|
48
|
+
expect(native.headers["apns-topic"]).toBe("com.openmercato.fake");
|
|
49
|
+
expect(native.payload.aps).toMatchObject({
|
|
50
|
+
alert: { title: "Order shipped", body: "Your order is on its way" },
|
|
51
|
+
badge: 5,
|
|
52
|
+
sound: "chime.caf"
|
|
53
|
+
});
|
|
54
|
+
expect(native.payload.aps["content-available"]).toBeUndefined();
|
|
55
|
+
} finally {
|
|
56
|
+
await deleteDeliveriesForDevice(userDeviceId);
|
|
57
|
+
await deleteFakePushDevice(request, adminToken, userDeviceId);
|
|
58
|
+
await deleteChannelIfExists(request, adminToken, channelId);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("a silent type produces a background content-available push with no user-facing copy", async ({ request }) => {
|
|
62
|
+
test.setTimeout(12e4);
|
|
63
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
64
|
+
const { tenantId, userId } = getTokenScope(adminToken);
|
|
65
|
+
const { pushToken, tokenTail } = makeFakePushToken(PROVIDER);
|
|
66
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
67
|
+
let channelId = null;
|
|
68
|
+
let userDeviceId = null;
|
|
69
|
+
try {
|
|
70
|
+
channelId = await connectFakePushChannel(request, adminToken, PROVIDER, "TC-CHANNEL-PUSH-006 APNs silent");
|
|
71
|
+
userDeviceId = await registerFakePushDevice(
|
|
72
|
+
request,
|
|
73
|
+
adminToken,
|
|
74
|
+
PROVIDER,
|
|
75
|
+
pushToken,
|
|
76
|
+
`qa-tc-channel-push-006-silent-${Date.now()}`
|
|
77
|
+
);
|
|
78
|
+
await createNotificationFixture(request, adminToken, {
|
|
79
|
+
recipientUserId: userId,
|
|
80
|
+
type: "admin.custom_silent",
|
|
81
|
+
title: "Should not be delivered as copy",
|
|
82
|
+
body: "Should not be delivered as copy",
|
|
83
|
+
data: { sync: "orders" }
|
|
84
|
+
});
|
|
85
|
+
await drainIntegrationQueue("events");
|
|
86
|
+
await drainIntegrationQueue("push-deliveries");
|
|
87
|
+
await expect.poll(async () => (await readLatestDelivery(tenantId, userDeviceId))?.status ?? null, {
|
|
88
|
+
timeout: 3e4
|
|
89
|
+
}).toBe("sent");
|
|
90
|
+
const row = await readLatestDelivery(tenantId, userDeviceId);
|
|
91
|
+
expect(row?.silent).toBe(true);
|
|
92
|
+
const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt);
|
|
93
|
+
expect(native.headers["apns-push-type"]).toBe("background");
|
|
94
|
+
expect(native.headers["apns-priority"]).toBe(5);
|
|
95
|
+
expect(native.payload.aps).toEqual({ "content-available": 1 });
|
|
96
|
+
expect(native.payload.sync).toBe("orders");
|
|
97
|
+
} finally {
|
|
98
|
+
await deleteDeliveriesForDevice(userDeviceId);
|
|
99
|
+
await deleteFakePushDevice(request, adminToken, userDeviceId);
|
|
100
|
+
await deleteChannelIfExists(request, adminToken, channelId);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
//# sourceMappingURL=TC-CHANNEL-PUSH-006.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.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-006 \u2014 the REAL APNs adapter, end-to-end, without live credentials.\n *\n * Only the network provider is faked (`OM_PUSH_FAKE_PROVIDERS`): the adapter's credential resolution and\n * `buildApnsNotification` run for real, against a real `apn.Notification`. The `.p8` key is never parsed\n * because parsing lives inside the sender factory the fake replaces \u2014 the connect route only\n * schema-validates it.\n *\n * The recorded message is therefore the WIRE form node-apn transmits: request `headers` (where\n * `apns-push-type` lives) and the compiled `aps` payload.\n */\nconst PROVIDER = 'apns'\n\ntype NativeApns = {\n headers: Record<string, unknown>\n payload: { aps: Record<string, unknown> } & Record<string, unknown>\n}\n\ntest.describe('TC-CHANNEL-PUSH-006: real APNs adapter reaches sent with a correct native notification', () => {\n test('maps pushOptions onto the aps payload for a visible notification', 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-006 APNs')\n userDeviceId = await registerFakePushDevice(\n request,\n adminToken,\n PROVIDER,\n pushToken,\n `qa-tc-channel-push-006-${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 pushOptions: { badge: 5, sound: 'chime.caf' },\n })\n\n await drainIntegrationQueue('events')\n await drainIntegrationQueue('push-deliveries')\n\n await expect\n .poll(async () => (await readLatestDelivery(tenantId, userDeviceId as string))?.status ?? null, {\n timeout: 30_000,\n })\n .toBe('sent')\n\n const native = (await expectNativeMessage(PROVIDER, tokenTail, startedAt)) as unknown as NativeApns\n expect(native.headers['apns-topic']).toBe('com.openmercato.fake')\n expect(native.payload.aps).toMatchObject({\n alert: { title: 'Order shipped', body: 'Your order is on its way' },\n badge: 5,\n sound: 'chime.caf',\n })\n expect(native.payload.aps['content-available']).toBeUndefined()\n } finally {\n await deleteDeliveriesForDevice(userDeviceId)\n await deleteFakePushDevice(request, adminToken, userDeviceId)\n await deleteChannelIfExists(request, adminToken, channelId)\n }\n })\n\n test('a silent type produces a background content-available push with no user-facing copy', 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-006 APNs silent')\n userDeviceId = await registerFakePushDevice(\n request,\n adminToken,\n PROVIDER,\n pushToken,\n `qa-tc-channel-push-006-silent-${Date.now()}`,\n )\n\n // Silent-ness is a property of the registered type, never a per-call flag.\n await createNotificationFixture(request, adminToken, {\n recipientUserId: userId,\n type: 'admin.custom_silent',\n title: 'Should not be delivered as copy',\n body: 'Should not be delivered as copy',\n data: { sync: '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?.silent).toBe(true)\n\n const native = (await expectNativeMessage(PROVIDER, tokenTail, startedAt)) as unknown as NativeApns\n // Apple requires both of these for a background push; sending an alert alongside them is invalid.\n expect(native.headers['apns-push-type']).toBe('background')\n expect(native.headers['apns-priority']).toBe(5)\n expect(native.payload.aps).toEqual({ 'content-available': 1 })\n expect(native.payload.sync).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": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const features = [
|
|
2
|
+
{ id: "channel_apns.view", title: "View APNs channel configuration", module: "channel_apns" },
|
|
3
|
+
{ id: "channel_apns.configure", title: "Configure APNs credentials", module: "channel_apns" }
|
|
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_apns/acl.ts"],
|
|
4
|
+
"sourcesContent": ["export const features = [\n { id: 'channel_apns.view', title: 'View APNs channel configuration', module: 'channel_apns' },\n { id: 'channel_apns.configure', title: 'Configure APNs credentials', module: 'channel_apns' },\n]\n\nexport default features\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,EAAE,IAAI,qBAAqB,OAAO,mCAAmC,QAAQ,eAAe;AAAA,EAC5F,EAAE,IAAI,0BAA0B,OAAO,8BAA8B,QAAQ,eAAe;AAC9F;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 { getApnsChannelAdapter } from "./lib/adapter.js";
|
|
7
|
+
import { ensureApnsFakeProviderInstalled } from "./lib/fake-provider.js";
|
|
8
|
+
import { channelApnsHealthCheck } from "./lib/health.js";
|
|
9
|
+
function register(container) {
|
|
10
|
+
ensureApnsFakeProviderInstalled();
|
|
11
|
+
if (!hasChannelAdapter("apns")) {
|
|
12
|
+
registerChannelAdapter(getApnsChannelAdapter());
|
|
13
|
+
}
|
|
14
|
+
container.register({
|
|
15
|
+
channelApnsAdapter: asValue(getApnsChannelAdapter()),
|
|
16
|
+
// Registered under the exact service name declared in `integration.ts`
|
|
17
|
+
// (`healthCheck.service`); without it the hub's resolve throws.
|
|
18
|
+
channelApnsHealthCheck: asValue(channelApnsHealthCheck)
|
|
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_apns/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 { getApnsChannelAdapter } from './lib/adapter'\nimport { ensureApnsFakeProviderInstalled } from './lib/fake-provider'\nimport { channelApnsHealthCheck } from './lib/health'\n\nexport function register(container: AppContainer): void {\n // Test-only: swap @parse/node-apn 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 ensureApnsFakeProviderInstalled()\n\n if (!hasChannelAdapter('apns')) {\n registerChannelAdapter(getApnsChannelAdapter())\n }\n container.register({\n channelApnsAdapter: asValue(getApnsChannelAdapter()),\n // Registered under the exact service name declared in `integration.ts`\n // (`healthCheck.service`); without it the hub's resolve throws.\n channelApnsHealthCheck: asValue(channelApnsHealthCheck),\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_apns",
|
|
3
|
+
title: "Apple Push Notification Channel",
|
|
4
|
+
description: "Deliver mobile push to iOS devices via the Apple Push Notification service (HTTP/2, token-based .p8 auth). Registers a `push` ChannelAdapter (providerKey `apns`) into the Communications Hub."
|
|
5
|
+
};
|
|
6
|
+
export {
|
|
7
|
+
metadata
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_apns/index.ts"],
|
|
4
|
+
"sourcesContent": ["export const metadata = {\n id: 'channel_apns',\n title: 'Apple Push Notification Channel',\n description:\n 'Deliver mobile push to iOS devices via the Apple Push Notification service (HTTP/2, token-based .p8 auth). Registers a `push` ChannelAdapter (providerKey `apns`) into the Communications Hub.',\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aACE;AACJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const integration = {
|
|
2
|
+
id: "channel_apns",
|
|
3
|
+
title: "Apple Push Notification service",
|
|
4
|
+
description: "Deliver mobile push to iOS devices via APNs (HTTP/2, token-based .p8 auth). Provider for the push_notifications channel.",
|
|
5
|
+
category: "communication",
|
|
6
|
+
hub: "communication_channels",
|
|
7
|
+
providerKey: "apns",
|
|
8
|
+
icon: "apple",
|
|
9
|
+
docsUrl: "https://developer.apple.com/documentation/usernotifications",
|
|
10
|
+
package: "@open-mercato/channel-apns",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
author: "Open Mercato Team",
|
|
13
|
+
company: "Open Mercato",
|
|
14
|
+
license: "MIT",
|
|
15
|
+
tags: ["push", "apns", "apple", "ios", "communication"],
|
|
16
|
+
apiVersions: [
|
|
17
|
+
{
|
|
18
|
+
id: "v1",
|
|
19
|
+
label: "APNs HTTP/2 (token auth)",
|
|
20
|
+
status: "stable",
|
|
21
|
+
default: true,
|
|
22
|
+
changelog: "Apple Push Notification service over HTTP/2 with token-based .p8 auth (@parse/node-apn)."
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
credentials: {
|
|
26
|
+
fields: [
|
|
27
|
+
{
|
|
28
|
+
key: "p8Key",
|
|
29
|
+
label: "APNs Auth Key (.p8)",
|
|
30
|
+
type: "secret",
|
|
31
|
+
required: true,
|
|
32
|
+
helpText: "Apple Developer -> Keys -> create an APNs key, download the .p8, and paste its contents here. Stored encrypted at rest."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "keyId",
|
|
36
|
+
label: "Key ID",
|
|
37
|
+
type: "text",
|
|
38
|
+
required: true,
|
|
39
|
+
helpText: "The 10-character identifier of the APNs key (from the key filename / Apple Developer portal)."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: "teamId",
|
|
43
|
+
label: "Apple Team ID",
|
|
44
|
+
type: "text",
|
|
45
|
+
required: true,
|
|
46
|
+
helpText: "Your 10-character Apple Developer Team ID."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "bundleId",
|
|
50
|
+
label: "App Bundle ID",
|
|
51
|
+
type: "text",
|
|
52
|
+
required: true,
|
|
53
|
+
placeholder: "com.example.app",
|
|
54
|
+
helpText: "The app bundle id, used as the APNs topic."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: "production",
|
|
58
|
+
label: "Production (use production APNs host)",
|
|
59
|
+
type: "boolean",
|
|
60
|
+
required: false,
|
|
61
|
+
helpText: "Leave off for the sandbox host during development."
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
healthCheck: { service: "channelApnsHealthCheck" }
|
|
66
|
+
};
|
|
67
|
+
const integrations = [integration];
|
|
68
|
+
const bundles = [];
|
|
69
|
+
const bundle = void 0;
|
|
70
|
+
export {
|
|
71
|
+
bundle,
|
|
72
|
+
bundles,
|
|
73
|
+
integration,
|
|
74
|
+
integrations
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=integration.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_apns/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_apns',\n title: 'Apple Push Notification service',\n description:\n 'Deliver mobile push to iOS devices via APNs (HTTP/2, token-based .p8 auth). Provider for the push_notifications channel.',\n category: 'communication',\n hub: 'communication_channels',\n providerKey: 'apns',\n icon: 'apple',\n docsUrl: 'https://developer.apple.com/documentation/usernotifications',\n package: '@open-mercato/channel-apns',\n version: '0.1.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['push', 'apns', 'apple', 'ios', 'communication'],\n apiVersions: [\n {\n id: 'v1',\n label: 'APNs HTTP/2 (token auth)',\n status: 'stable',\n default: true,\n changelog: 'Apple Push Notification service over HTTP/2 with token-based .p8 auth (@parse/node-apn).',\n },\n ],\n credentials: {\n fields: [\n {\n key: 'p8Key',\n label: 'APNs Auth Key (.p8)',\n type: 'secret',\n required: true,\n helpText:\n 'Apple Developer -> Keys -> create an APNs key, download the .p8, and paste its contents here. Stored encrypted at rest.',\n },\n {\n key: 'keyId',\n label: 'Key ID',\n type: 'text',\n required: true,\n helpText: 'The 10-character identifier of the APNs key (from the key filename / Apple Developer portal).',\n },\n {\n key: 'teamId',\n label: 'Apple Team ID',\n type: 'text',\n required: true,\n helpText: 'Your 10-character Apple Developer Team ID.',\n },\n {\n key: 'bundleId',\n label: 'App Bundle ID',\n type: 'text',\n required: true,\n placeholder: 'com.example.app',\n helpText: 'The app bundle id, used as the APNs topic.',\n },\n {\n key: 'production',\n label: 'Production (use production APNs host)',\n type: 'boolean',\n required: false,\n helpText: 'Leave off for the sandbox host during development.',\n },\n ],\n },\n healthCheck: { service: 'channelApnsHealthCheck' },\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,SAAS,OAAO,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,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AAAA,EACA,aAAa,EAAE,SAAS,yBAAyB;AACnD;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
BasePushChannelAdapter,
|
|
4
|
+
deviceUnregisteredResult,
|
|
5
|
+
MISSING_PUSH_TOKEN_RESULT,
|
|
6
|
+
readPushToken
|
|
7
|
+
} from "@open-mercato/core/modules/communication_channels/lib/push-adapter";
|
|
8
|
+
import { createRefCountedClientCache } from "@open-mercato/core/modules/communication_channels/lib/refcounted-client-cache";
|
|
9
|
+
import { readPushEnvelope, resolvePushBody } from "@open-mercato/core/modules/communication_channels/lib/push-envelope";
|
|
10
|
+
import {
|
|
11
|
+
apnsCredentialsSchema,
|
|
12
|
+
resolveApnsCredentials
|
|
13
|
+
} from "./credentials.js";
|
|
14
|
+
const PERMANENT_APNS_REASONS = /* @__PURE__ */ new Set(["Unregistered", "410"]);
|
|
15
|
+
let senderFactory = null;
|
|
16
|
+
function setApnsSenderFactory(factory) {
|
|
17
|
+
senderFactory = factory;
|
|
18
|
+
}
|
|
19
|
+
function credentialsHash(credentials) {
|
|
20
|
+
return createHash("sha256").update(`${credentials.keyId}:${credentials.teamId}:${credentials.bundleId}:${credentials.production}:${credentials.p8Key}`).digest("hex").slice(0, 16);
|
|
21
|
+
}
|
|
22
|
+
const PROVIDER_CACHE_MAX = 32;
|
|
23
|
+
const providerCache = createRefCountedClientCache({
|
|
24
|
+
max: PROVIDER_CACHE_MAX,
|
|
25
|
+
dispose: (provider) => {
|
|
26
|
+
try {
|
|
27
|
+
provider.shutdown?.();
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
async function createProvider(credentials) {
|
|
33
|
+
const apnModule = await import("@parse/node-apn");
|
|
34
|
+
const apn = apnModule.default ?? apnModule;
|
|
35
|
+
const Provider = apn.Provider;
|
|
36
|
+
return new Provider({
|
|
37
|
+
token: { key: credentials.p8Key, keyId: credentials.keyId, teamId: credentials.teamId },
|
|
38
|
+
production: credentials.production
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function buildApnsNotification(note, payload) {
|
|
42
|
+
const { options, silent } = payload;
|
|
43
|
+
note.topic = payload.topic;
|
|
44
|
+
note.payload = payload.data;
|
|
45
|
+
if (silent) {
|
|
46
|
+
note.contentAvailable = 1;
|
|
47
|
+
note.pushType = "background";
|
|
48
|
+
note.priority = 5;
|
|
49
|
+
} else {
|
|
50
|
+
note.alert = { title: payload.title, body: resolvePushBody(payload) };
|
|
51
|
+
note.sound = options.sound ?? "default";
|
|
52
|
+
if (typeof options.badge === "number") note.badge = options.badge;
|
|
53
|
+
if (options.priority === "normal") note.priority = 5;
|
|
54
|
+
}
|
|
55
|
+
return note;
|
|
56
|
+
}
|
|
57
|
+
function defaultSenderFactory(credentials) {
|
|
58
|
+
return async (payload, token) => {
|
|
59
|
+
const apnModule = await import("@parse/node-apn");
|
|
60
|
+
const apn = apnModule.default ?? apnModule;
|
|
61
|
+
const Notification = apn.Notification;
|
|
62
|
+
const note = buildApnsNotification(new Notification(), payload);
|
|
63
|
+
const lease = await providerCache.acquire(credentialsHash(credentials), () => createProvider(credentials));
|
|
64
|
+
try {
|
|
65
|
+
const result = await lease.client.send(note, token);
|
|
66
|
+
if (result.sent && result.sent.length > 0) return { ok: true };
|
|
67
|
+
const failure = result.failed?.[0];
|
|
68
|
+
if (!failure) return { ok: false, error: "no_response" };
|
|
69
|
+
if (failure.error) return { ok: false, error: failure.error.message };
|
|
70
|
+
const reason = failure.response?.reason ?? (failure.status != null ? String(failure.status) : void 0);
|
|
71
|
+
return { ok: false, reason };
|
|
72
|
+
} finally {
|
|
73
|
+
lease.release();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
class ApnsChannelAdapter extends BasePushChannelAdapter {
|
|
78
|
+
constructor() {
|
|
79
|
+
super(...arguments);
|
|
80
|
+
this.providerKey = "apns";
|
|
81
|
+
this.credentialsSchema = apnsCredentialsSchema;
|
|
82
|
+
}
|
|
83
|
+
async sendMessage(input) {
|
|
84
|
+
const token = readPushToken(input);
|
|
85
|
+
if (!token) return MISSING_PUSH_TOKEN_RESULT;
|
|
86
|
+
const parsedCredentials = apnsCredentialsSchema.safeParse(input.credentials);
|
|
87
|
+
if (!parsedCredentials.success) {
|
|
88
|
+
return { externalMessageId: "", status: "failed", error: "invalid_apns_credentials" };
|
|
89
|
+
}
|
|
90
|
+
const credentials = resolveApnsCredentials(parsedCredentials.data);
|
|
91
|
+
const envelope = readPushEnvelope(input.content);
|
|
92
|
+
const sender = (senderFactory ?? defaultSenderFactory)(credentials);
|
|
93
|
+
try {
|
|
94
|
+
const outcome = await sender({ ...envelope, topic: credentials.bundleId }, token);
|
|
95
|
+
if (outcome.ok) {
|
|
96
|
+
return { externalMessageId: "", status: "sent" };
|
|
97
|
+
}
|
|
98
|
+
if (outcome.reason && PERMANENT_APNS_REASONS.has(outcome.reason)) {
|
|
99
|
+
return deviceUnregisteredResult({ reason: outcome.reason });
|
|
100
|
+
}
|
|
101
|
+
return { externalMessageId: "", status: "failed", error: outcome.error ?? outcome.reason ?? "apns_send_failed" };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { externalMessageId: "", status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let cachedAdapter = null;
|
|
108
|
+
function getApnsChannelAdapter() {
|
|
109
|
+
if (!cachedAdapter) cachedAdapter = new ApnsChannelAdapter();
|
|
110
|
+
return cachedAdapter;
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
buildApnsNotification,
|
|
114
|
+
getApnsChannelAdapter,
|
|
115
|
+
setApnsSenderFactory
|
|
116
|
+
};
|
|
117
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_apns/lib/adapter.ts"],
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport type {\n SendMessageInput,\n SendMessageResult,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n BasePushChannelAdapter,\n deviceUnregisteredResult,\n MISSING_PUSH_TOKEN_RESULT,\n readPushToken,\n} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'\nimport { createRefCountedClientCache } from '@open-mercato/core/modules/communication_channels/lib/refcounted-client-cache'\nimport { readPushEnvelope, resolvePushBody, type PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'\nimport {\n apnsCredentialsSchema,\n resolveApnsCredentials,\n type ApnsResolvedCredentials,\n} from './credentials'\n\n/**\n * The ONLY APNs signal that a device token is permanently dead: HTTP 410 with reason `Unregistered`\n * (the two always travel together \u2014 410 is Apple's irrecoverable \"remove this token\" status). A bare\n * `410` status whose reason node-apn could not parse means the same thing, so it is included. Both map\n * to the uniform `device_unregistered` sentinel so the push worker soft-deletes the device (identical\n * contract across fcm/apns/expo).\n *\n * `BadDeviceToken` is deliberately EXCLUDED. Apple returns it for a VALID token presented to the wrong\n * environment (a production token hitting the sandbox host, or vice-versa \u2014 and `production` defaults\n * to false), not only for a malformed token, and APNs gives no code to disambiguate the two. Treating\n * it as permanent would soft-delete every live iOS device in a tenant on a single environment\n * misconfiguration, with no recovery path. It \u2014 like the other non-410 reasons (`DeviceTokenNotForTopic`,\n * `TopicDisallowed`, `PayloadTooLarge`, `TooManyRequests`, `InternalServerError`, `ServiceUnavailable`,\n * `ExpiredProviderToken`) \u2014 is a transient/config failure: the delivery retries then expires, but the\n * device is KEPT so it recovers once the sender-side config is fixed.\n */\nconst PERMANENT_APNS_REASONS = new Set(['Unregistered', '410'])\n\nexport interface ApnsSendOutcome {\n ok: boolean\n /** Provider rejection reason (e.g. `Unregistered`, `BadDeviceToken`), or the stringified HTTP status\n * (e.g. `410`) when node-apn could not parse a reason from the response body. */\n reason?: string\n /** Transport-level error message (network/auth), distinct from a provider rejection. */\n error?: string\n}\n\n/**\n * A bound APNs sender for one tenant's credentials. The seam keeps `@parse/node-apn`\n * (and its HTTP/2 provider) entirely out of the adapter's control flow and tests.\n */\nexport type ApnsSender = (payload: PushEnvelope & { topic: string }, token: string) => Promise<ApnsSendOutcome>\n\nexport type ApnsSenderFactory = (credentials: ApnsResolvedCredentials) => ApnsSender\n\nlet senderFactory: ApnsSenderFactory | null = null\n\n/** Test-only seam to swap the APNs sender factory. */\nexport function setApnsSenderFactory(factory: ApnsSenderFactory | null): void {\n senderFactory = factory\n}\n\ntype ApnsProviderLike = {\n send(notification: unknown, token: string): Promise<{\n sent?: Array<{ device: string }>\n failed?: Array<{ device: string; status?: string | number; error?: Error; response?: { reason?: string } }>\n }>\n /** node-apn's Provider exposes `shutdown()` to close its HTTP/2 socket to Apple. */\n shutdown?(): void\n}\n\nfunction credentialsHash(credentials: ApnsResolvedCredentials): string {\n return createHash('sha256')\n .update(`${credentials.keyId}:${credentials.teamId}:${credentials.bundleId}:${credentials.production}:${credentials.p8Key}`)\n .digest('hex')\n .slice(0, 16)\n}\n\n/**\n * Bounds the number of live HTTP/2 providers cached at once. Each entry holds an\n * open socket to Apple, so an unbounded cache would leak a connection every time a\n * tenant rotates their `.p8` key or toggles `production` (the hash changes \u2192 a new\n * entry, while the stale provider never shuts down). LRU-evicting the least-recently\n * used provider and calling `shutdown()` keeps the connection count bounded. Disposal\n * is fenced on in-flight sends: an evicted provider is shut down only once its last\n * concurrent `send` releases (see refcounted-client-cache), so eviction never closes\n * the HTTP/2 socket out from under a send.\n */\nconst PROVIDER_CACHE_MAX = 32\nconst providerCache = createRefCountedClientCache<ApnsProviderLike>({\n max: PROVIDER_CACHE_MAX,\n dispose: (provider) => {\n try {\n provider.shutdown?.()\n } catch {\n // shutdown is best-effort; a throwing/absent shutdown must not break eviction.\n }\n },\n})\n\nasync function createProvider(credentials: ApnsResolvedCredentials): Promise<ApnsProviderLike> {\n const apnModule = await import('@parse/node-apn')\n const apn = (apnModule as { default?: unknown }).default ?? apnModule\n const Provider = (apn as { Provider: new (options: unknown) => ApnsProviderLike }).Provider\n return new Provider({\n token: { key: credentials.p8Key, keyId: credentials.keyId, teamId: credentials.teamId },\n production: credentials.production,\n })\n}\n\n/**\n * Populate an APNs `Notification` from the push envelope, branching on `silent` (background\n * content-available wake-up \u2014 no alert/sound) and applying the recognized push options. Mutates and\n * returns `note`. Extracted from the sender so it is unit-testable without `@parse/node-apn`.\n */\nexport function buildApnsNotification(\n note: Record<string, unknown>,\n payload: PushEnvelope & { topic: string },\n): Record<string, unknown> {\n const { options, silent } = payload\n note.topic = payload.topic\n note.payload = payload.data\n if (silent) {\n note.contentAvailable = 1\n note.pushType = 'background'\n note.priority = 5\n } else {\n note.alert = { title: payload.title, body: resolvePushBody(payload) }\n note.sound = options.sound ?? 'default'\n if (typeof options.badge === 'number') note.badge = options.badge\n if (options.priority === 'normal') note.priority = 5\n }\n return note\n}\n\nfunction defaultSenderFactory(credentials: ApnsResolvedCredentials): ApnsSender {\n return async (payload, token) => {\n const apnModule = await import('@parse/node-apn')\n const apn = (apnModule as { default?: unknown }).default ?? apnModule\n const Notification = (apn as { Notification: new () => Record<string, unknown> }).Notification\n const note = buildApnsNotification(new Notification(), payload)\n\n // Borrow a cached provider for the duration of the send; release() in finally keeps an evicted\n // provider's socket open until this send completes.\n const lease = await providerCache.acquire(credentialsHash(credentials), () => createProvider(credentials))\n try {\n const result = await lease.client.send(note, token)\n if (result.sent && result.sent.length > 0) return { ok: true }\n const failure = result.failed?.[0]\n if (!failure) return { ok: false, error: 'no_response' }\n if (failure.error) return { ok: false, error: failure.error.message }\n const reason = failure.response?.reason ?? (failure.status != null ? String(failure.status) : undefined)\n return { ok: false, reason }\n } finally {\n lease.release()\n }\n }\n}\n\nclass ApnsChannelAdapter extends BasePushChannelAdapter {\n readonly providerKey = 'apns'\n protected readonly credentialsSchema = apnsCredentialsSchema\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 = apnsCredentialsSchema.safeParse(input.credentials)\n if (!parsedCredentials.success) {\n return { externalMessageId: '', status: 'failed', error: 'invalid_apns_credentials' }\n }\n\n const credentials = resolveApnsCredentials(parsedCredentials.data)\n const envelope = readPushEnvelope(input.content)\n const sender = (senderFactory ?? defaultSenderFactory)(credentials)\n\n try {\n const outcome = await sender({ ...envelope, topic: credentials.bundleId }, token)\n if (outcome.ok) {\n // node-apn returns no provider message id. Return an empty id rather than any slice of the\n // push token \u2014 the worker persists externalMessageId into the admin-exposed provider_response,\n // and this module only ever surfaces the last-8 via token_snapshot, never raw token material.\n return { externalMessageId: '', status: 'sent' }\n }\n if (outcome.reason && PERMANENT_APNS_REASONS.has(outcome.reason)) {\n return deviceUnregisteredResult({ reason: outcome.reason })\n }\n return { externalMessageId: '', status: 'failed', error: outcome.error ?? outcome.reason ?? 'apns_send_failed' }\n } catch (err) {\n return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n }\n}\n\nlet cachedAdapter: ApnsChannelAdapter | null = null\n\nexport function getApnsChannelAdapter(): ApnsChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new ApnsChannelAdapter()\n return cachedAdapter\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|