@aglyn/tenant-data-admin 1.0.0-beta.229 → 1.0.0-beta.231

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 (41) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/duplicate-resource.js +4 -1
  3. package/src/lib/server/duplicate-resource.js.map +1 -1
  4. package/src/lib/server/erase.js +14 -0
  5. package/src/lib/server/erase.js.map +1 -1
  6. package/src/lib/server/host-released-names.d.ts +22 -0
  7. package/src/lib/server/host-released-names.js +31 -0
  8. package/src/lib/server/host-released-names.js.map +1 -0
  9. package/src/lib/server/host-visitor-records.d.ts +1 -1
  10. package/src/lib/server/host-visitor-records.js +1 -1
  11. package/src/lib/server/host-visitor-records.js.map +1 -1
  12. package/src/lib/server/mobile-push-switch.d.ts +46 -0
  13. package/src/lib/server/mobile-push-switch.js +45 -0
  14. package/src/lib/server/mobile-push-switch.js.map +1 -0
  15. package/src/lib/server/native-push.d.ts +17 -0
  16. package/src/lib/server/native-push.js +35 -0
  17. package/src/lib/server/native-push.js.map +1 -0
  18. package/src/lib/server/notifications.js +23 -0
  19. package/src/lib/server/notifications.js.map +1 -1
  20. package/src/lib/server/personal-data-export.js +108 -0
  21. package/src/lib/server/personal-data-export.js.map +1 -1
  22. package/src/lib/server/plugin-event-outbox.d.ts +138 -0
  23. package/src/lib/server/plugin-event-outbox.js +229 -0
  24. package/src/lib/server/plugin-event-outbox.js.map +1 -0
  25. package/src/lib/server/push-apns.d.ts +73 -0
  26. package/src/lib/server/push-apns.js +225 -0
  27. package/src/lib/server/push-apns.js.map +1 -0
  28. package/src/lib/server/push-delivery.d.ts +78 -0
  29. package/src/lib/server/push-delivery.js +176 -0
  30. package/src/lib/server/push-delivery.js.map +1 -0
  31. package/src/lib/server/push-fcm.d.ts +29 -0
  32. package/src/lib/server/push-fcm.js +83 -0
  33. package/src/lib/server/push-fcm.js.map +1 -0
  34. package/src/lib/server/serve-media-cdn.d.ts +6 -0
  35. package/src/lib/server/serve-media-cdn.js +20 -3
  36. package/src/lib/server/serve-media-cdn.js.map +1 -1
  37. package/src/lib/server/site-marketing-sync.d.ts +146 -0
  38. package/src/lib/server/site-marketing-sync.js +315 -0
  39. package/src/lib/server/site-marketing-sync.js.map +1 -0
  40. package/src/lib/server/upsert-contact.js +6 -5
  41. package/src/lib/server/upsert-contact.js.map +1 -1
@@ -0,0 +1,138 @@
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
+ import { type PluginDomainEvent } from '@aglyn/aglyn/plugin-manager/plugin-domain-events';
18
+ /**
19
+ * The outbox behind plugin events (AGL-3611): raising an event is a write
20
+ * here, and a scheduled drain delivers it. See `plugin-domain-events.ts` in
21
+ * core for the contract a raiser and a subscriber see.
22
+ *
23
+ * WHY AN OUTBOX. A fact a plugin reports — an order paid, a return refunded —
24
+ * must reach accounting, shipping and the merchant's webhooks even when one of
25
+ * them is down, and even when the process that made the write dies a moment
26
+ * later. Calling the subscribers in the request loses the event on either.
27
+ * Writing it here, in the raiser's own transaction where it has one, makes
28
+ * the event exist exactly when the fact does.
29
+ *
30
+ * AT LEAST ONCE, PER SUBSCRIBER. The document records which subscribers
31
+ * took the event (`delivered`), and a retry calls only the rest. A
32
+ * subscriber can still see an event twice — its handler succeeded and the
33
+ * write recording that did not — so the envelope's `id` is the key it
34
+ * dedupes on.
35
+ *
36
+ * ONE DRAIN AT A TIME PER EVENT. A pass leases the document for a minute in
37
+ * a transaction before it delivers, so two overlapping beats do not both
38
+ * deliver it. A pass that dies holding the lease is retried once the lease
39
+ * lapses.
40
+ *
41
+ * A delivered event is deleted: the collection holds what is still owed, so
42
+ * the drain's query reads nothing on an idle platform. One that runs out of
43
+ * attempts stays, `failed`, with each subscriber's last error.
44
+ */
45
+ export declare const PLUGIN_EVENT_OUTBOX_COLLECTION = "pluginEventOutbox";
46
+ /** Attempts per event before it is left as a dead letter. */
47
+ export declare const PLUGIN_EVENT_MAX_ATTEMPTS = 8;
48
+ /** Wait before attempt n+1, by attempts made; the last step repeats. */
49
+ export declare const PLUGIN_EVENT_BACKOFF_MS: readonly number[];
50
+ /** How long a drain pass holds an event while it delivers. */
51
+ export declare const PLUGIN_EVENT_LEASE_MS = 60000;
52
+ /** Events one pass reads. */
53
+ export declare const PLUGIN_EVENT_SCAN_LIMIT = 100;
54
+ export type PluginEventOutboxStatus = 'pending' | 'failed';
55
+ /** `pluginEventOutbox/{id}`. Written and read only by the Admin SDK. */
56
+ export interface PluginEventOutboxRecord {
57
+ status: PluginEventOutboxStatus;
58
+ event: string;
59
+ /** The plugin that raised it. */
60
+ pluginId: string;
61
+ hostId: string;
62
+ orgId: string | null;
63
+ occurredAtMs: number;
64
+ payload: Record<string, unknown>;
65
+ attempts: number;
66
+ nextAttemptAtMs: number;
67
+ createdAtMs: number;
68
+ /** Subscribers that took it, by plugin id, when. */
69
+ delivered?: Record<string, number>;
70
+ /** Attempts per subscriber, for the envelope's `attempt`. */
71
+ subscriberAttempts?: Record<string, number>;
72
+ /** Each failing subscriber's last error. */
73
+ lastErrors?: Record<string, string>;
74
+ leaseUntilMs?: number;
75
+ failedAtMs?: number;
76
+ }
77
+ export declare function pluginEventBackoffMs(attempts: number): number;
78
+ /**
79
+ * The event's document id: the same raise twice is the same document. `key`
80
+ * names the occurrence (a fulfillment id, a refund id); without one every
81
+ * raise is new.
82
+ */
83
+ export declare function pluginEventId(hostId: string, event: string, key: string): string;
84
+ /** A transaction or a batch: anything that can `set` a document. */
85
+ export interface PluginEventWriter {
86
+ set(ref: FirebaseFirestore.DocumentReference, data: FirebaseFirestore.DocumentData): unknown;
87
+ }
88
+ export interface RaisePluginEventRequest<Payload> {
89
+ event: PluginDomainEvent<Payload> | string;
90
+ /** The raising plugin's id. */
91
+ pluginId: string;
92
+ hostId: string;
93
+ orgId?: string | null;
94
+ payload: Payload;
95
+ /** The occurrence: the same key raises the same event once. */
96
+ key: string;
97
+ occurredAtMs?: number;
98
+ }
99
+ /**
100
+ * Raises an event INSIDE the caller's transaction or batch, so it is written
101
+ * exactly when the fact is. The write is a `set` (a transaction cannot read
102
+ * after it writes), so the caller's own "already done" guard is what keeps a
103
+ * retry from raising it twice — and the id is keyed, so if it does, the
104
+ * event is the same document. Returns the id.
105
+ */
106
+ export declare function stagePluginEvent<Payload>(writer: PluginEventWriter, firestore: FirebaseFirestore.Firestore, request: RaisePluginEventRequest<Payload>, now?: number): string;
107
+ /**
108
+ * Raises an event after the fact is already written, for a raiser with no
109
+ * transaction to ride. `create`, so the same key raised twice — a webhook
110
+ * redelivered — is one event: the second answers `exists`.
111
+ */
112
+ export declare function raisePluginEvent<Payload>(firestore: FirebaseFirestore.Firestore, request: RaisePluginEventRequest<Payload>, now?: number): Promise<{
113
+ id: string;
114
+ outcome: 'queued' | 'exists' | 'failed';
115
+ }>;
116
+ export interface PluginEventDrainResult {
117
+ scanned: number;
118
+ delivered: number;
119
+ retried: number;
120
+ deadLettered: number;
121
+ skippedLocked: number;
122
+ skippedLeased: number;
123
+ }
124
+ type AttemptOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'leased';
125
+ /**
126
+ * Delivers one event: lease, hand it to the subscribers still owed it, and
127
+ * record what happened. Exported for a raiser that wants an event out now
128
+ * rather than on the next beat; the drain is the backstop either way.
129
+ */
130
+ export declare function attemptPluginEvent(firestore: FirebaseFirestore.Firestore, id: string, now?: number): Promise<AttemptOutcome>;
131
+ /**
132
+ * One beat of the drain: the events owed now, oldest first. A locked site's
133
+ * events wait, untouched, for the lock to lift.
134
+ */
135
+ export declare function drainPluginEvents(gate: {
136
+ isLocked(hostId: string): Promise<boolean>;
137
+ }, firestore: FirebaseFirestore.Firestore, now?: number): Promise<PluginEventDrainResult>;
138
+ export {};
@@ -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;