@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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
5
|
+
import {
|
|
6
|
+
clearFakePushLog,
|
|
7
|
+
findFakePush,
|
|
8
|
+
} from '@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder'
|
|
9
|
+
import { getApnsChannelAdapter, setApnsSenderFactory } from '../adapter'
|
|
10
|
+
import { ensureApnsFakeProviderInstalled } from '../fake-provider'
|
|
11
|
+
import { APNS_TEST_P8_KEY } from './apnsTestKey'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Drives the REAL APNs adapter against the fake sender, the same way `di.ts` installs it. The APNs seam
|
|
15
|
+
* sits above the message builder (the sender receives the raw envelope), so this also pins that the fake
|
|
16
|
+
* records the notification production would have sent.
|
|
17
|
+
*/
|
|
18
|
+
const credentials = {
|
|
19
|
+
p8Key: APNS_TEST_P8_KEY,
|
|
20
|
+
keyId: 'FAKEKEYID1',
|
|
21
|
+
teamId: 'FAKETEAMID',
|
|
22
|
+
bundleId: 'com.openmercato.fake',
|
|
23
|
+
production: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildInput(pushToken: string, silent = false): SendMessageInput {
|
|
27
|
+
return {
|
|
28
|
+
content: {
|
|
29
|
+
text: 'Body text',
|
|
30
|
+
bodyFormat: 'text',
|
|
31
|
+
raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped' }, options: { badge: 4 }, silent },
|
|
32
|
+
},
|
|
33
|
+
credentials,
|
|
34
|
+
scope: { tenantId: 't1', organizationId: 'o1' },
|
|
35
|
+
metadata: { pushToken, platform: 'ios' },
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let tempDir: string
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-apns-fake-'))
|
|
43
|
+
process.env.QUEUE_BASE_DIR = tempDir
|
|
44
|
+
process.env.OM_PUSH_FAKE_PROVIDERS = '1'
|
|
45
|
+
ensureApnsFakeProviderInstalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
setApnsSenderFactory(null)
|
|
50
|
+
delete process.env.OM_PUSH_FAKE_PROVIDERS
|
|
51
|
+
delete process.env.QUEUE_BASE_DIR
|
|
52
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
clearFakePushLog()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('APNs fake provider', () => {
|
|
60
|
+
it('sends through the real adapter and records the wire-form notification', async () => {
|
|
61
|
+
const result = await getApnsChannelAdapter().sendMessage(buildInput('device-token-ABCDEF12'))
|
|
62
|
+
expect(result.status).toBe('sent')
|
|
63
|
+
|
|
64
|
+
// Built against a real `apn.Notification`, so this is what node-apn would transmit.
|
|
65
|
+
const recorded = findFakePush('apns', 'ABCDEF12')
|
|
66
|
+
expect(recorded?.native.headers).toMatchObject({ 'apns-topic': 'com.openmercato.fake' })
|
|
67
|
+
expect(recorded?.native.payload).toMatchObject({
|
|
68
|
+
aps: { alert: { title: 'Hello', body: 'Body text' }, badge: 4 },
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('records a silent push as a background content-available notification', async () => {
|
|
73
|
+
const result = await getApnsChannelAdapter().sendMessage(buildInput('device-token-SILENT12', true))
|
|
74
|
+
expect(result.status).toBe('sent')
|
|
75
|
+
|
|
76
|
+
const recorded = findFakePush('apns', 'SILENT12')
|
|
77
|
+
expect(recorded?.native.headers).toMatchObject({ 'apns-push-type': 'background', 'apns-priority': 5 })
|
|
78
|
+
expect(recorded?.native.payload).toMatchObject({ aps: { 'content-available': 1 } })
|
|
79
|
+
expect((recorded?.native.payload as { aps: Record<string, unknown> }).aps.alert).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('maps the native Unregistered reason to the device_unregistered sentinel', async () => {
|
|
83
|
+
const result = await getApnsChannelAdapter().sendMessage(buildInput('device-unregistered-token-99'))
|
|
84
|
+
expect(result.status).toBe('failed')
|
|
85
|
+
expect(result.error).toBe('device_unregistered')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('treats a fail token as a retryable error, not a token verdict', async () => {
|
|
89
|
+
const result = await getApnsChannelAdapter().sendMessage(buildInput('device-fail-token-77'))
|
|
90
|
+
expect(result.status).toBe('failed')
|
|
91
|
+
expect(result.error).not.toBe('device_unregistered')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import apn from '@parse/node-apn'
|
|
2
|
+
import type { PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
|
|
3
|
+
import { buildApnsNotification } from '../adapter'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Golden assertions pinning the APNs notification we build against Apple's published reference.
|
|
7
|
+
*
|
|
8
|
+
* These are the only tests that catch serialization drift in *our* builder: the integration fakes
|
|
9
|
+
* replace the network provider, so nothing else validates the notification body. Drift in *Apple's*
|
|
10
|
+
* schema is out of reach of any fake and stays a manual live-key check.
|
|
11
|
+
*
|
|
12
|
+
* Built against a REAL `apn.Notification` — exactly as the production sender does
|
|
13
|
+
* (`adapter.ts:143`) — and asserted on the wire form node-apn actually transmits: the compiled `aps`
|
|
14
|
+
* payload and the request headers. Pinning a plain-object projection instead would pin a shape the SDK
|
|
15
|
+
* never serializes, which is precisely the drift these tests exist to catch.
|
|
16
|
+
*
|
|
17
|
+
* Reference (aps payload): https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification
|
|
18
|
+
* Reference (background push requires apns-push-type: background + apns-priority: 5):
|
|
19
|
+
* https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app
|
|
20
|
+
*
|
|
21
|
+
* Written as exact `toEqual` fixtures rather than snapshots: a snapshot would re-record drift on
|
|
22
|
+
* `--updateSnapshot` instead of failing.
|
|
23
|
+
*/
|
|
24
|
+
const TOPIC = 'com.example.app'
|
|
25
|
+
|
|
26
|
+
function payload(overrides: Partial<PushEnvelope> = {}): PushEnvelope & { topic: string } {
|
|
27
|
+
return {
|
|
28
|
+
topic: TOPIC,
|
|
29
|
+
title: 'Order shipped',
|
|
30
|
+
body: 'Your order #42 is on its way',
|
|
31
|
+
data: { type: 'orders.shipped', notificationId: 'n1' },
|
|
32
|
+
options: {},
|
|
33
|
+
silent: false,
|
|
34
|
+
...overrides,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type CompiledNotification = { headers: Record<string, unknown>; aps: Record<string, unknown> }
|
|
39
|
+
|
|
40
|
+
function build(envelope: PushEnvelope & { topic: string }): CompiledNotification {
|
|
41
|
+
const note = buildApnsNotification(new apn.Notification(), envelope) as unknown as {
|
|
42
|
+
headers(): Record<string, unknown>
|
|
43
|
+
compile(): string
|
|
44
|
+
}
|
|
45
|
+
return { headers: note.headers(), aps: JSON.parse(note.compile()) as Record<string, unknown> }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('buildApnsNotification — golden payloads', () => {
|
|
49
|
+
it('visible notification compiles to the reference aps payload', () => {
|
|
50
|
+
const { headers, aps } = build(payload())
|
|
51
|
+
// The envelope's custom `data` is carried as top-level keys beside `aps` on every branch, visible
|
|
52
|
+
// or silent — the app reads them from the notification's userInfo.
|
|
53
|
+
expect(aps).toEqual({
|
|
54
|
+
type: 'orders.shipped',
|
|
55
|
+
notificationId: 'n1',
|
|
56
|
+
aps: {
|
|
57
|
+
alert: { title: 'Order shipped', body: 'Your order #42 is on its way' },
|
|
58
|
+
sound: 'default',
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
expect(headers).toEqual({ 'apns-priority': 10, 'apns-topic': TOPIC })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('silent notification is a content-available background push at priority 5', () => {
|
|
65
|
+
const { headers, aps } = build(payload({ silent: true }))
|
|
66
|
+
// Data-only: the custom keys sit beside `aps`, and no alert/sound is present.
|
|
67
|
+
expect(aps).toEqual({
|
|
68
|
+
type: 'orders.shipped',
|
|
69
|
+
notificationId: 'n1',
|
|
70
|
+
aps: { 'content-available': 1 },
|
|
71
|
+
})
|
|
72
|
+
expect(headers).toEqual({
|
|
73
|
+
'apns-priority': 5,
|
|
74
|
+
'apns-topic': TOPIC,
|
|
75
|
+
'apns-push-type': 'background',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('full pushOptions map onto the aps payload', () => {
|
|
80
|
+
const { headers, aps } = build(
|
|
81
|
+
payload({
|
|
82
|
+
options: {
|
|
83
|
+
sound: 'chime.caf',
|
|
84
|
+
badge: 7,
|
|
85
|
+
priority: 'normal',
|
|
86
|
+
body: 'Overridden push body',
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
expect(aps).toEqual({
|
|
91
|
+
type: 'orders.shipped',
|
|
92
|
+
notificationId: 'n1',
|
|
93
|
+
aps: {
|
|
94
|
+
alert: { title: 'Order shipped', body: 'Overridden push body' },
|
|
95
|
+
sound: 'chime.caf',
|
|
96
|
+
badge: 7,
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
expect(headers).toEqual({ 'apns-priority': 5, 'apns-topic': TOPIC })
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type {
|
|
3
|
+
SendMessageInput,
|
|
4
|
+
SendMessageResult,
|
|
5
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
6
|
+
import {
|
|
7
|
+
BasePushChannelAdapter,
|
|
8
|
+
deviceUnregisteredResult,
|
|
9
|
+
MISSING_PUSH_TOKEN_RESULT,
|
|
10
|
+
readPushToken,
|
|
11
|
+
} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'
|
|
12
|
+
import { createRefCountedClientCache } from '@open-mercato/core/modules/communication_channels/lib/refcounted-client-cache'
|
|
13
|
+
import { readPushEnvelope, resolvePushBody, type PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
|
|
14
|
+
import {
|
|
15
|
+
apnsCredentialsSchema,
|
|
16
|
+
resolveApnsCredentials,
|
|
17
|
+
type ApnsResolvedCredentials,
|
|
18
|
+
} from './credentials'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The ONLY APNs signal that a device token is permanently dead: HTTP 410 with reason `Unregistered`
|
|
22
|
+
* (the two always travel together — 410 is Apple's irrecoverable "remove this token" status). A bare
|
|
23
|
+
* `410` status whose reason node-apn could not parse means the same thing, so it is included. Both map
|
|
24
|
+
* to the uniform `device_unregistered` sentinel so the push worker soft-deletes the device (identical
|
|
25
|
+
* contract across fcm/apns/expo).
|
|
26
|
+
*
|
|
27
|
+
* `BadDeviceToken` is deliberately EXCLUDED. Apple returns it for a VALID token presented to the wrong
|
|
28
|
+
* environment (a production token hitting the sandbox host, or vice-versa — and `production` defaults
|
|
29
|
+
* to false), not only for a malformed token, and APNs gives no code to disambiguate the two. Treating
|
|
30
|
+
* it as permanent would soft-delete every live iOS device in a tenant on a single environment
|
|
31
|
+
* misconfiguration, with no recovery path. It — like the other non-410 reasons (`DeviceTokenNotForTopic`,
|
|
32
|
+
* `TopicDisallowed`, `PayloadTooLarge`, `TooManyRequests`, `InternalServerError`, `ServiceUnavailable`,
|
|
33
|
+
* `ExpiredProviderToken`) — is a transient/config failure: the delivery retries then expires, but the
|
|
34
|
+
* device is KEPT so it recovers once the sender-side config is fixed.
|
|
35
|
+
*/
|
|
36
|
+
const PERMANENT_APNS_REASONS = new Set(['Unregistered', '410'])
|
|
37
|
+
|
|
38
|
+
export interface ApnsSendOutcome {
|
|
39
|
+
ok: boolean
|
|
40
|
+
/** Provider rejection reason (e.g. `Unregistered`, `BadDeviceToken`), or the stringified HTTP status
|
|
41
|
+
* (e.g. `410`) when node-apn could not parse a reason from the response body. */
|
|
42
|
+
reason?: string
|
|
43
|
+
/** Transport-level error message (network/auth), distinct from a provider rejection. */
|
|
44
|
+
error?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A bound APNs sender for one tenant's credentials. The seam keeps `@parse/node-apn`
|
|
49
|
+
* (and its HTTP/2 provider) entirely out of the adapter's control flow and tests.
|
|
50
|
+
*/
|
|
51
|
+
export type ApnsSender = (payload: PushEnvelope & { topic: string }, token: string) => Promise<ApnsSendOutcome>
|
|
52
|
+
|
|
53
|
+
export type ApnsSenderFactory = (credentials: ApnsResolvedCredentials) => ApnsSender
|
|
54
|
+
|
|
55
|
+
let senderFactory: ApnsSenderFactory | null = null
|
|
56
|
+
|
|
57
|
+
/** Test-only seam to swap the APNs sender factory. */
|
|
58
|
+
export function setApnsSenderFactory(factory: ApnsSenderFactory | null): void {
|
|
59
|
+
senderFactory = factory
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ApnsProviderLike = {
|
|
63
|
+
send(notification: unknown, token: string): Promise<{
|
|
64
|
+
sent?: Array<{ device: string }>
|
|
65
|
+
failed?: Array<{ device: string; status?: string | number; error?: Error; response?: { reason?: string } }>
|
|
66
|
+
}>
|
|
67
|
+
/** node-apn's Provider exposes `shutdown()` to close its HTTP/2 socket to Apple. */
|
|
68
|
+
shutdown?(): void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function credentialsHash(credentials: ApnsResolvedCredentials): string {
|
|
72
|
+
return createHash('sha256')
|
|
73
|
+
.update(`${credentials.keyId}:${credentials.teamId}:${credentials.bundleId}:${credentials.production}:${credentials.p8Key}`)
|
|
74
|
+
.digest('hex')
|
|
75
|
+
.slice(0, 16)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bounds the number of live HTTP/2 providers cached at once. Each entry holds an
|
|
80
|
+
* open socket to Apple, so an unbounded cache would leak a connection every time a
|
|
81
|
+
* tenant rotates their `.p8` key or toggles `production` (the hash changes → a new
|
|
82
|
+
* entry, while the stale provider never shuts down). LRU-evicting the least-recently
|
|
83
|
+
* used provider and calling `shutdown()` keeps the connection count bounded. Disposal
|
|
84
|
+
* is fenced on in-flight sends: an evicted provider is shut down only once its last
|
|
85
|
+
* concurrent `send` releases (see refcounted-client-cache), so eviction never closes
|
|
86
|
+
* the HTTP/2 socket out from under a send.
|
|
87
|
+
*/
|
|
88
|
+
const PROVIDER_CACHE_MAX = 32
|
|
89
|
+
const providerCache = createRefCountedClientCache<ApnsProviderLike>({
|
|
90
|
+
max: PROVIDER_CACHE_MAX,
|
|
91
|
+
dispose: (provider) => {
|
|
92
|
+
try {
|
|
93
|
+
provider.shutdown?.()
|
|
94
|
+
} catch {
|
|
95
|
+
// shutdown is best-effort; a throwing/absent shutdown must not break eviction.
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
async function createProvider(credentials: ApnsResolvedCredentials): Promise<ApnsProviderLike> {
|
|
101
|
+
const apnModule = await import('@parse/node-apn')
|
|
102
|
+
const apn = (apnModule as { default?: unknown }).default ?? apnModule
|
|
103
|
+
const Provider = (apn as { Provider: new (options: unknown) => ApnsProviderLike }).Provider
|
|
104
|
+
return new Provider({
|
|
105
|
+
token: { key: credentials.p8Key, keyId: credentials.keyId, teamId: credentials.teamId },
|
|
106
|
+
production: credentials.production,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Populate an APNs `Notification` from the push envelope, branching on `silent` (background
|
|
112
|
+
* content-available wake-up — no alert/sound) and applying the recognized push options. Mutates and
|
|
113
|
+
* returns `note`. Extracted from the sender so it is unit-testable without `@parse/node-apn`.
|
|
114
|
+
*/
|
|
115
|
+
export function buildApnsNotification(
|
|
116
|
+
note: Record<string, unknown>,
|
|
117
|
+
payload: PushEnvelope & { topic: string },
|
|
118
|
+
): Record<string, unknown> {
|
|
119
|
+
const { options, silent } = payload
|
|
120
|
+
note.topic = payload.topic
|
|
121
|
+
note.payload = payload.data
|
|
122
|
+
if (silent) {
|
|
123
|
+
note.contentAvailable = 1
|
|
124
|
+
note.pushType = 'background'
|
|
125
|
+
note.priority = 5
|
|
126
|
+
} else {
|
|
127
|
+
note.alert = { title: payload.title, body: resolvePushBody(payload) }
|
|
128
|
+
note.sound = options.sound ?? 'default'
|
|
129
|
+
if (typeof options.badge === 'number') note.badge = options.badge
|
|
130
|
+
if (options.priority === 'normal') note.priority = 5
|
|
131
|
+
}
|
|
132
|
+
return note
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function defaultSenderFactory(credentials: ApnsResolvedCredentials): ApnsSender {
|
|
136
|
+
return async (payload, token) => {
|
|
137
|
+
const apnModule = await import('@parse/node-apn')
|
|
138
|
+
const apn = (apnModule as { default?: unknown }).default ?? apnModule
|
|
139
|
+
const Notification = (apn as { Notification: new () => Record<string, unknown> }).Notification
|
|
140
|
+
const note = buildApnsNotification(new Notification(), payload)
|
|
141
|
+
|
|
142
|
+
// Borrow a cached provider for the duration of the send; release() in finally keeps an evicted
|
|
143
|
+
// provider's socket open until this send completes.
|
|
144
|
+
const lease = await providerCache.acquire(credentialsHash(credentials), () => createProvider(credentials))
|
|
145
|
+
try {
|
|
146
|
+
const result = await lease.client.send(note, token)
|
|
147
|
+
if (result.sent && result.sent.length > 0) return { ok: true }
|
|
148
|
+
const failure = result.failed?.[0]
|
|
149
|
+
if (!failure) return { ok: false, error: 'no_response' }
|
|
150
|
+
if (failure.error) return { ok: false, error: failure.error.message }
|
|
151
|
+
const reason = failure.response?.reason ?? (failure.status != null ? String(failure.status) : undefined)
|
|
152
|
+
return { ok: false, reason }
|
|
153
|
+
} finally {
|
|
154
|
+
lease.release()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class ApnsChannelAdapter extends BasePushChannelAdapter {
|
|
160
|
+
readonly providerKey = 'apns'
|
|
161
|
+
protected readonly credentialsSchema = apnsCredentialsSchema
|
|
162
|
+
|
|
163
|
+
async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
|
|
164
|
+
const token = readPushToken(input)
|
|
165
|
+
if (!token) return MISSING_PUSH_TOKEN_RESULT
|
|
166
|
+
|
|
167
|
+
const parsedCredentials = apnsCredentialsSchema.safeParse(input.credentials)
|
|
168
|
+
if (!parsedCredentials.success) {
|
|
169
|
+
return { externalMessageId: '', status: 'failed', error: 'invalid_apns_credentials' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const credentials = resolveApnsCredentials(parsedCredentials.data)
|
|
173
|
+
const envelope = readPushEnvelope(input.content)
|
|
174
|
+
const sender = (senderFactory ?? defaultSenderFactory)(credentials)
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const outcome = await sender({ ...envelope, topic: credentials.bundleId }, token)
|
|
178
|
+
if (outcome.ok) {
|
|
179
|
+
// node-apn returns no provider message id. Return an empty id rather than any slice of the
|
|
180
|
+
// push token — the worker persists externalMessageId into the admin-exposed provider_response,
|
|
181
|
+
// and this module only ever surfaces the last-8 via token_snapshot, never raw token material.
|
|
182
|
+
return { externalMessageId: '', status: 'sent' }
|
|
183
|
+
}
|
|
184
|
+
if (outcome.reason && PERMANENT_APNS_REASONS.has(outcome.reason)) {
|
|
185
|
+
return deviceUnregisteredResult({ reason: outcome.reason })
|
|
186
|
+
}
|
|
187
|
+
return { externalMessageId: '', status: 'failed', error: outcome.error ?? outcome.reason ?? 'apns_send_failed' }
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let cachedAdapter: ApnsChannelAdapter | null = null
|
|
195
|
+
|
|
196
|
+
export function getApnsChannelAdapter(): ApnsChannelAdapter {
|
|
197
|
+
if (!cachedAdapter) cachedAdapter = new ApnsChannelAdapter()
|
|
198
|
+
return cachedAdapter
|
|
199
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createPrivateKey } from 'node:crypto'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
4
|
+
import {
|
|
5
|
+
PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID,
|
|
6
|
+
PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID,
|
|
7
|
+
PUSH_CREDENTIAL_ERROR_INVALID_P8,
|
|
8
|
+
PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID,
|
|
9
|
+
PUSH_CREDENTIAL_ERROR_REQUIRED,
|
|
10
|
+
} from '@open-mercato/core/modules/communication_channels/lib/push-credential-errors'
|
|
11
|
+
|
|
12
|
+
/** Apple issues both Key IDs and Team IDs as exactly 10 alphanumeric characters. */
|
|
13
|
+
const APPLE_TEN_CHAR_ID = /^[A-Za-z0-9]{10}$/
|
|
14
|
+
/** Reverse-DNS app identifier, e.g. `com.example.app`; also the APNs `topic`. */
|
|
15
|
+
const BUNDLE_ID = /^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+$/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Structurally verify Apple's `.p8` signing key without contacting APNs.
|
|
19
|
+
* `createPrivateKey` parses the PEM and rejects anything that is not a readable
|
|
20
|
+
* private key, which is what makes a pasted-by-mistake string fail at connect
|
|
21
|
+
* time instead of silently producing a channel that can never deliver.
|
|
22
|
+
*
|
|
23
|
+
* This proves the key is well-formed, NOT that Apple accepts it — a
|
|
24
|
+
* syntactically valid key from the wrong developer account still connects. Live
|
|
25
|
+
* verification is tracked separately.
|
|
26
|
+
*/
|
|
27
|
+
function isParseablePrivateKey(value: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
createPrivateKey(value)
|
|
30
|
+
return true
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tenant-level APNs credentials persisted on `IntegrationCredentials` for provider
|
|
38
|
+
* `channel_apns`. Token-based auth (Apple's `.p8` key) — `p8Key` is the PEM
|
|
39
|
+
* contents (stored encrypted at rest), `keyId`/`teamId` identify the key, and
|
|
40
|
+
* `bundleId` is the app's APNs `topic`. `production` selects the APNs host
|
|
41
|
+
* (sandbox by default).
|
|
42
|
+
*/
|
|
43
|
+
export const apnsCredentialsSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
p8Key: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(1, PUSH_CREDENTIAL_ERROR_REQUIRED)
|
|
48
|
+
.refine(isParseablePrivateKey, PUSH_CREDENTIAL_ERROR_INVALID_P8),
|
|
49
|
+
keyId: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1, PUSH_CREDENTIAL_ERROR_REQUIRED)
|
|
52
|
+
.regex(APPLE_TEN_CHAR_ID, PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID),
|
|
53
|
+
teamId: z
|
|
54
|
+
.string()
|
|
55
|
+
.min(1, PUSH_CREDENTIAL_ERROR_REQUIRED)
|
|
56
|
+
.regex(APPLE_TEN_CHAR_ID, PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID),
|
|
57
|
+
bundleId: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1, PUSH_CREDENTIAL_ERROR_REQUIRED)
|
|
60
|
+
.regex(BUNDLE_ID, PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID),
|
|
61
|
+
production: z.union([z.boolean(), z.string()]).optional(),
|
|
62
|
+
})
|
|
63
|
+
.passthrough()
|
|
64
|
+
|
|
65
|
+
export type ApnsCredentials = z.infer<typeof apnsCredentialsSchema>
|
|
66
|
+
|
|
67
|
+
export interface ApnsResolvedCredentials {
|
|
68
|
+
p8Key: string
|
|
69
|
+
keyId: string
|
|
70
|
+
teamId: string
|
|
71
|
+
bundleId: string
|
|
72
|
+
production: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve validated credentials into the strongly-typed send config (parsing the production flag). */
|
|
76
|
+
export function resolveApnsCredentials(credentials: ApnsCredentials): ApnsResolvedCredentials {
|
|
77
|
+
const production =
|
|
78
|
+
typeof credentials.production === 'boolean'
|
|
79
|
+
? credentials.production
|
|
80
|
+
: parseBooleanWithDefault(credentials.production, false)
|
|
81
|
+
return {
|
|
82
|
+
p8Key: credentials.p8Key,
|
|
83
|
+
keyId: credentials.keyId,
|
|
84
|
+
teamId: credentials.teamId,
|
|
85
|
+
bundleId: credentials.bundleId,
|
|
86
|
+
production,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPushFakeProvidersEnabled,
|
|
3
|
+
recordFakePush,
|
|
4
|
+
warnPushFakeProvidersActive,
|
|
5
|
+
} from '@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder'
|
|
6
|
+
import { buildApnsNotification, setApnsSenderFactory } from './adapter'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Network-free `@parse/node-apn` sender used ONLY by integration tests.
|
|
10
|
+
*
|
|
11
|
+
* Swaps the SDK client behind the adapter's existing seam, so the real adapter still runs its
|
|
12
|
+
* credential resolution and its `Unregistered`/`410` → `device_unregistered` mapping. The adapter
|
|
13
|
+
* itself is never replaced or re-registered.
|
|
14
|
+
*
|
|
15
|
+
* Unlike FCM and Expo, the APNs seam sits *above* the message builder: the sender receives the raw
|
|
16
|
+
* envelope, and `buildApnsNotification(new Notification(), …)` runs inside the real sender factory this
|
|
17
|
+
* fake replaces. The fake therefore builds against a **real `apn.Notification`** too, and records the
|
|
18
|
+
* wire form node-apn would transmit (`headers()` + the compiled `aps` payload) rather than a plain-object
|
|
19
|
+
* projection the SDK never serializes. Only the network provider is faked. `.p8` parsing lives in the
|
|
20
|
+
* replaced factory, so fake credentials need only a valid shape.
|
|
21
|
+
*
|
|
22
|
+
* Token sentinels match `push_stub`'s convention (see push-stub-adapter.ts):
|
|
23
|
+
* - token containing `unregistered` → APNs' native permanent-token reason
|
|
24
|
+
* - token containing `fail` → a retryable error
|
|
25
|
+
* - otherwise → success
|
|
26
|
+
*
|
|
27
|
+
* Production safety: never installed at module import; no-op unless `OM_PUSH_FAKE_PROVIDERS` is set.
|
|
28
|
+
*/
|
|
29
|
+
type ApnsNotificationLike = Record<string, unknown> & {
|
|
30
|
+
headers(): Record<string, unknown>
|
|
31
|
+
compile(): string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function newApnsNotification(): Promise<ApnsNotificationLike> {
|
|
35
|
+
const apnModule = await import('@parse/node-apn')
|
|
36
|
+
const apn = (apnModule as { default?: unknown }).default ?? apnModule
|
|
37
|
+
const Notification = (apn as { Notification: new () => ApnsNotificationLike }).Notification
|
|
38
|
+
return new Notification()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ensureApnsFakeProviderInstalled(): void {
|
|
42
|
+
if (!isPushFakeProvidersEnabled()) return
|
|
43
|
+
warnPushFakeProvidersActive('apns')
|
|
44
|
+
setApnsSenderFactory(() => async (payload, token) => {
|
|
45
|
+
const note = buildApnsNotification(await newApnsNotification(), payload) as ApnsNotificationLike
|
|
46
|
+
recordFakePush('apns', token, {
|
|
47
|
+
headers: note.headers(),
|
|
48
|
+
payload: JSON.parse(note.compile()) as Record<string, unknown>,
|
|
49
|
+
})
|
|
50
|
+
if (token.includes('unregistered')) return { ok: false, reason: 'Unregistered' }
|
|
51
|
+
if (token.includes('fail')) return { ok: false, error: 'fake apns transient failure' }
|
|
52
|
+
return { ok: true }
|
|
53
|
+
})
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { makePushClientConfigHealthCheck } from '@open-mercato/core/modules/push_notifications/lib/push-health'
|
|
2
|
+
import { apnsCredentialsSchema } from './credentials'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Liveness probe for the APNs integration. The hub passes the tenant-scoped
|
|
6
|
+
* credentials (.p8 key + key/team/bundle ids), so the probe confirms they are
|
|
7
|
+
* present and well-formed — no network call. Per-device token validity surfaces
|
|
8
|
+
* on delivery (`device_unregistered` soft-deletes).
|
|
9
|
+
*/
|
|
10
|
+
export const channelApnsHealthCheck = makePushClientConfigHealthCheck({
|
|
11
|
+
schema: apnsCredentialsSchema,
|
|
12
|
+
providerLabel: 'APNs',
|
|
13
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
|
+
import {
|
|
3
|
+
hasChannelAdapter,
|
|
4
|
+
registerChannelAdapter,
|
|
5
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
|
|
6
|
+
import { getApnsChannelAdapter } from './lib/adapter'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register the APNs `ChannelAdapter` once per process at import time. Guarded with
|
|
10
|
+
* `hasChannelAdapter` to silence the duplicate error on dev-mode HMR + repeated
|
|
11
|
+
* test imports. Provider credentials (.p8 key + ids) are persisted per tenant via
|
|
12
|
+
* the standard `IntegrationCredentials` flow for the `channel_apns` provider.
|
|
13
|
+
*/
|
|
14
|
+
function ensureApnsAdapterRegistered(): void {
|
|
15
|
+
if (hasChannelAdapter('apns')) return
|
|
16
|
+
registerChannelAdapter(getApnsChannelAdapter())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ensureApnsAdapterRegistered()
|
|
20
|
+
|
|
21
|
+
export const setup: ModuleSetupConfig = {
|
|
22
|
+
defaultRoleFeatures: {
|
|
23
|
+
superadmin: ['channel_apns.view', 'channel_apns.configure'],
|
|
24
|
+
admin: ['channel_apns.view', 'channel_apns.configure'],
|
|
25
|
+
},
|
|
26
|
+
async onTenantCreated() {
|
|
27
|
+
ensureApnsAdapterRegistered()
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default setup
|