@aglyn/tenant-runtime 1.0.0-beta.143

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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +26 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +21 -0
  5. package/src/index.js +22 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/apply-publish-schedule.d.ts +34 -0
  8. package/src/lib/apply-publish-schedule.js +285 -0
  9. package/src/lib/apply-publish-schedule.js.map +1 -0
  10. package/src/lib/assign-contact-owner.d.ts +162 -0
  11. package/src/lib/assign-contact-owner.js +322 -0
  12. package/src/lib/assign-contact-owner.js.map +1 -0
  13. package/src/lib/associate-company-by-domain.d.ts +70 -0
  14. package/src/lib/associate-company-by-domain.js +150 -0
  15. package/src/lib/associate-company-by-domain.js.map +1 -0
  16. package/src/lib/author-page-nodes.d.ts +36 -0
  17. package/src/lib/author-page-nodes.js +296 -0
  18. package/src/lib/author-page-nodes.js.map +1 -0
  19. package/src/lib/built-in-page-layout.d.ts +48 -0
  20. package/src/lib/built-in-page-layout.js +107 -0
  21. package/src/lib/built-in-page-layout.js.map +1 -0
  22. package/src/lib/capture-host-contact.d.ts +95 -0
  23. package/src/lib/capture-host-contact.js +137 -0
  24. package/src/lib/capture-host-contact.js.map +1 -0
  25. package/src/lib/collection-fallback-nodes.d.ts +90 -0
  26. package/src/lib/collection-fallback-nodes.js +602 -0
  27. package/src/lib/collection-fallback-nodes.js.map +1 -0
  28. package/src/lib/compose-author-page.d.ts +109 -0
  29. package/src/lib/compose-author-page.js +225 -0
  30. package/src/lib/compose-author-page.js.map +1 -0
  31. package/src/lib/compose-collection-page.d.ts +94 -0
  32. package/src/lib/compose-collection-page.js +256 -0
  33. package/src/lib/compose-collection-page.js.map +1 -0
  34. package/src/lib/compose-screen-nodes.d.ts +156 -0
  35. package/src/lib/compose-screen-nodes.js +547 -0
  36. package/src/lib/compose-screen-nodes.js.map +1 -0
  37. package/src/lib/compose-search-page.d.ts +39 -0
  38. package/src/lib/compose-search-page.js +57 -0
  39. package/src/lib/compose-search-page.js.map +1 -0
  40. package/src/lib/convert-host-lead.d.ts +189 -0
  41. package/src/lib/convert-host-lead.js +443 -0
  42. package/src/lib/convert-host-lead.js.map +1 -0
  43. package/src/lib/defer-lazy-panels.d.ts +58 -0
  44. package/src/lib/defer-lazy-panels.js +171 -0
  45. package/src/lib/defer-lazy-panels.js.map +1 -0
  46. package/src/lib/element-animation-assets.d.ts +62 -0
  47. package/src/lib/element-animation-assets.js +290 -0
  48. package/src/lib/element-animation-assets.js.map +1 -0
  49. package/src/lib/emit-host-event.d.ts +29 -0
  50. package/src/lib/emit-host-event.js +30 -0
  51. package/src/lib/emit-host-event.js.map +1 -0
  52. package/src/lib/enrich-gated-page.d.ts +67 -0
  53. package/src/lib/enrich-gated-page.js +134 -0
  54. package/src/lib/enrich-gated-page.js.map +1 -0
  55. package/src/lib/entry-link-routes.d.ts +61 -0
  56. package/src/lib/entry-link-routes.js +169 -0
  57. package/src/lib/entry-link-routes.js.map +1 -0
  58. package/src/lib/get-author-content.d.ts +119 -0
  59. package/src/lib/get-author-content.js +288 -0
  60. package/src/lib/get-author-content.js.map +1 -0
  61. package/src/lib/get-collection-content.d.ts +290 -0
  62. package/src/lib/get-collection-content.js +565 -0
  63. package/src/lib/get-collection-content.js.map +1 -0
  64. package/src/lib/get-components.d.ts +24 -0
  65. package/src/lib/get-components.js +102 -0
  66. package/src/lib/get-components.js.map +1 -0
  67. package/src/lib/get-datasets.d.ts +45 -0
  68. package/src/lib/get-datasets.js +169 -0
  69. package/src/lib/get-datasets.js.map +1 -0
  70. package/src/lib/get-forms.d.ts +24 -0
  71. package/src/lib/get-forms.js +116 -0
  72. package/src/lib/get-forms.js.map +1 -0
  73. package/src/lib/get-layout-version.d.ts +26 -0
  74. package/src/lib/get-layout-version.js +99 -0
  75. package/src/lib/get-layout-version.js.map +1 -0
  76. package/src/lib/get-media-asset-facts.d.ts +74 -0
  77. package/src/lib/get-media-asset-facts.js +116 -0
  78. package/src/lib/get-media-asset-facts.js.map +1 -0
  79. package/src/lib/get-plugin-installs.d.ts +29 -0
  80. package/src/lib/get-plugin-installs.js +94 -0
  81. package/src/lib/get-plugin-installs.js.map +1 -0
  82. package/src/lib/get-screen-version.d.ts +27 -0
  83. package/src/lib/get-screen-version.js +66 -0
  84. package/src/lib/get-screen-version.js.map +1 -0
  85. package/src/lib/get-screen.d.ts +39 -0
  86. package/src/lib/get-screen.js +126 -0
  87. package/src/lib/get-screen.js.map +1 -0
  88. package/src/lib/get-variables.d.ts +41 -0
  89. package/src/lib/get-variables.js +128 -0
  90. package/src/lib/get-variables.js.map +1 -0
  91. package/src/lib/get-video-asset-facts.d.ts +22 -0
  92. package/src/lib/get-video-asset-facts.js +22 -0
  93. package/src/lib/get-video-asset-facts.js.map +1 -0
  94. package/src/lib/host-event-listeners.d.ts +83 -0
  95. package/src/lib/host-event-listeners.js +91 -0
  96. package/src/lib/host-event-listeners.js.map +1 -0
  97. package/src/lib/org-permissions.d.ts +63 -0
  98. package/src/lib/org-permissions.js +187 -0
  99. package/src/lib/org-permissions.js.map +1 -0
  100. package/src/lib/repeat-record-pages.d.ts +48 -0
  101. package/src/lib/repeat-record-pages.js +64 -0
  102. package/src/lib/repeat-record-pages.js.map +1 -0
  103. package/src/lib/required-site-plugins.d.ts +114 -0
  104. package/src/lib/required-site-plugins.js +121 -0
  105. package/src/lib/required-site-plugins.js.map +1 -0
  106. package/src/lib/resolve-dataset.d.ts +40 -0
  107. package/src/lib/resolve-dataset.js +64 -0
  108. package/src/lib/resolve-dataset.js.map +1 -0
  109. package/src/lib/search-results-nodes.d.ts +48 -0
  110. package/src/lib/search-results-nodes.js +302 -0
  111. package/src/lib/search-results-nodes.js.map +1 -0
  112. package/src/lib/social-image-facts.d.ts +107 -0
  113. package/src/lib/social-image-facts.js +133 -0
  114. package/src/lib/social-image-facts.js.map +1 -0
  115. package/src/lib/stamp-form-dataset-bindings.d.ts +32 -0
  116. package/src/lib/stamp-form-dataset-bindings.js +59 -0
  117. package/src/lib/stamp-form-dataset-bindings.js.map +1 -0
  118. package/src/lib/template-screens.d.ts +227 -0
  119. package/src/lib/template-screens.js +364 -0
  120. package/src/lib/template-screens.js.map +1 -0
@@ -0,0 +1,91 @@
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
+ */ const registrations = [];
17
+ /** Whether the no-listener warning has been printed in this process. */ let warnedEmpty = false;
18
+ /** A listener's answer as a list: nothing it said counts as no alerts. */ function alertsOf(answer) {
19
+ return Array.isArray(answer) ? [
20
+ ...answer
21
+ ] : [];
22
+ }
23
+ /**
24
+ * Subscribes a plugin to every host event. Registering again under the same
25
+ * plugin id replaces the earlier listener in place, so a register function
26
+ * the platform calls twice — boot, then the API surface — leaves one.
27
+ */ export function registerHostEventListener(pluginId, listener) {
28
+ const id = String(pluginId != null ? pluginId : '').trim();
29
+ if (!id) {
30
+ throw new Error('a host event listener needs the id of the plugin it belongs to');
31
+ }
32
+ const at = registrations.findIndex((entry)=>entry.pluginId === id);
33
+ if (at >= 0) registrations[at] = {
34
+ pluginId: id,
35
+ listener
36
+ };
37
+ else registrations.push({
38
+ pluginId: id,
39
+ listener
40
+ });
41
+ }
42
+ /** The plugins listening, in registration order, for diagnostics. */ export function listHostEventListeners() {
43
+ return registrations.map((entry)=>entry.pluginId);
44
+ }
45
+ /** Test seam: forget every listener. */ export function resetHostEventListenersForTests() {
46
+ registrations.length = 0;
47
+ warnedEmpty = false;
48
+ }
49
+ /**
50
+ * Hands one event to every listener, concurrently, and gathers the alerts
51
+ * they produced. Never rejects.
52
+ */ export async function runHostEventListeners(hostId, event, payload = {}) {
53
+ if (!registrations.length) {
54
+ /*
55
+ * Said once per process rather than per event. No listener means the
56
+ * plugins' server declarations never ran here — every automation on
57
+ * every site served by this process is silent, and a line in the log is
58
+ * the only place that is visible.
59
+ */ if (!warnedEmpty) {
60
+ warnedEmpty = true;
61
+ console.warn(`[host-events] nothing is listening; "${event}" on ${hostId} ran no ` + 'automation. Listeners register from the plugins’ server ' + 'declarations at boot.');
62
+ }
63
+ return [];
64
+ }
65
+ const settled = await Promise.all(registrations.map(async ({ pluginId, listener })=>{
66
+ try {
67
+ return alertsOf(await listener.onEvent(hostId, event, payload));
68
+ } catch (error) {
69
+ console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error);
70
+ return [];
71
+ }
72
+ }));
73
+ return settled.flat();
74
+ }
75
+ /**
76
+ * Hands one page-dispatched automation to the listeners that dispatch, and
77
+ * gathers the alerts it produced. Never rejects.
78
+ */ export async function dispatchHostAutomation(hostId, automationId, event, payload = {}) {
79
+ const alerts = [];
80
+ for (const { pluginId, listener } of registrations){
81
+ if (!listener.onDispatch) continue;
82
+ try {
83
+ alerts.push(...alertsOf(await listener.onDispatch(hostId, automationId, event, payload)));
84
+ } catch (error) {
85
+ console.error(`[host-events] ${pluginId} failed to dispatch ${automationId}`, hostId, error);
86
+ }
87
+ }
88
+ return alerts;
89
+ }
90
+
91
+ //# sourceMappingURL=host-event-listeners.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/host-event-listeners.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 type { HostActionAlert } from '@aglyn/aglyn/server'\n\n/**\n * WHO HEARS A HOST EVENT.\n *\n * A host event — a form submitted, a member signed up, a deal won — is raised\n * by the server door that performed the write, through `emitHostEvent`. What\n * runs because of it is not the runtime's business: an automation engine, an\n * integration, a marketplace plugin. Each registers a listener here and the\n * runtime fans every event out to all of them, knowing none by name.\n *\n * ## Registered by a call, at boot\n *\n * A listener is registered by a function the platform calls BY NAME — a\n * plugin's `serverDeclarations` entry, which both apps run once per process\n * from `instrumentation.ts`, and its API register functions, which the plugin\n * loader runs. Never by a bare `import './x'`: a bundler drops a module its\n * package does not list as a side effect, and jest does not, so a registration\n * that relies on one passes every spec and runs in no build (AGL-3025).\n *\n * Boot registration is what lets a core route that never loads a plugin — the\n * form submission, the page-view collector — still reach every listener.\n *\n * ## Isolated, and never thrown into the emitter\n *\n * The event has already happened: the submission is stored, the member exists.\n * A listener that throws is logged against its plugin and the others still\n * run, and the emitting request never sees the failure.\n */\n\n/** What an event carries into everything that listens for it. */\nexport type HostEventPayload = Record<string, string | number | boolean>\n\nexport interface HostEventListener {\n /**\n * Runs whatever this plugin does when `event` happens on the site.\n *\n * Resolves the site alerts a request/response emitter shows its visitor —\n * a form submission returns them in its response — or nothing.\n */\n onEvent(\n hostId: string,\n event: string,\n payload: HostEventPayload,\n ): Promise<readonly HostActionAlert[] | void>\n /**\n * Runs ONE automation, by id, that a published page fired for a site event\n * it evaluated itself — a scroll depth, a click, exit intent. The page has\n * already decided the trigger matched, so the listener runs that one\n * automation rather than every automation on the event; a listener that\n * does not own the id resolves nothing.\n */\n onDispatch?(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload,\n ): Promise<readonly HostActionAlert[] | void>\n}\n\ninterface Registration {\n pluginId: string\n listener: HostEventListener\n}\n\nconst registrations: Registration[] = []\n\n/** Whether the no-listener warning has been printed in this process. */\nlet warnedEmpty = false\n\n/** A listener's answer as a list: nothing it said counts as no alerts. */\nfunction alertsOf(answer: readonly HostActionAlert[] | void): HostActionAlert[] {\n return Array.isArray(answer) ? [...answer] : []\n}\n\n/**\n * Subscribes a plugin to every host event. Registering again under the same\n * plugin id replaces the earlier listener in place, so a register function\n * the platform calls twice — boot, then the API surface — leaves one.\n */\nexport function registerHostEventListener(\n pluginId: string,\n listener: HostEventListener,\n): void {\n const id = String(pluginId ?? '').trim()\n if (!id) {\n throw new Error('a host event listener needs the id of the plugin it belongs to')\n }\n const at = registrations.findIndex((entry) => entry.pluginId === id)\n if (at >= 0) registrations[at] = { pluginId: id, listener }\n else registrations.push({ pluginId: id, listener })\n}\n\n/** The plugins listening, in registration order, for diagnostics. */\nexport function listHostEventListeners(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every listener. */\nexport function resetHostEventListenersForTests(): void {\n registrations.length = 0\n warnedEmpty = false\n}\n\n/**\n * Hands one event to every listener, concurrently, and gathers the alerts\n * they produced. Never rejects.\n */\nexport async function runHostEventListeners(\n hostId: string,\n event: string,\n payload: HostEventPayload = {},\n): Promise<HostActionAlert[]> {\n if (!registrations.length) {\n /*\n * Said once per process rather than per event. No listener means the\n * plugins' server declarations never ran here — every automation on\n * every site served by this process is silent, and a line in the log is\n * the only place that is visible.\n */\n if (!warnedEmpty) {\n warnedEmpty = true\n console.warn(\n `[host-events] nothing is listening; \"${event}\" on ${hostId} ran no ` +\n 'automation. Listeners register from the plugins’ server ' +\n 'declarations at boot.',\n )\n }\n return []\n }\n const settled = await Promise.all(\n registrations.map(async ({ pluginId, listener }) => {\n try {\n return alertsOf(await listener.onEvent(hostId, event, payload))\n } catch (error) {\n console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error)\n return []\n }\n }),\n )\n return settled.flat()\n}\n\n/**\n * Hands one page-dispatched automation to the listeners that dispatch, and\n * gathers the alerts it produced. Never rejects.\n */\nexport async function dispatchHostAutomation(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload = {},\n): Promise<HostActionAlert[]> {\n const alerts: HostActionAlert[] = []\n for (const { pluginId, listener } of registrations) {\n if (!listener.onDispatch) continue\n try {\n alerts.push(\n ...alertsOf(await listener.onDispatch(hostId, automationId, event, payload)),\n )\n } catch (error) {\n console.error(\n `[host-events] ${pluginId} failed to dispatch ${automationId}`,\n hostId,\n error,\n )\n }\n }\n return alerts\n}\n"],"names":["registrations","warnedEmpty","alertsOf","answer","Array","isArray","registerHostEventListener","pluginId","listener","id","String","trim","Error","at","findIndex","entry","push","listHostEventListeners","map","resetHostEventListenersForTests","length","runHostEventListeners","hostId","event","payload","console","warn","settled","Promise","all","onEvent","error","flat","dispatchHostAutomation","automationId","alerts","onDispatch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAmED,MAAMA,gBAAgC,EAAE;AAExC,sEAAsE,GACtE,IAAIC,cAAc;AAElB,wEAAwE,GACxE,SAASC,SAASC,MAAyC;IACzD,OAAOC,MAAMC,OAAO,CAACF,UAAU;WAAIA;KAAO,GAAG,EAAE;AACjD;AAEA;;;;CAIC,GACD,OAAO,SAASG,0BACdC,QAAgB,EAChBC,QAA2B;IAE3B,MAAMC,KAAKC,OAAOH,mBAAAA,WAAY,IAAII,IAAI;IACtC,IAAI,CAACF,IAAI;QACP,MAAM,IAAIG,MAAM;IAClB;IACA,MAAMC,KAAKb,cAAcc,SAAS,CAAC,CAACC,QAAUA,MAAMR,QAAQ,KAAKE;IACjE,IAAII,MAAM,GAAGb,aAAa,CAACa,GAAG,GAAG;QAAEN,UAAUE;QAAID;IAAS;SACrDR,cAAcgB,IAAI,CAAC;QAAET,UAAUE;QAAID;IAAS;AACnD;AAEA,mEAAmE,GACnE,OAAO,SAASS;IACd,OAAOjB,cAAckB,GAAG,CAAC,CAACH,QAAUA,MAAMR,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASY;IACdnB,cAAcoB,MAAM,GAAG;IACvBnB,cAAc;AAChB;AAEA;;;CAGC,GACD,OAAO,eAAeoB,sBACpBC,MAAc,EACdC,KAAa,EACbC,UAA4B,CAAC,CAAC;IAE9B,IAAI,CAACxB,cAAcoB,MAAM,EAAE;QACzB;;;;;KAKC,GACD,IAAI,CAACnB,aAAa;YAChBA,cAAc;YACdwB,QAAQC,IAAI,CACV,CAAC,qCAAqC,EAAEH,MAAM,KAAK,EAAED,OAAO,QAAQ,CAAC,GACnE,6DACA;QAEN;QACA,OAAO,EAAE;IACX;IACA,MAAMK,UAAU,MAAMC,QAAQC,GAAG,CAC/B7B,cAAckB,GAAG,CAAC,OAAO,EAAEX,QAAQ,EAAEC,QAAQ,EAAE;QAC7C,IAAI;YACF,OAAON,SAAS,MAAMM,SAASsB,OAAO,CAACR,QAAQC,OAAOC;QACxD,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CAAC,CAAC,cAAc,EAAExB,SAAS,WAAW,EAAEgB,OAAO,EAAED,QAAQS;YACtE,OAAO,EAAE;QACX;IACF;IAEF,OAAOJ,QAAQK,IAAI;AACrB;AAEA;;;CAGC,GACD,OAAO,eAAeC,uBACpBX,MAAc,EACdY,YAAoB,EACpBX,KAAa,EACbC,UAA4B,CAAC,CAAC;IAE9B,MAAMW,SAA4B,EAAE;IACpC,KAAK,MAAM,EAAE5B,QAAQ,EAAEC,QAAQ,EAAE,IAAIR,cAAe;QAClD,IAAI,CAACQ,SAAS4B,UAAU,EAAE;QAC1B,IAAI;YACFD,OAAOnB,IAAI,IACNd,SAAS,MAAMM,SAAS4B,UAAU,CAACd,QAAQY,cAAcX,OAAOC;QAEvE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CACX,CAAC,cAAc,EAAExB,SAAS,oBAAoB,EAAE2B,cAAc,EAC9DZ,QACAS;QAEJ;IACF;IACA,OAAOI;AACT"}
@@ -0,0 +1,63 @@
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 HostAccessRole, type OrgRole, type OrgPermissionSet, type ResolvedOrgPermissionSet } from '@aglyn/aglyn/server';
18
+ export type { OrgPermissionSet, ResolvedOrgPermissionSet };
19
+ export interface ResolvedOrgPermissions {
20
+ /** Org the permissions were resolved in (null before the first org). */
21
+ orgId: string | null;
22
+ role: OrgRole | null;
23
+ /** Owner/admin of the org — full account-level control. */
24
+ isOwner: boolean;
25
+ /**
26
+ * Widened past the legacy six (AGL-2474) so a route can read a
27
+ * plugin-declared key such as `permissions.managePos` without a cast.
28
+ */
29
+ permissions: ResolvedOrgPermissionSet;
30
+ /**
31
+ * The member's reach across the org (AGL-1026). `false` for a site
32
+ * collaborator, whose membership exists only to carry access to named
33
+ * sites — anything org-wide (the roster, billing, the shared CRM) is not
34
+ * theirs to see, and callers that answer org-level questions must say so.
35
+ */
36
+ orgWide: boolean;
37
+ /**
38
+ * Their role on the host in context, when one was given. Null means they
39
+ * have no access to THAT site even though they are on the org roster.
40
+ */
41
+ /**
42
+ * The caller's role ON the host in context, or null. Spelled as the shared
43
+ * `HostAccessRole` since AGL-2334 rather than repeated inline: the union
44
+ * gained `author`, and a hand-copied duplicate is a tripwire that fires as
45
+ * a type error somewhere unrelated instead of a decision made here.
46
+ */
47
+ hostRole: HostAccessRole | null;
48
+ }
49
+ /**
50
+ * Org-role permission resolver (AGL-238, replacing the manager-seat
51
+ * resolver from AGL-108): the user's role in the relevant org decides the
52
+ * permission flags. Accounts with no org yet resolve as owners with full
53
+ * access — the org is created on first need. A lookup error fails CLOSED
54
+ * when a specific org/host was targeted (AGL-506) — routes like hosts/members
55
+ * OR these flags into an auth decision, so a transient error must not hand
56
+ * out manageMembers. Only the context-free fresh-account case keeps the
57
+ * owner default.
58
+ */
59
+ export declare function resolveOrgPermissions(uid: string, context?: {
60
+ orgId?: string | null;
61
+ hostId?: string | null;
62
+ }): Promise<ResolvedOrgPermissions>;
63
+ export default resolveOrgPermissions;
@@ -0,0 +1,187 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { hostRoleFor, isOrgWideMember, resolveRolePermissions, toLegacyPermissions, listPluginPermissionKeys, ORG_ROLE_TIER } from "@aglyn/aglyn/server";
18
+ import { resolveMemberOrgPermissions, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
19
+ /** Org roles map onto the built-in permission sets key-for-key. */ // Which host-role permission set each ORG role resolves to. `author` is
20
+ // deliberately absent as a VALUE here (AGL-2334): it is a per-site grant, so
21
+ // no org role maps onto it and `hostRoleFor` can never produce one for an
22
+ // `allHosts` member. Kept as the narrow union so that stays enforced.
23
+ // One table, shared with the console since AGL-2474 (`ORG_ROLE_TIER`): a
24
+ // second copy here is how the client and the server come to disagree about
25
+ // what an owner is. The local NAME stays — `org-permissions-client-server-
26
+ // agree.spec.ts` matches on this expression's source text.
27
+ const ORG_ROLE_PERMISSION_BASE = ORG_ROLE_TIER;
28
+ // EVALUATED PER CALL, not once at module load (AGL-2474). Plugins register
29
+ // their permission keys at module scope (`registerCommerceApi`), and whether
30
+ // that has happened by the time THIS module is first imported is import-order
31
+ // luck. Snapshotting here produced a map with no plugin keys at all, so an
32
+ // owner on the fresh-account path read `managePos === undefined` — falsy, and
33
+ // denied — while an identical owner resolved a millisecond later through the
34
+ // membership path got `true`. The cost is one small object per call.
35
+ const allTrue = ()=>resolveRolePermissions('admin');
36
+ const none = ()=>resolveRolePermissions('viewer');
37
+ /**
38
+ * Per-member overrides for PLUGIN-declared keys (AGL-2474). The dotted
39
+ * granular catalog that `toLegacyPermissions` translates knows nothing about
40
+ * them, so without this pass a plugin key could not be revoked from a member
41
+ * who has it by tier default, nor granted to one who does not — the override
42
+ * map on the member doc is already typed `Record<string, boolean>` and has
43
+ * always been able to CARRY the key; nothing read it.
44
+ */ function pluginPermissionOverrides(overrides) {
45
+ const applied = {};
46
+ if (!overrides) return applied;
47
+ for (const key of listPluginPermissionKeys()){
48
+ const value = overrides[key];
49
+ if (typeof value === 'boolean') applied[key] = value;
50
+ }
51
+ return applied;
52
+ }
53
+ /**
54
+ * Permissions that are about the ORG, not about a site (AGL-1026).
55
+ *
56
+ * The permission set mixes the two: `editHosts` and `installPlugins` are
57
+ * things you do to a site, while creating sites, paying for them, publishing
58
+ * as the org and changing the roster are things you do to the workspace. A
59
+ * site collaborator can be an admin OF THEIR SITE — that is the whole point of
60
+ * the role — so their host role has to grant the site-level keys while these
61
+ * stay off regardless of it.
62
+ */ const ORG_LEVEL_PERMISSIONS = [
63
+ 'createHosts',
64
+ 'editBilling',
65
+ 'manageMembers',
66
+ // Publishing is org-owned since AGL-652 — a listing carries the workspace's
67
+ // name and takes its money, so a contractor on one site does not ship one.
68
+ 'publishToMarketplace'
69
+ ];
70
+ /** On the roster but with no standing here — or not on it at all. */ const denied = ()=>({
71
+ orgId: null,
72
+ role: null,
73
+ isOwner: false,
74
+ permissions: none(),
75
+ orgWide: false,
76
+ hostRole: null
77
+ });
78
+ /**
79
+ * Org-role permission resolver (AGL-238, replacing the manager-seat
80
+ * resolver from AGL-108): the user's role in the relevant org decides the
81
+ * permission flags. Accounts with no org yet resolve as owners with full
82
+ * access — the org is created on first need. A lookup error fails CLOSED
83
+ * when a specific org/host was targeted (AGL-506) — routes like hosts/members
84
+ * OR these flags into an auth decision, so a transient error must not hand
85
+ * out manageMembers. Only the context-free fresh-account case keeps the
86
+ * owner default.
87
+ */ export async function resolveOrgPermissions(uid, context = {}) {
88
+ try {
89
+ var _context_orgId;
90
+ const orgId = (_context_orgId = context.orgId) != null ? _context_orgId : context.hostId ? await resolveOrgIdForHost(context.hostId) : null;
91
+ const membership = await resolveOrgMembership(uid, orgId);
92
+ if (!membership) {
93
+ // No org at all → fresh account, acts as its future org's owner.
94
+ // An org WAS targeted but the uid is not on the roster → no access.
95
+ return orgId ? _extends({}, denied(), {
96
+ orgId
97
+ }) : {
98
+ orgId: null,
99
+ role: null,
100
+ isOwner: true,
101
+ permissions: allTrue(),
102
+ orgWide: true,
103
+ hostRole: null
104
+ };
105
+ }
106
+ const role = membership.member.role;
107
+ // Owner/admin, `allHosts`, and the legacy pre-`allHosts` shape all read
108
+ // as org-wide; `isOrgWideMember` owns that call so this gate, the
109
+ // `scopeTokens` projection and the rules cannot drift apart (AGL-1038).
110
+ const orgWide = isOrgWideMember(membership.member);
111
+ const hostRole = context.hostId ? hostRoleFor(membership.member, context.hostId) : null;
112
+ // A site collaborator's permissions are their HOST role's, never their org
113
+ // role's (AGL-1026). The membership doc exists to carry site access, so
114
+ // reading `role` off it and stopping there answered an org-level question
115
+ // with a site-level fact: an "editor on one site" resolved as an editor of
116
+ // every site in the org. When a host is named and they have no access to
117
+ // it, that is simply no access — being on the roster is not a grant.
118
+ if (!orgWide) {
119
+ if (context.hostId && !hostRole) return _extends({}, denied(), {
120
+ orgId: membership.orgId
121
+ });
122
+ return {
123
+ orgId: membership.orgId,
124
+ role,
125
+ isOwner: false,
126
+ // With no host in context there is nothing to scope to, so a scoped
127
+ // member gets the floor rather than their site role — the caller is
128
+ // asking an org-wide question they have no standing to answer.
129
+ permissions: _extends({}, resolveRolePermissions(hostRole != null ? hostRole : 'viewer'), Object.fromEntries(ORG_LEVEL_PERMISSIONS.map((key)=>[
130
+ key,
131
+ false
132
+ ])), pluginPermissionOverrides(membership.member.permissions)),
133
+ orgWide: false,
134
+ hostRole
135
+ };
136
+ }
137
+ // Custom role + per-member overrides, which this resolver used to drop
138
+ // (AGL-2350). `resolveRolePermissions(tier)` alone is the BUILT-IN role's
139
+ // defaults, so every refinement `custom-roles.md` sells was invisible to
140
+ // the server while the console applied it — a permission granted by a
141
+ // custom role showed in the UI and 403'd on POST, and one revoked by an
142
+ // override was hidden in the UI and still succeeded on POST.
143
+ //
144
+ // OVERLAID, never substituted: `resolveRolePermissions` also mixes in
145
+ // plugin-declared keys (AGL-435) that the granular catalog knows nothing
146
+ // about, so replacing the map wholesale would silently strip them.
147
+ const granular = await resolveMemberOrgPermissions(membership.orgId, membership.member);
148
+ return {
149
+ orgId: membership.orgId,
150
+ role,
151
+ isOwner: role === 'owner' || role === 'admin',
152
+ permissions: _extends({}, resolveRolePermissions(ORG_ROLE_PERMISSION_BASE[role]), toLegacyPermissions(granular, role), pluginPermissionOverrides(membership.member.permissions)),
153
+ orgWide: true,
154
+ hostRole: hostRole != null ? hostRole : ORG_ROLE_PERMISSION_BASE[role]
155
+ };
156
+ } catch (error) {
157
+ // Fail CLOSED when a specific org/host was targeted (AGL-506): a lookup
158
+ // error must never grant full permissions for a real org. Only the
159
+ // context-free fresh-account case keeps the owner default.
160
+ // PRESENCE, not truthiness (AGL-1881). `publish-plugin` passes
161
+ // `{ orgId: String(body?.orgId ?? '').trim() || undefined }`, so an
162
+ // attacker-controlled body that simply omits `orgId` made this test false
163
+ // and a transient Firestore error resolved through the OPEN branch below
164
+ // with `publishToMarketplace: true`. The key is present on every targeted
165
+ // call; only the genuinely context-free `resolveOrgPermissions(uid)` — the
166
+ // fresh-account case the open branch exists for — omits both.
167
+ if ('orgId' in context || 'hostId' in context) {
168
+ var _context_orgId1;
169
+ console.error('org-permissions resolve failed (failing closed)', error);
170
+ return _extends({}, denied(), {
171
+ orgId: (_context_orgId1 = context.orgId) != null ? _context_orgId1 : null
172
+ });
173
+ }
174
+ console.error('org-permissions resolve failed (no org targeted)', error);
175
+ return {
176
+ orgId: null,
177
+ role: null,
178
+ isOwner: true,
179
+ permissions: allTrue(),
180
+ orgWide: true,
181
+ hostRole: null
182
+ };
183
+ }
184
+ }
185
+ export default resolveOrgPermissions;
186
+
187
+ //# sourceMappingURL=org-permissions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/org-permissions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type HostAccessRole,\n hostRoleFor,\n isOrgWideMember,\n type OrgRole,\n resolveRolePermissions,\n toLegacyPermissions,\n type OrgPermissionSet,\n type ResolvedOrgPermissionSet,\n listPluginPermissionKeys,\n ORG_ROLE_TIER,\n} from '@aglyn/aglyn/server'\nimport {\n resolveMemberOrgPermissions,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n\nexport type { OrgPermissionSet, ResolvedOrgPermissionSet }\n\nexport interface ResolvedOrgPermissions {\n /** Org the permissions were resolved in (null before the first org). */\n orgId: string | null\n role: OrgRole | null\n /** Owner/admin of the org — full account-level control. */\n isOwner: boolean\n /**\n * Widened past the legacy six (AGL-2474) so a route can read a\n * plugin-declared key such as `permissions.managePos` without a cast.\n */\n permissions: ResolvedOrgPermissionSet\n /**\n * The member's reach across the org (AGL-1026). `false` for a site\n * collaborator, whose membership exists only to carry access to named\n * sites — anything org-wide (the roster, billing, the shared CRM) is not\n * theirs to see, and callers that answer org-level questions must say so.\n */\n orgWide: boolean\n /**\n * Their role on the host in context, when one was given. Null means they\n * have no access to THAT site even though they are on the org roster.\n */\n /**\n * The caller's role ON the host in context, or null. Spelled as the shared\n * `HostAccessRole` since AGL-2334 rather than repeated inline: the union\n * gained `author`, and a hand-copied duplicate is a tripwire that fires as\n * a type error somewhere unrelated instead of a decision made here.\n */\n hostRole: HostAccessRole | null\n}\n\n/** Org roles map onto the built-in permission sets key-for-key. */\n// Which host-role permission set each ORG role resolves to. `author` is\n// deliberately absent as a VALUE here (AGL-2334): it is a per-site grant, so\n// no org role maps onto it and `hostRoleFor` can never produce one for an\n// `allHosts` member. Kept as the narrow union so that stays enforced.\n// One table, shared with the console since AGL-2474 (`ORG_ROLE_TIER`): a\n// second copy here is how the client and the server come to disagree about\n// what an owner is. The local NAME stays — `org-permissions-client-server-\n// agree.spec.ts` matches on this expression's source text.\nconst ORG_ROLE_PERMISSION_BASE: Record<OrgRole, 'admin' | 'editor' | 'viewer'> =\n ORG_ROLE_TIER as Record<OrgRole, 'admin' | 'editor' | 'viewer'>\n\n// EVALUATED PER CALL, not once at module load (AGL-2474). Plugins register\n// their permission keys at module scope (`registerCommerceApi`), and whether\n// that has happened by the time THIS module is first imported is import-order\n// luck. Snapshotting here produced a map with no plugin keys at all, so an\n// owner on the fresh-account path read `managePos === undefined` — falsy, and\n// denied — while an identical owner resolved a millisecond later through the\n// membership path got `true`. The cost is one small object per call.\nconst allTrue = (): ResolvedOrgPermissionSet => resolveRolePermissions('admin')\nconst none = (): ResolvedOrgPermissionSet => resolveRolePermissions('viewer')\n\n/**\n * Per-member overrides for PLUGIN-declared keys (AGL-2474). The dotted\n * granular catalog that `toLegacyPermissions` translates knows nothing about\n * them, so without this pass a plugin key could not be revoked from a member\n * who has it by tier default, nor granted to one who does not — the override\n * map on the member doc is already typed `Record<string, boolean>` and has\n * always been able to CARRY the key; nothing read it.\n */\nfunction pluginPermissionOverrides(\n overrides: Record<string, boolean> | null | undefined,\n): Record<string, boolean> {\n const applied: Record<string, boolean> = {}\n if (!overrides) return applied\n for (const key of listPluginPermissionKeys()) {\n const value = overrides[key]\n if (typeof value === 'boolean') applied[key] = value\n }\n return applied\n}\n\n/**\n * Permissions that are about the ORG, not about a site (AGL-1026).\n *\n * The permission set mixes the two: `editHosts` and `installPlugins` are\n * things you do to a site, while creating sites, paying for them, publishing\n * as the org and changing the roster are things you do to the workspace. A\n * site collaborator can be an admin OF THEIR SITE — that is the whole point of\n * the role — so their host role has to grant the site-level keys while these\n * stay off regardless of it.\n */\nconst ORG_LEVEL_PERMISSIONS = [\n 'createHosts',\n 'editBilling',\n 'manageMembers',\n // Publishing is org-owned since AGL-652 — a listing carries the workspace's\n // name and takes its money, so a contractor on one site does not ship one.\n 'publishToMarketplace',\n] as const\n\n/** On the roster but with no standing here — or not on it at all. */\nconst denied = (): ResolvedOrgPermissions => ({\n orgId: null,\n role: null,\n isOwner: false,\n permissions: none(),\n orgWide: false,\n hostRole: null,\n})\n\n/**\n * Org-role permission resolver (AGL-238, replacing the manager-seat\n * resolver from AGL-108): the user's role in the relevant org decides the\n * permission flags. Accounts with no org yet resolve as owners with full\n * access — the org is created on first need. A lookup error fails CLOSED\n * when a specific org/host was targeted (AGL-506) — routes like hosts/members\n * OR these flags into an auth decision, so a transient error must not hand\n * out manageMembers. Only the context-free fresh-account case keeps the\n * owner default.\n */\nexport async function resolveOrgPermissions(\n uid: string,\n context: { orgId?: string | null; hostId?: string | null } = {},\n): Promise<ResolvedOrgPermissions> {\n try {\n const orgId =\n context.orgId ??\n (context.hostId ? await resolveOrgIdForHost(context.hostId) : null)\n const membership = await resolveOrgMembership(uid, orgId)\n if (!membership) {\n // No org at all → fresh account, acts as its future org's owner.\n // An org WAS targeted but the uid is not on the roster → no access.\n return orgId\n ? { ...denied(), orgId }\n : {\n orgId: null,\n role: null,\n isOwner: true,\n permissions: allTrue(),\n orgWide: true,\n hostRole: null,\n }\n }\n const role = membership.member.role\n // Owner/admin, `allHosts`, and the legacy pre-`allHosts` shape all read\n // as org-wide; `isOrgWideMember` owns that call so this gate, the\n // `scopeTokens` projection and the rules cannot drift apart (AGL-1038).\n const orgWide = isOrgWideMember(membership.member)\n const hostRole = context.hostId\n ? hostRoleFor(membership.member, context.hostId)\n : null\n\n // A site collaborator's permissions are their HOST role's, never their org\n // role's (AGL-1026). The membership doc exists to carry site access, so\n // reading `role` off it and stopping there answered an org-level question\n // with a site-level fact: an \"editor on one site\" resolved as an editor of\n // every site in the org. When a host is named and they have no access to\n // it, that is simply no access — being on the roster is not a grant.\n if (!orgWide) {\n if (context.hostId && !hostRole) return { ...denied(), orgId: membership.orgId }\n return {\n orgId: membership.orgId,\n role,\n isOwner: false,\n // With no host in context there is nothing to scope to, so a scoped\n // member gets the floor rather than their site role — the caller is\n // asking an org-wide question they have no standing to answer.\n permissions: {\n ...resolveRolePermissions(hostRole ?? 'viewer'),\n ...Object.fromEntries(\n ORG_LEVEL_PERMISSIONS.map((key) => [key, false]),\n ),\n // Plugin-key overrides apply to a collaborator TOO (AGL-2474).\n // Their six built-in keys are deliberately their HOST role's and\n // not their org role's, but a plugin key revoked on the member doc\n // is a revocation either way — skipping it here would mean an admin\n // could take `managePos` off an org-wide member and silently fail to\n // take it off the contractor actually standing at the register.\n // ORG_LEVEL_PERMISSIONS still wins above for the keys it names.\n ...pluginPermissionOverrides(\n membership.member.permissions as Record<string, boolean>,\n ),\n },\n orgWide: false,\n hostRole,\n }\n }\n // Custom role + per-member overrides, which this resolver used to drop\n // (AGL-2350). `resolveRolePermissions(tier)` alone is the BUILT-IN role's\n // defaults, so every refinement `custom-roles.md` sells was invisible to\n // the server while the console applied it — a permission granted by a\n // custom role showed in the UI and 403'd on POST, and one revoked by an\n // override was hidden in the UI and still succeeded on POST.\n //\n // OVERLAID, never substituted: `resolveRolePermissions` also mixes in\n // plugin-declared keys (AGL-435) that the granular catalog knows nothing\n // about, so replacing the map wholesale would silently strip them.\n const granular = await resolveMemberOrgPermissions(\n membership.orgId,\n membership.member,\n )\n return {\n orgId: membership.orgId,\n role,\n isOwner: role === 'owner' || role === 'admin',\n permissions: {\n ...resolveRolePermissions(ORG_ROLE_PERMISSION_BASE[role]),\n ...toLegacyPermissions(granular, role),\n // LAST, so a per-member override wins (AGL-2474). The legacy spread\n // above rebuilds all six keys from the dotted catalog every time and\n // would otherwise stomp nothing here — but plugin keys are absent from\n // that catalog entirely, so they have to be laid back on explicitly.\n ...pluginPermissionOverrides(\n membership.member.permissions as Record<string, boolean>,\n ),\n },\n orgWide: true,\n hostRole: hostRole ?? ORG_ROLE_PERMISSION_BASE[role],\n }\n } catch (error) {\n // Fail CLOSED when a specific org/host was targeted (AGL-506): a lookup\n // error must never grant full permissions for a real org. Only the\n // context-free fresh-account case keeps the owner default.\n // PRESENCE, not truthiness (AGL-1881). `publish-plugin` passes\n // `{ orgId: String(body?.orgId ?? '').trim() || undefined }`, so an\n // attacker-controlled body that simply omits `orgId` made this test false\n // and a transient Firestore error resolved through the OPEN branch below\n // with `publishToMarketplace: true`. The key is present on every targeted\n // call; only the genuinely context-free `resolveOrgPermissions(uid)` — the\n // fresh-account case the open branch exists for — omits both.\n if ('orgId' in context || 'hostId' in context) {\n console.error('org-permissions resolve failed (failing closed)', error)\n return { ...denied(), orgId: context.orgId ?? null }\n }\n console.error('org-permissions resolve failed (no org targeted)', error)\n return {\n orgId: null,\n role: null,\n isOwner: true,\n permissions: allTrue(),\n orgWide: true,\n hostRole: null,\n }\n }\n}\n\nexport default resolveOrgPermissions\n"],"names":["hostRoleFor","isOrgWideMember","resolveRolePermissions","toLegacyPermissions","listPluginPermissionKeys","ORG_ROLE_TIER","resolveMemberOrgPermissions","resolveOrgIdForHost","resolveOrgMembership","ORG_ROLE_PERMISSION_BASE","allTrue","none","pluginPermissionOverrides","overrides","applied","key","value","ORG_LEVEL_PERMISSIONS","denied","orgId","role","isOwner","permissions","orgWide","hostRole","resolveOrgPermissions","uid","context","hostId","membership","member","Object","fromEntries","map","granular","error","console"],"mappings":"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"}
@@ -0,0 +1,48 @@
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 HostDatasetRecord } from '@aglyn/aglyn/app-utils/datasets';
18
+ /** One record document as either Firestore SDK hands it back. */
19
+ export interface RepeatRecordSnapshot {
20
+ id: string;
21
+ data: HostDatasetRecord;
22
+ }
23
+ /**
24
+ * The records a repeat renders, in the order it renders them, from the two
25
+ * bounded reads that find them (AGL-1302).
26
+ *
27
+ * The rows a repeat shows are the ones `sortDatasetRecords` puts first: every
28
+ * record with an editor `order`, ascending, then the records without one —
29
+ * forms and Actions append those — by document id. A `limit()` with no
30
+ * `orderBy` cannot find them: Firestore answers it in document-id order, so on
31
+ * a dataset past the bound it reads an arbitrary sample, and sorting that
32
+ * sample afterwards only makes it look ordered.
33
+ *
34
+ * `byOrder` is the page ordered by `order`, bounded by
35
+ * {@link REPEAT_MAX_RECORDS}, which matches only documents that carry the
36
+ * field. When it comes back short every ordered record is already in hand, so
37
+ * `byId` — the same bound over the document id — supplies exactly the first
38
+ * unordered ones; a caller whose `byOrder` came back full does not need to
39
+ * read it, and passes an empty list.
40
+ *
41
+ * The published page's composition and the besigner's canvas preview both
42
+ * read through this, with the Admin SDK and the client SDK respectively, so
43
+ * the canvas cannot preview a row the page would not render.
44
+ *
45
+ * Each row is the record's value map, with `$id` inside it so incoming
46
+ * reference hops (AGL-180) can resolve rows.
47
+ */
48
+ export declare function repeatRecordsFromPages(byOrder: readonly RepeatRecordSnapshot[], byId: readonly RepeatRecordSnapshot[]): Array<Record<string, unknown>>;
@@ -0,0 +1,64 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ // By path, not `@aglyn/aglyn/server`: this module is also read in the browser
18
+ // (the besigner's repeat preview, AGL-3111), and the server entry carries
19
+ // `node:` modules no browser bundle can hold.
20
+ import { sortDatasetRecords } from "@aglyn/aglyn/app-utils/datasets";
21
+ import { REPEAT_MAX_RECORDS } from "@aglyn/aglyn/app-utils/expand-repeatables";
22
+ /**
23
+ * The records a repeat renders, in the order it renders them, from the two
24
+ * bounded reads that find them (AGL-1302).
25
+ *
26
+ * The rows a repeat shows are the ones `sortDatasetRecords` puts first: every
27
+ * record with an editor `order`, ascending, then the records without one —
28
+ * forms and Actions append those — by document id. A `limit()` with no
29
+ * `orderBy` cannot find them: Firestore answers it in document-id order, so on
30
+ * a dataset past the bound it reads an arbitrary sample, and sorting that
31
+ * sample afterwards only makes it look ordered.
32
+ *
33
+ * `byOrder` is the page ordered by `order`, bounded by
34
+ * {@link REPEAT_MAX_RECORDS}, which matches only documents that carry the
35
+ * field. When it comes back short every ordered record is already in hand, so
36
+ * `byId` — the same bound over the document id — supplies exactly the first
37
+ * unordered ones; a caller whose `byOrder` came back full does not need to
38
+ * read it, and passes an empty list.
39
+ *
40
+ * The published page's composition and the besigner's canvas preview both
41
+ * read through this, with the Admin SDK and the client SDK respectively, so
42
+ * the canvas cannot preview a row the page would not render.
43
+ *
44
+ * Each row is the record's value map, with `$id` inside it so incoming
45
+ * reference hops (AGL-180) can resolve rows.
46
+ */ export function repeatRecordsFromPages(byOrder, byId) {
47
+ const snapshots = [
48
+ ...byOrder
49
+ ];
50
+ if (byOrder.length < REPEAT_MAX_RECORDS) {
51
+ const held = new Set(byOrder.map((snapshot)=>snapshot.id));
52
+ snapshots.push(...byId.filter((snapshot)=>!held.has(snapshot.id)));
53
+ }
54
+ return sortDatasetRecords(snapshots.map((snapshot)=>_extends({
55
+ $id: snapshot.id
56
+ }, snapshot.data))).slice(0, REPEAT_MAX_RECORDS).map((record)=>{
57
+ var _record_values;
58
+ return _extends({}, (_record_values = record.values) != null ? _record_values : {}, {
59
+ $id: record.$id
60
+ });
61
+ });
62
+ }
63
+
64
+ //# sourceMappingURL=repeat-record-pages.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/repeat-record-pages.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// By path, not `@aglyn/aglyn/server`: this module is also read in the browser\n// (the besigner's repeat preview, AGL-3111), and the server entry carries\n// `node:` modules no browser bundle can hold.\nimport {\n type HostDatasetRecord,\n sortDatasetRecords,\n} from '@aglyn/aglyn/app-utils/datasets'\nimport { REPEAT_MAX_RECORDS } from '@aglyn/aglyn/app-utils/expand-repeatables'\n\n/** One record document as either Firestore SDK hands it back. */\nexport interface RepeatRecordSnapshot {\n id: string\n data: HostDatasetRecord\n}\n\n/**\n * The records a repeat renders, in the order it renders them, from the two\n * bounded reads that find them (AGL-1302).\n *\n * The rows a repeat shows are the ones `sortDatasetRecords` puts first: every\n * record with an editor `order`, ascending, then the records without one —\n * forms and Actions append those — by document id. A `limit()` with no\n * `orderBy` cannot find them: Firestore answers it in document-id order, so on\n * a dataset past the bound it reads an arbitrary sample, and sorting that\n * sample afterwards only makes it look ordered.\n *\n * `byOrder` is the page ordered by `order`, bounded by\n * {@link REPEAT_MAX_RECORDS}, which matches only documents that carry the\n * field. When it comes back short every ordered record is already in hand, so\n * `byId` — the same bound over the document id — supplies exactly the first\n * unordered ones; a caller whose `byOrder` came back full does not need to\n * read it, and passes an empty list.\n *\n * The published page's composition and the besigner's canvas preview both\n * read through this, with the Admin SDK and the client SDK respectively, so\n * the canvas cannot preview a row the page would not render.\n *\n * Each row is the record's value map, with `$id` inside it so incoming\n * reference hops (AGL-180) can resolve rows.\n */\nexport function repeatRecordsFromPages(\n byOrder: readonly RepeatRecordSnapshot[],\n byId: readonly RepeatRecordSnapshot[],\n): Array<Record<string, unknown>> {\n const snapshots = [...byOrder]\n if (byOrder.length < REPEAT_MAX_RECORDS) {\n const held = new Set(byOrder.map((snapshot) => snapshot.id))\n snapshots.push(...byId.filter((snapshot) => !held.has(snapshot.id)))\n }\n return sortDatasetRecords(\n snapshots.map((snapshot) => ({ $id: snapshot.id, ...snapshot.data })),\n )\n .slice(0, REPEAT_MAX_RECORDS)\n .map((record) => ({ ...(record.values ?? {}), $id: record.$id }))\n}\n"],"names":["sortDatasetRecords","REPEAT_MAX_RECORDS","repeatRecordsFromPages","byOrder","byId","snapshots","length","held","Set","map","snapshot","id","push","filter","has","$id","data","slice","record","values"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,0EAA0E;AAC1E,8CAA8C;AAC9C,SAEEA,kBAAkB,QACb,kCAAiC;AACxC,SAASC,kBAAkB,QAAQ,4CAA2C;AAQ9E;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,uBACdC,OAAwC,EACxCC,IAAqC;IAErC,MAAMC,YAAY;WAAIF;KAAQ;IAC9B,IAAIA,QAAQG,MAAM,GAAGL,oBAAoB;QACvC,MAAMM,OAAO,IAAIC,IAAIL,QAAQM,GAAG,CAAC,CAACC,WAAaA,SAASC,EAAE;QAC1DN,UAAUO,IAAI,IAAIR,KAAKS,MAAM,CAAC,CAACH,WAAa,CAACH,KAAKO,GAAG,CAACJ,SAASC,EAAE;IACnE;IACA,OAAOX,mBACLK,UAAUI,GAAG,CAAC,CAACC,WAAc;YAAEK,KAAKL,SAASC,EAAE;WAAKD,SAASM,IAAI,IAEhEC,KAAK,CAAC,GAAGhB,oBACTQ,GAAG,CAAC,CAACS;YAAkBA;eAAN,cAAMA,iBAAAA,OAAOC,MAAM,YAAbD,iBAAiB,CAAC;YAAIH,KAAKG,OAAOH,GAAG;;;AACjE"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Which site plugins a page must have registered *before it can render*
19
+ * (AGL-1289).
20
+ *
21
+ * The tenant client suspends on `sitePluginLoader.ensure(enabledPlugins)`, and
22
+ * `enabledPlugins` is the ORG's list — nothing narrows it to the page. On
23
+ * aglyn.com that is twelve enabled plugins, of which the `/pricing` document
24
+ * uses exactly one: 2,816 of its 2,818 nodes are `mui` and the other two carry
25
+ * no `pluginId` at all. The bundles with a site surface that it does not use —
26
+ * bookings, commerce, email, events-calendar — were downloaded and evaluated
27
+ * in front of first render, in a second request wave measured starting ~1.7 s
28
+ * after the first and finishing at ~4.0 s.
29
+ *
30
+ * A plugin belongs in front of the render for exactly two reasons:
31
+ *
32
+ * 1. **Its components are on the page.** Without it registered the canvas has
33
+ * no component to render for those nodes — the blank-site failure
34
+ * (AGL-52). Read straight off each node's `pluginId`.
35
+ * 2. **It contributed something to this page.** A site runtime only mounts
36
+ * once its plugin registers, so a late one shows its announcement bar a
37
+ * beat after paint and — the case that actually costs something — an
38
+ * experiment runner can paint the control variant before it swaps.
39
+ *
40
+ * The second is why this takes `contributors` rather than inspecting props: it
41
+ * must distinguish "the marketing plugin returned `announcementBar: null`"
42
+ * from "the marketing plugin returned a live bar". Every marketing enricher
43
+ * returns its whole key set on every page, so presence proves nothing;
44
+ * `runSitePageEnrichers` reports only the plugins whose output had content.
45
+ *
46
+ * `/pricing` is the case that shows why the distinction matters. Its
47
+ * `announcementBar`, `popup`, `experiments` and `automationOverlays` are all
48
+ * empty — but its `clientAutomations` is NOT, so `marketing` genuinely has to
49
+ * register early and only the other four move.
50
+ *
51
+ * Everything here fails toward "no narrowing": an unattributable contribution,
52
+ * a missing enabled list, no nodes to read. The cost of refusing is a slower
53
+ * page; the cost of wrongly dropping a plugin is a site that does not render.
54
+ */
55
+ import { type PresenceNode } from '@aglyn/aglyn/plugin-manager/plugin-contributions';
56
+ import type { RealmPluginInstall } from '@aglyn/aglyn/plugin-manager/realm-plugins';
57
+ /** Registered regardless of the org's list; the canvas cannot render without it. */
58
+ export declare const ALWAYS_ON_SITE_PLUGINS: readonly string[];
59
+ export interface RequiredSitePluginsInput {
60
+ /** The FULL composed document — not one already pruned for deferral. */
61
+ nodes?: Record<string, {
62
+ pluginId?: string;
63
+ }> | null;
64
+ /** Plugins whose enrichers produced non-empty output for this page. */
65
+ contributors?: readonly string[];
66
+ /** True when some contribution could not be traced to a plugin. */
67
+ unattributed?: boolean;
68
+ enabledPlugins?: readonly string[] | null;
69
+ }
70
+ /**
71
+ * The plugins that must register before first render, or `null` when the page
72
+ * has to keep waiting for the whole enabled set.
73
+ *
74
+ * Always a subset of `enabledPlugins`, in that list's order, so this can never
75
+ * turn on a plugin the org has not enabled.
76
+ */
77
+ export declare function requiredSitePlugins({ nodes, contributors, unattributed, enabledPlugins, }: RequiredSitePluginsInput): string[] | null;
78
+ /**
79
+ * The canvas component ids a published page places (AGL-3141).
80
+ *
81
+ * The loader carries this to each site plugin's register function, and a
82
+ * plugin that can register a part of itself — the MUI bundle, which owns
83
+ * sixty elements where a page renders about nineteen — registers only what
84
+ * is here.
85
+ *
86
+ * Read from the FULL composed document, like `requiredSitePlugins`: an
87
+ * element inside a withheld lazy panel is still this page's element, and the
88
+ * visitor who opens that panel would otherwise find nothing in it.
89
+ *
90
+ * `null` for a page with no document to read, because a narrowing that cannot
91
+ * see the page must not be made: an element whose component never registered
92
+ * renders NOTHING, silently (AGL-52). Sorted so the loader's cache key for a
93
+ * page is the same whatever order the document stored its nodes in.
94
+ */
95
+ export declare function placedComponentIds(nodes: Record<string, PresenceNode | null | undefined> | null | undefined): string[] | null;
96
+ /**
97
+ * The trusted-realm installs a published page uses (AGL-3116), and only
98
+ * those: the page loads the realm-plugin host — which hands a bundle the
99
+ * whole core namespace — and each bundle only when something on the page
100
+ * uses it.
101
+ *
102
+ * Installation is not use. A workspace that installed a plugin for its
103
+ * console widget used to load the host and the bundle on every page of every
104
+ * site it owns, and the bundle's `register()` then added a widget no
105
+ * published page renders. An install is kept when its version declares a
106
+ * component this page places or a site feature, or when a node is stamped
107
+ * with its id; one that declares nothing is kept only for a stamped node, the
108
+ * default `plugin-contributions.ts` documents.
109
+ *
110
+ * Read from the FULL composed document, like `requiredSitePlugins`: an
111
+ * element inside a withheld lazy panel is still this page's element. A page
112
+ * with no nodes places nothing, so only a site feature can be in use on it.
113
+ */
114
+ export declare function realmPluginsInUse(installs: readonly RealmPluginInstall[], nodes: Record<string, PresenceNode | null | undefined> | null | undefined): RealmPluginInstall[];