@aglyn/tenant-data-admin 1.0.0-beta.176 → 1.0.0-beta.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +7 -6
  2. package/src/lib/server/account-acquisition.d.ts +84 -0
  3. package/src/lib/server/account-acquisition.js +107 -0
  4. package/src/lib/server/account-acquisition.js.map +1 -0
  5. package/src/lib/server/admin-audit.js +5 -1
  6. package/src/lib/server/admin-audit.js.map +1 -1
  7. package/src/lib/server/duplicate-resource.js +8 -1
  8. package/src/lib/server/duplicate-resource.js.map +1 -1
  9. package/src/lib/server/email-flow-gate.js +3 -1
  10. package/src/lib/server/email-flow-gate.js.map +1 -1
  11. package/src/lib/server/email-marketing-gate.d.ts +14 -0
  12. package/src/lib/server/email-marketing-gate.js +132 -17
  13. package/src/lib/server/email-marketing-gate.js.map +1 -1
  14. package/src/lib/server/email-sender-reputation.d.ts +11 -0
  15. package/src/lib/server/email-sender-reputation.js +4 -1
  16. package/src/lib/server/email-sender-reputation.js.map +1 -1
  17. package/src/lib/server/email-suppression.d.ts +58 -7
  18. package/src/lib/server/email-suppression.js +113 -28
  19. package/src/lib/server/email-suppression.js.map +1 -1
  20. package/src/lib/server/erase.d.ts +2 -0
  21. package/src/lib/server/erase.js +7 -0
  22. package/src/lib/server/erase.js.map +1 -1
  23. package/src/lib/server/first-party-hosts.d.ts +61 -0
  24. package/src/lib/server/first-party-hosts.js +110 -0
  25. package/src/lib/server/first-party-hosts.js.map +1 -0
  26. package/src/lib/server/first-touch-handoff.d.ts +51 -0
  27. package/src/lib/server/first-touch-handoff.js +88 -0
  28. package/src/lib/server/first-touch-handoff.js.map +1 -0
  29. package/src/lib/server/first-touch-route.d.ts +48 -0
  30. package/src/lib/server/first-touch-route.js +164 -0
  31. package/src/lib/server/first-touch-route.js.map +1 -0
  32. package/src/lib/server/host-email-tokens.js +10 -1
  33. package/src/lib/server/host-email-tokens.js.map +1 -1
  34. package/src/lib/server/live-page-usage.d.ts +33 -9
  35. package/src/lib/server/live-page-usage.js +26 -8
  36. package/src/lib/server/live-page-usage.js.map +1 -1
  37. package/src/lib/server/organizations.js +9 -0
  38. package/src/lib/server/organizations.js.map +1 -1
  39. package/src/lib/server/personal-data-export.js +11 -0
  40. package/src/lib/server/personal-data-export.js.map +1 -1
  41. package/src/lib/server/platform-marketing-consent.d.ts +157 -8
  42. package/src/lib/server/platform-marketing-consent.js +240 -21
  43. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  44. package/src/lib/server/sending-domains.js +2 -2
  45. package/src/lib/server/sending-domains.js.map +1 -1
  46. package/src/lib/server/tracking-hosts.d.ts +98 -0
  47. package/src/lib/server/tracking-hosts.js +264 -0
  48. package/src/lib/server/tracking-hosts.js.map +1 -0
  49. package/src/lib/server/upsert-contact.d.ts +1 -1
  50. package/src/lib/server/upsert-contact.js +6 -0
  51. 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.176",
3
+ "version": "1.0.0-beta.184",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.176",
29
- "@aglyn/shared-util-email": "1.0.0-beta.176",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.176",
31
- "@aglyn/shared-util-http": "1.0.0-beta.176",
32
- "@aglyn/shared-util-tools": "1.0.0-beta.176",
28
+ "@aglyn/aglyn": "1.0.0-beta.184",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.184",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.184",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.184",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.184",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.184",
33
34
  "@msgpack/msgpack": "^3.1.3",
34
35
  "@swc/helpers": "0.5.23",
35
36
  "sharp": "^0.35.3",
@@ -0,0 +1,84 @@
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 AccountAcquisition, type AcquisitionDoor } from '@aglyn/aglyn/app-utils/account-acquisition';
18
+ import { type HeaderReader } from '@aglyn/aglyn/app-utils/request-geo';
19
+ /**
20
+ * Write where an account came from, ONCE, at account creation (AGL-3289).
21
+ *
22
+ * The capture kept the first touch on the visitor's device; the door that
23
+ * created the account hands it here, with what only the server knows beside
24
+ * it: the door, the sign-in provider from the verified token, where the
25
+ * request came from as the edge reported it, and whether the address had an
26
+ * invitation waiting. The record goes on `users/{uid}` inside a transaction
27
+ * that writes nothing when one is already there — the first write wins, and
28
+ * a retry, a second tab or a later call can never restate it.
29
+ *
30
+ * The rules deny the field to every client, so this Admin SDK write is the
31
+ * only way in. `createOrganization` copies it onto the workspace the account
32
+ * creates.
33
+ */
34
+ /**
35
+ * How long after an account is created its door may still record where it
36
+ * came from. A door calls within seconds of creating the account; a mobile
37
+ * redirect or a slow verification page within minutes. Anything later is not
38
+ * account creation, and a record written then would describe a later visit.
39
+ */
40
+ export declare const ACCOUNT_ACQUISITION_WINDOW_MS: number;
41
+ /** What a door knows when it records an account. */
42
+ export interface RecordAccountAcquisitionInput {
43
+ uid: string;
44
+ /** When the auth record says the account was created; null refuses. */
45
+ accountCreatedAtMs: number | null;
46
+ /** The first touch the device kept — untrusted. */
47
+ touch: unknown;
48
+ door: AcquisitionDoor;
49
+ /** From the verified token's `sign_in_provider`. */
50
+ provider: string | null;
51
+ /** The account's address, for the invitation check; null when it has none. */
52
+ email: string | null;
53
+ /** The organization an invitation the door already found came from. */
54
+ invitedToOrgId?: string | null;
55
+ /** The account-creating request's headers, for its geography. */
56
+ headers: HeaderReader;
57
+ recordedBy: 'signup' | 'sso';
58
+ nowMs?: number;
59
+ firestore?: FirebaseFirestore.Firestore;
60
+ }
61
+ export type RecordAccountAcquisitionResult = {
62
+ status: 'recorded';
63
+ record: AccountAcquisition;
64
+ } | {
65
+ status: 'exists';
66
+ record: AccountAcquisition | null;
67
+ }
68
+ /** The account was not created just now, so this is not its creation. */
69
+ | {
70
+ status: 'not-new';
71
+ };
72
+ /**
73
+ * The organization whose invitation this address has waiting, or null. The
74
+ * same query the console's invitation banner runs, one address wide.
75
+ */
76
+ export declare function pendingInvitationOrgId(email: string | null, firestore?: FirebaseFirestore.Firestore): Promise<string | null>;
77
+ /** Where the request came from, as a person reads it — no address, no coordinates. */
78
+ export declare function requestGeography(headers: HeaderReader): {
79
+ country: string | null;
80
+ region: string | null;
81
+ city: string | null;
82
+ };
83
+ /** Record the account's acquisition unless it already has one. */
84
+ export declare function recordAccountAcquisition(input: RecordAccountAcquisitionInput): Promise<RecordAccountAcquisitionResult>;
@@ -0,0 +1,107 @@
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 { ACCOUNT_ACQUISITION_FIELD, buildAccountAcquisition, readAccountAcquisition } from "@aglyn/aglyn/app-utils/account-acquisition";
17
+ import { readRequestCity, readRequestGeo, readRequestRegionLabel } from "@aglyn/aglyn/app-utils/request-geo";
18
+ import firebaseAdmin from "./firebase-admin.js";
19
+ /**
20
+ * Write where an account came from, ONCE, at account creation (AGL-3289).
21
+ *
22
+ * The capture kept the first touch on the visitor's device; the door that
23
+ * created the account hands it here, with what only the server knows beside
24
+ * it: the door, the sign-in provider from the verified token, where the
25
+ * request came from as the edge reported it, and whether the address had an
26
+ * invitation waiting. The record goes on `users/{uid}` inside a transaction
27
+ * that writes nothing when one is already there — the first write wins, and
28
+ * a retry, a second tab or a later call can never restate it.
29
+ *
30
+ * The rules deny the field to every client, so this Admin SDK write is the
31
+ * only way in. `createOrganization` copies it onto the workspace the account
32
+ * creates.
33
+ */ /**
34
+ * How long after an account is created its door may still record where it
35
+ * came from. A door calls within seconds of creating the account; a mobile
36
+ * redirect or a slow verification page within minutes. Anything later is not
37
+ * account creation, and a record written then would describe a later visit.
38
+ */ export const ACCOUNT_ACQUISITION_WINDOW_MS = 30 * 60 * 1000;
39
+ /**
40
+ * The organization whose invitation this address has waiting, or null. The
41
+ * same query the console's invitation banner runs, one address wide.
42
+ */ export async function pendingInvitationOrgId(email, firestore = firebaseAdmin.app().firestore()) {
43
+ const address = String(email != null ? email : '').trim().toLowerCase();
44
+ if (!address) return null;
45
+ try {
46
+ var _ref;
47
+ var _snapshot_docs__ref_parent_parent, _snapshot_docs_;
48
+ const snapshot = await firestore.collectionGroup('invites').where('email', 'in', [
49
+ address
50
+ ]).where('acceptedAt', '==', null).limit(1).get();
51
+ return (_ref = (_snapshot_docs_ = snapshot.docs[0]) == null ? void 0 : (_snapshot_docs__ref_parent_parent = _snapshot_docs_.ref.parent.parent) == null ? void 0 : _snapshot_docs__ref_parent_parent.id) != null ? _ref : null;
52
+ } catch (error) {
53
+ // An unanswerable question costs the door's label, never the record.
54
+ console.error('[account-acquisition] invitation lookup failed', error);
55
+ return null;
56
+ }
57
+ }
58
+ /** Where the request came from, as a person reads it — no address, no coordinates. */ export function requestGeography(headers) {
59
+ return {
60
+ country: readRequestGeo(headers).country,
61
+ region: readRequestRegionLabel(headers),
62
+ city: readRequestCity(headers)
63
+ };
64
+ }
65
+ /** Record the account's acquisition unless it already has one. */ export async function recordAccountAcquisition(input) {
66
+ var _input_firestore, _input_nowMs, _input_invitedToOrgId;
67
+ const firestore = (_input_firestore = input.firestore) != null ? _input_firestore : firebaseAdmin.app().firestore();
68
+ const nowMs = (_input_nowMs = input.nowMs) != null ? _input_nowMs : Date.now();
69
+ if (input.accountCreatedAtMs === null || !Number.isFinite(input.accountCreatedAtMs) || nowMs - input.accountCreatedAtMs > ACCOUNT_ACQUISITION_WINDOW_MS) {
70
+ return {
71
+ status: 'not-new'
72
+ };
73
+ }
74
+ const invitedToOrgId = (_input_invitedToOrgId = input.invitedToOrgId) != null ? _input_invitedToOrgId : input.door === 'signup-password' || input.door === 'signup-google' ? await pendingInvitationOrgId(input.email, firestore) : null;
75
+ const record = buildAccountAcquisition({
76
+ touch: input.touch,
77
+ door: invitedToOrgId && input.door !== 'sso' ? 'invite' : input.door,
78
+ provider: input.provider,
79
+ geo: requestGeography(input.headers),
80
+ invitedToOrgId,
81
+ accountCreatedAtMs: input.accountCreatedAtMs,
82
+ recordedBy: input.recordedBy,
83
+ nowMs
84
+ });
85
+ const userRef = firestore.collection('users').doc(input.uid);
86
+ return firestore.runTransaction(async (tx)=>{
87
+ const snapshot = await tx.get(userRef);
88
+ const existing = snapshot.exists ? snapshot.get(ACCOUNT_ACQUISITION_FIELD) : undefined;
89
+ if (existing !== undefined && existing !== null) {
90
+ return {
91
+ status: 'exists',
92
+ record: readAccountAcquisition(existing)
93
+ };
94
+ }
95
+ tx.set(userRef, {
96
+ [ACCOUNT_ACQUISITION_FIELD]: record
97
+ }, {
98
+ merge: true
99
+ });
100
+ return {
101
+ status: 'recorded',
102
+ record
103
+ };
104
+ });
105
+ }
106
+
107
+ //# sourceMappingURL=account-acquisition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-acquisition.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 ACCOUNT_ACQUISITION_FIELD,\n buildAccountAcquisition,\n readAccountAcquisition,\n type AccountAcquisition,\n type AcquisitionDoor,\n} from '@aglyn/aglyn/app-utils/account-acquisition'\nimport {\n readRequestCity,\n readRequestGeo,\n readRequestRegionLabel,\n type HeaderReader,\n} from '@aglyn/aglyn/app-utils/request-geo'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * Write where an account came from, ONCE, at account creation (AGL-3289).\n *\n * The capture kept the first touch on the visitor's device; the door that\n * created the account hands it here, with what only the server knows beside\n * it: the door, the sign-in provider from the verified token, where the\n * request came from as the edge reported it, and whether the address had an\n * invitation waiting. The record goes on `users/{uid}` inside a transaction\n * that writes nothing when one is already there — the first write wins, and\n * a retry, a second tab or a later call can never restate it.\n *\n * The rules deny the field to every client, so this Admin SDK write is the\n * only way in. `createOrganization` copies it onto the workspace the account\n * creates.\n */\n\n/**\n * How long after an account is created its door may still record where it\n * came from. A door calls within seconds of creating the account; a mobile\n * redirect or a slow verification page within minutes. Anything later is not\n * account creation, and a record written then would describe a later visit.\n */\nexport const ACCOUNT_ACQUISITION_WINDOW_MS = 30 * 60 * 1000\n\n/** What a door knows when it records an account. */\nexport interface RecordAccountAcquisitionInput {\n uid: string\n /** When the auth record says the account was created; null refuses. */\n accountCreatedAtMs: number | null\n /** The first touch the device kept — untrusted. */\n touch: unknown\n door: AcquisitionDoor\n /** From the verified token's `sign_in_provider`. */\n provider: string | null\n /** The account's address, for the invitation check; null when it has none. */\n email: string | null\n /** The organization an invitation the door already found came from. */\n invitedToOrgId?: string | null\n /** The account-creating request's headers, for its geography. */\n headers: HeaderReader\n recordedBy: 'signup' | 'sso'\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}\n\nexport type RecordAccountAcquisitionResult =\n | { status: 'recorded'; record: AccountAcquisition }\n | { status: 'exists'; record: AccountAcquisition | null }\n /** The account was not created just now, so this is not its creation. */\n | { status: 'not-new' }\n\n/**\n * The organization whose invitation this address has waiting, or null. The\n * same query the console's invitation banner runs, one address wide.\n */\nexport async function pendingInvitationOrgId(\n email: string | null,\n firestore: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<string | null> {\n const address = String(email ?? '').trim().toLowerCase()\n if (!address) return null\n try {\n const snapshot = await firestore\n .collectionGroup('invites')\n .where('email', 'in', [address])\n .where('acceptedAt', '==', null)\n .limit(1)\n .get()\n return snapshot.docs[0]?.ref.parent.parent?.id ?? null\n } catch (error) {\n // An unanswerable question costs the door's label, never the record.\n console.error('[account-acquisition] invitation lookup failed', error)\n return null\n }\n}\n\n/** Where the request came from, as a person reads it — no address, no coordinates. */\nexport function requestGeography(headers: HeaderReader): {\n country: string | null\n region: string | null\n city: string | null\n} {\n return {\n country: readRequestGeo(headers).country,\n region: readRequestRegionLabel(headers),\n city: readRequestCity(headers),\n }\n}\n\n/** Record the account's acquisition unless it already has one. */\nexport async function recordAccountAcquisition(\n input: RecordAccountAcquisitionInput,\n): Promise<RecordAccountAcquisitionResult> {\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = input.nowMs ?? Date.now()\n if (\n input.accountCreatedAtMs === null ||\n !Number.isFinite(input.accountCreatedAtMs) ||\n nowMs - input.accountCreatedAtMs > ACCOUNT_ACQUISITION_WINDOW_MS\n ) {\n return { status: 'not-new' }\n }\n const invitedToOrgId =\n input.invitedToOrgId ??\n (input.door === 'signup-password' || input.door === 'signup-google'\n ? await pendingInvitationOrgId(input.email, firestore)\n : null)\n const record = buildAccountAcquisition({\n touch: input.touch,\n door: invitedToOrgId && input.door !== 'sso' ? 'invite' : input.door,\n provider: input.provider,\n geo: requestGeography(input.headers),\n invitedToOrgId,\n accountCreatedAtMs: input.accountCreatedAtMs,\n recordedBy: input.recordedBy,\n nowMs,\n })\n const userRef = firestore.collection('users').doc(input.uid)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(userRef)\n const existing = snapshot.exists ? snapshot.get(ACCOUNT_ACQUISITION_FIELD) : undefined\n if (existing !== undefined && existing !== null) {\n return { status: 'exists', record: readAccountAcquisition(existing) } as const\n }\n tx.set(userRef, { [ACCOUNT_ACQUISITION_FIELD]: record }, { merge: true })\n return { status: 'recorded', record } as const\n })\n}\n"],"names":["ACCOUNT_ACQUISITION_FIELD","buildAccountAcquisition","readAccountAcquisition","readRequestCity","readRequestGeo","readRequestRegionLabel","firebaseAdmin","ACCOUNT_ACQUISITION_WINDOW_MS","pendingInvitationOrgId","email","firestore","app","address","String","trim","toLowerCase","snapshot","collectionGroup","where","limit","get","docs","ref","parent","id","error","console","requestGeography","headers","country","region","city","recordAccountAcquisition","input","nowMs","Date","now","accountCreatedAtMs","Number","isFinite","status","invitedToOrgId","door","record","touch","provider","geo","recordedBy","userRef","collection","doc","uid","runTransaction","tx","existing","exists","undefined","set","merge"],"mappings":"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"}
@@ -66,7 +66,11 @@ import { firebaseAdmin } from "./firebase-admin.js";
66
66
  * high-consequence act even though it mutates nothing, and it belongs beside
67
67
  * the impersonations rather than beside the record views.
68
68
  */ const ADMIN_AUDIT_ACCESS_ACTIONS = new Set([
69
- 'email.message-viewed'
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'
70
74
  ]);
71
75
  /**
72
76
  * An access when the platform or a plugin declares the action a read — a
@@ -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])\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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,sDAAqD;AAC9F,SAASC,6BAA6B,QAAQ,yBAAqB;AACnE,SAASC,mBAAmB,QAAQ,yBAAqB;AACzD,SAASC,aAAa,QAAQ,sBAAkB;AAEhD;;;;;;;;;;;;;;;;;CAiBC,GAED,OAAO,MAAMC,yBAAyB,aAAY;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAKlD;;;;;;;;;;;;;CAaC,GACD,MAAMC,6BAAkD,IAAIC,IAAI;IAC9D;CACD;AAED;;;;CAIC,GACD,OAAO,SAASC,eACdC,MAAiC;IAEjC,OAAOA,UAAWH,CAAAA,2BAA2BI,GAAG,CAACD,WAAWT,yBAAyBS,OAAM,IACvF,WACA;AACN;AA4DA,SAASE,OAAOC,KAAc;QACV;IAAlB,MAAMC,YAAaD,0BAAD,gBAAA,AAACA,MAA0CD,MAAM,qBAAjD,mBAACC;IACnB,IAAIC,qBAAqBC,MAAM,OAAOD;IACtC,OAAOD,iBAAiBE,OAAOF,QAAQ;AACzC;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeG,iBAAiBC,KAAsB;IAC3D,MAAMC,YAAYd,cAAce,GAAG,GAAGD,SAAS;IAC/C,MAAME,aAAaF,UAAUE,UAAU,CAACf;IACxC,MAAMgB,MAAM,IAAIN;IAChB,MAAMO,qBAAqBF,WACxBG,KAAK,CAAC,UAAU,MAAMN,MAAMO,MAAM,EAClCC,OAAO,CAAC,MAAM,QACdC,KAAK,CAAC;IAET,MAAMR,UAAUS,cAAc,CAAC,OAAOC;YAG/BhB,SA0CGK;QA5CR,MAAMY,WAAW,AAAC,CAAA,MAAMD,YAAYE,GAAG,CAACR,mBAAkB,EAAGS,IAAI,CAAC,EAAE;QACpE,MAAMC,aAAaH,YACdjB,UAAAA,OAAOiB,SAASC,GAAG,CAAC,sBAApBlB,UAAkCA,OAAOiB,SAASC,GAAG,CAAC,SACvD;QACJ,MAAMG,UACJ,CAAC,CAACJ,YACFA,SAASC,GAAG,CAAC,gBAAgBb,MAAMiB,QAAQ,IAC3CL,SAASC,GAAG,CAAC,cAAcb,MAAMP,MAAM;QACzC;;;;;;KAMC,GACD,MAAMyB,eACJ,CAAC,CAACH,cACFI,KAAKC,GAAG,CAAChB,IAAIiB,OAAO,KAAKN,WAAWM,OAAO,OACzChC;QAEJ,IAAIuB,YAAYI,WAAWE,cAAc;YACvCP,YAAYW,MAAM,CAACV,SAASW,GAAG,EAAE;gBAC/BC,aAAa,AAACC,CAAAA,OAAOb,SAASC,GAAG,CAAC,mBAAmB,CAAA,IAAK;gBAC1Da,QAAQtB;YACV;YACA;QACF;QAEAO,YAAYgB,MAAM,CAACxB,WAAWyB,GAAG,IAAI;YACnCX,UAAUjB,MAAMiB,QAAQ;YACxBxB,QAAQO,MAAMP,MAAM;YACpBc,QAAQP,MAAMO,MAAM;WAOhBP,MAAM6B,UAAU,GAAG;YAAEA,YAAY7B,MAAM6B,UAAU;QAAC,IAAI,CAAC,GAGvD7B,MAAM8B,iBAAiB,GACvB;YAAEA,mBAAmB9B,MAAM8B,iBAAiB;QAAC,IAC7C,CAAC;YACLC,IAAI,GAAE/B,cAAAA,MAAM+B,IAAI,YAAV/B,cAAc;YACpBgC,IAAI5B;YACJ;;;;;OAKC,GACDsB,QAAQtB;YACRoB,aAAa;;IAEjB;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASS,iBAAiBC,OAAkC;IACjE,MAAMC,UAAUC,OAAOF,kBAAAA,UAAW,IAAIG,IAAI;IAC1C,MAAML,KAAKG,QAAQG,WAAW,CAAC;IAC/B,IAAIN,MAAM,KAAKA,OAAOG,QAAQI,MAAM,GAAG,GAAG,OAAO;IACjD,OAAO,GAAGJ,QAAQK,KAAK,CAAC,GAAG,GAAG,IAAI,EAAEL,QAAQK,KAAK,CAACR,KAAK,IAAI;AAC7D;AAEA,uDAAuD,GACvD,OAAO,SAASS,mBACdC,SAA+C;IAE/C,OAAO,CAACA,oBAAAA,YAAa,EAAE,EAAEC,GAAG,CAACV,kBAAkBW,IAAI,CAAC;AACtD;AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,yBACpBX,OAAkC;IAElC,MAAMY,QAAQV,OAAOF,kBAAAA,UAAW,IAAIG,IAAI;IACxC,IAAI,CAACS,OAAO,OAAO;IACnB,IAAI;QACF;;;;;;;;;;;;;;;;;KAiBC,GACD,OAAO,MAAM7D,8BAA8B6D;IAC7C,EAAE,eAAM;QACN,yEAAyE;QACzE,uEAAuE;QACvE,kDAAkD;QAClD,OAAO;IACT;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,+BACdL,SAA+C;IAE/C,KAAK,MAAMR,WAAWQ,oBAAAA,YAAa,EAAE,CAAE;QACrC,MAAMM,MAAM9D,oBAAoBgD;QAChC,IAAIc,KAAK,OAAOA;IAClB;IACA,OAAO;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,+BACpBP,SAA+C;IAE/C,KAAK,MAAMR,WAAWQ,oBAAAA,YAAa,EAAE,CAAE;QACrC,MAAMQ,MAAM,MAAML,yBAAyBX;QAC3C,IAAIgB,KAAK,OAAOA;IAClB;IACA,OAAO;AACT"}
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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"}
@@ -108,12 +108,16 @@ import firebaseAdmin from "./firebase-admin.js";
108
108
  component: {
109
109
  collection: 'components',
110
110
  nameField: 'displayName',
111
+ // `kind` keeps an email block an email block (AGL-3287): without it the
112
+ // copy reads as a page component, leaves every email editor's palette,
113
+ // and turns up in page editors where its email blocks cannot render.
111
114
  fields: [
112
115
  'description',
113
116
  'icon',
114
117
  'rootId',
115
118
  'nodes',
116
- 'props'
119
+ 'props',
120
+ 'kind'
117
121
  ],
118
122
  versionParentField: 'componentId',
119
123
  entitlement: 'reusableComponents'
@@ -132,6 +136,9 @@ import firebaseAdmin from "./firebase-admin.js";
132
136
  nameField: 'displayName',
133
137
  fields: [
134
138
  'kind',
139
+ // A component template's own kind — email block or page component —
140
+ // so Use template on a copy still makes the right one (AGL-3287).
141
+ 'componentKind',
135
142
  'description',
136
143
  'category',
137
144
  'placeholders',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n nameSearchKey,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n fields: ['description', 'icon', 'rootId', 'nodes', 'props'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n ...(recipe.collection === 'screens' ? { nameLower: nameSearchKey(name) } : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","nameSearchKey","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","nameLower","createdAt","now","updatedAt","createdBy","versionNodes","nodes","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n nameSearchKey,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n ...(recipe.collection === 'screens' ? { nameLower: nameSearchKey(name) } : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","nameSearchKey","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","nameLower","createdAt","now","updatedAt","createdBy","versionNodes","nodes","displayName"],"mappings":"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"}
@@ -151,9 +151,11 @@ import { orgDataQueryForHost } from "./organizations.js";
151
151
  }
152
152
  const topicId = flowEmailTopicId(options.topicId, options.scope);
153
153
  if (!topicId) return null;
154
+ // Across the same group: a person who left this stream on a sibling site
155
+ // left it from the sender this automation mails as.
154
156
  const sendable = await filterTopicSendable(options.hostId, topicId, [
155
157
  email
156
- ], options.firestore);
158
+ ], options.firestore, group);
157
159
  return sendable.length ? null : 'topic-unsubscribed';
158
160
  }
159
161