@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
@@ -32,6 +32,7 @@
32
32
  * are about the console's own "what would I break" copy.
33
33
  */
34
34
  import { type ReusableComponentProp } from '@aglyn/aglyn/server';
35
+ import { TENANT_EMAIL_COLLECTION } from '@aglyn/shared-util-email/tenant-email-catalog';
35
36
  /** A screen/layout/component reduced to what a usage scan needs. */
36
37
  export interface UsageCandidate {
37
38
  id: string;
@@ -60,7 +61,13 @@ export interface UsageCandidate {
60
61
  }
61
62
  /** One document that depends on the artifact a scan was asked about. */
62
63
  export interface UsageDependent {
63
- type: 'screen' | 'layout' | 'component' | 'collection';
64
+ /**
65
+ * `emailTemplate` is one of the site's own transactional emails
66
+ * (`hosts/{h}/emailTemplates/{key}`), `id` its catalog key (AGL-3287). An
67
+ * email designed for a campaign is a `kind: 'email'` SCREEN and reports as
68
+ * `screen`.
69
+ */
70
+ type: 'screen' | 'layout' | 'component' | 'collection' | 'emailTemplate';
64
71
  id: string;
65
72
  name: string;
66
73
  via: Array<'id' | 'name'>;
@@ -78,11 +85,19 @@ export interface UsageDependent {
78
85
  */
79
86
  relation?: 'link' | 'child' | 'template';
80
87
  }
81
- /** The three corpora every closure below walks. */
88
+ /** The three corpora every closure below walks, and a fourth for components. */
82
89
  export interface UsageSources {
83
90
  screens: UsageCandidate[];
84
91
  layouts: UsageCandidate[];
85
92
  components: UsageCandidate[];
93
+ /**
94
+ * The site's transactional email designs, each on its published version
95
+ * (AGL-3287). Read only where a scan asks "what places this component" —
96
+ * {@link scanComponentUsage} lists them — because an email is not a page:
97
+ * nothing is cached for it, so no closure that decides which pages to drop
98
+ * has any use for them, and none reads them.
99
+ */
100
+ emailTemplates?: UsageCandidate[];
86
101
  }
87
102
  /** `displayName`, falling back to a legacy `name`, then the raw id. */
88
103
  export declare function usageCandidateLabel(candidate: UsageCandidate): string;
@@ -91,12 +106,14 @@ export declare const isLiveUsageCandidate: (candidate: UsageCandidate) => boolea
91
106
  /**
92
107
  * Everything that references a reusable component (AGL-703).
93
108
  *
94
- * Three places, because the renderer expands instances in three places:
95
- * published screen versions, published layout versions, and OTHER component
96
- * definitions — `composeReusableComponentNodes` grafts nested instances, so
97
- * a component used only inside another component is genuinely used. Omitting
98
- * that third scan would report "used nowhere" for it and invite a confident
99
- * deletion, which is worse than showing nothing at all.
109
+ * Every place the renderer expands an instance: published screen versions
110
+ * (email designs among them — a campaign email is a screen), published layout
111
+ * versions, OTHER component definitions — `composeReusableComponentNodes`
112
+ * grafts nested instances, so a component used only inside another component
113
+ * is genuinely used — and, when the caller read them, the site's transactional
114
+ * emails, which graft a placed header or footer at send time (AGL-3287).
115
+ * Omitting any of them would report "used nowhere" for something that renders
116
+ * and invite a confident deletion, which is worse than showing nothing at all.
100
117
  */
101
118
  export declare function scanComponentUsage(componentId: string, sources: UsageSources): UsageDependent[];
102
119
  /**
@@ -182,8 +199,15 @@ export interface UsageCandidateRead {
182
199
  * `limit` is a real bound, not a guess: it is fetched with one extra document
183
200
  * so exceeding it is DETECTED rather than assumed away. A caller that ignores
184
201
  * `truncated` is choosing to be wrong quietly.
202
+ *
203
+ * `emailTemplates` — the site's transactional email designs (AGL-3287) — has
204
+ * the screens' shape: a parent holding the published `versionId`, the tree on
205
+ * that version. The catalog is code and fixed, and a template document only
206
+ * exists once somebody pressed Design, so most sites have none and the read
207
+ * costs one empty query. Its documents carry no name, so each is labelled with
208
+ * the catalog's.
185
209
  */
186
- export declare function readUsageCandidates(hostRef: FirebaseFirestore.DocumentReference, collectionName: 'screens' | 'layouts' | 'components', options: {
210
+ export declare function readUsageCandidates(hostRef: FirebaseFirestore.DocumentReference, collectionName: 'screens' | 'layouts' | 'components' | typeof TENANT_EMAIL_COLLECTION, options: {
187
211
  withNodes: boolean;
188
212
  limit: number;
189
213
  }): Promise<UsageCandidateRead>;
@@ -31,6 +31,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
31
31
  * console's `scan-artifact-usage` re-exports these and keeps the parts that
32
32
  * are about the console's own "what would I break" copy.
33
33
  */ import { nodesReferenceComponent, decodeStoredNodes } from "@aglyn/aglyn/server";
34
+ import { getTenantEmail, TENANT_EMAIL_COLLECTION } from "@aglyn/shared-util-email/tenant-email-catalog";
34
35
  /** `displayName`, falling back to a legacy `name`, then the raw id. */ export function usageCandidateLabel(candidate) {
35
36
  var _ref, _candidate_displayName;
36
37
  return String((_ref = (_candidate_displayName = candidate.displayName) != null ? _candidate_displayName : candidate.name) != null ? _ref : candidate.id);
@@ -39,13 +40,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
39
40
  /**
40
41
  * Everything that references a reusable component (AGL-703).
41
42
  *
42
- * Three places, because the renderer expands instances in three places:
43
- * published screen versions, published layout versions, and OTHER component
44
- * definitions — `composeReusableComponentNodes` grafts nested instances, so
45
- * a component used only inside another component is genuinely used. Omitting
46
- * that third scan would report "used nowhere" for it and invite a confident
47
- * deletion, which is worse than showing nothing at all.
43
+ * Every place the renderer expands an instance: published screen versions
44
+ * (email designs among them — a campaign email is a screen), published layout
45
+ * versions, OTHER component definitions — `composeReusableComponentNodes`
46
+ * grafts nested instances, so a component used only inside another component
47
+ * is genuinely used — and, when the caller read them, the site's transactional
48
+ * emails, which graft a placed header or footer at send time (AGL-3287).
49
+ * Omitting any of them would report "used nowhere" for something that renders
50
+ * and invite a confident deletion, which is worse than showing nothing at all.
48
51
  */ export function scanComponentUsage(componentId, sources) {
52
+ var _sources_emailTemplates;
49
53
  if (!componentId) return [];
50
54
  const dependents = [];
51
55
  const collect = (candidates, type)=>{
@@ -70,6 +74,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
70
74
  collect(sources.screens, 'screen');
71
75
  collect(sources.layouts, 'layout');
72
76
  collect(sources.components, 'component');
77
+ collect((_sources_emailTemplates = sources.emailTemplates) != null ? _sources_emailTemplates : [], 'emailTemplate');
73
78
  return dependents;
74
79
  }
75
80
  /**
@@ -199,10 +204,14 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
199
204
  for (const screenId of screenIdsUsingLayoutDeep(dependent.id, sources.screens, sources.layouts)){
200
205
  screenIds.add(screenId);
201
206
  }
202
- } else if (!seenComponents.has(dependent.id)) {
207
+ } else if (dependent.type === 'component' && !seenComponents.has(dependent.id)) {
203
208
  seenComponents.add(dependent.id);
204
209
  next.push(dependent.id);
205
210
  }
211
+ // A transactional email is not a page and nothing caches it, so it
212
+ // contributes no screen and is not followed (AGL-3287). Its id is a
213
+ // catalog key, and walking it as a component would read a key as a
214
+ // definition that does not exist.
206
215
  }
207
216
  }
208
217
  frontier = next;
@@ -218,6 +227,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
218
227
  * `limit` is a real bound, not a guess: it is fetched with one extra document
219
228
  * so exceeding it is DETECTED rather than assumed away. A caller that ignores
220
229
  * `truncated` is choosing to be wrong quietly.
230
+ *
231
+ * `emailTemplates` — the site's transactional email designs (AGL-3287) — has
232
+ * the screens' shape: a parent holding the published `versionId`, the tree on
233
+ * that version. The catalog is code and fixed, and a template document only
234
+ * exists once somebody pressed Design, so most sites have none and the read
235
+ * costs one empty query. Its documents carry no name, so each is labelled with
236
+ * the catalog's.
221
237
  */ export async function readUsageCandidates(hostRef, collectionName, options) {
222
238
  const { withNodes, limit } = options;
223
239
  // One over the limit: if the extra document comes back, there was more than
@@ -226,6 +242,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
226
242
  const truncated = docs.size > limit;
227
243
  const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs;
228
244
  const candidates = await Promise.all(inScope.map(async (docSnapshot)=>{
245
+ var _ref;
246
+ var _getTenantEmail;
229
247
  const versionId = docSnapshot.get('versionId');
230
248
  // Components keep their tree on the document; screens and layouts keep
231
249
  // it on the published version.
@@ -245,7 +263,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
245
263
  const nodes = collectionName === 'components' ? decodeStoredNodes(docSnapshot.get('nodes')) : withNodes && versionId ? await docSnapshot.ref.collection('versions').doc(String(versionId)).get().then((version)=>decodeStoredNodes(version.get('nodes'))).catch(()=>null) : null;
246
264
  return _extends({
247
265
  id: docSnapshot.id,
248
- displayName: docSnapshot.get('displayName'),
266
+ displayName: collectionName === TENANT_EMAIL_COLLECTION ? (_ref = (_getTenantEmail = getTenantEmail(docSnapshot.id)) == null ? void 0 : _getTenantEmail.name) != null ? _ref : docSnapshot.id : docSnapshot.get('displayName'),
249
267
  name: docSnapshot.get('name'),
250
268
  deletedAt: docSnapshot.get('deletedAt'),
251
269
  nodes
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-usage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Which live pages a change reaches, for a site's screens, layouts and\n * components.\n *\n * The corpus read and the two closures over it, held here rather than in the\n * console because the console is no longer the only side that needs them. A\n * dataset record is written from the tenant as well — a form submission, an\n * automation step — and those writes make the same pages stale as a console\n * edit does. Two implementations of \"which screens render this\" is the shape\n * that put AGL-1223 in the tree: one reader handled a node tree's second\n * storage form and the other did not, so half the corpus answered \"used\n * nowhere\" and a publish dropped no cache.\n *\n * Nothing here is dataset-specific, and nothing is console-specific. The\n * console's `scan-artifact-usage` re-exports these and keeps the parts that\n * are about the console's own \"what would I break\" copy.\n */\n\nimport {\n nodesReferenceComponent,\n decodeStoredNodes,\n type ReusableComponentProp,\n} from '@aglyn/aglyn/server'\n\n/** A screen/layout/component reduced to what a usage scan needs. */\nexport interface UsageCandidate {\n id: string\n displayName?: string\n /** Legacy field some older documents used instead of `displayName`. */\n name?: string\n deletedAt?: unknown\n /**\n * Node tree to search. For screens and layouts this is the PUBLISHED\n * version's nodes (what visitors see); for components it is the definition\n * tree off the component document, which is what the runtime reads.\n */\n nodes?: Record<string, any> | null\n /** Published version, carried through so the caller can deep-link. */\n versionId?: string\n /** Screens only: the layout they render inside. */\n layoutId?: string\n /** Screens only: the screen they nest under, which is part of their path. */\n parentId?: string\n /**\n * Components only: the properties the definition declares (AGL-1247). A\n * Link property's default renders as a link wherever an instance leaves the\n * property unset, and it is stored here, not in `nodes` (AGL-2846).\n */\n props?: ReadonlyArray<ReusableComponentProp | null | undefined> | null\n}\n\n/** One document that depends on the artifact a scan was asked about. */\nexport interface UsageDependent {\n type: 'screen' | 'layout' | 'component' | 'collection'\n id: string\n name: string\n via: Array<'id' | 'name'>\n versionId?: string\n /**\n * HOW the dependent references the artifact — screens and collection\n * listings (AGL-703, AGL-2806).\n *\n * A component or a layout has exactly one kind of dependent and the noun\n * says everything: an instance, or a binding. A screen has three, and they\n * break in three different ways — a link goes dead, a child moves, a\n * collection loses the page it renders through. Copy that could not tell\n * them apart would have to describe the worst case every time. A collection\n * listing's dependents are all links, and say so.\n */\n relation?: 'link' | 'child' | 'template'\n}\n\n/** The three corpora every closure below walks. */\nexport interface UsageSources {\n screens: UsageCandidate[]\n layouts: UsageCandidate[]\n components: UsageCandidate[]\n}\n\n/** `displayName`, falling back to a legacy `name`, then the raw id. */\nexport function usageCandidateLabel(candidate: UsageCandidate): string {\n return String(candidate.displayName ?? candidate.name ?? candidate.id)\n}\n\n/** A soft-deleted document renders nothing, so it depends on nothing. */\nexport const isLiveUsageCandidate = (candidate: UsageCandidate): boolean =>\n !candidate.deletedAt\n\n/**\n * Everything that references a reusable component (AGL-703).\n *\n * Three places, because the renderer expands instances in three places:\n * published screen versions, published layout versions, and OTHER component\n * definitions — `composeReusableComponentNodes` grafts nested instances, so\n * a component used only inside another component is genuinely used. Omitting\n * that third scan would report \"used nowhere\" for it and invite a confident\n * deletion, which is worse than showing nothing at all.\n */\nexport function scanComponentUsage(\n componentId: string,\n sources: UsageSources,\n): UsageDependent[] {\n if (!componentId) return []\n const dependents: UsageDependent[] = []\n const collect = (\n candidates: UsageCandidate[],\n type: UsageDependent['type'],\n ) => {\n for (const candidate of candidates) {\n if (!isLiveUsageCandidate(candidate)) continue\n // A component never counts as using itself, however it nests.\n if (type === 'component' && candidate.id === componentId) continue\n if (!nodesReferenceComponent(candidate.nodes, componentId)) continue\n dependents.push({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n // Instances reference by id, so a rename can never break them.\n via: ['id'],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n })\n }\n }\n collect(sources.screens, 'screen')\n collect(sources.layouts, 'layout')\n collect(sources.components, 'component')\n return dependents\n}\n\n/**\n * Everything rendering inside a layout (AGL-703).\n *\n * Two kinds of dependent, both expressed by the same `layoutId` pointer:\n *\n * - **screens**, which name the layout they render inside;\n * - **other layouts**, since a layout can itself sit inside one. A nested\n * layout is a real dependent — deleting its parent unwraps every screen\n * underneath it — so leaving layouts out would report a parent layout as\n * used only by the screens that name it directly, and none of the ones\n * that reach it through a child.\n *\n * A layout never counts as its own dependent; `canNestLayout` refuses that,\n * and this refuses to report it even if stored data holds one.\n */\nexport function scanLayoutUsage(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): UsageDependent[] {\n if (!layoutId) return []\n const dependentsOf = (\n candidates: UsageCandidate[],\n type: 'screen' | 'layout',\n ) =>\n candidates\n .filter(\n (candidate) =>\n isLiveUsageCandidate(candidate) &&\n candidate.layoutId === layoutId &&\n candidate.id !== layoutId,\n )\n .map((candidate) => ({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n via: ['id' as const],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n }))\n return [\n ...dependentsOf(screens, 'screen'),\n ...dependentsOf(layouts, 'layout'),\n ]\n}\n\n/**\n * Every live screen rendered inside `layoutId`, at ANY nesting depth\n * (AGL-1150).\n *\n * `scanLayoutUsage` answers one level. Layouts nest — a screen points at a\n * layout, which can point at a parent layout, and `compose-screen-nodes` walks\n * that whole chain when composing a page. So publishing a layout changes every\n * screen below it, not just the ones bound to it directly, and a cache drop\n * that only handles the direct level leaves the rest showing stale chrome for\n * the full revalidate window.\n *\n * Pure, and separate from the Firestore read, so the nesting behaviour is\n * testable without a database.\n *\n * Cycle-safe. `canNestLayout` refuses to create a cycle, but a document written\n * straight to Firestore is not bound by that, and a cycle here would hang a\n * publish request rather than surface anything.\n */\nexport function screenIdsUsingLayoutDeep(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): string[] {\n if (!layoutId) return []\n const screenIds = new Set<string>()\n const seenLayouts = new Set<string>([layoutId])\n let frontier = [layoutId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanLayoutUsage(id, screens, layouts)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (!seenLayouts.has(dependent.id)) {\n seenLayouts.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output contains `componentId`, however\n * indirectly (AGL-1161).\n *\n * `scanComponentUsage` answers one level and returns three kinds of dependent.\n * Only one of them is a screen, and the other two both reach screens by routes\n * a single-level scan cannot see:\n *\n * - a **component** dependent nests the target inside itself, and that outer\n * component may itself only be used inside a third — so component→component\n * edges have to be followed to a fixed point;\n * - a **layout** dependent puts the component in page chrome, which every\n * screen under that layout renders. Layouts nest, so that is\n * `screenIdsUsingLayoutDeep`, not a direct `layoutId` match.\n *\n * Miss either and a publish reports success while some pages keep serving the\n * old component for the full revalidate window — the failure this whole arc\n * exists to remove, and the one that is hardest to notice because the pages\n * that ARE dropped update instantly.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database — the same split `screenIdsUsingLayoutDeep` uses.\n *\n * Cycle-safe. `composeReusableComponentNodes` would not survive a cycle, but a\n * document written straight to Firestore is not bound by what the editor\n * allows, and a cycle here would hang a publish rather than surface anything.\n */\nexport function screenIdsUsingComponentDeep(\n componentId: string,\n sources: UsageSources,\n): string[] {\n if (!componentId) return []\n const screenIds = new Set<string>()\n const seenComponents = new Set<string>([componentId])\n // Layouts are resolved through their own deep walk, so remember which ones\n // have already been expanded: two components in the same layout would\n // otherwise re-walk the whole layout tree once each.\n const seenLayouts = new Set<string>()\n let frontier = [componentId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanComponentUsage(id, sources)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (dependent.type === 'layout') {\n if (seenLayouts.has(dependent.id)) continue\n seenLayouts.add(dependent.id)\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n dependent.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n } else if (!seenComponents.has(dependent.id)) {\n seenComponents.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\nexport interface UsageCandidateRead {\n candidates: UsageCandidate[]\n /**\n * The collection held more documents than `limit` allowed, so the scan below\n * it is INCOMPLETE.\n *\n * Returned rather than logged, because the two callers owe the user\n * different things: an advisory \"what would I break\" can show a partial\n * answer and say so, while a cache drop that silently scans a prefix reports\n * a successful publish and leaves real pages stale.\n */\n truncated: boolean\n}\n\n/**\n * One collection's documents, with published nodes attached when the scan\n * needs to search them.\n *\n * `limit` is a real bound, not a guess: it is fetched with one extra document\n * so exceeding it is DETECTED rather than assumed away. A caller that ignores\n * `truncated` is choosing to be wrong quietly.\n */\nexport async function readUsageCandidates(\n hostRef: FirebaseFirestore.DocumentReference,\n collectionName: 'screens' | 'layouts' | 'components',\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n const { withNodes, limit } = options\n // One over the limit: if the extra document comes back, there was more than\n // we are about to look at. Cheaper than a count() and exact.\n const docs = await hostRef.collection(collectionName).limit(limit + 1).get()\n const truncated = docs.size > limit\n const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs\n\n const candidates = await Promise.all(\n inScope.map(async (docSnapshot) => {\n const versionId = docSnapshot.get('versionId')\n // Components keep their tree on the document; screens and layouts keep\n // it on the published version.\n //\n // BOTH reads are decoded (AGL-1223). A component document is msgpack\n // for anything promoted since components were compressed and a plain\n // map for everything older, and `decodeStoredNodes` returns a map\n // unchanged — so the branch below cannot be simplified back into a raw\n // read on either side.\n //\n // What rides on this: every consumer walks the value with\n // `Object.values`, which over a `Buffer` yields byte NUMBERS and\n // matches nothing. The answer that produces is \"used nowhere\" — for\n // `/api/hosts/where-used` an invitation to delete something a live page\n // renders, and for `/api/screens/revalidate` a publish that drops no\n // cache and leaves the old page served.\n const nodes =\n collectionName === 'components'\n ? decodeStoredNodes(docSnapshot.get('nodes'))\n : withNodes && versionId\n ? await docSnapshot.ref\n .collection('versions')\n .doc(String(versionId))\n .get()\n .then((version) => decodeStoredNodes(version.get('nodes')))\n .catch(() => null)\n : null\n return {\n id: docSnapshot.id,\n displayName: docSnapshot.get('displayName'),\n name: docSnapshot.get('name'),\n deletedAt: docSnapshot.get('deletedAt'),\n nodes,\n ...(versionId ? { versionId: String(versionId) } : {}),\n ...(docSnapshot.get('layoutId')\n ? { layoutId: String(docSnapshot.get('layoutId')) }\n : {}),\n // Screens only, and only the screen scan reads it: a child screen is\n // a dependent of its parent, because its PATH is built from it\n // (AGL-703).\n ...(docSnapshot.get('parentId')\n ? { parentId: String(docSnapshot.get('parentId')) }\n : {}),\n // Components only: the declared properties, whose Link defaults render\n // as links wherever an instance leaves one unset (AGL-2846). The same\n // document the tree came from, so it costs no read.\n ...(collectionName === 'components' &&\n Array.isArray(docSnapshot.get('props'))\n ? { props: docSnapshot.get('props') }\n : {}),\n } satisfies UsageCandidate\n }),\n )\n\n return { candidates, truncated }\n}\n\n/**\n * Every screen, layout and component of a site, with their node trees — the\n * corpus every tree-searching closure above walks.\n *\n * Each collection ONCE, in memory: a query per level would multiply round\n * trips by the nesting depth of the graph. Shared so two scans cannot read\n * different corpora under different bounds, and a change to one cannot quietly\n * narrow the other.\n */\nexport async function readUsageSources(\n hostRef: FirebaseFirestore.DocumentReference,\n limit: number,\n): Promise<{ candidates: UsageSources; truncated: boolean }> {\n const [screens, layouts, components] = await Promise.all([\n readUsageCandidates(hostRef, 'screens', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'layouts', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'components', { withNodes: true, limit }),\n ])\n return {\n candidates: {\n screens: screens.candidates,\n layouts: layouts.candidates,\n components: components.candidates,\n },\n truncated: screens.truncated || layouts.truncated || components.truncated,\n }\n}\n"],"names":["nodesReferenceComponent","decodeStoredNodes","usageCandidateLabel","candidate","String","displayName","name","id","isLiveUsageCandidate","deletedAt","scanComponentUsage","componentId","sources","dependents","collect","candidates","type","nodes","push","via","versionId","screens","layouts","components","scanLayoutUsage","layoutId","dependentsOf","filter","map","screenIdsUsingLayoutDeep","screenIds","Set","seenLayouts","frontier","length","next","dependent","add","has","screenIdsUsingComponentDeep","seenComponents","screenId","readUsageCandidates","hostRef","collectionName","options","withNodes","limit","docs","collection","get","truncated","size","inScope","slice","Promise","all","docSnapshot","ref","doc","then","version","catch","parentId","Array","isArray","props","readUsageSources"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,SACEA,uBAAuB,EACvBC,iBAAiB,QAEZ,sBAAqB;AAyD5B,qEAAqE,GACrE,OAAO,SAASC,oBAAoBC,SAAyB;QAC7CA,MAAAA;IAAd,OAAOC,QAAOD,QAAAA,yBAAAA,UAAUE,WAAW,YAArBF,yBAAyBA,UAAUG,IAAI,YAAvCH,OAA2CA,UAAUI,EAAE;AACvE;AAEA,uEAAuE,GACvE,OAAO,MAAMC,uBAAuB,CAACL,YACnC,CAACA,UAAUM,SAAS,CAAA;AAEtB;;;;;;;;;CASC,GACD,OAAO,SAASC,mBACdC,WAAmB,EACnBC,OAAqB;IAErB,IAAI,CAACD,aAAa,OAAO,EAAE;IAC3B,MAAME,aAA+B,EAAE;IACvC,MAAMC,UAAU,CACdC,YACAC;QAEA,KAAK,MAAMb,aAAaY,WAAY;YAClC,IAAI,CAACP,qBAAqBL,YAAY;YACtC,8DAA8D;YAC9D,IAAIa,SAAS,eAAeb,UAAUI,EAAE,KAAKI,aAAa;YAC1D,IAAI,CAACX,wBAAwBG,UAAUc,KAAK,EAAEN,cAAc;YAC5DE,WAAWK,IAAI,CAAC;gBACdF;gBACAT,IAAIJ,UAAUI,EAAE;gBAChBD,MAAMJ,oBAAoBC;gBAC1B,+DAA+D;gBAC/DgB,KAAK;oBAAC;iBAAK;eACPhB,UAAUiB,SAAS,GAAG;gBAAEA,WAAWjB,UAAUiB,SAAS;YAAC,IAAI,CAAC;QAEpE;IACF;IACAN,QAAQF,QAAQS,OAAO,EAAE;IACzBP,QAAQF,QAAQU,OAAO,EAAE;IACzBR,QAAQF,QAAQW,UAAU,EAAE;IAC5B,OAAOV;AACT;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASW,gBACdC,QAAgB,EAChBJ,OAAyB,EACzBC,UAA4B,EAAE;IAE9B,IAAI,CAACG,UAAU,OAAO,EAAE;IACxB,MAAMC,eAAe,CACnBX,YACAC,OAEAD,WACGY,MAAM,CACL,CAACxB,YACCK,qBAAqBL,cACrBA,UAAUsB,QAAQ,KAAKA,YACvBtB,UAAUI,EAAE,KAAKkB,UAEpBG,GAAG,CAAC,CAACzB,YAAe;gBACnBa;gBACAT,IAAIJ,UAAUI,EAAE;gBAChBD,MAAMJ,oBAAoBC;gBAC1BgB,KAAK;oBAAC;iBAAc;eAChBhB,UAAUiB,SAAS,GAAG;gBAAEA,WAAWjB,UAAUiB,SAAS;YAAC,IAAI,CAAC;IAEtE,OAAO;WACFM,aAAaL,SAAS;WACtBK,aAAaJ,SAAS;KAC1B;AACH;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASO,yBACdJ,QAAgB,EAChBJ,OAAyB,EACzBC,UAA4B,EAAE;IAE9B,IAAI,CAACG,UAAU,OAAO,EAAE;IACxB,MAAMK,YAAY,IAAIC;IACtB,MAAMC,cAAc,IAAID,IAAY;QAACN;KAAS;IAC9C,IAAIQ,WAAW;QAACR;KAAS;IAEzB,MAAOQ,SAASC,MAAM,CAAE;QACtB,MAAMC,OAAiB,EAAE;QACzB,KAAK,MAAM5B,MAAM0B,SAAU;YACzB,KAAK,MAAMG,aAAaZ,gBAAgBjB,IAAIc,SAASC,SAAU;gBAC7D,IAAIc,UAAUpB,IAAI,KAAK,UAAU;oBAC/Bc,UAAUO,GAAG,CAACD,UAAU7B,EAAE;gBAC5B,OAAO,IAAI,CAACyB,YAAYM,GAAG,CAACF,UAAU7B,EAAE,GAAG;oBACzCyB,YAAYK,GAAG,CAACD,UAAU7B,EAAE;oBAC5B4B,KAAKjB,IAAI,CAACkB,UAAU7B,EAAE;gBACxB;YACF;QACF;QACA0B,WAAWE;IACb;IAEA,OAAO;WAAIL;KAAU;AACvB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASS,4BACd5B,WAAmB,EACnBC,OAAqB;IAErB,IAAI,CAACD,aAAa,OAAO,EAAE;IAC3B,MAAMmB,YAAY,IAAIC;IACtB,MAAMS,iBAAiB,IAAIT,IAAY;QAACpB;KAAY;IACpD,2EAA2E;IAC3E,sEAAsE;IACtE,qDAAqD;IACrD,MAAMqB,cAAc,IAAID;IACxB,IAAIE,WAAW;QAACtB;KAAY;IAE5B,MAAOsB,SAASC,MAAM,CAAE;QACtB,MAAMC,OAAiB,EAAE;QACzB,KAAK,MAAM5B,MAAM0B,SAAU;YACzB,KAAK,MAAMG,aAAa1B,mBAAmBH,IAAIK,SAAU;gBACvD,IAAIwB,UAAUpB,IAAI,KAAK,UAAU;oBAC/Bc,UAAUO,GAAG,CAACD,UAAU7B,EAAE;gBAC5B,OAAO,IAAI6B,UAAUpB,IAAI,KAAK,UAAU;oBACtC,IAAIgB,YAAYM,GAAG,CAACF,UAAU7B,EAAE,GAAG;oBACnCyB,YAAYK,GAAG,CAACD,UAAU7B,EAAE;oBAC5B,+DAA+D;oBAC/D,KAAK,MAAMkC,YAAYZ,yBACrBO,UAAU7B,EAAE,EACZK,QAAQS,OAAO,EACfT,QAAQU,OAAO,EACd;wBACDQ,UAAUO,GAAG,CAACI;oBAChB;gBACF,OAAO,IAAI,CAACD,eAAeF,GAAG,CAACF,UAAU7B,EAAE,GAAG;oBAC5CiC,eAAeH,GAAG,CAACD,UAAU7B,EAAE;oBAC/B4B,KAAKjB,IAAI,CAACkB,UAAU7B,EAAE;gBACxB;YACF;QACF;QACA0B,WAAWE;IACb;IAEA,OAAO;WAAIL;KAAU;AACvB;AAgBA;;;;;;;CAOC,GACD,OAAO,eAAeY,oBACpBC,OAA4C,EAC5CC,cAAoD,EACpDC,OAA8C;IAE9C,MAAM,EAAEC,SAAS,EAAEC,KAAK,EAAE,GAAGF;IAC7B,4EAA4E;IAC5E,6DAA6D;IAC7D,MAAMG,OAAO,MAAML,QAAQM,UAAU,CAACL,gBAAgBG,KAAK,CAACA,QAAQ,GAAGG,GAAG;IAC1E,MAAMC,YAAYH,KAAKI,IAAI,GAAGL;IAC9B,MAAMM,UAAUF,YAAYH,KAAKA,IAAI,CAACM,KAAK,CAAC,GAAGP,SAASC,KAAKA,IAAI;IAEjE,MAAMjC,aAAa,MAAMwC,QAAQC,GAAG,CAClCH,QAAQzB,GAAG,CAAC,OAAO6B;QACjB,MAAMrC,YAAYqC,YAAYP,GAAG,CAAC;QAClC,uEAAuE;QACvE,+BAA+B;QAC/B,EAAE;QACF,qEAAqE;QACrE,qEAAqE;QACrE,kEAAkE;QAClE,uEAAuE;QACvE,uBAAuB;QACvB,EAAE;QACF,0DAA0D;QAC1D,iEAAiE;QACjE,oEAAoE;QACpE,wEAAwE;QACxE,qEAAqE;QACrE,wCAAwC;QACxC,MAAMjC,QACJ2B,mBAAmB,eACf3C,kBAAkBwD,YAAYP,GAAG,CAAC,YAClCJ,aAAa1B,YACX,MAAMqC,YAAYC,GAAG,CAClBT,UAAU,CAAC,YACXU,GAAG,CAACvD,OAAOgB,YACX8B,GAAG,GACHU,IAAI,CAAC,CAACC,UAAY5D,kBAAkB4D,QAAQX,GAAG,CAAC,WAChDY,KAAK,CAAC,IAAM,QACf;QACR,OAAO;YACLvD,IAAIkD,YAAYlD,EAAE;YAClBF,aAAaoD,YAAYP,GAAG,CAAC;YAC7B5C,MAAMmD,YAAYP,GAAG,CAAC;YACtBzC,WAAWgD,YAAYP,GAAG,CAAC;YAC3BjC;WACIG,YAAY;YAAEA,WAAWhB,OAAOgB;QAAW,IAAI,CAAC,GAChDqC,YAAYP,GAAG,CAAC,cAChB;YAAEzB,UAAUrB,OAAOqD,YAAYP,GAAG,CAAC;QAAa,IAChD,CAAC,GAIDO,YAAYP,GAAG,CAAC,cAChB;YAAEa,UAAU3D,OAAOqD,YAAYP,GAAG,CAAC;QAAa,IAChD,CAAC,GAIDN,mBAAmB,gBACvBoB,MAAMC,OAAO,CAACR,YAAYP,GAAG,CAAC,YAC1B;YAAEgB,OAAOT,YAAYP,GAAG,CAAC;QAAS,IAClC,CAAC;IAET;IAGF,OAAO;QAAEnC;QAAYoC;IAAU;AACjC;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAegB,iBACpBxB,OAA4C,EAC5CI,KAAa;IAEb,MAAM,CAAC1B,SAASC,SAASC,WAAW,GAAG,MAAMgC,QAAQC,GAAG,CAAC;QACvDd,oBAAoBC,SAAS,WAAW;YAAEG,WAAW;YAAMC;QAAM;QACjEL,oBAAoBC,SAAS,WAAW;YAAEG,WAAW;YAAMC;QAAM;QACjEL,oBAAoBC,SAAS,cAAc;YAAEG,WAAW;YAAMC;QAAM;KACrE;IACD,OAAO;QACLhC,YAAY;YACVM,SAASA,QAAQN,UAAU;YAC3BO,SAASA,QAAQP,UAAU;YAC3BQ,YAAYA,WAAWR,UAAU;QACnC;QACAoC,WAAW9B,QAAQ8B,SAAS,IAAI7B,QAAQ6B,SAAS,IAAI5B,WAAW4B,SAAS;IAC3E;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-usage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Which live pages a change reaches, for a site's screens, layouts and\n * components.\n *\n * The corpus read and the two closures over it, held here rather than in the\n * console because the console is no longer the only side that needs them. A\n * dataset record is written from the tenant as well — a form submission, an\n * automation step — and those writes make the same pages stale as a console\n * edit does. Two implementations of \"which screens render this\" is the shape\n * that put AGL-1223 in the tree: one reader handled a node tree's second\n * storage form and the other did not, so half the corpus answered \"used\n * nowhere\" and a publish dropped no cache.\n *\n * Nothing here is dataset-specific, and nothing is console-specific. The\n * console's `scan-artifact-usage` re-exports these and keeps the parts that\n * are about the console's own \"what would I break\" copy.\n */\n\nimport {\n nodesReferenceComponent,\n decodeStoredNodes,\n type ReusableComponentProp,\n} from '@aglyn/aglyn/server'\nimport {\n getTenantEmail,\n TENANT_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/tenant-email-catalog'\n\n/** A screen/layout/component reduced to what a usage scan needs. */\nexport interface UsageCandidate {\n id: string\n displayName?: string\n /** Legacy field some older documents used instead of `displayName`. */\n name?: string\n deletedAt?: unknown\n /**\n * Node tree to search. For screens and layouts this is the PUBLISHED\n * version's nodes (what visitors see); for components it is the definition\n * tree off the component document, which is what the runtime reads.\n */\n nodes?: Record<string, any> | null\n /** Published version, carried through so the caller can deep-link. */\n versionId?: string\n /** Screens only: the layout they render inside. */\n layoutId?: string\n /** Screens only: the screen they nest under, which is part of their path. */\n parentId?: string\n /**\n * Components only: the properties the definition declares (AGL-1247). A\n * Link property's default renders as a link wherever an instance leaves the\n * property unset, and it is stored here, not in `nodes` (AGL-2846).\n */\n props?: ReadonlyArray<ReusableComponentProp | null | undefined> | null\n}\n\n/** One document that depends on the artifact a scan was asked about. */\nexport interface UsageDependent {\n /**\n * `emailTemplate` is one of the site's own transactional emails\n * (`hosts/{h}/emailTemplates/{key}`), `id` its catalog key (AGL-3287). An\n * email designed for a campaign is a `kind: 'email'` SCREEN and reports as\n * `screen`.\n */\n type: 'screen' | 'layout' | 'component' | 'collection' | 'emailTemplate'\n id: string\n name: string\n via: Array<'id' | 'name'>\n versionId?: string\n /**\n * HOW the dependent references the artifact — screens and collection\n * listings (AGL-703, AGL-2806).\n *\n * A component or a layout has exactly one kind of dependent and the noun\n * says everything: an instance, or a binding. A screen has three, and they\n * break in three different ways — a link goes dead, a child moves, a\n * collection loses the page it renders through. Copy that could not tell\n * them apart would have to describe the worst case every time. A collection\n * listing's dependents are all links, and say so.\n */\n relation?: 'link' | 'child' | 'template'\n}\n\n/** The three corpora every closure below walks, and a fourth for components. */\nexport interface UsageSources {\n screens: UsageCandidate[]\n layouts: UsageCandidate[]\n components: UsageCandidate[]\n /**\n * The site's transactional email designs, each on its published version\n * (AGL-3287). Read only where a scan asks \"what places this component\" —\n * {@link scanComponentUsage} lists them — because an email is not a page:\n * nothing is cached for it, so no closure that decides which pages to drop\n * has any use for them, and none reads them.\n */\n emailTemplates?: UsageCandidate[]\n}\n\n/** `displayName`, falling back to a legacy `name`, then the raw id. */\nexport function usageCandidateLabel(candidate: UsageCandidate): string {\n return String(candidate.displayName ?? candidate.name ?? candidate.id)\n}\n\n/** A soft-deleted document renders nothing, so it depends on nothing. */\nexport const isLiveUsageCandidate = (candidate: UsageCandidate): boolean =>\n !candidate.deletedAt\n\n/**\n * Everything that references a reusable component (AGL-703).\n *\n * Every place the renderer expands an instance: published screen versions\n * (email designs among them — a campaign email is a screen), published layout\n * versions, OTHER component definitions — `composeReusableComponentNodes`\n * grafts nested instances, so a component used only inside another component\n * is genuinely used — and, when the caller read them, the site's transactional\n * emails, which graft a placed header or footer at send time (AGL-3287).\n * Omitting any of them would report \"used nowhere\" for something that renders\n * and invite a confident deletion, which is worse than showing nothing at all.\n */\nexport function scanComponentUsage(\n componentId: string,\n sources: UsageSources,\n): UsageDependent[] {\n if (!componentId) return []\n const dependents: UsageDependent[] = []\n const collect = (\n candidates: UsageCandidate[],\n type: UsageDependent['type'],\n ) => {\n for (const candidate of candidates) {\n if (!isLiveUsageCandidate(candidate)) continue\n // A component never counts as using itself, however it nests.\n if (type === 'component' && candidate.id === componentId) continue\n if (!nodesReferenceComponent(candidate.nodes, componentId)) continue\n dependents.push({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n // Instances reference by id, so a rename can never break them.\n via: ['id'],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n })\n }\n }\n collect(sources.screens, 'screen')\n collect(sources.layouts, 'layout')\n collect(sources.components, 'component')\n collect(sources.emailTemplates ?? [], 'emailTemplate')\n return dependents\n}\n\n/**\n * Everything rendering inside a layout (AGL-703).\n *\n * Two kinds of dependent, both expressed by the same `layoutId` pointer:\n *\n * - **screens**, which name the layout they render inside;\n * - **other layouts**, since a layout can itself sit inside one. A nested\n * layout is a real dependent — deleting its parent unwraps every screen\n * underneath it — so leaving layouts out would report a parent layout as\n * used only by the screens that name it directly, and none of the ones\n * that reach it through a child.\n *\n * A layout never counts as its own dependent; `canNestLayout` refuses that,\n * and this refuses to report it even if stored data holds one.\n */\nexport function scanLayoutUsage(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): UsageDependent[] {\n if (!layoutId) return []\n const dependentsOf = (\n candidates: UsageCandidate[],\n type: 'screen' | 'layout',\n ) =>\n candidates\n .filter(\n (candidate) =>\n isLiveUsageCandidate(candidate) &&\n candidate.layoutId === layoutId &&\n candidate.id !== layoutId,\n )\n .map((candidate) => ({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n via: ['id' as const],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n }))\n return [\n ...dependentsOf(screens, 'screen'),\n ...dependentsOf(layouts, 'layout'),\n ]\n}\n\n/**\n * Every live screen rendered inside `layoutId`, at ANY nesting depth\n * (AGL-1150).\n *\n * `scanLayoutUsage` answers one level. Layouts nest — a screen points at a\n * layout, which can point at a parent layout, and `compose-screen-nodes` walks\n * that whole chain when composing a page. So publishing a layout changes every\n * screen below it, not just the ones bound to it directly, and a cache drop\n * that only handles the direct level leaves the rest showing stale chrome for\n * the full revalidate window.\n *\n * Pure, and separate from the Firestore read, so the nesting behaviour is\n * testable without a database.\n *\n * Cycle-safe. `canNestLayout` refuses to create a cycle, but a document written\n * straight to Firestore is not bound by that, and a cycle here would hang a\n * publish request rather than surface anything.\n */\nexport function screenIdsUsingLayoutDeep(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): string[] {\n if (!layoutId) return []\n const screenIds = new Set<string>()\n const seenLayouts = new Set<string>([layoutId])\n let frontier = [layoutId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanLayoutUsage(id, screens, layouts)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (!seenLayouts.has(dependent.id)) {\n seenLayouts.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output contains `componentId`, however\n * indirectly (AGL-1161).\n *\n * `scanComponentUsage` answers one level and returns three kinds of dependent.\n * Only one of them is a screen, and the other two both reach screens by routes\n * a single-level scan cannot see:\n *\n * - a **component** dependent nests the target inside itself, and that outer\n * component may itself only be used inside a third — so component→component\n * edges have to be followed to a fixed point;\n * - a **layout** dependent puts the component in page chrome, which every\n * screen under that layout renders. Layouts nest, so that is\n * `screenIdsUsingLayoutDeep`, not a direct `layoutId` match.\n *\n * Miss either and a publish reports success while some pages keep serving the\n * old component for the full revalidate window — the failure this whole arc\n * exists to remove, and the one that is hardest to notice because the pages\n * that ARE dropped update instantly.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database — the same split `screenIdsUsingLayoutDeep` uses.\n *\n * Cycle-safe. `composeReusableComponentNodes` would not survive a cycle, but a\n * document written straight to Firestore is not bound by what the editor\n * allows, and a cycle here would hang a publish rather than surface anything.\n */\nexport function screenIdsUsingComponentDeep(\n componentId: string,\n sources: UsageSources,\n): string[] {\n if (!componentId) return []\n const screenIds = new Set<string>()\n const seenComponents = new Set<string>([componentId])\n // Layouts are resolved through their own deep walk, so remember which ones\n // have already been expanded: two components in the same layout would\n // otherwise re-walk the whole layout tree once each.\n const seenLayouts = new Set<string>()\n let frontier = [componentId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanComponentUsage(id, sources)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (dependent.type === 'layout') {\n if (seenLayouts.has(dependent.id)) continue\n seenLayouts.add(dependent.id)\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n dependent.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n } else if (\n dependent.type === 'component' &&\n !seenComponents.has(dependent.id)\n ) {\n seenComponents.add(dependent.id)\n next.push(dependent.id)\n }\n // A transactional email is not a page and nothing caches it, so it\n // contributes no screen and is not followed (AGL-3287). Its id is a\n // catalog key, and walking it as a component would read a key as a\n // definition that does not exist.\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\nexport interface UsageCandidateRead {\n candidates: UsageCandidate[]\n /**\n * The collection held more documents than `limit` allowed, so the scan below\n * it is INCOMPLETE.\n *\n * Returned rather than logged, because the two callers owe the user\n * different things: an advisory \"what would I break\" can show a partial\n * answer and say so, while a cache drop that silently scans a prefix reports\n * a successful publish and leaves real pages stale.\n */\n truncated: boolean\n}\n\n/**\n * One collection's documents, with published nodes attached when the scan\n * needs to search them.\n *\n * `limit` is a real bound, not a guess: it is fetched with one extra document\n * so exceeding it is DETECTED rather than assumed away. A caller that ignores\n * `truncated` is choosing to be wrong quietly.\n *\n * `emailTemplates` — the site's transactional email designs (AGL-3287) — has\n * the screens' shape: a parent holding the published `versionId`, the tree on\n * that version. The catalog is code and fixed, and a template document only\n * exists once somebody pressed Design, so most sites have none and the read\n * costs one empty query. Its documents carry no name, so each is labelled with\n * the catalog's.\n */\nexport async function readUsageCandidates(\n hostRef: FirebaseFirestore.DocumentReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n const { withNodes, limit } = options\n // One over the limit: if the extra document comes back, there was more than\n // we are about to look at. Cheaper than a count() and exact.\n const docs = await hostRef.collection(collectionName).limit(limit + 1).get()\n const truncated = docs.size > limit\n const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs\n\n const candidates = await Promise.all(\n inScope.map(async (docSnapshot) => {\n const versionId = docSnapshot.get('versionId')\n // Components keep their tree on the document; screens and layouts keep\n // it on the published version.\n //\n // BOTH reads are decoded (AGL-1223). A component document is msgpack\n // for anything promoted since components were compressed and a plain\n // map for everything older, and `decodeStoredNodes` returns a map\n // unchanged — so the branch below cannot be simplified back into a raw\n // read on either side.\n //\n // What rides on this: every consumer walks the value with\n // `Object.values`, which over a `Buffer` yields byte NUMBERS and\n // matches nothing. The answer that produces is \"used nowhere\" — for\n // `/api/hosts/where-used` an invitation to delete something a live page\n // renders, and for `/api/screens/revalidate` a publish that drops no\n // cache and leaves the old page served.\n const nodes =\n collectionName === 'components'\n ? decodeStoredNodes(docSnapshot.get('nodes'))\n : withNodes && versionId\n ? await docSnapshot.ref\n .collection('versions')\n .doc(String(versionId))\n .get()\n .then((version) => decodeStoredNodes(version.get('nodes')))\n .catch(() => null)\n : null\n return {\n id: docSnapshot.id,\n displayName:\n collectionName === TENANT_EMAIL_COLLECTION\n ? (getTenantEmail(docSnapshot.id)?.name ?? docSnapshot.id)\n : docSnapshot.get('displayName'),\n name: docSnapshot.get('name'),\n deletedAt: docSnapshot.get('deletedAt'),\n nodes,\n ...(versionId ? { versionId: String(versionId) } : {}),\n ...(docSnapshot.get('layoutId')\n ? { layoutId: String(docSnapshot.get('layoutId')) }\n : {}),\n // Screens only, and only the screen scan reads it: a child screen is\n // a dependent of its parent, because its PATH is built from it\n // (AGL-703).\n ...(docSnapshot.get('parentId')\n ? { parentId: String(docSnapshot.get('parentId')) }\n : {}),\n // Components only: the declared properties, whose Link defaults render\n // as links wherever an instance leaves one unset (AGL-2846). The same\n // document the tree came from, so it costs no read.\n ...(collectionName === 'components' &&\n Array.isArray(docSnapshot.get('props'))\n ? { props: docSnapshot.get('props') }\n : {}),\n } satisfies UsageCandidate\n }),\n )\n\n return { candidates, truncated }\n}\n\n/**\n * Every screen, layout and component of a site, with their node trees — the\n * corpus every tree-searching closure above walks.\n *\n * Each collection ONCE, in memory: a query per level would multiply round\n * trips by the nesting depth of the graph. Shared so two scans cannot read\n * different corpora under different bounds, and a change to one cannot quietly\n * narrow the other.\n */\nexport async function readUsageSources(\n hostRef: FirebaseFirestore.DocumentReference,\n limit: number,\n): Promise<{ candidates: UsageSources; truncated: boolean }> {\n const [screens, layouts, components] = await Promise.all([\n readUsageCandidates(hostRef, 'screens', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'layouts', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'components', { withNodes: true, limit }),\n ])\n return {\n candidates: {\n screens: screens.candidates,\n layouts: layouts.candidates,\n components: components.candidates,\n },\n truncated: screens.truncated || layouts.truncated || components.truncated,\n }\n}\n"],"names":["nodesReferenceComponent","decodeStoredNodes","getTenantEmail","TENANT_EMAIL_COLLECTION","usageCandidateLabel","candidate","String","displayName","name","id","isLiveUsageCandidate","deletedAt","scanComponentUsage","componentId","sources","dependents","collect","candidates","type","nodes","push","via","versionId","screens","layouts","components","emailTemplates","scanLayoutUsage","layoutId","dependentsOf","filter","map","screenIdsUsingLayoutDeep","screenIds","Set","seenLayouts","frontier","length","next","dependent","add","has","screenIdsUsingComponentDeep","seenComponents","screenId","readUsageCandidates","hostRef","collectionName","options","withNodes","limit","docs","collection","get","truncated","size","inScope","slice","Promise","all","docSnapshot","ref","doc","then","version","catch","parentId","Array","isArray","props","readUsageSources"],"mappings":"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"}
@@ -20,6 +20,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
20
20
  * and the projections the security rules authorize against are never
21
21
  * client-writable (docs/MULTI_TENANT_FIRESTORE.md §8).
22
22
  */ import { consentGroupForHost, checkHostCollaboratorQuota, checkSeatQuota, countCollaboratorSeats, countManagerSeatsExcluding, createResourceUid, generateOrgSlug, projectMemberResolvedPermissions, resolveOrgPermissions, isOrgWideMember, isValidOrgSlug, hostPermissionKeys, orgPermissionLabel, pluginOrgPermissionKeys, projectHostMemberPermissions, projectHostMemberRoles, projectMemberScopeTokens, resolveCollaboratorHostPermissions, scopeTokensForHost } from "@aglyn/aglyn/server";
23
+ import { ACCOUNT_ACQUISITION_FIELD, organizationAcquisition } from "@aglyn/aglyn/app-utils/account-acquisition";
23
24
  import { nameSearchKey, nameSearchReversed, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
24
25
  // LEAF MODULE, NOT THE BARREL (AGL-1289). This file is itself reachable
25
26
  // through `@aglyn/aglyn/server`, and the verdict route proved this week that a
@@ -171,6 +172,11 @@ reservation, now = Date.now()) {
171
172
  if (!isSlugReservationClaimable(held, null) || isSlugReservationLapsed(held) && await lapsedReservationIsStillHeld(held)) {
172
173
  throw new OrgSlugTakenError(slug);
173
174
  }
175
+ // Where the workspace came from is where its creator came from
176
+ // (AGL-3289): the creator's acquisition is copied onto the org at birth,
177
+ // naming the account it was copied from. Read here, before the ceiling's
178
+ // first write, because a transaction reads everything first.
179
+ const creator = await tx.get(db.collection('users').doc(ownerUid));
174
180
  // Last read, first write: the ceiling counts inside this transaction, so
175
181
  // a retry recounts, and it writes the per-owner marker that makes two
176
182
  // concurrent creates by one account contend. Throws
@@ -239,6 +245,9 @@ reservation, now = Date.now()) {
239
245
  // stops "hand the workspace to an alt account, create another, take it
240
246
  // back" from being a way past the free-workspace ceiling.
241
247
  createdByUid: ownerUid,
248
+ // Written once, here, and denied to every client by the rules: an
249
+ // organization's origin is the platform's record, never its own.
250
+ [ACCOUNT_ACQUISITION_FIELD]: organizationAcquisition(creator.exists ? creator.get(ACCOUNT_ACQUISITION_FIELD) : null, ownerUid, Date.now()),
242
251
  hosts: {},
243
252
  createdAt: FieldValue.serverTimestamp(),
244
253
  updatedAt: FieldValue.serverTimestamp()