@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.
- package/package.json +7 -7
- package/src/lib/server/account-acquisition.d.ts +30 -0
- package/src/lib/server/account-acquisition.js +37 -2
- package/src/lib/server/account-acquisition.js.map +1 -1
- package/src/lib/server/api-keys.d.ts +14 -1
- package/src/lib/server/api-keys.js +28 -0
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/content-entry-drafts.d.ts +131 -0
- package/src/lib/server/content-entry-drafts.js +307 -0
- package/src/lib/server/content-entry-drafts.js.map +1 -0
- package/src/lib/server/erase.js +14 -0
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-released-names.d.ts +22 -0
- package/src/lib/server/host-released-names.js +31 -0
- package/src/lib/server/host-released-names.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +1 -1
- package/src/lib/server/host-visitor-records.js +1 -1
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/mobile-push-switch.d.ts +46 -0
- package/src/lib/server/mobile-push-switch.js +45 -0
- package/src/lib/server/mobile-push-switch.js.map +1 -0
- package/src/lib/server/native-push.d.ts +17 -0
- package/src/lib/server/native-push.js +35 -0
- package/src/lib/server/native-push.js.map +1 -0
- package/src/lib/server/notifications.js +23 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/personal-data-export.js +230 -0
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/plugin-event-outbox.d.ts +138 -0
- package/src/lib/server/plugin-event-outbox.js +229 -0
- package/src/lib/server/plugin-event-outbox.js.map +1 -0
- package/src/lib/server/push-apns.d.ts +73 -0
- package/src/lib/server/push-apns.js +225 -0
- package/src/lib/server/push-apns.js.map +1 -0
- package/src/lib/server/push-delivery.d.ts +78 -0
- package/src/lib/server/push-delivery.js +176 -0
- package/src/lib/server/push-delivery.js.map +1 -0
- package/src/lib/server/push-fcm.d.ts +29 -0
- package/src/lib/server/push-fcm.js +83 -0
- package/src/lib/server/push-fcm.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +6 -0
- package/src/lib/server/serve-media-cdn.js +20 -3
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-marketing-sync.d.ts +146 -0
- package/src/lib/server/site-marketing-sync.js +315 -0
- package/src/lib/server/site-marketing-sync.js.map +1 -0
- package/src/lib/server/upsert-contact.js +6 -5
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-fcm.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 * Firebase Cloud Messaging for the Android app (AGL-3651), through\n * firebase-admin's `getMessaging().sendEach` with the project credentials\n * the server already holds, so it needs no secret of its own. The\n * notification is shown by the system; the tap data rides in `data`, and\n * Android delivers it at high priority.\n *\n * A token FCM reports unregistered or invalid is gone, and the row is pruned.\n *\n * Server-only, imported by `push-delivery.ts` only when a device is on FCM.\n */\n\nimport { getApp } from 'firebase-admin/app'\nimport { getMessaging, type BatchResponse, type TokenMessage } from 'firebase-admin/messaging'\nimport './firebase-admin'\nimport type { PushMessage, PushOutcome, PushTarget, PushTransport } from './push-delivery'\n\n/** `sendEach` takes at most 500 messages. */\nexport const FCM_BATCH = 500\n\nconst GONE = new Set([\n 'messaging/registration-token-not-registered',\n 'messaging/invalid-registration-token',\n 'messaging/invalid-argument',\n])\n\n/** The slice of firebase-admin's Messaging this transport uses; injected in tests. */\nexport interface FcmMessaging {\n sendEach(messages: TokenMessage[]): Promise<BatchResponse>\n}\n\nexport function fcmMessage(token: string, message: PushMessage): TokenMessage {\n return {\n token,\n notification: { title: message.title, ...(message.body ? { body: message.body } : {}) },\n // FCM data values are strings; MobilePushData's are.\n data: { ...message.data },\n android: { priority: 'high', notification: { sound: 'default' } },\n }\n}\n\nexport function createFcmTransport(messaging: FcmMessaging): PushTransport {\n return {\n async send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]> {\n const outcomes: PushOutcome[] = []\n for (let start = 0; start < targets.length; start += FCM_BATCH) {\n const batch = targets.slice(start, start + FCM_BATCH)\n try {\n const { responses } = await messaging.sendEach(batch.map((target) => fcmMessage(target.token, message)))\n for (const [index] of batch.entries()) {\n const response = responses[index]\n if (response?.success) outcomes.push('sent')\n else outcomes.push(GONE.has(response?.error?.code ?? '') ? 'prune' : 'failed')\n }\n } catch {\n outcomes.push(...batch.map((): PushOutcome => 'failed'))\n }\n }\n return outcomes\n },\n }\n}\n\nlet configured: PushTransport | null = null\n\n/** The deployment's FCM transport, on the firebase-admin default app. */\nexport function fcmTransport(): PushTransport {\n configured ??= createFcmTransport(getMessaging(getApp()))\n return configured\n}\n"],"names":["getApp","getMessaging","FCM_BATCH","GONE","Set","fcmMessage","token","message","notification","title","body","data","android","priority","sound","createFcmTransport","messaging","send","targets","outcomes","start","length","batch","slice","responses","sendEach","map","target","index","entries","response","success","push","has","error","code","configured","fcmTransport"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAED,SAASA,MAAM,QAAQ,qBAAoB;AAC3C,SAASC,YAAY,QAA+C,2BAA0B;AAC9F,OAAO,sBAAkB;AAGzB,2CAA2C,GAC3C,OAAO,MAAMC,YAAY,IAAG;AAE5B,MAAMC,OAAO,IAAIC,IAAI;IACnB;IACA;IACA;CACD;AAOD,OAAO,SAASC,WAAWC,KAAa,EAAEC,OAAoB;IAC5D,OAAO;QACLD;QACAE,cAAc;YAAEC,OAAOF,QAAQE,KAAK;WAAMF,QAAQG,IAAI,GAAG;YAAEA,MAAMH,QAAQG,IAAI;QAAC,IAAI,CAAC;QACnF,qDAAqD;QACrDC,MAAM,aAAKJ,QAAQI,IAAI;QACvBC,SAAS;YAAEC,UAAU;YAAQL,cAAc;gBAAEM,OAAO;YAAU;QAAE;IAClE;AACF;AAEA,OAAO,SAASC,mBAAmBC,SAAuB;IACxD,OAAO;QACL,MAAMC,MAAKC,OAA8B,EAAEX,OAAoB;YAC7D,MAAMY,WAA0B,EAAE;YAClC,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,QAAQG,MAAM,EAAED,SAASlB,UAAW;gBAC9D,MAAMoB,QAAQJ,QAAQK,KAAK,CAACH,OAAOA,QAAQlB;gBAC3C,IAAI;oBACF,MAAM,EAAEsB,SAAS,EAAE,GAAG,MAAMR,UAAUS,QAAQ,CAACH,MAAMI,GAAG,CAAC,CAACC,SAAWtB,WAAWsB,OAAOrB,KAAK,EAAEC;oBAC9F,KAAK,MAAM,CAACqB,MAAM,IAAIN,MAAMO,OAAO,GAAI;;4BAGTC;wBAF5B,MAAMA,WAAWN,SAAS,CAACI,MAAM;wBACjC,IAAIE,4BAAAA,SAAUC,OAAO,EAAEZ,SAASa,IAAI,CAAC;6BAChCb,SAASa,IAAI,CAAC7B,KAAK8B,GAAG,SAACH,6BAAAA,kBAAAA,SAAUI,KAAK,qBAAfJ,gBAAiBK,IAAI,mBAAI,MAAM,UAAU;oBACvE;gBACF,EAAE,eAAM;oBACNhB,SAASa,IAAI,IAAIV,MAAMI,GAAG,CAAC,IAAmB;gBAChD;YACF;YACA,OAAOP;QACT;IACF;AACF;AAEA,IAAIiB,aAAmC;AAEvC,uEAAuE,GACvE,OAAO,SAASC;IACdD,qBAAAA,aAAAA,aAAerB,mBAAmBd,aAAaD;IAC/C,OAAOoC;AACT"}
|
|
@@ -180,6 +180,12 @@ export declare function mediaCdnVersionIsCurrent(options: {
|
|
|
180
180
|
/** The document's {@link MEDIA_VARIANT_ENCODER_VERSION_FIELD}. */
|
|
181
181
|
documentEncoderVersion: unknown;
|
|
182
182
|
}): boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Whether an asset of this type is only ever served as its own bytes — no
|
|
185
|
+
* `?w=` variant, display copy, poster or rendition (AGL-3656). A web font is
|
|
186
|
+
* the case: a versioned URL that names its hash names everything served.
|
|
187
|
+
*/
|
|
188
|
+
export declare function mediaCdnHasNoDerivedForms(contentType: unknown): boolean;
|
|
183
189
|
/** The immutable content-hashed URL's policy for edge-cacheable (image) types. */
|
|
184
190
|
export declare const MEDIA_CDN_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
185
191
|
/**
|
|
@@ -221,6 +221,13 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
221
221
|
const generation = typeof recorded === 'number' && Number.isInteger(recorded) && recorded > 0 ? recorded : 1;
|
|
222
222
|
return token.encoderVersion === MEDIA_VARIANT_ENCODER_VERSION && generation === token.encoderVersion;
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Whether an asset of this type is only ever served as its own bytes — no
|
|
226
|
+
* `?w=` variant, display copy, poster or rendition (AGL-3656). A web font is
|
|
227
|
+
* the case: a versioned URL that names its hash names everything served.
|
|
228
|
+
*/ export function mediaCdnHasNoDerivedForms(contentType) {
|
|
229
|
+
return String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase() === 'font/woff2';
|
|
230
|
+
}
|
|
224
231
|
/** The immutable content-hashed URL's policy for edge-cacheable (image) types. */ export const MEDIA_CDN_IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
225
232
|
/**
|
|
226
233
|
* The immutable URL's policy for edge-bypassing types (AGL-1515): the
|
|
@@ -246,7 +253,12 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
246
253
|
*
|
|
247
254
|
* Unknown or absent types return false: correctness over cache.
|
|
248
255
|
*/ export function mediaCdnEdgeCacheable(contentType) {
|
|
249
|
-
|
|
256
|
+
const type = String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase();
|
|
257
|
+
// A web font (AGL-3656) is fetched the way an image is — one plain GET
|
|
258
|
+
// from an `@font-face` rule, never a range — and it is part of the page
|
|
259
|
+
// weight the band already measures, so it takes the image policy: the
|
|
260
|
+
// edge holds it, and a versioned URL a page names is kept for a year.
|
|
261
|
+
return type.startsWith('image/') || type === 'font/woff2';
|
|
250
262
|
}
|
|
251
263
|
/**
|
|
252
264
|
* The CDN's OWN Content-Security-Policy (AGL-1474).
|
|
@@ -337,7 +349,9 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
337
349
|
* decision back without adding to it.
|
|
338
350
|
*/ function servesInline(contentType) {
|
|
339
351
|
const type = String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase();
|
|
340
|
-
return type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/') || type === 'application/pdf'
|
|
352
|
+
return type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/') || type === 'application/pdf' || // A web font (AGL-3656): an `@font-face` rule loads it, and a font is
|
|
353
|
+
// inert outside a stylesheet, so a top-level open has nothing to run.
|
|
354
|
+
type === 'font/woff2';
|
|
341
355
|
}
|
|
342
356
|
/** The `Content-Security-Policy` for a response serving `contentType`. */ export function mediaCdnContentSecurityPolicy(contentType) {
|
|
343
357
|
const type = String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase();
|
|
@@ -1311,7 +1325,10 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1311
1325
|
widthRequested: Boolean(width),
|
|
1312
1326
|
variantServed: exactVariant,
|
|
1313
1327
|
otherRepresentation: usePoster || Boolean(rendition),
|
|
1314
|
-
|
|
1328
|
+
// A web font has no derived form for an encoder to have made, so no
|
|
1329
|
+
// generation can be behind (AGL-3656): only its bytes are named, and
|
|
1330
|
+
// a library on a plan without generated variants records none.
|
|
1331
|
+
documentEncoderVersion: mediaCdnHasNoDerivedForms(snapshot.get('contentType')) ? MEDIA_VARIANT_ENCODER_VERSION : snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD)
|
|
1315
1332
|
});
|
|
1316
1333
|
const stableCacheControlFor = (type)=>mediaCdnEdgeCacheable(type) ? versionCurrent ? MEDIA_CDN_VERSIONED_CACHE_CONTROL : MEDIA_CDN_STABLE_CACHE_CONTROL : MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL;
|
|
1317
1334
|
// A poster is `image/webp` and therefore edge-cacheable through the rule
|