@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.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +32 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js +47 -0
  7. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js.map +7 -0
  8. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js +7 -0
  9. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js.map +7 -0
  10. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js +104 -0
  11. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js.map +7 -0
  12. package/dist/modules/channel_apns/acl.js +10 -0
  13. package/dist/modules/channel_apns/acl.js.map +7 -0
  14. package/dist/modules/channel_apns/di.js +24 -0
  15. package/dist/modules/channel_apns/di.js.map +7 -0
  16. package/dist/modules/channel_apns/index.js +9 -0
  17. package/dist/modules/channel_apns/index.js.map +7 -0
  18. package/dist/modules/channel_apns/integration.js +76 -0
  19. package/dist/modules/channel_apns/integration.js.map +7 -0
  20. package/dist/modules/channel_apns/lib/adapter.js +117 -0
  21. package/dist/modules/channel_apns/lib/adapter.js.map +7 -0
  22. package/dist/modules/channel_apns/lib/credentials.js +42 -0
  23. package/dist/modules/channel_apns/lib/credentials.js.map +7 -0
  24. package/dist/modules/channel_apns/lib/fake-provider.js +30 -0
  25. package/dist/modules/channel_apns/lib/fake-provider.js.map +7 -0
  26. package/dist/modules/channel_apns/lib/health.js +10 -0
  27. package/dist/modules/channel_apns/lib/health.js.map +7 -0
  28. package/dist/modules/channel_apns/setup.js +25 -0
  29. package/dist/modules/channel_apns/setup.js.map +7 -0
  30. package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js +225 -0
  31. package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js.map +7 -0
  32. package/dist/modules/channel_apns/widgets/injection/connect/widget.js +17 -0
  33. package/dist/modules/channel_apns/widgets/injection/connect/widget.js.map +7 -0
  34. package/dist/modules/channel_apns/widgets/injection-table.js +15 -0
  35. package/dist/modules/channel_apns/widgets/injection-table.js.map +7 -0
  36. package/jest.config.cjs +34 -0
  37. package/package.json +96 -0
  38. package/src/index.ts +1 -0
  39. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.ts +67 -0
  40. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.ts +3 -0
  41. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.ts +145 -0
  42. package/src/modules/channel_apns/acl.ts +6 -0
  43. package/src/modules/channel_apns/di.ts +26 -0
  44. package/src/modules/channel_apns/index.ts +6 -0
  45. package/src/modules/channel_apns/integration.ts +77 -0
  46. package/src/modules/channel_apns/lib/__tests__/adapter.test.ts +158 -0
  47. package/src/modules/channel_apns/lib/__tests__/apnsTestKey.ts +21 -0
  48. package/src/modules/channel_apns/lib/__tests__/credentials.test.ts +98 -0
  49. package/src/modules/channel_apns/lib/__tests__/fake-provider.test.ts +93 -0
  50. package/src/modules/channel_apns/lib/__tests__/message-golden.test.ts +101 -0
  51. package/src/modules/channel_apns/lib/adapter.ts +199 -0
  52. package/src/modules/channel_apns/lib/credentials.ts +88 -0
  53. package/src/modules/channel_apns/lib/fake-provider.ts +54 -0
  54. package/src/modules/channel_apns/lib/health.ts +13 -0
  55. package/src/modules/channel_apns/setup.ts +31 -0
  56. package/src/modules/channel_apns/widgets/injection/connect/widget.client.tsx +251 -0
  57. package/src/modules/channel_apns/widgets/injection/connect/widget.ts +16 -0
  58. package/src/modules/channel_apns/widgets/injection-table.ts +13 -0
  59. package/tsconfig.json +9 -0
  60. 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