@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,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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"}
@@ -0,0 +1,78 @@
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
+ * The native apps' push delivery (AGL-3651): a notification the fan-out
19
+ * wrote goes to each recipient's registered devices, grouped by transport —
20
+ * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).
21
+ *
22
+ * Server-only. `registerNativePushSenders` (`native-push.ts`) registers
23
+ * `nativePushSender` with the fan-out and imports this file only when a
24
+ * notification has push recipients, and this file imports each transport
25
+ * only when a device needs it.
26
+ *
27
+ * A row the registry no longer accepts is pruned here: an Expo row from the
28
+ * React Native app, a row with no transport, a token that does not fit its
29
+ * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a
30
+ * transport reports as gone is pruned after the send.
31
+ */
32
+ import { type ApnsEnvironment, type MobileDeviceApp, type MobilePushData, type MobilePushTransport } from '@aglyn/aglyn/app-utils/notification-push';
33
+ import type { Firestore } from 'firebase-admin/firestore';
34
+ import type { MobilePushPayload, MobilePushSender } from './mobile-push-switch';
35
+ /** One device a transport sends to. */
36
+ export interface PushTarget {
37
+ token: string;
38
+ app: MobileDeviceApp;
39
+ /** APNs: the app's bundle id, which is the push topic. */
40
+ topic: string;
41
+ apnsEnvironment?: ApnsEnvironment;
42
+ }
43
+ /** What every device of one notification is sent. */
44
+ export interface PushMessage {
45
+ title: string;
46
+ body?: string;
47
+ data: MobilePushData;
48
+ /** Critical and warning notifications ask the platform to deliver now. */
49
+ urgent: boolean;
50
+ }
51
+ /** A transport's verdict for each target, in order. `prune`: the token is gone; forget the row. */
52
+ export type PushOutcome = 'sent' | 'prune' | 'failed';
53
+ export interface PushTransport {
54
+ send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]>;
55
+ }
56
+ export interface PushDeliveryDeps {
57
+ db: Firestore;
58
+ /** Each transport, or null when the deployment has none configured. Resolved only when a device needs it. */
59
+ transport: (kind: MobilePushTransport) => Promise<PushTransport | null>;
60
+ now?: () => number;
61
+ }
62
+ export interface PushDeliveryResult {
63
+ sent: number;
64
+ /** Rows removed: stale, Expo or malformed rows, and tokens a transport reported gone. */
65
+ pruned: number;
66
+ failed: number;
67
+ /** Devices not sent to because their transport is not configured. */
68
+ skipped: number;
69
+ }
70
+ export declare function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage;
71
+ /**
72
+ * Sends one notification to every live device of `uids`, each through its
73
+ * own transport. Never throws: the console notification is the record, and a
74
+ * push is a courtesy beside it.
75
+ */
76
+ export declare function deliverNativePush(uids: readonly string[], payload: MobilePushPayload, deps: PushDeliveryDeps): Promise<PushDeliveryResult>;
77
+ /** The sender `registerNativePushSenders` hands the fan-out. */
78
+ export declare const nativePushSender: MobilePushSender;
@@ -0,0 +1,176 @@
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
+ * The native apps' push delivery (AGL-3651): a notification the fan-out
19
+ * wrote goes to each recipient's registered devices, grouped by transport —
20
+ * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).
21
+ *
22
+ * Server-only. `registerNativePushSenders` (`native-push.ts`) registers
23
+ * `nativePushSender` with the fan-out and imports this file only when a
24
+ * notification has push recipients, and this file imports each transport
25
+ * only when a device needs it.
26
+ *
27
+ * A row the registry no longer accepts is pruned here: an Expo row from the
28
+ * React Native app, a row with no transport, a token that does not fit its
29
+ * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a
30
+ * transport reports as gone is pruned after the send.
31
+ */ import { isMobilePushToken, MOBILE_APP_BUNDLES, MOBILE_DEVICE_STALE_MS, MOBILE_DEVICES_COLLECTION, MOBILE_DEVICES_PER_USER, mobilePushData } from "@aglyn/aglyn/app-utils/notification-push";
32
+ import { normalizeNotificationLink } from "@aglyn/aglyn/server";
33
+ /** The most devices one notification is sent to, whatever the audience. */ const MAX_DEVICES = 1000;
34
+ /** A stored timestamp as epoch millis, or null when there is none. */ function millis(value) {
35
+ if (!value) return null;
36
+ if (typeof value === 'number') return value;
37
+ const withMillis = value;
38
+ return typeof withMillis.toMillis === 'function' ? withMillis.toMillis() : null;
39
+ }
40
+ export function pushMessage(payload, link) {
41
+ return _extends({
42
+ title: payload.title
43
+ }, payload.body ? {
44
+ body: payload.body
45
+ } : {}, {
46
+ data: mobilePushData(_extends({}, payload, {
47
+ link
48
+ })),
49
+ urgent: payload.level === 'critical' || payload.level === 'warning'
50
+ });
51
+ }
52
+ /** The link the app opens: the stored one, rewritten onto today's console routes. */ async function pushLink(db, payload) {
53
+ if (!payload.link) return undefined;
54
+ if (!payload.orgId) return normalizeNotificationLink(payload.link, {});
55
+ try {
56
+ var _payload_hostId;
57
+ const [org, host] = await Promise.all([
58
+ db.collection('orgs').doc(payload.orgId).get(),
59
+ payload.hostId ? db.collection('hosts').doc(payload.hostId).get() : Promise.resolve(null)
60
+ ]);
61
+ const slug = org.get('slug');
62
+ const subdomain = host == null ? void 0 : host.get('subdomain');
63
+ return normalizeNotificationLink(payload.link, {
64
+ orgSlug: typeof slug === 'string' ? slug : null,
65
+ hostId: (_payload_hostId = payload.hostId) != null ? _payload_hostId : null,
66
+ hostSubdomain: typeof subdomain === 'string' ? subdomain : null
67
+ });
68
+ } catch (unused) {
69
+ return normalizeNotificationLink(payload.link, {});
70
+ }
71
+ }
72
+ const isApp = (value)=>value === 'aglyn' || value === 'aglyn-pos';
73
+ /**
74
+ * Sends one notification to every live device of `uids`, each through its
75
+ * own transport. Never throws: the console notification is the record, and a
76
+ * push is a courtesy beside it.
77
+ */ export async function deliverNativePush(uids, payload, deps) {
78
+ var _deps_now;
79
+ const result = {
80
+ sent: 0,
81
+ pruned: 0,
82
+ failed: 0,
83
+ skipped: 0
84
+ };
85
+ if (!uids.length) return result;
86
+ const now = ((_deps_now = deps.now) != null ? _deps_now : Date.now)();
87
+ const prune = async (ref)=>{
88
+ await ref.delete().catch(()=>undefined);
89
+ result.pruned += 1;
90
+ };
91
+ try {
92
+ const { db } = deps;
93
+ const groups = new Map();
94
+ const seen = new Set();
95
+ let total = 0;
96
+ for (const uid of uids){
97
+ const devices = await db.collection('users').doc(uid).collection(MOBILE_DEVICES_COLLECTION).limit(MOBILE_DEVICES_PER_USER).get();
98
+ for (const device of devices.docs){
99
+ var _groups_get;
100
+ const transport = device.get('transport');
101
+ const token = device.get('token');
102
+ const app = device.get('app');
103
+ if (!isMobilePushToken(transport, token) || !isApp(app)) {
104
+ await prune(device.ref);
105
+ continue;
106
+ }
107
+ const lastSeen = millis(device.get('lastSeen'));
108
+ if (lastSeen !== null && now - lastSeen > MOBILE_DEVICE_STALE_MS) {
109
+ await prune(device.ref);
110
+ continue;
111
+ }
112
+ if (seen.has(token) || total >= MAX_DEVICES) continue;
113
+ seen.add(token);
114
+ total += 1;
115
+ const environment = device.get('apnsEnvironment');
116
+ const target = _extends({
117
+ token,
118
+ app,
119
+ topic: MOBILE_APP_BUNDLES[app]
120
+ }, transport === 'apns' && (environment === 'sandbox' || environment === 'production') ? {
121
+ apnsEnvironment: environment
122
+ } : {});
123
+ const group = (_groups_get = groups.get(transport)) != null ? _groups_get : [];
124
+ group.push({
125
+ target,
126
+ ref: device.ref
127
+ });
128
+ groups.set(transport, group);
129
+ }
130
+ }
131
+ if (!groups.size) return result;
132
+ const message = pushMessage(payload, await pushLink(db, payload));
133
+ for (const [kind, entries] of groups){
134
+ let transport = null;
135
+ try {
136
+ transport = await deps.transport(kind);
137
+ } catch (error) {
138
+ console.error(`push transport ${kind} failed to load`, error);
139
+ }
140
+ if (!transport) {
141
+ result.skipped += entries.length;
142
+ continue;
143
+ }
144
+ let outcomes;
145
+ try {
146
+ outcomes = await transport.send(entries.map((entry)=>entry.target), message);
147
+ } catch (error) {
148
+ console.error(`push transport ${kind} failed`, error);
149
+ result.failed += entries.length;
150
+ continue;
151
+ }
152
+ for (const [index, entry] of entries.entries()){
153
+ var _outcomes_index;
154
+ const outcome = (_outcomes_index = outcomes[index]) != null ? _outcomes_index : 'failed';
155
+ if (outcome === 'sent') result.sent += 1;
156
+ else if (outcome === 'prune') await prune(entry.ref);
157
+ else result.failed += 1;
158
+ }
159
+ }
160
+ } catch (error) {
161
+ console.error('push delivery failed', error);
162
+ }
163
+ return result;
164
+ }
165
+ /** The deployment's transports, each imported the first time a device needs it. */ async function deploymentTransport(kind) {
166
+ if (kind === 'apns') return (await import("./push-apns.js")).apnsTransport();
167
+ return (await import("./push-fcm.js")).fcmTransport();
168
+ }
169
+ /** The sender `registerNativePushSenders` hands the fan-out. */ export const nativePushSender = async (uids, payload, { db })=>{
170
+ await deliverNativePush(uids, payload, {
171
+ db,
172
+ transport: deploymentTransport
173
+ });
174
+ };
175
+
176
+ //# sourceMappingURL=push-delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-delivery.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 * The native apps' push delivery (AGL-3651): a notification the fan-out\n * wrote goes to each recipient's registered devices, grouped by transport —\n * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).\n *\n * Server-only. `registerNativePushSenders` (`native-push.ts`) registers\n * `nativePushSender` with the fan-out and imports this file only when a\n * notification has push recipients, and this file imports each transport\n * only when a device needs it.\n *\n * A row the registry no longer accepts is pruned here: an Expo row from the\n * React Native app, a row with no transport, a token that does not fit its\n * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a\n * transport reports as gone is pruned after the send.\n */\n\nimport {\n isMobilePushToken,\n MOBILE_APP_BUNDLES,\n MOBILE_DEVICE_STALE_MS,\n MOBILE_DEVICES_COLLECTION,\n MOBILE_DEVICES_PER_USER,\n mobilePushData,\n type ApnsEnvironment,\n type MobileDeviceApp,\n type MobilePushData,\n type MobilePushTransport,\n} from '@aglyn/aglyn/app-utils/notification-push'\nimport { normalizeNotificationLink } from '@aglyn/aglyn/server'\nimport type { DocumentReference, Firestore } from 'firebase-admin/firestore'\nimport type { MobilePushPayload, MobilePushSender } from './mobile-push-switch'\n\n/** The most devices one notification is sent to, whatever the audience. */\nconst MAX_DEVICES = 1000\n\n/** One device a transport sends to. */\nexport interface PushTarget {\n token: string\n app: MobileDeviceApp\n /** APNs: the app's bundle id, which is the push topic. */\n topic: string\n apnsEnvironment?: ApnsEnvironment\n}\n\n/** What every device of one notification is sent. */\nexport interface PushMessage {\n title: string\n body?: string\n data: MobilePushData\n /** Critical and warning notifications ask the platform to deliver now. */\n urgent: boolean\n}\n\n/** A transport's verdict for each target, in order. `prune`: the token is gone; forget the row. */\nexport type PushOutcome = 'sent' | 'prune' | 'failed'\n\nexport interface PushTransport {\n send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]>\n}\n\nexport interface PushDeliveryDeps {\n db: Firestore\n /** Each transport, or null when the deployment has none configured. Resolved only when a device needs it. */\n transport: (kind: MobilePushTransport) => Promise<PushTransport | null>\n now?: () => number\n}\n\nexport interface PushDeliveryResult {\n sent: number\n /** Rows removed: stale, Expo or malformed rows, and tokens a transport reported gone. */\n pruned: number\n failed: number\n /** Devices not sent to because their transport is not configured. */\n skipped: number\n}\n\n/** A stored timestamp as epoch millis, or null when there is none. */\nfunction millis(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return value\n const withMillis = value as { toMillis?: () => number }\n return typeof withMillis.toMillis === 'function' ? withMillis.toMillis() : null\n}\n\nexport function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage {\n return {\n title: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n data: mobilePushData({ ...payload, link }),\n urgent: payload.level === 'critical' || payload.level === 'warning',\n }\n}\n\n/** The link the app opens: the stored one, rewritten onto today's console routes. */\nasync function pushLink(db: Firestore, payload: MobilePushPayload): Promise<string | undefined> {\n if (!payload.link) return undefined\n if (!payload.orgId) return normalizeNotificationLink(payload.link, {})\n try {\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId ? db.collection('hosts').doc(payload.hostId).get() : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return normalizeNotificationLink(payload.link, {\n orgSlug: typeof slug === 'string' ? slug : null,\n hostId: payload.hostId ?? null,\n hostSubdomain: typeof subdomain === 'string' ? subdomain : null,\n })\n } catch {\n return normalizeNotificationLink(payload.link, {})\n }\n}\n\nconst isApp = (value: unknown): value is MobileDeviceApp => value === 'aglyn' || value === 'aglyn-pos'\n\n/**\n * Sends one notification to every live device of `uids`, each through its\n * own transport. Never throws: the console notification is the record, and a\n * push is a courtesy beside it.\n */\nexport async function deliverNativePush(\n uids: readonly string[],\n payload: MobilePushPayload,\n deps: PushDeliveryDeps,\n): Promise<PushDeliveryResult> {\n const result: PushDeliveryResult = { sent: 0, pruned: 0, failed: 0, skipped: 0 }\n if (!uids.length) return result\n const now = (deps.now ?? Date.now)()\n const prune = async (ref: DocumentReference) => {\n await ref.delete().catch(() => undefined)\n result.pruned += 1\n }\n try {\n const { db } = deps\n const groups = new Map<MobilePushTransport, Array<{ target: PushTarget; ref: DocumentReference }>>()\n const seen = new Set<string>()\n let total = 0\n for (const uid of uids) {\n const devices = await db\n .collection('users')\n .doc(uid)\n .collection(MOBILE_DEVICES_COLLECTION)\n .limit(MOBILE_DEVICES_PER_USER)\n .get()\n for (const device of devices.docs) {\n const transport = device.get('transport')\n const token = device.get('token')\n const app = device.get('app')\n if (!isMobilePushToken(transport, token) || !isApp(app)) {\n await prune(device.ref)\n continue\n }\n const lastSeen = millis(device.get('lastSeen'))\n if (lastSeen !== null && now - lastSeen > MOBILE_DEVICE_STALE_MS) {\n await prune(device.ref)\n continue\n }\n if (seen.has(token) || total >= MAX_DEVICES) continue\n seen.add(token)\n total += 1\n const environment = device.get('apnsEnvironment')\n const target: PushTarget = {\n token,\n app,\n topic: MOBILE_APP_BUNDLES[app],\n ...(transport === 'apns' && (environment === 'sandbox' || environment === 'production')\n ? { apnsEnvironment: environment }\n : {}),\n }\n const group = groups.get(transport) ?? []\n group.push({ target, ref: device.ref })\n groups.set(transport, group)\n }\n }\n if (!groups.size) return result\n const message = pushMessage(payload, await pushLink(db, payload))\n for (const [kind, entries] of groups) {\n let transport: PushTransport | null = null\n try {\n transport = await deps.transport(kind)\n } catch (error) {\n console.error(`push transport ${kind} failed to load`, error)\n }\n if (!transport) {\n result.skipped += entries.length\n continue\n }\n let outcomes: PushOutcome[]\n try {\n outcomes = await transport.send(\n entries.map((entry) => entry.target),\n message,\n )\n } catch (error) {\n console.error(`push transport ${kind} failed`, error)\n result.failed += entries.length\n continue\n }\n for (const [index, entry] of entries.entries()) {\n const outcome = outcomes[index] ?? 'failed'\n if (outcome === 'sent') result.sent += 1\n else if (outcome === 'prune') await prune(entry.ref)\n else result.failed += 1\n }\n }\n } catch (error) {\n console.error('push delivery failed', error)\n }\n return result\n}\n\n/** The deployment's transports, each imported the first time a device needs it. */\nasync function deploymentTransport(kind: MobilePushTransport): Promise<PushTransport | null> {\n if (kind === 'apns') return (await import('./push-apns')).apnsTransport()\n return (await import('./push-fcm')).fcmTransport()\n}\n\n/** The sender `registerNativePushSenders` hands the fan-out. */\nexport const nativePushSender: MobilePushSender = async (uids, payload, { db }) => {\n await deliverNativePush(uids, payload, { db, transport: deploymentTransport })\n}\n"],"names":["isMobilePushToken","MOBILE_APP_BUNDLES","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_COLLECTION","MOBILE_DEVICES_PER_USER","mobilePushData","normalizeNotificationLink","MAX_DEVICES","millis","value","withMillis","toMillis","pushMessage","payload","link","title","body","data","urgent","level","pushLink","db","undefined","orgId","org","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","orgSlug","hostSubdomain","isApp","deliverNativePush","uids","deps","result","sent","pruned","failed","skipped","length","now","Date","prune","ref","delete","catch","groups","Map","seen","Set","total","uid","devices","limit","device","docs","transport","token","app","lastSeen","has","add","environment","target","topic","apnsEnvironment","group","push","set","size","message","kind","entries","error","console","outcomes","send","map","entry","index","outcome","deploymentTransport","apnsTransport","fcmTransport","nativePushSender"],"mappings":"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"}
@@ -0,0 +1,29 @@
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 BatchResponse, type TokenMessage } from 'firebase-admin/messaging';
18
+ import './firebase-admin';
19
+ import type { PushMessage, PushTransport } from './push-delivery';
20
+ /** `sendEach` takes at most 500 messages. */
21
+ export declare const FCM_BATCH = 500;
22
+ /** The slice of firebase-admin's Messaging this transport uses; injected in tests. */
23
+ export interface FcmMessaging {
24
+ sendEach(messages: TokenMessage[]): Promise<BatchResponse>;
25
+ }
26
+ export declare function fcmMessage(token: string, message: PushMessage): TokenMessage;
27
+ export declare function createFcmTransport(messaging: FcmMessaging): PushTransport;
28
+ /** The deployment's FCM transport, on the firebase-admin default app. */
29
+ export declare function fcmTransport(): PushTransport;
@@ -0,0 +1,83 @@
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
+ * Firebase Cloud Messaging for the Android app (AGL-3651), through
19
+ * firebase-admin's `getMessaging().sendEach` with the project credentials
20
+ * the server already holds, so it needs no secret of its own. The
21
+ * notification is shown by the system; the tap data rides in `data`, and
22
+ * Android delivers it at high priority.
23
+ *
24
+ * A token FCM reports unregistered or invalid is gone, and the row is pruned.
25
+ *
26
+ * Server-only, imported by `push-delivery.ts` only when a device is on FCM.
27
+ */ import { getApp } from "firebase-admin/app";
28
+ import { getMessaging } from "firebase-admin/messaging";
29
+ import "./firebase-admin.js";
30
+ /** `sendEach` takes at most 500 messages. */ export const FCM_BATCH = 500;
31
+ const GONE = new Set([
32
+ 'messaging/registration-token-not-registered',
33
+ 'messaging/invalid-registration-token',
34
+ 'messaging/invalid-argument'
35
+ ]);
36
+ export function fcmMessage(token, message) {
37
+ return {
38
+ token,
39
+ notification: _extends({
40
+ title: message.title
41
+ }, message.body ? {
42
+ body: message.body
43
+ } : {}),
44
+ // FCM data values are strings; MobilePushData's are.
45
+ data: _extends({}, message.data),
46
+ android: {
47
+ priority: 'high',
48
+ notification: {
49
+ sound: 'default'
50
+ }
51
+ }
52
+ };
53
+ }
54
+ export function createFcmTransport(messaging) {
55
+ return {
56
+ async send (targets, message) {
57
+ const outcomes = [];
58
+ for(let start = 0; start < targets.length; start += FCM_BATCH){
59
+ const batch = targets.slice(start, start + FCM_BATCH);
60
+ try {
61
+ const { responses } = await messaging.sendEach(batch.map((target)=>fcmMessage(target.token, message)));
62
+ for (const [index] of batch.entries()){
63
+ var _ref;
64
+ var _response_error;
65
+ const response = responses[index];
66
+ if (response == null ? void 0 : response.success) outcomes.push('sent');
67
+ else outcomes.push(GONE.has((_ref = response == null ? void 0 : (_response_error = response.error) == null ? void 0 : _response_error.code) != null ? _ref : '') ? 'prune' : 'failed');
68
+ }
69
+ } catch (unused) {
70
+ outcomes.push(...batch.map(()=>'failed'));
71
+ }
72
+ }
73
+ return outcomes;
74
+ }
75
+ };
76
+ }
77
+ let configured = null;
78
+ /** The deployment's FCM transport, on the firebase-admin default app. */ export function fcmTransport() {
79
+ configured != null ? configured : configured = createFcmTransport(getMessaging(getApp()));
80
+ return configured;
81
+ }
82
+
83
+ //# sourceMappingURL=push-fcm.js.map