@aglyn/tenant-data-admin 1.0.0-beta.198 → 1.0.0-beta.200
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/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +83 -0
- package/src/lib/server/collection-live-pages.js +198 -0
- package/src/lib/server/collection-live-pages.js.map +1 -0
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -14
- package/src/lib/server/email-suppression.js +14 -31
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +22 -3
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +23 -3
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +33 -0
- package/src/lib/server/live-page-usage.js +61 -1
- package/src/lib/server/live-page-usage.js.map +1 -1
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.200",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.200",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.200",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.200",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.200",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.200",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.200",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.3",
|
|
@@ -0,0 +1,28 @@
|
|
|
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 AdminAuditIndexSource } from '@aglyn/aglyn/app-utils/admin-audit-index';
|
|
18
|
+
export declare const ADMIN_AUDIT_COLLECTION = "adminAudit";
|
|
19
|
+
/** A batch or a transaction: anything that sets a document. */
|
|
20
|
+
type AuditWriter = Pick<FirebaseFirestore.WriteBatch, 'set'> | Pick<FirebaseFirestore.Transaction, 'set'>;
|
|
21
|
+
/** Add one audit row, stamped. Resolves to the new document's reference. */
|
|
22
|
+
export declare function addAdminAudit<Entry extends AdminAuditIndexSource>(firestore: FirebaseFirestore.Firestore, entry: Entry): Promise<FirebaseFirestore.DocumentReference>;
|
|
23
|
+
/**
|
|
24
|
+
* Set one new audit row inside a batch or a transaction, stamped, so it
|
|
25
|
+
* commits with the change it records. Returns the new document's reference.
|
|
26
|
+
*/
|
|
27
|
+
export declare function setAdminAudit<Entry extends AdminAuditIndexSource>(writer: AuditWriter, firestore: FirebaseFirestore.Firestore, entry: Entry): FirebaseFirestore.DocumentReference;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
*/ import { withAdminAuditIndex } from "@aglyn/aglyn/app-utils/admin-audit-index";
|
|
17
|
+
/*
|
|
18
|
+
* THE ONE DOOR INTO `adminAudit` (AGL-3321).
|
|
19
|
+
*
|
|
20
|
+
* The staff audit page queries each row by the `actionGroup` and
|
|
21
|
+
* `searchTokens` the writer stamped (`withAdminAuditIndex`). A row written
|
|
22
|
+
* without them is a row the Action group filter and the search can never
|
|
23
|
+
* find, while it still lists normally, so the gap would show only as an
|
|
24
|
+
* audit search that quietly misses one entry. Every server write therefore
|
|
25
|
+
* goes through these two functions, and
|
|
26
|
+
* `apps/console/specs/admin-audit-writes-are-stamped.spec.ts` refuses a
|
|
27
|
+
* write to the collection anywhere else.
|
|
28
|
+
*
|
|
29
|
+
* The Firestore instance is the caller's, never looked up here. A route's
|
|
30
|
+
* own handle is the one its spec replaces, and this module imports no
|
|
31
|
+
* Firebase at all, so reaching it costs a caller nothing it did not already
|
|
32
|
+
* load.
|
|
33
|
+
*/ export const ADMIN_AUDIT_COLLECTION = 'adminAudit';
|
|
34
|
+
/** Add one audit row, stamped. Resolves to the new document's reference. */ export function addAdminAudit(firestore, entry) {
|
|
35
|
+
return firestore.collection(ADMIN_AUDIT_COLLECTION).add(withAdminAuditIndex(entry));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Set one new audit row inside a batch or a transaction, stamped, so it
|
|
39
|
+
* commits with the change it records. Returns the new document's reference.
|
|
40
|
+
*/ export function setAdminAudit(writer, firestore, entry) {
|
|
41
|
+
const ref = firestore.collection(ADMIN_AUDIT_COLLECTION).doc();
|
|
42
|
+
writer.set(ref, withAdminAuditIndex(entry));
|
|
43
|
+
return ref;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=admin-audit-write.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit-write.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 type AdminAuditIndexSource,\n withAdminAuditIndex,\n} from '@aglyn/aglyn/app-utils/admin-audit-index'\n\n/*\n * THE ONE DOOR INTO `adminAudit` (AGL-3321).\n *\n * The staff audit page queries each row by the `actionGroup` and\n * `searchTokens` the writer stamped (`withAdminAuditIndex`). A row written\n * without them is a row the Action group filter and the search can never\n * find, while it still lists normally, so the gap would show only as an\n * audit search that quietly misses one entry. Every server write therefore\n * goes through these two functions, and\n * `apps/console/specs/admin-audit-writes-are-stamped.spec.ts` refuses a\n * write to the collection anywhere else.\n *\n * The Firestore instance is the caller's, never looked up here. A route's\n * own handle is the one its spec replaces, and this module imports no\n * Firebase at all, so reaching it costs a caller nothing it did not already\n * load.\n */\n\nexport const ADMIN_AUDIT_COLLECTION = 'adminAudit'\n\n/** A batch or a transaction: anything that sets a document. */\ntype AuditWriter = Pick<FirebaseFirestore.WriteBatch, 'set'> | Pick<FirebaseFirestore.Transaction, 'set'>\n\n/** Add one audit row, stamped. Resolves to the new document's reference. */\nexport function addAdminAudit<Entry extends AdminAuditIndexSource>(\n firestore: FirebaseFirestore.Firestore,\n entry: Entry,\n): Promise<FirebaseFirestore.DocumentReference> {\n return firestore.collection(ADMIN_AUDIT_COLLECTION).add(withAdminAuditIndex(entry))\n}\n\n/**\n * Set one new audit row inside a batch or a transaction, stamped, so it\n * commits with the change it records. Returns the new document's reference.\n */\nexport function setAdminAudit<Entry extends AdminAuditIndexSource>(\n writer: AuditWriter,\n firestore: FirebaseFirestore.Firestore,\n entry: Entry,\n): FirebaseFirestore.DocumentReference {\n const ref = firestore.collection(ADMIN_AUDIT_COLLECTION).doc()\n ;(writer as Pick<FirebaseFirestore.WriteBatch, 'set'>).set(ref, withAdminAuditIndex(entry))\n return ref\n}\n"],"names":["withAdminAuditIndex","ADMIN_AUDIT_COLLECTION","addAdminAudit","firestore","entry","collection","add","setAdminAudit","writer","ref","doc","set"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,mBAAmB,QACd,2CAA0C;AAEjD;;;;;;;;;;;;;;;;CAgBC,GAED,OAAO,MAAMC,yBAAyB,aAAY;AAKlD,0EAA0E,GAC1E,OAAO,SAASC,cACdC,SAAsC,EACtCC,KAAY;IAEZ,OAAOD,UAAUE,UAAU,CAACJ,wBAAwBK,GAAG,CAACN,oBAAoBI;AAC9E;AAEA;;;CAGC,GACD,OAAO,SAASG,cACdC,MAAmB,EACnBL,SAAsC,EACtCC,KAAY;IAEZ,MAAMK,MAAMN,UAAUE,UAAU,CAACJ,wBAAwBS,GAAG;IAC1DF,OAAqDG,GAAG,CAACF,KAAKT,oBAAoBI;IACpF,OAAOK;AACT"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import { ADMIN_AUDIT_COLLECTION } from './admin-audit-write';
|
|
17
18
|
/**
|
|
18
19
|
* THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.
|
|
19
20
|
*
|
|
@@ -32,7 +33,8 @@
|
|
|
32
33
|
* `subjectUid` is the separate fact, and it is what makes "who at Aglyn
|
|
33
34
|
* read my email" a query rather than a manual trawl.
|
|
34
35
|
*/
|
|
35
|
-
export
|
|
36
|
+
export { ADMIN_AUDIT_COLLECTION };
|
|
37
|
+
export { addAdminAudit, setAdminAudit } from './admin-audit-write';
|
|
36
38
|
/**
|
|
37
39
|
* How close together two identical acts must be to count as ONE act
|
|
38
40
|
* recorded twice.
|
|
@@ -50,14 +52,7 @@ export declare const ADMIN_AUDIT_COLLECTION = "adminAudit";
|
|
|
50
52
|
* interval at which a second look is a second decision.
|
|
51
53
|
*/
|
|
52
54
|
export declare const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10000;
|
|
53
|
-
|
|
54
|
-
export type AdminAuditKind = 'access' | 'change';
|
|
55
|
-
/**
|
|
56
|
-
* An access when the platform or a plugin declares the action a read — a
|
|
57
|
-
* plugin's staff card opening on an org or an account names its own read
|
|
58
|
-
* actions through its activity group (AGL-2939) — and a change otherwise.
|
|
59
|
-
*/
|
|
60
|
-
export declare function adminAuditKind(action: string | null | undefined): AdminAuditKind;
|
|
55
|
+
export { ADMIN_AUDIT_ACCESS_ACTIONS, type AdminAuditKind, adminAuditKind, } from '@aglyn/aglyn/app-utils/admin-audit-index';
|
|
61
56
|
export interface AdminAuditWrite {
|
|
62
57
|
/** The staff account performing the act. */
|
|
63
58
|
actorUid: string;
|
|
@@ -14,8 +14,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { withAdminAuditIndex } from "@aglyn/aglyn/app-utils/admin-audit-index";
|
|
18
18
|
import { attributableAccountForAddress } from "./account-addresses.js";
|
|
19
|
+
import { ADMIN_AUDIT_COLLECTION } from "./admin-audit-write.js";
|
|
19
20
|
import { emailSuppressionKey } from "./email-suppression.js";
|
|
20
21
|
import { firebaseAdmin } from "./firebase-admin.js";
|
|
21
22
|
/**
|
|
@@ -35,7 +36,8 @@ import { firebaseAdmin } from "./firebase-admin.js";
|
|
|
35
36
|
* cannot also name the person, because a message id is not a user path.
|
|
36
37
|
* `subjectUid` is the separate fact, and it is what makes "who at Aglyn
|
|
37
38
|
* read my email" a query rather than a manual trawl.
|
|
38
|
-
*/ export
|
|
39
|
+
*/ export { ADMIN_AUDIT_COLLECTION };
|
|
40
|
+
export { addAdminAudit, setAdminAudit } from "./admin-audit-write.js";
|
|
39
41
|
/**
|
|
40
42
|
* How close together two identical acts must be to count as ONE act
|
|
41
43
|
* recorded twice.
|
|
@@ -52,33 +54,10 @@ import { firebaseAdmin } from "./firebase-admin.js";
|
|
|
52
54
|
* separateness of two accesses, so it must stay far shorter than the
|
|
53
55
|
* interval at which a second look is a second decision.
|
|
54
56
|
*/ export const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10000;
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
* matters more than the membership: anything absent is a `change`. A change
|
|
60
|
-
* is the louder half of the console's audit card, so an action nobody has
|
|
61
|
-
* classified yet gets the MORE prominent treatment rather than the quieter
|
|
62
|
-
* one. The failure mode of the opposite default is an unclassified
|
|
63
|
-
* impersonation rendering as routine browsing.
|
|
64
|
-
*
|
|
65
|
-
* An export is deliberately NOT here. Data leaving the platform is a
|
|
66
|
-
* high-consequence act even though it mutates nothing, and it belongs beside
|
|
67
|
-
* the impersonations rather than beside the record views.
|
|
68
|
-
*/ const ADMIN_AUDIT_ACCESS_ACTIONS = new Set([
|
|
69
|
-
'email.message-viewed',
|
|
70
|
-
// The acquisition card (AGL-3289): where an account or a workspace came
|
|
71
|
-
// from, cross-checked against the sales workspace's people. Read only.
|
|
72
|
-
'user.acquisition-viewed',
|
|
73
|
-
'org.acquisition-viewed'
|
|
74
|
-
]);
|
|
75
|
-
/**
|
|
76
|
-
* An access when the platform or a plugin declares the action a read — a
|
|
77
|
-
* plugin's staff card opening on an org or an account names its own read
|
|
78
|
-
* actions through its activity group (AGL-2939) — and a change otherwise.
|
|
79
|
-
*/ export function adminAuditKind(action) {
|
|
80
|
-
return action && (ADMIN_AUDIT_ACCESS_ACTIONS.has(action) || isPluginStaffAuditAccess(action)) ? 'access' : 'change';
|
|
81
|
-
}
|
|
57
|
+
/*
|
|
58
|
+
* Whether an act only looked is stamped on the row (`kind`, AGL-3321), so the
|
|
59
|
+
* rule that decides it lives beside the other stamped fields.
|
|
60
|
+
*/ export { ADMIN_AUDIT_ACCESS_ACTIONS, adminAuditKind } from "@aglyn/aglyn/app-utils/admin-audit-index";
|
|
82
61
|
function toDate(value) {
|
|
83
62
|
var _value_toDate;
|
|
84
63
|
const converted = value == null ? void 0 : (_value_toDate = value.toDate) == null ? void 0 : _value_toDate.call(value);
|
|
@@ -123,7 +102,9 @@ function toDate(value) {
|
|
|
123
102
|
});
|
|
124
103
|
return;
|
|
125
104
|
}
|
|
126
|
-
|
|
105
|
+
// Stamped like every other row (`withAdminAuditIndex`): the audit
|
|
106
|
+
// page's Action group filter and search query these fields.
|
|
107
|
+
transaction.create(collection.doc(), withAdminAuditIndex(_extends({
|
|
127
108
|
actorUid: entry.actorUid,
|
|
128
109
|
action: entry.action,
|
|
129
110
|
target: entry.target
|
|
@@ -141,7 +122,7 @@ function toDate(value) {
|
|
|
141
122
|
* row that says how often somebody looked.
|
|
142
123
|
*/ lastAt: now,
|
|
143
124
|
repeatCount: 1
|
|
144
|
-
}));
|
|
125
|
+
})));
|
|
145
126
|
});
|
|
146
127
|
}
|
|
147
128
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit.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 { isPluginStaffAuditAccess } from '@aglyn/aglyn/plugin-manager/plugin-activity-actions'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { emailSuppressionKey } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.\n *\n * `adminAudit` is not a convenience log. Firestore rules close\n * `emailDeliveries` to EVERYONE, staff included, and the console route is\n * allowed to read it only because it establishes a staff claim and records\n * who looked. The row this module writes is that compensating control, so\n * the two properties it exists to provide are:\n *\n * 1. **No access is lost.** Two views minutes apart are two accesses. A\n * collapse that dropped one would answer \"nobody read your mail\" about a\n * read that happened, which is the single failure this collection cannot\n * have.\n * 2. **The subject is answerable.** `target` names the THING acted on; it\n * cannot also name the person, because a message id is not a user path.\n * `subjectUid` is the separate fact, and it is what makes \"who at Aglyn\n * read my email\" a query rather than a manual trawl.\n */\n\nexport const ADMIN_AUDIT_COLLECTION = 'adminAudit'\n\n/**\n * How close together two identical acts must be to count as ONE act\n * recorded twice.\n *\n * A single click can reach this writer more than once — a re-run effect, a\n * retried request, a double submit — and each arrival is the same access.\n * Ten seconds sits an order of magnitude above that (the observed re-entry\n * was one second apart) and well below a person deliberately re-opening a\n * record (observed at fifty-three seconds). Nothing a human does twice on\n * purpose lands inside it.\n *\n * ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry\n * it, and the console renders both. Widening this window trades away the\n * separateness of two accesses, so it must stay far shorter than the\n * interval at which a second look is a second decision.\n */\nexport const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10_000\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nconst ADMIN_AUDIT_ACCESS_ACTIONS: ReadonlySet<string> = new Set([\n 'email.message-viewed',\n // The acquisition card (AGL-3289): where an account or a workspace came\n // from, cross-checked against the sales workspace's people. Read only.\n 'user.acquisition-viewed',\n 'org.acquisition-viewed',\n])\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(\n action: string | null | undefined,\n): AdminAuditKind {\n return action && (ADMIN_AUDIT_ACCESS_ACTIONS.has(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\nexport interface AdminAuditWrite {\n /** The staff account performing the act. */\n actorUid: string\n action: string\n /** The THING acted on, as a path or a stable identifier. */\n target: string\n /**\n * The person the act is ABOUT, when one can be resolved.\n *\n * Absent is a correct answer, not a failure: a recipient may be a site\n * member, a prospect or a bare address with no platform account behind it,\n * and inventing a uid for one of those would put an act on an innocent\n * person's page.\n */\n subjectUid?: string | null\n /**\n * `sha256` of the recipient address the act was about, when it had one.\n *\n * ## Why a hash and not the address\n *\n * The SAME derivation `emailSuppressionKey` uses, so this joins the\n * delivery log without a second key space. It is hashed for the reason\n * {@link maskEmailAddress} exists: `adminAudit` is readable by any staff\n * role, and a dump of it must not yield a mailing list.\n *\n * ## Why it exists at all, next to `subjectUid`\n *\n * `subjectUid` can only be written when the address resolves to exactly one\n * account, and an address is not reliably resolvable to one — a\n * provider-supplied address may be held by a second account with nothing\n * recording it. So the subject is now OMITTED whenever the answer is\n * ambiguous, and omitting it alone would make the access invisible on every\n * page, which is worse than the guess it replaced.\n *\n * This is the fact that needs no guess. The account page queries it with\n * the keys of every address that account holds, so one access appears on\n * the page of each account holding the address — which is the honest answer\n * when the mail went to a mailbox rather than to a uid.\n *\n * ## It opens no tier side-door\n *\n * `adminAudit` is `allow read, create: if isStaff()` with NO tier gate, so\n * anything written here is readable by every staff session whatever its\n * role — which makes an audit row a way around a tier restriction on the\n * surface the value came from. Checked, and it is not one here: the writer\n * is the message route, which gates on `staff` alone and nothing finer, so\n * this field is no more widely readable than its source.\n *\n * The hash adds no reach of its own either. It is the document id\n * `emailDeliveries` is already filed under, so any staff session that can\n * read this row could already derive the same value from an address it\n * guessed. A field whose source DID restrict by tier would not belong here.\n */\n subjectAddressKey?: string | null\n /** Human-readable context. ⚠️ Never a raw address — see `maskEmailAddress`. */\n note?: string | null\n}\n\nfunction toDate(value: unknown): Date | null {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted\n return value instanceof Date ? value : null\n}\n\n/**\n * Write one audit row, collapsing an immediate repeat of the same act onto\n * the row already there.\n *\n * The candidate is the MOST RECENT row on the same target, which the live\n * `target ASC, at DESC` index already answers — so this costs one indexed\n * single-document read and needs no index of its own. Anything else landing\n * on that target in between ends the run, which is the conservative\n * direction: an interleaved act means the two views were not one action.\n *\n * The read and the write are one TRANSACTION because the duplicates this\n * exists to catch are CONCURRENT. Two requests from one click race each\n * other; a plain read-then-write would let both see \"nothing recent\" and\n * both insert, defeating the collapse exactly when it is needed.\n */\nexport async function recordAdminAudit(entry: AdminAuditWrite): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(ADMIN_AUDIT_COLLECTION)\n const now = new Date()\n const mostRecentOnTarget = collection\n .where('target', '==', entry.target)\n .orderBy('at', 'desc')\n .limit(1)\n\n await firestore.runTransaction(async (transaction) => {\n const previous = (await transaction.get(mostRecentOnTarget)).docs[0]\n const previousAt = previous\n ? (toDate(previous.get('lastAt')) ?? toDate(previous.get('at')))\n : null\n const sameAct =\n !!previous &&\n previous.get('actorUid') === entry.actorUid &&\n previous.get('action') === entry.action\n /*\n * Absolute distance, because the rows this compares against are not all\n * written with the same clock: most writers use a server timestamp and\n * this one uses the request's own instant, so a neighboring row can\n * carry a moment slightly ahead of `now`. A signed comparison would read\n * that as \"not recent\" and stop collapsing.\n */\n const withinWindow =\n !!previousAt &&\n Math.abs(now.getTime() - previousAt.getTime()) <=\n ADMIN_AUDIT_DEDUPE_WINDOW_MS\n\n if (previous && sameAct && withinWindow) {\n transaction.update(previous.ref, {\n repeatCount: (Number(previous.get('repeatCount')) || 1) + 1,\n lastAt: now,\n })\n return\n }\n\n transaction.create(collection.doc(), {\n actorUid: entry.actorUid,\n action: entry.action,\n target: entry.target,\n /*\n * OMITTED rather than null when there is nobody to name. The subject\n * query is `where('subjectUid','==',uid).orderBy('at','desc')`, which\n * matches only documents that HAVE the field — so leaving it out keeps\n * the index to the rows that can actually answer a subject question.\n */\n ...(entry.subjectUid ? { subjectUid: entry.subjectUid } : {}),\n // Omitted rather than null on the same reasoning as `subjectUid`: the\n // address query matches only documents that HAVE the field.\n ...(entry.subjectAddressKey\n ? { subjectAddressKey: entry.subjectAddressKey }\n : {}),\n note: entry.note ?? null,\n at: now,\n /*\n * `lastAt` and `repeatCount` are set on the FIRST write, not added on\n * the first repeat. A reader that has to treat a missing field as \"one,\n * at `at`\" will eventually forget to, and the row it gets wrong is the\n * row that says how often somebody looked.\n */\n lastAt: now,\n repeatCount: 1,\n })\n })\n}\n\n/**\n * An address a human can recognize without the log holding the address.\n *\n * `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep\n * a readable list of who we mail. An audit row echoing the address in the\n * clear made this collection the less careful of the two for the same data,\n * and `adminAudit` is readable by any staff role.\n *\n * The first character and the domain survive because an audit entry a human\n * cannot read is a weaker control: a staffer checking their own access, or an\n * investigator confirming which of a shortlist was opened, needs to recognize\n * the row. What does not survive is the harvestable part — a dump of this\n * collection yields no mailing list.\n */\nexport function maskEmailAddress(address: string | null | undefined): string {\n const trimmed = String(address ?? '').trim()\n const at = trimmed.lastIndexOf('@')\n if (at <= 0 || at === trimmed.length - 1) return '***'\n return `${trimmed.slice(0, 1)}***@${trimmed.slice(at + 1)}`\n}\n\n/** Every recipient, masked, in one note-sized string. */\nexport function maskEmailAddresses(\n addresses: readonly string[] | null | undefined,\n): string {\n return (addresses ?? []).map(maskEmailAddress).join(', ')\n}\n\n/**\n * The uid behind a recipient address, or null when there is not one.\n *\n * Across POOLS, not the project pool alone: an enterprise account signing in\n * through GCIP lives in a tenant, and a project-pool `getUserByEmail` would\n * report the customer most in need of this record as \"not a user\".\n *\n * Null is a real answer. Most addresses we send to belong to site members,\n * prospects and plain contacts with no platform account, and this must never\n * guess one for them.\n */\nexport async function resolveSubjectUidByEmail(\n address: string | null | undefined,\n): Promise<string | null> {\n const email = String(address ?? '').trim()\n if (!email) return null\n try {\n /*\n * AMBIGUITY RESOLVES TO NOBODY.\n *\n * This was `findUserByEmailAcrossPools(...).uid` — the first account\n * whose PRIMARY matched. That is a guess whenever a second account holds\n * the same address, which is a real shape: a federated provider's address\n * never entered the uniqueness index, so an account can hold one that is\n * also somebody else's primary with nothing recording the clash.\n *\n * `attributableAccountForAddress` returns null for \"more than one\" as\n * well as for \"nobody\". Both are the same instruction to this writer:\n * name no subject. A row naming the wrong customer is not a weaker answer\n * to \"who read my data\" — it is a false one, and it puts one customer's\n * name on another's data access.\n *\n * The access is still reachable: `subjectAddressKey` carries the hashed\n * recipient, and the account page finds it from the addresses it holds.\n */\n return await attributableAccountForAddress(email)\n } catch {\n // A lookup that could not run leaves the row without a subject, which is\n // the same answer as \"no account\" and is safe: it under-reports rather\n // than attributing an access to the wrong person.\n return null\n }\n}\n\n/**\n * The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.\n *\n * The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar\n * cannot represent a multi-recipient message, and the masked note carries the\n * full list either way.\n */\nexport function subjectAddressKeyForRecipients(\n addresses: readonly string[] | null | undefined,\n): string | null {\n for (const address of addresses ?? []) {\n const key = emailSuppressionKey(address)\n if (key) return key\n }\n return null\n}\n\n/**\n * The first recipient with a platform account.\n *\n * A single scalar subject cannot represent a message sent to several people,\n * and multi-recipient system mail is rare enough that an array field plus its\n * own `array-contains` index would be carried by every writer to serve almost\n * no rows. The full (masked) recipient list stays on the note, so a row for a\n * multi-recipient message still shows that the others were involved.\n */\nexport async function resolveSubjectUidForRecipients(\n addresses: readonly string[] | null | undefined,\n): Promise<string | null> {\n for (const address of addresses ?? []) {\n const uid = await resolveSubjectUidByEmail(address)\n if (uid) return uid\n }\n return null\n}\n"],"names":["isPluginStaffAuditAccess","attributableAccountForAddress","emailSuppressionKey","firebaseAdmin","ADMIN_AUDIT_COLLECTION","ADMIN_AUDIT_DEDUPE_WINDOW_MS","ADMIN_AUDIT_ACCESS_ACTIONS","Set","adminAuditKind","action","has","toDate","value","converted","Date","recordAdminAudit","entry","firestore","app","collection","now","mostRecentOnTarget","where","target","orderBy","limit","runTransaction","transaction","previous","get","docs","previousAt","sameAct","actorUid","withinWindow","Math","abs","getTime","update","ref","repeatCount","Number","lastAt","create","doc","subjectUid","subjectAddressKey","note","at","maskEmailAddress","address","trimmed","String","trim","lastIndexOf","length","slice","maskEmailAddresses","addresses","map","join","resolveSubjectUidByEmail","email","subjectAddressKeyForRecipients","key","resolveSubjectUidForRecipients","uid"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit.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 { withAdminAuditIndex } from '@aglyn/aglyn/app-utils/admin-audit-index'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { ADMIN_AUDIT_COLLECTION } from './admin-audit-write'\nimport { emailSuppressionKey } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.\n *\n * `adminAudit` is not a convenience log. Firestore rules close\n * `emailDeliveries` to EVERYONE, staff included, and the console route is\n * allowed to read it only because it establishes a staff claim and records\n * who looked. The row this module writes is that compensating control, so\n * the two properties it exists to provide are:\n *\n * 1. **No access is lost.** Two views minutes apart are two accesses. A\n * collapse that dropped one would answer \"nobody read your mail\" about a\n * read that happened, which is the single failure this collection cannot\n * have.\n * 2. **The subject is answerable.** `target` names the THING acted on; it\n * cannot also name the person, because a message id is not a user path.\n * `subjectUid` is the separate fact, and it is what makes \"who at Aglyn\n * read my email\" a query rather than a manual trawl.\n */\n\nexport { ADMIN_AUDIT_COLLECTION }\nexport { addAdminAudit, setAdminAudit } from './admin-audit-write'\n\n/**\n * How close together two identical acts must be to count as ONE act\n * recorded twice.\n *\n * A single click can reach this writer more than once — a re-run effect, a\n * retried request, a double submit — and each arrival is the same access.\n * Ten seconds sits an order of magnitude above that (the observed re-entry\n * was one second apart) and well below a person deliberately re-opening a\n * record (observed at fifty-three seconds). Nothing a human does twice on\n * purpose lands inside it.\n *\n * ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry\n * it, and the console renders both. Widening this window trades away the\n * separateness of two accesses, so it must stay far shorter than the\n * interval at which a second look is a second decision.\n */\nexport const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10_000\n\n/*\n * Whether an act only looked is stamped on the row (`kind`, AGL-3321), so the\n * rule that decides it lives beside the other stamped fields.\n */\nexport {\n ADMIN_AUDIT_ACCESS_ACTIONS,\n type AdminAuditKind,\n adminAuditKind,\n} from '@aglyn/aglyn/app-utils/admin-audit-index'\n\nexport interface AdminAuditWrite {\n /** The staff account performing the act. */\n actorUid: string\n action: string\n /** The THING acted on, as a path or a stable identifier. */\n target: string\n /**\n * The person the act is ABOUT, when one can be resolved.\n *\n * Absent is a correct answer, not a failure: a recipient may be a site\n * member, a prospect or a bare address with no platform account behind it,\n * and inventing a uid for one of those would put an act on an innocent\n * person's page.\n */\n subjectUid?: string | null\n /**\n * `sha256` of the recipient address the act was about, when it had one.\n *\n * ## Why a hash and not the address\n *\n * The SAME derivation `emailSuppressionKey` uses, so this joins the\n * delivery log without a second key space. It is hashed for the reason\n * {@link maskEmailAddress} exists: `adminAudit` is readable by any staff\n * role, and a dump of it must not yield a mailing list.\n *\n * ## Why it exists at all, next to `subjectUid`\n *\n * `subjectUid` can only be written when the address resolves to exactly one\n * account, and an address is not reliably resolvable to one — a\n * provider-supplied address may be held by a second account with nothing\n * recording it. So the subject is now OMITTED whenever the answer is\n * ambiguous, and omitting it alone would make the access invisible on every\n * page, which is worse than the guess it replaced.\n *\n * This is the fact that needs no guess. The account page queries it with\n * the keys of every address that account holds, so one access appears on\n * the page of each account holding the address — which is the honest answer\n * when the mail went to a mailbox rather than to a uid.\n *\n * ## It opens no tier side-door\n *\n * `adminAudit` is `allow read, create: if isStaff()` with NO tier gate, so\n * anything written here is readable by every staff session whatever its\n * role — which makes an audit row a way around a tier restriction on the\n * surface the value came from. Checked, and it is not one here: the writer\n * is the message route, which gates on `staff` alone and nothing finer, so\n * this field is no more widely readable than its source.\n *\n * The hash adds no reach of its own either. It is the document id\n * `emailDeliveries` is already filed under, so any staff session that can\n * read this row could already derive the same value from an address it\n * guessed. A field whose source DID restrict by tier would not belong here.\n */\n subjectAddressKey?: string | null\n /** Human-readable context. ⚠️ Never a raw address — see `maskEmailAddress`. */\n note?: string | null\n}\n\nfunction toDate(value: unknown): Date | null {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted\n return value instanceof Date ? value : null\n}\n\n/**\n * Write one audit row, collapsing an immediate repeat of the same act onto\n * the row already there.\n *\n * The candidate is the MOST RECENT row on the same target, which the live\n * `target ASC, at DESC` index already answers — so this costs one indexed\n * single-document read and needs no index of its own. Anything else landing\n * on that target in between ends the run, which is the conservative\n * direction: an interleaved act means the two views were not one action.\n *\n * The read and the write are one TRANSACTION because the duplicates this\n * exists to catch are CONCURRENT. Two requests from one click race each\n * other; a plain read-then-write would let both see \"nothing recent\" and\n * both insert, defeating the collapse exactly when it is needed.\n */\nexport async function recordAdminAudit(entry: AdminAuditWrite): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(ADMIN_AUDIT_COLLECTION)\n const now = new Date()\n const mostRecentOnTarget = collection\n .where('target', '==', entry.target)\n .orderBy('at', 'desc')\n .limit(1)\n\n await firestore.runTransaction(async (transaction) => {\n const previous = (await transaction.get(mostRecentOnTarget)).docs[0]\n const previousAt = previous\n ? (toDate(previous.get('lastAt')) ?? toDate(previous.get('at')))\n : null\n const sameAct =\n !!previous &&\n previous.get('actorUid') === entry.actorUid &&\n previous.get('action') === entry.action\n /*\n * Absolute distance, because the rows this compares against are not all\n * written with the same clock: most writers use a server timestamp and\n * this one uses the request's own instant, so a neighboring row can\n * carry a moment slightly ahead of `now`. A signed comparison would read\n * that as \"not recent\" and stop collapsing.\n */\n const withinWindow =\n !!previousAt &&\n Math.abs(now.getTime() - previousAt.getTime()) <=\n ADMIN_AUDIT_DEDUPE_WINDOW_MS\n\n if (previous && sameAct && withinWindow) {\n transaction.update(previous.ref, {\n repeatCount: (Number(previous.get('repeatCount')) || 1) + 1,\n lastAt: now,\n })\n return\n }\n\n // Stamped like every other row (`withAdminAuditIndex`): the audit\n // page's Action group filter and search query these fields.\n transaction.create(collection.doc(), withAdminAuditIndex({\n actorUid: entry.actorUid,\n action: entry.action,\n target: entry.target,\n /*\n * OMITTED rather than null when there is nobody to name. The subject\n * query is `where('subjectUid','==',uid).orderBy('at','desc')`, which\n * matches only documents that HAVE the field — so leaving it out keeps\n * the index to the rows that can actually answer a subject question.\n */\n ...(entry.subjectUid ? { subjectUid: entry.subjectUid } : {}),\n // Omitted rather than null on the same reasoning as `subjectUid`: the\n // address query matches only documents that HAVE the field.\n ...(entry.subjectAddressKey\n ? { subjectAddressKey: entry.subjectAddressKey }\n : {}),\n note: entry.note ?? null,\n at: now,\n /*\n * `lastAt` and `repeatCount` are set on the FIRST write, not added on\n * the first repeat. A reader that has to treat a missing field as \"one,\n * at `at`\" will eventually forget to, and the row it gets wrong is the\n * row that says how often somebody looked.\n */\n lastAt: now,\n repeatCount: 1,\n }))\n })\n}\n\n/**\n * An address a human can recognize without the log holding the address.\n *\n * `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep\n * a readable list of who we mail. An audit row echoing the address in the\n * clear made this collection the less careful of the two for the same data,\n * and `adminAudit` is readable by any staff role.\n *\n * The first character and the domain survive because an audit entry a human\n * cannot read is a weaker control: a staffer checking their own access, or an\n * investigator confirming which of a shortlist was opened, needs to recognize\n * the row. What does not survive is the harvestable part — a dump of this\n * collection yields no mailing list.\n */\nexport function maskEmailAddress(address: string | null | undefined): string {\n const trimmed = String(address ?? '').trim()\n const at = trimmed.lastIndexOf('@')\n if (at <= 0 || at === trimmed.length - 1) return '***'\n return `${trimmed.slice(0, 1)}***@${trimmed.slice(at + 1)}`\n}\n\n/** Every recipient, masked, in one note-sized string. */\nexport function maskEmailAddresses(\n addresses: readonly string[] | null | undefined,\n): string {\n return (addresses ?? []).map(maskEmailAddress).join(', ')\n}\n\n/**\n * The uid behind a recipient address, or null when there is not one.\n *\n * Across POOLS, not the project pool alone: an enterprise account signing in\n * through GCIP lives in a tenant, and a project-pool `getUserByEmail` would\n * report the customer most in need of this record as \"not a user\".\n *\n * Null is a real answer. Most addresses we send to belong to site members,\n * prospects and plain contacts with no platform account, and this must never\n * guess one for them.\n */\nexport async function resolveSubjectUidByEmail(\n address: string | null | undefined,\n): Promise<string | null> {\n const email = String(address ?? '').trim()\n if (!email) return null\n try {\n /*\n * AMBIGUITY RESOLVES TO NOBODY.\n *\n * This was `findUserByEmailAcrossPools(...).uid` — the first account\n * whose PRIMARY matched. That is a guess whenever a second account holds\n * the same address, which is a real shape: a federated provider's address\n * never entered the uniqueness index, so an account can hold one that is\n * also somebody else's primary with nothing recording the clash.\n *\n * `attributableAccountForAddress` returns null for \"more than one\" as\n * well as for \"nobody\". Both are the same instruction to this writer:\n * name no subject. A row naming the wrong customer is not a weaker answer\n * to \"who read my data\" — it is a false one, and it puts one customer's\n * name on another's data access.\n *\n * The access is still reachable: `subjectAddressKey` carries the hashed\n * recipient, and the account page finds it from the addresses it holds.\n */\n return await attributableAccountForAddress(email)\n } catch {\n // A lookup that could not run leaves the row without a subject, which is\n // the same answer as \"no account\" and is safe: it under-reports rather\n // than attributing an access to the wrong person.\n return null\n }\n}\n\n/**\n * The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.\n *\n * The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar\n * cannot represent a multi-recipient message, and the masked note carries the\n * full list either way.\n */\nexport function subjectAddressKeyForRecipients(\n addresses: readonly string[] | null | undefined,\n): string | null {\n for (const address of addresses ?? []) {\n const key = emailSuppressionKey(address)\n if (key) return key\n }\n return null\n}\n\n/**\n * The first recipient with a platform account.\n *\n * A single scalar subject cannot represent a message sent to several people,\n * and multi-recipient system mail is rare enough that an array field plus its\n * own `array-contains` index would be carried by every writer to serve almost\n * no rows. The full (masked) recipient list stays on the note, so a row for a\n * multi-recipient message still shows that the others were involved.\n */\nexport async function resolveSubjectUidForRecipients(\n addresses: readonly string[] | null | undefined,\n): Promise<string | null> {\n for (const address of addresses ?? []) {\n const uid = await resolveSubjectUidByEmail(address)\n if (uid) return uid\n }\n return null\n}\n"],"names":["withAdminAuditIndex","attributableAccountForAddress","ADMIN_AUDIT_COLLECTION","emailSuppressionKey","firebaseAdmin","addAdminAudit","setAdminAudit","ADMIN_AUDIT_DEDUPE_WINDOW_MS","ADMIN_AUDIT_ACCESS_ACTIONS","adminAuditKind","toDate","value","converted","Date","recordAdminAudit","entry","firestore","app","collection","now","mostRecentOnTarget","where","target","orderBy","limit","runTransaction","transaction","previous","get","docs","previousAt","sameAct","actorUid","action","withinWindow","Math","abs","getTime","update","ref","repeatCount","Number","lastAt","create","doc","subjectUid","subjectAddressKey","note","at","maskEmailAddress","address","trimmed","String","trim","lastIndexOf","length","slice","maskEmailAddresses","addresses","map","join","resolveSubjectUidByEmail","email","subjectAddressKeyForRecipients","key","resolveSubjectUidForRecipients","uid"],"mappings":"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"}
|
|
@@ -0,0 +1,83 @@
|
|
|
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 { Firestore } from 'firebase-admin/firestore';
|
|
18
|
+
import type { DatasetLivePageTarget } from './dataset-live-pages';
|
|
19
|
+
/**
|
|
20
|
+
* How many documents per collection the placement scan reads.
|
|
21
|
+
*
|
|
22
|
+
* The number every other cache-dropping scan uses, for their reason
|
|
23
|
+
* (AGL-1161): this decides which caches get dropped, so a prefix scan reports
|
|
24
|
+
* a refreshed site and leaves real pages stale. When the bound bites it is
|
|
25
|
+
* returned as `truncated`, never absorbed.
|
|
26
|
+
*/
|
|
27
|
+
export declare const COLLECTION_SCAN_LIMIT = 2000;
|
|
28
|
+
export interface CollectionLivePageScope {
|
|
29
|
+
/**
|
|
30
|
+
* Site-absolute addresses derived from the collection's slug — the entry
|
|
31
|
+
* pages, the listing and its pages, each category listing — most important
|
|
32
|
+
* first, because the tenant's path cap keeps the FIRST paths it is handed.
|
|
33
|
+
*/
|
|
34
|
+
paths: string[];
|
|
35
|
+
/** Screens that render the collection somewhere else, by id. */
|
|
36
|
+
screenIds: string[];
|
|
37
|
+
/** The placement scan read fewer documents than the site holds. */
|
|
38
|
+
truncated: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What an entry change makes stale, for one content collection.
|
|
42
|
+
*
|
|
43
|
+
* A content entry is not published through a version pointer, so nothing here
|
|
44
|
+
* looks like the publish paths above: the write is a client Firestore write,
|
|
45
|
+
* and the page that renders it is reached by ADDRESS rather than by a screen
|
|
46
|
+
* document. `/blog`, `/blog/page/2`, `/blog/category/guides` and
|
|
47
|
+
* `/blog/my-post` are served by the catch-all's collection fallback, which may
|
|
48
|
+
* have no screen of its own at all, so a routing-map lookup finds nothing to
|
|
49
|
+
* drop and the site keeps serving the old post.
|
|
50
|
+
*
|
|
51
|
+
* Two halves, therefore, and both are needed:
|
|
52
|
+
*
|
|
53
|
+
* - the collection's own ADDRESSES, derived here from its slug;
|
|
54
|
+
* - the SCREENS that render the collection somewhere else — a rail on the
|
|
55
|
+
* home page, category pills in a layout — which are found by searching node
|
|
56
|
+
* trees, exactly as a form's placements are.
|
|
57
|
+
*
|
|
58
|
+
* Refuses anything that is not a CONTENT collection. Commerce shares
|
|
59
|
+
* `hosts/{hostId}/collections`, and a product collection's pages are routed by
|
|
60
|
+
* the store's templates rather than by these shapes, so building content
|
|
61
|
+
* addresses from one would drop paths that belong to nothing.
|
|
62
|
+
*/
|
|
63
|
+
export declare function collectionLivePageScope(options: {
|
|
64
|
+
firestore: Firestore;
|
|
65
|
+
hostId: string;
|
|
66
|
+
collectionId: string;
|
|
67
|
+
entrySlugs: readonly string[];
|
|
68
|
+
}): Promise<CollectionLivePageScope>;
|
|
69
|
+
/**
|
|
70
|
+
* The same scope as ONE site's drop target: the collection's addresses with
|
|
71
|
+
* the dependent screens resolved through the routing map, in the shape a
|
|
72
|
+
* `LivePageDropper` takes.
|
|
73
|
+
*
|
|
74
|
+
* `null` when there is nothing to drop — a site with no subdomain has no
|
|
75
|
+
* tenant deployment holding pages, and a collection that is gone or is the
|
|
76
|
+
* store's has no content addresses.
|
|
77
|
+
*/
|
|
78
|
+
export declare function collectionLivePageTarget(options: {
|
|
79
|
+
firestore: Firestore;
|
|
80
|
+
hostId: string;
|
|
81
|
+
collectionId: string;
|
|
82
|
+
entrySlugs: readonly string[];
|
|
83
|
+
}): Promise<DatasetLivePageTarget | null>;
|
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
* Which live pages a content collection's change makes stale (AGL-3340).
|
|
19
|
+
*
|
|
20
|
+
* Two callers ask, from two runtimes. The console asks when somebody saves,
|
|
21
|
+
* publishes, schedules, re-dates or deletes an entry, and announces the answer
|
|
22
|
+
* to the tenant over the shared secret. The tenant asks when a SCHEDULED entry
|
|
23
|
+
* comes due, which is a change nobody makes: no write from the console, no
|
|
24
|
+
* request, only the clock — so the tenant's own beat has to find the pages and
|
|
25
|
+
* drop them in process. Two copies of this answer are how a scheduled post
|
|
26
|
+
* would come to reach `/blog` and miss the home page's rail, so it is here and
|
|
27
|
+
* both import it.
|
|
28
|
+
*
|
|
29
|
+
* Reads only. Announcing is the caller's, for the reason
|
|
30
|
+
* `dataset-live-pages.ts` gives: the two runtimes drop caches differently.
|
|
31
|
+
*/ import { COLLECTION_CATEGORIES_MAX, COLLECTION_LIST_PAGE_SIZE, COLLECTION_SOURCE_MAX, collectionCategorySlug, collectionListUrl, hostCollectionKind, screenRoutePathToUrl } from "@aglyn/aglyn/server";
|
|
32
|
+
import { readUsageSources, screenIdsUsingCollectionDeep } from "./live-page-usage.js";
|
|
33
|
+
/**
|
|
34
|
+
* How many documents per collection the placement scan reads.
|
|
35
|
+
*
|
|
36
|
+
* The number every other cache-dropping scan uses, for their reason
|
|
37
|
+
* (AGL-1161): this decides which caches get dropped, so a prefix scan reports
|
|
38
|
+
* a refreshed site and leaves real pages stale. When the bound bites it is
|
|
39
|
+
* returned as `truncated`, never absorbed.
|
|
40
|
+
*/ export const COLLECTION_SCAN_LIMIT = 2000;
|
|
41
|
+
/**
|
|
42
|
+
* What an entry change makes stale, for one content collection.
|
|
43
|
+
*
|
|
44
|
+
* A content entry is not published through a version pointer, so nothing here
|
|
45
|
+
* looks like the publish paths above: the write is a client Firestore write,
|
|
46
|
+
* and the page that renders it is reached by ADDRESS rather than by a screen
|
|
47
|
+
* document. `/blog`, `/blog/page/2`, `/blog/category/guides` and
|
|
48
|
+
* `/blog/my-post` are served by the catch-all's collection fallback, which may
|
|
49
|
+
* have no screen of its own at all, so a routing-map lookup finds nothing to
|
|
50
|
+
* drop and the site keeps serving the old post.
|
|
51
|
+
*
|
|
52
|
+
* Two halves, therefore, and both are needed:
|
|
53
|
+
*
|
|
54
|
+
* - the collection's own ADDRESSES, derived here from its slug;
|
|
55
|
+
* - the SCREENS that render the collection somewhere else — a rail on the
|
|
56
|
+
* home page, category pills in a layout — which are found by searching node
|
|
57
|
+
* trees, exactly as a form's placements are.
|
|
58
|
+
*
|
|
59
|
+
* Refuses anything that is not a CONTENT collection. Commerce shares
|
|
60
|
+
* `hosts/{hostId}/collections`, and a product collection's pages are routed by
|
|
61
|
+
* the store's templates rather than by these shapes, so building content
|
|
62
|
+
* addresses from one would drop paths that belong to nothing.
|
|
63
|
+
*/ export async function collectionLivePageScope(options) {
|
|
64
|
+
var _collectionSnapshot_data, _data_slug;
|
|
65
|
+
const { firestore, hostId, collectionId, entrySlugs } = options;
|
|
66
|
+
const empty = {
|
|
67
|
+
paths: [],
|
|
68
|
+
screenIds: [],
|
|
69
|
+
truncated: false
|
|
70
|
+
};
|
|
71
|
+
const collectionSnapshot = await firestore.collection('hosts').doc(hostId).collection('collections').doc(collectionId).get();
|
|
72
|
+
if (!collectionSnapshot.exists) return empty;
|
|
73
|
+
const data = (_collectionSnapshot_data = collectionSnapshot.data()) != null ? _collectionSnapshot_data : {};
|
|
74
|
+
if (hostCollectionKind(data) !== 'content') return empty;
|
|
75
|
+
if (data['deletedAt']) return empty;
|
|
76
|
+
const collectionSlug = String((_data_slug = data['slug']) != null ? _data_slug : '').trim();
|
|
77
|
+
if (!collectionSlug || collectionSlug.includes('/')) return empty;
|
|
78
|
+
/**
|
|
79
|
+
* Ordered by how much each address matters, because the tenant's path cap
|
|
80
|
+
* takes the FIRST `MAX_PATHS` it is handed. A site whose collection is
|
|
81
|
+
* rendered on more pages than the cap admits therefore loses its deepest
|
|
82
|
+
* category listings rather than the post that was just edited.
|
|
83
|
+
*/ const paths = [];
|
|
84
|
+
const add = (path)=>{
|
|
85
|
+
if (path && !paths.includes(path)) paths.push(path);
|
|
86
|
+
};
|
|
87
|
+
// The entry's own address first — the one page whose author is watching.
|
|
88
|
+
// Both slugs when a save renamed it: the new address has never been
|
|
89
|
+
// rendered, and the OLD one is a cached page that now belongs to nothing.
|
|
90
|
+
for (const entrySlug of entrySlugs)add(`/${collectionSlug}/${entrySlug}`);
|
|
91
|
+
add(collectionListUrl({
|
|
92
|
+
collectionSlug
|
|
93
|
+
}));
|
|
94
|
+
/**
|
|
95
|
+
* Every page of the unfiltered listing that the CACHED read covers.
|
|
96
|
+
*
|
|
97
|
+
* A constant range rather than a count-derived one: dropping a page that
|
|
98
|
+
* does not exist is a cache-key delete against a key nothing holds, which
|
|
99
|
+
* costs nothing, while asking Firestore for the exact page count would trade
|
|
100
|
+
* that for a read on every save and still be wrong the moment publishing an
|
|
101
|
+
* entry adds a page.
|
|
102
|
+
*
|
|
103
|
+
* It used to be the whole range, because `COLLECTION_SOURCE_MAX` bounded the
|
|
104
|
+
* LIVE SET and not merely one read of it — a listing had no eleventh page to
|
|
105
|
+
* miss. AGL-3213 ended that: the constant still bounds the cached head every
|
|
106
|
+
* listing address shares, but the collection now runs past it, and a page
|
|
107
|
+
* that starts beyond the head is served by its own window read at its own
|
|
108
|
+
* address.
|
|
109
|
+
*
|
|
110
|
+
* The range deliberately did not follow it there. `MAX_PATHS` takes the
|
|
111
|
+
* FIRST paths it is handed, so extending this to a collection's real page
|
|
112
|
+
* count would spend the budget on pages almost nobody opens and drop the
|
|
113
|
+
* dependent screens below instead — and those deep pages are exactly the
|
|
114
|
+
* ones whose entries did not change. They catch up on their own ISR window,
|
|
115
|
+
* which is the argument the category listings below already make for
|
|
116
|
+
* themselves.
|
|
117
|
+
*
|
|
118
|
+
* Since AGL-3219 there is nothing for them to catch up ON. A deep page is
|
|
119
|
+
* addressed by the entry it continues from, so publishing at the head of
|
|
120
|
+
* the collection does not change which entries it holds — a stale cursor
|
|
121
|
+
* page is merely old, never wrong. It was POSITIONAL addressing that made
|
|
122
|
+
* this lag visible: the head refreshed here, the tail did not, and the two
|
|
123
|
+
* then disagreed by exactly the one entry that had been inserted above
|
|
124
|
+
* them both.
|
|
125
|
+
*/ const listPages = Math.ceil(COLLECTION_SOURCE_MAX / COLLECTION_LIST_PAGE_SIZE);
|
|
126
|
+
for(let page = 2; page <= listPages; page += 1){
|
|
127
|
+
add(collectionListUrl({
|
|
128
|
+
collectionSlug,
|
|
129
|
+
page
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Page one of every category listing.
|
|
134
|
+
*
|
|
135
|
+
* Every category rather than the changed entry's, because an entry can move
|
|
136
|
+
* between two in one save and a delete leaves no entry to ask — so the set
|
|
137
|
+
* that is certainly right is the collection's own, and it is bounded by
|
|
138
|
+
* `COLLECTION_CATEGORIES_MAX`. Page one only: the same range applied to each
|
|
139
|
+
* category is `COLLECTION_CATEGORIES_MAX` times as many paths as the
|
|
140
|
+
* unfiltered listing, which would push the cap over on the categories alone
|
|
141
|
+
* and take the dependent screens with it. Deeper category pages catch up on
|
|
142
|
+
* their own ISR window.
|
|
143
|
+
*/ const categories = Array.isArray(data['categories']) ? data['categories'] : [];
|
|
144
|
+
for (const category of categories.slice(0, COLLECTION_CATEGORIES_MAX)){
|
|
145
|
+
var _ref;
|
|
146
|
+
const name = String((_ref = category == null ? void 0 : category.name) != null ? _ref : '').trim();
|
|
147
|
+
if (!name) continue;
|
|
148
|
+
const categorySlug = collectionCategorySlug(name);
|
|
149
|
+
if (!categorySlug) continue;
|
|
150
|
+
add(collectionListUrl({
|
|
151
|
+
collectionSlug,
|
|
152
|
+
categorySlug
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
const sources = await readUsageSources(firestore.collection('hosts').doc(hostId), COLLECTION_SCAN_LIMIT);
|
|
156
|
+
return {
|
|
157
|
+
paths,
|
|
158
|
+
screenIds: screenIdsUsingCollectionDeep(collectionSlug, sources.candidates),
|
|
159
|
+
truncated: sources.truncated
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The same scope as ONE site's drop target: the collection's addresses with
|
|
164
|
+
* the dependent screens resolved through the routing map, in the shape a
|
|
165
|
+
* `LivePageDropper` takes.
|
|
166
|
+
*
|
|
167
|
+
* `null` when there is nothing to drop — a site with no subdomain has no
|
|
168
|
+
* tenant deployment holding pages, and a collection that is gone or is the
|
|
169
|
+
* store's has no content addresses.
|
|
170
|
+
*/ export async function collectionLivePageTarget(options) {
|
|
171
|
+
var _hostSnapshot_get, _hostSnapshot_get1, _hostSnapshot_get2;
|
|
172
|
+
const { firestore, hostId } = options;
|
|
173
|
+
const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
|
|
174
|
+
if (!hostSnapshot.exists) return null;
|
|
175
|
+
const subdomain = String((_hostSnapshot_get = hostSnapshot.get('subdomain')) != null ? _hostSnapshot_get : '');
|
|
176
|
+
if (!subdomain) return null;
|
|
177
|
+
const scope = await collectionLivePageScope(options);
|
|
178
|
+
const screens = (_hostSnapshot_get1 = hostSnapshot.get('screens')) != null ? _hostSnapshot_get1 : {};
|
|
179
|
+
// The derived addresses lead, as they do on the console route: they are the
|
|
180
|
+
// pages the change is about, and the cap keeps what it is handed first.
|
|
181
|
+
const paths = [
|
|
182
|
+
...scope.paths,
|
|
183
|
+
...scope.screenIds.map((screenId)=>screens[screenId]).filter((path)=>Boolean(path)).map((path)=>screenRoutePathToUrl(path))
|
|
184
|
+
].filter((path, index, all)=>all.indexOf(path) === index);
|
|
185
|
+
if (!paths.length) return null;
|
|
186
|
+
const cname = String((_hostSnapshot_get2 = hostSnapshot.get('cname')) != null ? _hostSnapshot_get2 : '');
|
|
187
|
+
return _extends({
|
|
188
|
+
hostId,
|
|
189
|
+
subdomain
|
|
190
|
+
}, cname ? {
|
|
191
|
+
cname
|
|
192
|
+
} : {}, {
|
|
193
|
+
paths,
|
|
194
|
+
truncated: scope.truncated
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
//# sourceMappingURL=collection-live-pages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/collection-live-pages.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 * Which live pages a content collection's change makes stale (AGL-3340).\n *\n * Two callers ask, from two runtimes. The console asks when somebody saves,\n * publishes, schedules, re-dates or deletes an entry, and announces the answer\n * to the tenant over the shared secret. The tenant asks when a SCHEDULED entry\n * comes due, which is a change nobody makes: no write from the console, no\n * request, only the clock — so the tenant's own beat has to find the pages and\n * drop them in process. Two copies of this answer are how a scheduled post\n * would come to reach `/blog` and miss the home page's rail, so it is here and\n * both import it.\n *\n * Reads only. Announcing is the caller's, for the reason\n * `dataset-live-pages.ts` gives: the two runtimes drop caches differently.\n */\n\nimport {\n COLLECTION_CATEGORIES_MAX,\n COLLECTION_LIST_PAGE_SIZE,\n COLLECTION_SOURCE_MAX,\n collectionCategorySlug,\n collectionListUrl,\n hostCollectionKind,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport type { DatasetLivePageTarget } from './dataset-live-pages'\nimport {\n readUsageSources,\n screenIdsUsingCollectionDeep,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the placement scan reads.\n *\n * The number every other cache-dropping scan uses, for their reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan reports\n * a refreshed site and leaves real pages stale. When the bound bites it is\n * returned as `truncated`, never absorbed.\n */\nexport const COLLECTION_SCAN_LIMIT = 2000\n\nexport interface CollectionLivePageScope {\n /**\n * Site-absolute addresses derived from the collection's slug — the entry\n * pages, the listing and its pages, each category listing — most important\n * first, because the tenant's path cap keeps the FIRST paths it is handed.\n */\n paths: string[]\n /** Screens that render the collection somewhere else, by id. */\n screenIds: string[]\n /** The placement scan read fewer documents than the site holds. */\n truncated: boolean\n}\n\n/**\n * What an entry change makes stale, for one content collection.\n *\n * A content entry is not published through a version pointer, so nothing here\n * looks like the publish paths above: the write is a client Firestore write,\n * and the page that renders it is reached by ADDRESS rather than by a screen\n * document. `/blog`, `/blog/page/2`, `/blog/category/guides` and\n * `/blog/my-post` are served by the catch-all's collection fallback, which may\n * have no screen of its own at all, so a routing-map lookup finds nothing to\n * drop and the site keeps serving the old post.\n *\n * Two halves, therefore, and both are needed:\n *\n * - the collection's own ADDRESSES, derived here from its slug;\n * - the SCREENS that render the collection somewhere else — a rail on the\n * home page, category pills in a layout — which are found by searching node\n * trees, exactly as a form's placements are.\n *\n * Refuses anything that is not a CONTENT collection. Commerce shares\n * `hosts/{hostId}/collections`, and a product collection's pages are routed by\n * the store's templates rather than by these shapes, so building content\n * addresses from one would drop paths that belong to nothing.\n */\nexport async function collectionLivePageScope(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<CollectionLivePageScope> {\n const { firestore, hostId, collectionId, entrySlugs } = options\n const empty: CollectionLivePageScope = {\n paths: [],\n screenIds: [],\n truncated: false,\n }\n const collectionSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n .doc(collectionId)\n .get()\n if (!collectionSnapshot.exists) return empty\n const data = collectionSnapshot.data() ?? {}\n if (hostCollectionKind(data) !== 'content') return empty\n if (data['deletedAt']) return empty\n const collectionSlug = String(data['slug'] ?? '').trim()\n if (!collectionSlug || collectionSlug.includes('/')) return empty\n\n /**\n * Ordered by how much each address matters, because the tenant's path cap\n * takes the FIRST `MAX_PATHS` it is handed. A site whose collection is\n * rendered on more pages than the cap admits therefore loses its deepest\n * category listings rather than the post that was just edited.\n */\n const paths: string[] = []\n const add = (path: string) => {\n if (path && !paths.includes(path)) paths.push(path)\n }\n\n // The entry's own address first — the one page whose author is watching.\n // Both slugs when a save renamed it: the new address has never been\n // rendered, and the OLD one is a cached page that now belongs to nothing.\n for (const entrySlug of entrySlugs) add(`/${collectionSlug}/${entrySlug}`)\n add(collectionListUrl({ collectionSlug }))\n\n /**\n * Every page of the unfiltered listing that the CACHED read covers.\n *\n * A constant range rather than a count-derived one: dropping a page that\n * does not exist is a cache-key delete against a key nothing holds, which\n * costs nothing, while asking Firestore for the exact page count would trade\n * that for a read on every save and still be wrong the moment publishing an\n * entry adds a page.\n *\n * It used to be the whole range, because `COLLECTION_SOURCE_MAX` bounded the\n * LIVE SET and not merely one read of it — a listing had no eleventh page to\n * miss. AGL-3213 ended that: the constant still bounds the cached head every\n * listing address shares, but the collection now runs past it, and a page\n * that starts beyond the head is served by its own window read at its own\n * address.\n *\n * The range deliberately did not follow it there. `MAX_PATHS` takes the\n * FIRST paths it is handed, so extending this to a collection's real page\n * count would spend the budget on pages almost nobody opens and drop the\n * dependent screens below instead — and those deep pages are exactly the\n * ones whose entries did not change. They catch up on their own ISR window,\n * which is the argument the category listings below already make for\n * themselves.\n *\n * Since AGL-3219 there is nothing for them to catch up ON. A deep page is\n * addressed by the entry it continues from, so publishing at the head of\n * the collection does not change which entries it holds — a stale cursor\n * page is merely old, never wrong. It was POSITIONAL addressing that made\n * this lag visible: the head refreshed here, the tail did not, and the two\n * then disagreed by exactly the one entry that had been inserted above\n * them both.\n */\n const listPages = Math.ceil(COLLECTION_SOURCE_MAX / COLLECTION_LIST_PAGE_SIZE)\n for (let page = 2; page <= listPages; page += 1) {\n add(collectionListUrl({ collectionSlug, page }))\n }\n\n /**\n * Page one of every category listing.\n *\n * Every category rather than the changed entry's, because an entry can move\n * between two in one save and a delete leaves no entry to ask — so the set\n * that is certainly right is the collection's own, and it is bounded by\n * `COLLECTION_CATEGORIES_MAX`. Page one only: the same range applied to each\n * category is `COLLECTION_CATEGORIES_MAX` times as many paths as the\n * unfiltered listing, which would push the cap over on the categories alone\n * and take the dependent screens with it. Deeper category pages catch up on\n * their own ISR window.\n */\n const categories = Array.isArray(data['categories']) ? data['categories'] : []\n for (const category of categories.slice(0, COLLECTION_CATEGORIES_MAX)) {\n const name = String((category as { name?: unknown })?.name ?? '').trim()\n if (!name) continue\n const categorySlug = collectionCategorySlug(name)\n if (!categorySlug) continue\n add(collectionListUrl({ collectionSlug, categorySlug }))\n }\n\n const sources = await readUsageSources(\n firestore.collection('hosts').doc(hostId),\n COLLECTION_SCAN_LIMIT,\n )\n return {\n paths,\n screenIds: screenIdsUsingCollectionDeep(collectionSlug, sources.candidates),\n truncated: sources.truncated,\n }\n}\n\n/**\n * The same scope as ONE site's drop target: the collection's addresses with\n * the dependent screens resolved through the routing map, in the shape a\n * `LivePageDropper` takes.\n *\n * `null` when there is nothing to drop — a site with no subdomain has no\n * tenant deployment holding pages, and a collection that is gone or is the\n * store's has no content addresses.\n */\nexport async function collectionLivePageTarget(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<DatasetLivePageTarget | null> {\n const { firestore, hostId } = options\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n if (!subdomain) return null\n\n const scope = await collectionLivePageScope(options)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n // The derived addresses lead, as they do on the console route: they are the\n // pages the change is about, and the cap keeps what it is handed first.\n const paths = [\n ...scope.paths,\n ...scope.screenIds\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path)),\n ].filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: scope.truncated,\n }\n}\n"],"names":["COLLECTION_CATEGORIES_MAX","COLLECTION_LIST_PAGE_SIZE","COLLECTION_SOURCE_MAX","collectionCategorySlug","collectionListUrl","hostCollectionKind","screenRoutePathToUrl","readUsageSources","screenIdsUsingCollectionDeep","COLLECTION_SCAN_LIMIT","collectionLivePageScope","options","collectionSnapshot","data","firestore","hostId","collectionId","entrySlugs","empty","paths","screenIds","truncated","collection","doc","get","exists","collectionSlug","String","trim","includes","add","path","push","entrySlug","listPages","Math","ceil","page","categories","Array","isArray","category","slice","name","categorySlug","sources","candidates","collectionLivePageTarget","hostSnapshot","subdomain","scope","screens","map","screenId","filter","Boolean","index","all","indexOf","length","cname"],"mappings":"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"}
|