@aglyn/tenant-data-admin 1.0.0-beta.230 → 1.0.0-beta.232

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 (48) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/account-acquisition.d.ts +30 -0
  3. package/src/lib/server/account-acquisition.js +37 -2
  4. package/src/lib/server/account-acquisition.js.map +1 -1
  5. package/src/lib/server/api-keys.d.ts +14 -1
  6. package/src/lib/server/api-keys.js +28 -0
  7. package/src/lib/server/api-keys.js.map +1 -1
  8. package/src/lib/server/content-entry-drafts.d.ts +131 -0
  9. package/src/lib/server/content-entry-drafts.js +307 -0
  10. package/src/lib/server/content-entry-drafts.js.map +1 -0
  11. package/src/lib/server/erase.js +14 -0
  12. package/src/lib/server/erase.js.map +1 -1
  13. package/src/lib/server/host-released-names.d.ts +22 -0
  14. package/src/lib/server/host-released-names.js +31 -0
  15. package/src/lib/server/host-released-names.js.map +1 -0
  16. package/src/lib/server/host-visitor-records.d.ts +1 -1
  17. package/src/lib/server/host-visitor-records.js +1 -1
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/mobile-push-switch.d.ts +46 -0
  20. package/src/lib/server/mobile-push-switch.js +45 -0
  21. package/src/lib/server/mobile-push-switch.js.map +1 -0
  22. package/src/lib/server/native-push.d.ts +17 -0
  23. package/src/lib/server/native-push.js +35 -0
  24. package/src/lib/server/native-push.js.map +1 -0
  25. package/src/lib/server/notifications.js +23 -0
  26. package/src/lib/server/notifications.js.map +1 -1
  27. package/src/lib/server/personal-data-export.js +230 -0
  28. package/src/lib/server/personal-data-export.js.map +1 -1
  29. package/src/lib/server/plugin-event-outbox.d.ts +138 -0
  30. package/src/lib/server/plugin-event-outbox.js +229 -0
  31. package/src/lib/server/plugin-event-outbox.js.map +1 -0
  32. package/src/lib/server/push-apns.d.ts +73 -0
  33. package/src/lib/server/push-apns.js +225 -0
  34. package/src/lib/server/push-apns.js.map +1 -0
  35. package/src/lib/server/push-delivery.d.ts +78 -0
  36. package/src/lib/server/push-delivery.js +176 -0
  37. package/src/lib/server/push-delivery.js.map +1 -0
  38. package/src/lib/server/push-fcm.d.ts +29 -0
  39. package/src/lib/server/push-fcm.js +83 -0
  40. package/src/lib/server/push-fcm.js.map +1 -0
  41. package/src/lib/server/serve-media-cdn.d.ts +6 -0
  42. package/src/lib/server/serve-media-cdn.js +20 -3
  43. package/src/lib/server/serve-media-cdn.js.map +1 -1
  44. package/src/lib/server/site-marketing-sync.d.ts +146 -0
  45. package/src/lib/server/site-marketing-sync.js +315 -0
  46. package/src/lib/server/site-marketing-sync.js.map +1 -0
  47. package/src/lib/server/upsert-contact.js +6 -5
  48. package/src/lib/server/upsert-contact.js.map +1 -1
@@ -0,0 +1,229 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { deliverPluginDomainEvent } from "@aglyn/aglyn/plugin-manager/plugin-domain-events";
18
+ import { createHash } from "crypto";
19
+ /**
20
+ * The outbox behind plugin events (AGL-3611): raising an event is a write
21
+ * here, and a scheduled drain delivers it. See `plugin-domain-events.ts` in
22
+ * core for the contract a raiser and a subscriber see.
23
+ *
24
+ * WHY AN OUTBOX. A fact a plugin reports — an order paid, a return refunded —
25
+ * must reach accounting, shipping and the merchant's webhooks even when one of
26
+ * them is down, and even when the process that made the write dies a moment
27
+ * later. Calling the subscribers in the request loses the event on either.
28
+ * Writing it here, in the raiser's own transaction where it has one, makes
29
+ * the event exist exactly when the fact does.
30
+ *
31
+ * AT LEAST ONCE, PER SUBSCRIBER. The document records which subscribers
32
+ * took the event (`delivered`), and a retry calls only the rest. A
33
+ * subscriber can still see an event twice — its handler succeeded and the
34
+ * write recording that did not — so the envelope's `id` is the key it
35
+ * dedupes on.
36
+ *
37
+ * ONE DRAIN AT A TIME PER EVENT. A pass leases the document for a minute in
38
+ * a transaction before it delivers, so two overlapping beats do not both
39
+ * deliver it. A pass that dies holding the lease is retried once the lease
40
+ * lapses.
41
+ *
42
+ * A delivered event is deleted: the collection holds what is still owed, so
43
+ * the drain's query reads nothing on an idle platform. One that runs out of
44
+ * attempts stays, `failed`, with each subscriber's last error.
45
+ */ export const PLUGIN_EVENT_OUTBOX_COLLECTION = 'pluginEventOutbox';
46
+ /** Attempts per event before it is left as a dead letter. */ export const PLUGIN_EVENT_MAX_ATTEMPTS = 8;
47
+ /** Wait before attempt n+1, by attempts made; the last step repeats. */ export const PLUGIN_EVENT_BACKOFF_MS = [
48
+ 30000,
49
+ 120000,
50
+ 600000,
51
+ 1800000,
52
+ 3600000,
53
+ 21600000,
54
+ 43200000
55
+ ];
56
+ /** How long a drain pass holds an event while it delivers. */ export const PLUGIN_EVENT_LEASE_MS = 60000;
57
+ /** Events one pass reads. */ export const PLUGIN_EVENT_SCAN_LIMIT = 100;
58
+ export function pluginEventBackoffMs(attempts) {
59
+ const last = PLUGIN_EVENT_BACKOFF_MS.length - 1;
60
+ return PLUGIN_EVENT_BACKOFF_MS[Math.max(0, Math.min(attempts - 1, last))];
61
+ }
62
+ /**
63
+ * The event's document id: the same raise twice is the same document. `key`
64
+ * names the occurrence (a fulfillment id, a refund id); without one every
65
+ * raise is new.
66
+ */ export function pluginEventId(hostId, event, key) {
67
+ return createHash('sha256').update(`${hostId}\u0000${event}\u0000${key}`).digest('hex').slice(0, 40);
68
+ }
69
+ /** Firestore refuses `undefined`; the payload is data, so it is plain JSON. */ function plainPayload(payload) {
70
+ const plain = JSON.parse(JSON.stringify(payload != null ? payload : {}));
71
+ return plain && typeof plain === 'object' && !Array.isArray(plain) ? plain : {
72
+ value: plain
73
+ };
74
+ }
75
+ function recordFor(request, now) {
76
+ var _request_orgId, _request_occurredAtMs;
77
+ return {
78
+ status: 'pending',
79
+ event: typeof request.event === 'string' ? request.event : request.event.id,
80
+ pluginId: request.pluginId,
81
+ hostId: request.hostId,
82
+ orgId: (_request_orgId = request.orgId) != null ? _request_orgId : null,
83
+ occurredAtMs: (_request_occurredAtMs = request.occurredAtMs) != null ? _request_occurredAtMs : now,
84
+ payload: plainPayload(request.payload),
85
+ attempts: 0,
86
+ nextAttemptAtMs: now,
87
+ createdAtMs: now
88
+ };
89
+ }
90
+ /**
91
+ * Raises an event INSIDE the caller's transaction or batch, so it is written
92
+ * exactly when the fact is. The write is a `set` (a transaction cannot read
93
+ * after it writes), so the caller's own "already done" guard is what keeps a
94
+ * retry from raising it twice — and the id is keyed, so if it does, the
95
+ * event is the same document. Returns the id.
96
+ */ export function stagePluginEvent(writer, firestore, request, now = Date.now()) {
97
+ const record = recordFor(request, now);
98
+ const id = pluginEventId(request.hostId, record.event, request.key);
99
+ writer.set(firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id), record);
100
+ return id;
101
+ }
102
+ const GRPC_ALREADY_EXISTS = 6;
103
+ /**
104
+ * Raises an event after the fact is already written, for a raiser with no
105
+ * transaction to ride. `create`, so the same key raised twice — a webhook
106
+ * redelivered — is one event: the second answers `exists`.
107
+ */ export async function raisePluginEvent(firestore, request, now = Date.now()) {
108
+ const record = recordFor(request, now);
109
+ const id = pluginEventId(request.hostId, record.event, request.key);
110
+ try {
111
+ await firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id).create(record);
112
+ return {
113
+ id,
114
+ outcome: 'queued'
115
+ };
116
+ } catch (error) {
117
+ if ((error == null ? void 0 : error.code) === GRPC_ALREADY_EXISTS) return {
118
+ id,
119
+ outcome: 'exists'
120
+ };
121
+ console.error('[plugin-events] not queued', record.event, request.hostId, error);
122
+ return {
123
+ id,
124
+ outcome: 'failed'
125
+ };
126
+ }
127
+ }
128
+ /**
129
+ * Delivers one event: lease, hand it to the subscribers still owed it, and
130
+ * record what happened. Exported for a raiser that wants an event out now
131
+ * rather than on the next beat; the drain is the backstop either way.
132
+ */ export async function attemptPluginEvent(firestore, id, now = Date.now()) {
133
+ var _leased_delivered, _leased_subscriberAttempts, _leased_orgId, _leased_attempts, _leased_lastErrors;
134
+ const ref = firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id);
135
+ const leased = await firestore.runTransaction(async (transaction)=>{
136
+ var _data_leaseUntilMs, _data_nextAttemptAtMs;
137
+ const snapshot = await transaction.get(ref);
138
+ if (!snapshot.exists) return null;
139
+ const data = snapshot.data();
140
+ if (data.status !== 'pending') return null;
141
+ if (Number((_data_leaseUntilMs = data.leaseUntilMs) != null ? _data_leaseUntilMs : 0) > now) return null;
142
+ if (Number((_data_nextAttemptAtMs = data.nextAttemptAtMs) != null ? _data_nextAttemptAtMs : 0) > now) return null;
143
+ transaction.update(ref, {
144
+ leaseUntilMs: now + PLUGIN_EVENT_LEASE_MS
145
+ });
146
+ return data;
147
+ });
148
+ if (!leased) return 'leased';
149
+ const delivered = _extends({}, (_leased_delivered = leased.delivered) != null ? _leased_delivered : {});
150
+ const subscriberAttempts = _extends({}, (_leased_subscriberAttempts = leased.subscriberAttempts) != null ? _leased_subscriberAttempts : {});
151
+ const result = await deliverPluginDomainEvent({
152
+ id,
153
+ event: leased.event,
154
+ hostId: leased.hostId,
155
+ orgId: (_leased_orgId = leased.orgId) != null ? _leased_orgId : null,
156
+ occurredAtMs: leased.occurredAtMs,
157
+ payload: leased.payload
158
+ }, {
159
+ skip: new Set(Object.keys(delivered)),
160
+ attempts: subscriberAttempts
161
+ });
162
+ for (const pluginId of result.delivered){
163
+ var _subscriberAttempts_pluginId;
164
+ delivered[pluginId] = now;
165
+ subscriberAttempts[pluginId] = ((_subscriberAttempts_pluginId = subscriberAttempts[pluginId]) != null ? _subscriberAttempts_pluginId : 0) + 1;
166
+ }
167
+ for (const { pluginId } of result.failed){
168
+ var _subscriberAttempts_pluginId1;
169
+ subscriberAttempts[pluginId] = ((_subscriberAttempts_pluginId1 = subscriberAttempts[pluginId]) != null ? _subscriberAttempts_pluginId1 : 0) + 1;
170
+ }
171
+ if (result.failed.length === 0) {
172
+ await ref.delete();
173
+ return 'delivered';
174
+ }
175
+ const attempts = Number((_leased_attempts = leased.attempts) != null ? _leased_attempts : 0) + 1;
176
+ const exhausted = attempts >= PLUGIN_EVENT_MAX_ATTEMPTS;
177
+ const lastErrors = _extends({}, (_leased_lastErrors = leased.lastErrors) != null ? _leased_lastErrors : {});
178
+ for (const { pluginId, error } of result.failed)lastErrors[pluginId] = error;
179
+ await ref.update(_extends({
180
+ status: exhausted ? 'failed' : 'pending',
181
+ attempts,
182
+ delivered,
183
+ subscriberAttempts,
184
+ lastErrors,
185
+ leaseUntilMs: 0
186
+ }, exhausted ? {
187
+ failedAtMs: now
188
+ } : {
189
+ nextAttemptAtMs: now + pluginEventBackoffMs(attempts)
190
+ }));
191
+ if (exhausted) {
192
+ console.error(`[plugin-events] ${leased.event} ${id} on ${leased.hostId} gave up after ${attempts} attempts:`, lastErrors);
193
+ return 'dead-lettered';
194
+ }
195
+ return 'retried';
196
+ }
197
+ /**
198
+ * One beat of the drain: the events owed now, oldest first. A locked site's
199
+ * events wait, untouched, for the lock to lift.
200
+ */ export async function drainPluginEvents(gate, firestore, now = Date.now()) {
201
+ const due = await firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).where('status', '==', 'pending').where('nextAttemptAtMs', '<=', now).orderBy('nextAttemptAtMs').limit(PLUGIN_EVENT_SCAN_LIMIT).get();
202
+ const result = {
203
+ scanned: due.size,
204
+ delivered: 0,
205
+ retried: 0,
206
+ deadLettered: 0,
207
+ skippedLocked: 0,
208
+ skippedLeased: 0
209
+ };
210
+ for (const snapshot of due.docs){
211
+ var _snapshot_get;
212
+ const hostId = String((_snapshot_get = snapshot.get('hostId')) != null ? _snapshot_get : '');
213
+ if (await gate.isLocked(hostId)) {
214
+ result.skippedLocked += 1;
215
+ continue;
216
+ }
217
+ const outcome = await attemptPluginEvent(firestore, snapshot.id, now).catch((error)=>{
218
+ console.error('[plugin-events] attempt failed', snapshot.id, error);
219
+ return null;
220
+ });
221
+ if (outcome === 'delivered') result.delivered += 1;
222
+ else if (outcome === 'retried') result.retried += 1;
223
+ else if (outcome === 'dead-lettered') result.deadLettered += 1;
224
+ else if (outcome === 'leased') result.skippedLeased += 1;
225
+ }
226
+ return result;
227
+ }
228
+
229
+ //# sourceMappingURL=plugin-event-outbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/plugin-event-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n deliverPluginDomainEvent,\n type PluginDomainEvent,\n} from '@aglyn/aglyn/plugin-manager/plugin-domain-events'\nimport { createHash } from 'crypto'\n\n/**\n * The outbox behind plugin events (AGL-3611): raising an event is a write\n * here, and a scheduled drain delivers it. See `plugin-domain-events.ts` in\n * core for the contract a raiser and a subscriber see.\n *\n * WHY AN OUTBOX. A fact a plugin reports — an order paid, a return refunded —\n * must reach accounting, shipping and the merchant's webhooks even when one of\n * them is down, and even when the process that made the write dies a moment\n * later. Calling the subscribers in the request loses the event on either.\n * Writing it here, in the raiser's own transaction where it has one, makes\n * the event exist exactly when the fact does.\n *\n * AT LEAST ONCE, PER SUBSCRIBER. The document records which subscribers\n * took the event (`delivered`), and a retry calls only the rest. A\n * subscriber can still see an event twice — its handler succeeded and the\n * write recording that did not — so the envelope's `id` is the key it\n * dedupes on.\n *\n * ONE DRAIN AT A TIME PER EVENT. A pass leases the document for a minute in\n * a transaction before it delivers, so two overlapping beats do not both\n * deliver it. A pass that dies holding the lease is retried once the lease\n * lapses.\n *\n * A delivered event is deleted: the collection holds what is still owed, so\n * the drain's query reads nothing on an idle platform. One that runs out of\n * attempts stays, `failed`, with each subscriber's last error.\n */\n\nexport const PLUGIN_EVENT_OUTBOX_COLLECTION = 'pluginEventOutbox'\n\n/** Attempts per event before it is left as a dead letter. */\nexport const PLUGIN_EVENT_MAX_ATTEMPTS = 8\n\n/** Wait before attempt n+1, by attempts made; the last step repeats. */\nexport const PLUGIN_EVENT_BACKOFF_MS: readonly number[] = [\n 30_000,\n 120_000,\n 600_000,\n 1_800_000,\n 3_600_000,\n 21_600_000,\n 43_200_000,\n]\n\n/** How long a drain pass holds an event while it delivers. */\nexport const PLUGIN_EVENT_LEASE_MS = 60_000\n\n/** Events one pass reads. */\nexport const PLUGIN_EVENT_SCAN_LIMIT = 100\n\nexport type PluginEventOutboxStatus = 'pending' | 'failed'\n\n/** `pluginEventOutbox/{id}`. Written and read only by the Admin SDK. */\nexport interface PluginEventOutboxRecord {\n status: PluginEventOutboxStatus\n event: string\n /** The plugin that raised it. */\n pluginId: string\n hostId: string\n orgId: string | null\n occurredAtMs: number\n payload: Record<string, unknown>\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n /** Subscribers that took it, by plugin id, when. */\n delivered?: Record<string, number>\n /** Attempts per subscriber, for the envelope's `attempt`. */\n subscriberAttempts?: Record<string, number>\n /** Each failing subscriber's last error. */\n lastErrors?: Record<string, string>\n leaseUntilMs?: number\n failedAtMs?: number\n}\n\nexport function pluginEventBackoffMs(attempts: number): number {\n const last = PLUGIN_EVENT_BACKOFF_MS.length - 1\n return PLUGIN_EVENT_BACKOFF_MS[Math.max(0, Math.min(attempts - 1, last))]\n}\n\n/**\n * The event's document id: the same raise twice is the same document. `key`\n * names the occurrence (a fulfillment id, a refund id); without one every\n * raise is new.\n */\nexport function pluginEventId(hostId: string, event: string, key: string): string {\n return createHash('sha256').update(`${hostId}\\u0000${event}\\u0000${key}`).digest('hex').slice(0, 40)\n}\n\n/** A transaction or a batch: anything that can `set` a document. */\nexport interface PluginEventWriter {\n set(ref: FirebaseFirestore.DocumentReference, data: FirebaseFirestore.DocumentData): unknown\n}\n\nexport interface RaisePluginEventRequest<Payload> {\n event: PluginDomainEvent<Payload> | string\n /** The raising plugin's id. */\n pluginId: string\n hostId: string\n orgId?: string | null\n payload: Payload\n /** The occurrence: the same key raises the same event once. */\n key: string\n occurredAtMs?: number\n}\n\n/** Firestore refuses `undefined`; the payload is data, so it is plain JSON. */\nfunction plainPayload(payload: unknown): Record<string, unknown> {\n const plain = JSON.parse(JSON.stringify(payload ?? {}))\n return plain && typeof plain === 'object' && !Array.isArray(plain) ? plain : { value: plain }\n}\n\nfunction recordFor<Payload>(request: RaisePluginEventRequest<Payload>, now: number): PluginEventOutboxRecord {\n return {\n status: 'pending',\n event: typeof request.event === 'string' ? request.event : request.event.id,\n pluginId: request.pluginId,\n hostId: request.hostId,\n orgId: request.orgId ?? null,\n occurredAtMs: request.occurredAtMs ?? now,\n payload: plainPayload(request.payload),\n attempts: 0,\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n}\n\n/**\n * Raises an event INSIDE the caller's transaction or batch, so it is written\n * exactly when the fact is. The write is a `set` (a transaction cannot read\n * after it writes), so the caller's own \"already done\" guard is what keeps a\n * retry from raising it twice — and the id is keyed, so if it does, the\n * event is the same document. Returns the id.\n */\nexport function stagePluginEvent<Payload>(\n writer: PluginEventWriter,\n firestore: FirebaseFirestore.Firestore,\n request: RaisePluginEventRequest<Payload>,\n now = Date.now(),\n): string {\n const record = recordFor(request, now)\n const id = pluginEventId(request.hostId, record.event, request.key)\n writer.set(\n firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id),\n record as unknown as FirebaseFirestore.DocumentData,\n )\n return id\n}\n\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * Raises an event after the fact is already written, for a raiser with no\n * transaction to ride. `create`, so the same key raised twice — a webhook\n * redelivered — is one event: the second answers `exists`.\n */\nexport async function raisePluginEvent<Payload>(\n firestore: FirebaseFirestore.Firestore,\n request: RaisePluginEventRequest<Payload>,\n now = Date.now(),\n): Promise<{ id: string; outcome: 'queued' | 'exists' | 'failed' }> {\n const record = recordFor(request, now)\n const id = pluginEventId(request.hostId, record.event, request.key)\n try {\n await firestore\n .collection(PLUGIN_EVENT_OUTBOX_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return { id, outcome: 'queued' }\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) return { id, outcome: 'exists' }\n console.error('[plugin-events] not queued', record.event, request.hostId, error)\n return { id, outcome: 'failed' }\n }\n}\n\nexport interface PluginEventDrainResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n skippedLocked: number\n skippedLeased: number\n}\n\ntype AttemptOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'leased'\n\n/**\n * Delivers one event: lease, hand it to the subscribers still owed it, and\n * record what happened. Exported for a raiser that wants an event out now\n * rather than on the next beat; the drain is the backstop either way.\n */\nexport async function attemptPluginEvent(\n firestore: FirebaseFirestore.Firestore,\n id: string,\n now = Date.now(),\n): Promise<AttemptOutcome> {\n const ref = firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id)\n const leased = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) return null\n const data = snapshot.data() as PluginEventOutboxRecord\n if (data.status !== 'pending') return null\n if (Number(data.leaseUntilMs ?? 0) > now) return null\n if (Number(data.nextAttemptAtMs ?? 0) > now) return null\n transaction.update(ref, { leaseUntilMs: now + PLUGIN_EVENT_LEASE_MS })\n return data\n })\n if (!leased) return 'leased'\n\n const delivered = { ...(leased.delivered ?? {}) }\n const subscriberAttempts = { ...(leased.subscriberAttempts ?? {}) }\n const result = await deliverPluginDomainEvent(\n {\n id,\n event: leased.event,\n hostId: leased.hostId,\n orgId: leased.orgId ?? null,\n occurredAtMs: leased.occurredAtMs,\n payload: leased.payload,\n },\n { skip: new Set(Object.keys(delivered)), attempts: subscriberAttempts },\n )\n for (const pluginId of result.delivered) {\n delivered[pluginId] = now\n subscriberAttempts[pluginId] = (subscriberAttempts[pluginId] ?? 0) + 1\n }\n for (const { pluginId } of result.failed) {\n subscriberAttempts[pluginId] = (subscriberAttempts[pluginId] ?? 0) + 1\n }\n\n if (result.failed.length === 0) {\n await ref.delete()\n return 'delivered'\n }\n const attempts = Number(leased.attempts ?? 0) + 1\n const exhausted = attempts >= PLUGIN_EVENT_MAX_ATTEMPTS\n const lastErrors = { ...(leased.lastErrors ?? {}) }\n for (const { pluginId, error } of result.failed) lastErrors[pluginId] = error\n await ref.update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n delivered,\n subscriberAttempts,\n lastErrors,\n leaseUntilMs: 0,\n ...(exhausted ? { failedAtMs: now } : { nextAttemptAtMs: now + pluginEventBackoffMs(attempts) }),\n })\n if (exhausted) {\n console.error(\n `[plugin-events] ${leased.event} ${id} on ${leased.hostId} gave up after ${attempts} attempts:`,\n lastErrors,\n )\n return 'dead-lettered'\n }\n return 'retried'\n}\n\n/**\n * One beat of the drain: the events owed now, oldest first. A locked site's\n * events wait, untouched, for the lock to lift.\n */\nexport async function drainPluginEvents(\n gate: { isLocked(hostId: string): Promise<boolean> },\n firestore: FirebaseFirestore.Firestore,\n now = Date.now(),\n): Promise<PluginEventDrainResult> {\n const due = await firestore\n .collection(PLUGIN_EVENT_OUTBOX_COLLECTION)\n .where('status', '==', 'pending')\n .where('nextAttemptAtMs', '<=', now)\n .orderBy('nextAttemptAtMs')\n .limit(PLUGIN_EVENT_SCAN_LIMIT)\n .get()\n const result: PluginEventDrainResult = {\n scanned: due.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n skippedLocked: 0,\n skippedLeased: 0,\n }\n for (const snapshot of due.docs) {\n const hostId = String(snapshot.get('hostId') ?? '')\n if (await gate.isLocked(hostId)) {\n result.skippedLocked += 1\n continue\n }\n const outcome = await attemptPluginEvent(firestore, snapshot.id, now).catch((error) => {\n console.error('[plugin-events] attempt failed', snapshot.id, error)\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'leased') result.skippedLeased += 1\n }\n return result\n}\n"],"names":["deliverPluginDomainEvent","createHash","PLUGIN_EVENT_OUTBOX_COLLECTION","PLUGIN_EVENT_MAX_ATTEMPTS","PLUGIN_EVENT_BACKOFF_MS","PLUGIN_EVENT_LEASE_MS","PLUGIN_EVENT_SCAN_LIMIT","pluginEventBackoffMs","attempts","last","length","Math","max","min","pluginEventId","hostId","event","key","update","digest","slice","plainPayload","payload","plain","JSON","parse","stringify","Array","isArray","value","recordFor","request","now","status","id","pluginId","orgId","occurredAtMs","nextAttemptAtMs","createdAtMs","stagePluginEvent","writer","firestore","Date","record","set","collection","doc","GRPC_ALREADY_EXISTS","raisePluginEvent","create","outcome","error","code","console","attemptPluginEvent","leased","ref","runTransaction","transaction","data","snapshot","get","exists","Number","leaseUntilMs","delivered","subscriberAttempts","result","skip","Set","Object","keys","failed","delete","exhausted","lastErrors","failedAtMs","drainPluginEvents","gate","due","where","orderBy","limit","scanned","size","retried","deadLettered","skippedLocked","skippedLeased","docs","String","isLocked","catch"],"mappings":"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"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to
19
+ * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development
20
+ * build's token), authenticated with a provider token, an ES256 JWT signed
21
+ * with the team's APNs key.
22
+ *
23
+ * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded
24
+ * APNS_KEY_ID the key's 10-character id
25
+ * APNS_TEAM_ID the Apple Developer team id
26
+ *
27
+ * Without all three the transport is off: a device on APNs is skipped and
28
+ * that is logged once per process. A provider token is reused for up to 50
29
+ * minutes (Apple refuses one older than an hour, and one refreshed more often
30
+ * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the
31
+ * token is gone, and the row is pruned.
32
+ *
33
+ * Server-only, imported by `push-delivery.ts` only when a device is on APNs.
34
+ */
35
+ import { type KeyObject } from 'node:crypto';
36
+ import type { PushMessage, PushOutcome, PushTransport } from './push-delivery';
37
+ export declare const APNS_HOSTS: {
38
+ readonly production: "https://api.push.apple.com";
39
+ readonly sandbox: "https://api.sandbox.push.apple.com";
40
+ };
41
+ /** A provider token is reused for at most this long. */
42
+ export declare const APNS_TOKEN_TTL_MS: number;
43
+ export interface ApnsCredentials {
44
+ key: KeyObject;
45
+ keyId: string;
46
+ teamId: string;
47
+ }
48
+ export interface ApnsResponse {
49
+ status: number;
50
+ body: string;
51
+ }
52
+ /** One HTTP/2 POST to an APNs origin; injectable for tests. */
53
+ export type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>;
54
+ /** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */
55
+ export declare function parseApnsKey(raw: string): KeyObject;
56
+ export declare function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null;
57
+ /** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */
58
+ export declare function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string;
59
+ /** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */
60
+ export declare function apnsPayload(message: PushMessage): string;
61
+ export declare function apnsOutcome(response: ApnsResponse): PushOutcome;
62
+ export interface ApnsTransportOptions {
63
+ credentials: ApnsCredentials;
64
+ /** Injected in tests; the default opens HTTP/2 sessions per send. */
65
+ request?: ApnsRequest;
66
+ now?: () => number;
67
+ }
68
+ export declare function createApnsTransport(options: ApnsTransportOptions): PushTransport;
69
+ /**
70
+ * The deployment's APNs transport from `APNS_*`, or null — logged once —
71
+ * when they are not set.
72
+ */
73
+ export declare function apnsTransport(env?: Record<string, string | undefined>): PushTransport | null;
@@ -0,0 +1,225 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to
19
+ * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development
20
+ * build's token), authenticated with a provider token, an ES256 JWT signed
21
+ * with the team's APNs key.
22
+ *
23
+ * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded
24
+ * APNS_KEY_ID the key's 10-character id
25
+ * APNS_TEAM_ID the Apple Developer team id
26
+ *
27
+ * Without all three the transport is off: a device on APNs is skipped and
28
+ * that is logged once per process. A provider token is reused for up to 50
29
+ * minutes (Apple refuses one older than an hour, and one refreshed more often
30
+ * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the
31
+ * token is gone, and the row is pruned.
32
+ *
33
+ * Server-only, imported by `push-delivery.ts` only when a device is on APNs.
34
+ */ import { createPrivateKey, sign } from "node:crypto";
35
+ import { connect } from "node:http2";
36
+ export const APNS_HOSTS = {
37
+ production: 'https://api.push.apple.com',
38
+ sandbox: 'https://api.sandbox.push.apple.com'
39
+ };
40
+ /** A provider token is reused for at most this long. */ export const APNS_TOKEN_TTL_MS = 50 * 60 * 1000;
41
+ /** Requests in flight at once on one connection. */ const CONCURRENCY = 20;
42
+ const base64url = (input)=>Buffer.from(input).toString('base64url');
43
+ /** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */ export function parseApnsKey(raw) {
44
+ const text = raw.trim().replace(/\\n/g, '\n');
45
+ if (text.includes('-----BEGIN')) return createPrivateKey(text);
46
+ const decoded = Buffer.from(text, 'base64');
47
+ const asText = decoded.toString('utf8');
48
+ if (asText.includes('-----BEGIN')) return createPrivateKey(asText);
49
+ return createPrivateKey({
50
+ key: decoded,
51
+ format: 'der',
52
+ type: 'pkcs8'
53
+ });
54
+ }
55
+ export function apnsCredentials(env) {
56
+ var _env_APNS_KEY_P8, _env_APNS_KEY_ID, _env_APNS_TEAM_ID;
57
+ const raw = (_env_APNS_KEY_P8 = env['APNS_KEY_P8']) == null ? void 0 : _env_APNS_KEY_P8.trim();
58
+ const keyId = (_env_APNS_KEY_ID = env['APNS_KEY_ID']) == null ? void 0 : _env_APNS_KEY_ID.trim();
59
+ const teamId = (_env_APNS_TEAM_ID = env['APNS_TEAM_ID']) == null ? void 0 : _env_APNS_TEAM_ID.trim();
60
+ if (!raw || !keyId || !teamId) return null;
61
+ return {
62
+ key: parseApnsKey(raw),
63
+ keyId,
64
+ teamId
65
+ };
66
+ }
67
+ /** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */ export function apnsProviderToken(credentials, nowMs) {
68
+ const header = base64url(JSON.stringify({
69
+ alg: 'ES256',
70
+ kid: credentials.keyId
71
+ }));
72
+ const claims = base64url(JSON.stringify({
73
+ iss: credentials.teamId,
74
+ iat: Math.floor(nowMs / 1000)
75
+ }));
76
+ const signature = sign('sha256', Buffer.from(`${header}.${claims}`), {
77
+ key: credentials.key,
78
+ dsaEncoding: 'ieee-p1363'
79
+ });
80
+ return `${header}.${claims}.${base64url(signature)}`;
81
+ }
82
+ /** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */ export function apnsPayload(message) {
83
+ return JSON.stringify(_extends({
84
+ aps: {
85
+ alert: _extends({
86
+ title: message.title
87
+ }, message.body ? {
88
+ body: message.body
89
+ } : {}),
90
+ sound: 'default'
91
+ }
92
+ }, message.data));
93
+ }
94
+ const GONE = new Set([
95
+ 'BadDeviceToken',
96
+ 'Unregistered',
97
+ 'DeviceTokenNotForTopic'
98
+ ]);
99
+ export function apnsOutcome(response) {
100
+ if (response.status === 200) return 'sent';
101
+ if (response.status === 410) return 'prune';
102
+ let reason = '';
103
+ try {
104
+ var _ref;
105
+ var _JSON_parse;
106
+ reason = String((_ref = (_JSON_parse = JSON.parse(response.body)) == null ? void 0 : _JSON_parse.reason) != null ? _ref : '');
107
+ } catch (unused) {
108
+ // An unreadable body is a failure, not a verdict on the token.
109
+ }
110
+ return GONE.has(reason) ? 'prune' : 'failed';
111
+ }
112
+ /** The default request: one HTTP/2 session per origin for the life of a send. */ function http2Requester() {
113
+ const sessions = new Map();
114
+ const session = (origin)=>{
115
+ let open = sessions.get(origin);
116
+ if (!open || open.closed || open.destroyed) {
117
+ open = connect(origin);
118
+ open.on('error', ()=>undefined);
119
+ sessions.set(origin, open);
120
+ }
121
+ return open;
122
+ };
123
+ const request = (origin, headers, body)=>new Promise((resolve, reject)=>{
124
+ const stream = session(origin).request(_extends({
125
+ ':method': 'POST'
126
+ }, headers));
127
+ let status = 0;
128
+ let text = '';
129
+ stream.setEncoding('utf8');
130
+ stream.setTimeout(15000, ()=>stream.close());
131
+ stream.on('response', (head)=>{
132
+ var _head_status;
133
+ status = Number((_head_status = head[':status']) != null ? _head_status : 0);
134
+ });
135
+ stream.on('data', (chunk)=>{
136
+ text += chunk;
137
+ });
138
+ stream.on('end', ()=>resolve({
139
+ status,
140
+ body: text
141
+ }));
142
+ stream.on('error', reject);
143
+ stream.end(body);
144
+ });
145
+ return {
146
+ request,
147
+ close: ()=>{
148
+ for (const open of sessions.values())open.close();
149
+ sessions.clear();
150
+ }
151
+ };
152
+ }
153
+ export function createApnsTransport(options) {
154
+ var _options_now;
155
+ const now = (_options_now = options.now) != null ? _options_now : Date.now;
156
+ let cached = null;
157
+ const providerToken = ()=>{
158
+ const at = now();
159
+ if (!cached || at - cached.at >= APNS_TOKEN_TTL_MS) cached = {
160
+ token: apnsProviderToken(options.credentials, at),
161
+ at
162
+ };
163
+ return cached.token;
164
+ };
165
+ return {
166
+ async send (targets, message) {
167
+ const { request, close } = options.request ? {
168
+ request: options.request,
169
+ close: ()=>undefined
170
+ } : http2Requester();
171
+ const body = apnsPayload(message);
172
+ const outcomes = new Array(targets.length).fill('failed');
173
+ try {
174
+ for(let start = 0; start < targets.length; start += CONCURRENCY){
175
+ const batch = targets.slice(start, start + CONCURRENCY);
176
+ await Promise.all(batch.map(async (target, offset)=>{
177
+ try {
178
+ var _target_apnsEnvironment;
179
+ const response = await request(APNS_HOSTS[(_target_apnsEnvironment = target.apnsEnvironment) != null ? _target_apnsEnvironment : 'production'], {
180
+ ':path': `/3/device/${target.token}`,
181
+ authorization: `bearer ${providerToken()}`,
182
+ 'apns-topic': target.topic,
183
+ 'apns-push-type': 'alert',
184
+ 'apns-priority': message.urgent ? '10' : '5',
185
+ 'content-type': 'application/json'
186
+ }, body);
187
+ // An expired or refused provider token is ours to mint again, not the device's fault.
188
+ if (response.status === 403) cached = null;
189
+ outcomes[start + offset] = apnsOutcome(response);
190
+ } catch (unused) {
191
+ outcomes[start + offset] = 'failed';
192
+ }
193
+ }));
194
+ }
195
+ } finally{
196
+ close();
197
+ }
198
+ return outcomes;
199
+ }
200
+ };
201
+ }
202
+ let warnedMissingKey = false;
203
+ let configured = null;
204
+ /**
205
+ * The deployment's APNs transport from `APNS_*`, or null — logged once —
206
+ * when they are not set.
207
+ */ export function apnsTransport(env = process.env) {
208
+ if (configured) return configured.transport;
209
+ const credentials = apnsCredentials(env);
210
+ if (!credentials) {
211
+ if (!warnedMissingKey) {
212
+ warnedMissingKey = true;
213
+ console.warn('push: APNS_KEY_P8, APNS_KEY_ID and APNS_TEAM_ID are not all set; iOS and macOS devices are skipped');
214
+ }
215
+ return null;
216
+ }
217
+ configured = {
218
+ transport: createApnsTransport({
219
+ credentials
220
+ })
221
+ };
222
+ return configured.transport;
223
+ }
224
+
225
+ //# sourceMappingURL=push-apns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-apns.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to\n * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development\n * build's token), authenticated with a provider token, an ES256 JWT signed\n * with the team's APNs key.\n *\n * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded\n * APNS_KEY_ID the key's 10-character id\n * APNS_TEAM_ID the Apple Developer team id\n *\n * Without all three the transport is off: a device on APNs is skipped and\n * that is logged once per process. A provider token is reused for up to 50\n * minutes (Apple refuses one older than an hour, and one refreshed more often\n * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the\n * token is gone, and the row is pruned.\n *\n * Server-only, imported by `push-delivery.ts` only when a device is on APNs.\n */\n\nimport { createPrivateKey, sign, type KeyObject } from 'node:crypto'\nimport { connect, type ClientHttp2Session } from 'node:http2'\nimport type { PushMessage, PushOutcome, PushTarget, PushTransport } from './push-delivery'\n\nexport const APNS_HOSTS = {\n production: 'https://api.push.apple.com',\n sandbox: 'https://api.sandbox.push.apple.com',\n} as const\n\n/** A provider token is reused for at most this long. */\nexport const APNS_TOKEN_TTL_MS = 50 * 60 * 1000\n\n/** Requests in flight at once on one connection. */\nconst CONCURRENCY = 20\n\nexport interface ApnsCredentials {\n key: KeyObject\n keyId: string\n teamId: string\n}\n\nexport interface ApnsResponse {\n status: number\n body: string\n}\n\n/** One HTTP/2 POST to an APNs origin; injectable for tests. */\nexport type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>\n\nconst base64url = (input: Buffer | string) => Buffer.from(input).toString('base64url')\n\n/** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */\nexport function parseApnsKey(raw: string): KeyObject {\n const text = raw.trim().replace(/\\\\n/g, '\\n')\n if (text.includes('-----BEGIN')) return createPrivateKey(text)\n const decoded = Buffer.from(text, 'base64')\n const asText = decoded.toString('utf8')\n if (asText.includes('-----BEGIN')) return createPrivateKey(asText)\n return createPrivateKey({ key: decoded, format: 'der', type: 'pkcs8' })\n}\n\nexport function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null {\n const raw = env['APNS_KEY_P8']?.trim()\n const keyId = env['APNS_KEY_ID']?.trim()\n const teamId = env['APNS_TEAM_ID']?.trim()\n if (!raw || !keyId || !teamId) return null\n return { key: parseApnsKey(raw), keyId, teamId }\n}\n\n/** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */\nexport function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: credentials.keyId }))\n const claims = base64url(JSON.stringify({ iss: credentials.teamId, iat: Math.floor(nowMs / 1000) }))\n const signature = sign('sha256', Buffer.from(`${header}.${claims}`), {\n key: credentials.key,\n dsaEncoding: 'ieee-p1363',\n })\n return `${header}.${claims}.${base64url(signature)}`\n}\n\n/** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */\nexport function apnsPayload(message: PushMessage): string {\n return JSON.stringify({\n aps: {\n alert: { title: message.title, ...(message.body ? { body: message.body } : {}) },\n sound: 'default',\n },\n ...message.data,\n })\n}\n\nconst GONE = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])\n\nexport function apnsOutcome(response: ApnsResponse): PushOutcome {\n if (response.status === 200) return 'sent'\n if (response.status === 410) return 'prune'\n let reason = ''\n try {\n reason = String(JSON.parse(response.body)?.reason ?? '')\n } catch {\n // An unreadable body is a failure, not a verdict on the token.\n }\n return GONE.has(reason) ? 'prune' : 'failed'\n}\n\n/** The default request: one HTTP/2 session per origin for the life of a send. */\nfunction http2Requester(): { request: ApnsRequest; close: () => void } {\n const sessions = new Map<string, ClientHttp2Session>()\n const session = (origin: string) => {\n let open = sessions.get(origin)\n if (!open || open.closed || open.destroyed) {\n open = connect(origin)\n open.on('error', () => undefined)\n sessions.set(origin, open)\n }\n return open\n }\n const request: ApnsRequest = (origin, headers, body) =>\n new Promise((resolve, reject) => {\n const stream = session(origin).request({ ':method': 'POST', ...headers })\n let status = 0\n let text = ''\n stream.setEncoding('utf8')\n stream.setTimeout(15_000, () => stream.close())\n stream.on('response', (head) => {\n status = Number(head[':status'] ?? 0)\n })\n stream.on('data', (chunk: string) => {\n text += chunk\n })\n stream.on('end', () => resolve({ status, body: text }))\n stream.on('error', reject)\n stream.end(body)\n })\n return {\n request,\n close: () => {\n for (const open of sessions.values()) open.close()\n sessions.clear()\n },\n }\n}\n\nexport interface ApnsTransportOptions {\n credentials: ApnsCredentials\n /** Injected in tests; the default opens HTTP/2 sessions per send. */\n request?: ApnsRequest\n now?: () => number\n}\n\nexport function createApnsTransport(options: ApnsTransportOptions): PushTransport {\n const now = options.now ?? Date.now\n let cached: { token: string; at: number } | null = null\n const providerToken = () => {\n const at = now()\n if (!cached || at - cached.at >= APNS_TOKEN_TTL_MS) cached = { token: apnsProviderToken(options.credentials, at), at }\n return cached.token\n }\n return {\n async send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]> {\n const { request, close } = options.request\n ? { request: options.request, close: () => undefined }\n : http2Requester()\n const body = apnsPayload(message)\n const outcomes: PushOutcome[] = new Array(targets.length).fill('failed')\n try {\n for (let start = 0; start < targets.length; start += CONCURRENCY) {\n const batch = targets.slice(start, start + CONCURRENCY)\n await Promise.all(\n batch.map(async (target, offset) => {\n try {\n const response = await request(\n APNS_HOSTS[target.apnsEnvironment ?? 'production'],\n {\n ':path': `/3/device/${target.token}`,\n authorization: `bearer ${providerToken()}`,\n 'apns-topic': target.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': message.urgent ? '10' : '5',\n 'content-type': 'application/json',\n },\n body,\n )\n // An expired or refused provider token is ours to mint again, not the device's fault.\n if (response.status === 403) cached = null\n outcomes[start + offset] = apnsOutcome(response)\n } catch {\n outcomes[start + offset] = 'failed'\n }\n }),\n )\n }\n } finally {\n close()\n }\n return outcomes\n },\n }\n}\n\nlet warnedMissingKey = false\nlet configured: { transport: PushTransport } | null = null\n\n/**\n * The deployment's APNs transport from `APNS_*`, or null — logged once —\n * when they are not set.\n */\nexport function apnsTransport(env: Record<string, string | undefined> = process.env): PushTransport | null {\n if (configured) return configured.transport\n const credentials = apnsCredentials(env)\n if (!credentials) {\n if (!warnedMissingKey) {\n warnedMissingKey = true\n console.warn('push: APNS_KEY_P8, APNS_KEY_ID and APNS_TEAM_ID are not all set; iOS and macOS devices are skipped')\n }\n return null\n }\n configured = { transport: createApnsTransport({ credentials }) }\n return configured.transport\n}\n"],"names":["createPrivateKey","sign","connect","APNS_HOSTS","production","sandbox","APNS_TOKEN_TTL_MS","CONCURRENCY","base64url","input","Buffer","from","toString","parseApnsKey","raw","text","trim","replace","includes","decoded","asText","key","format","type","apnsCredentials","env","keyId","teamId","apnsProviderToken","credentials","nowMs","header","JSON","stringify","alg","kid","claims","iss","iat","Math","floor","signature","dsaEncoding","apnsPayload","message","aps","alert","title","body","sound","data","GONE","Set","apnsOutcome","response","status","reason","String","parse","has","http2Requester","sessions","Map","session","origin","open","get","closed","destroyed","on","undefined","set","request","headers","Promise","resolve","reject","stream","setEncoding","setTimeout","close","head","Number","chunk","end","values","clear","createApnsTransport","options","now","Date","cached","providerToken","at","token","send","targets","outcomes","Array","length","fill","start","batch","slice","all","map","target","offset","apnsEnvironment","authorization","topic","urgent","warnedMissingKey","configured","apnsTransport","process","transport","console","warn"],"mappings":"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"}