@aglyn/aglyn 1.0.0-beta.213 → 1.0.0-beta.215

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 (40) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
  3. package/src/lib/app-utils/docs-help.generated.js +2 -0
  4. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  5. package/src/lib/app-utils/docs-index.generated.js +16 -9
  6. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  7. package/src/lib/app-utils/enabled-plugins-context.d.ts +8 -4
  8. package/src/lib/app-utils/enabled-plugins-context.js +9 -4
  9. package/src/lib/app-utils/enabled-plugins-context.js.map +1 -1
  10. package/src/lib/app-utils/host-naming.d.ts +2 -29
  11. package/src/lib/app-utils/host-naming.js +5 -28
  12. package/src/lib/app-utils/host-naming.js.map +1 -1
  13. package/src/lib/app-utils/media-ref.js +1 -1
  14. package/src/lib/app-utils/media-ref.js.map +1 -1
  15. package/src/lib/app-utils/notifications.d.ts +7 -0
  16. package/src/lib/app-utils/notifications.js.map +1 -1
  17. package/src/lib/app-utils/plan-entitlements.js +14 -8
  18. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  19. package/src/lib/app-utils/platform-brand.d.ts +4 -0
  20. package/src/lib/app-utils/platform-brand.js +2 -0
  21. package/src/lib/app-utils/platform-brand.js.map +1 -1
  22. package/src/lib/app-utils/plugin-bundle-checks.js +193 -15
  23. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  24. package/src/lib/app-utils/realm-elements-context.d.ts +17 -0
  25. package/src/lib/app-utils/realm-elements-context.js +35 -0
  26. package/src/lib/app-utils/realm-elements-context.js.map +1 -0
  27. package/src/lib/app-utils/tenant-apex.d.ts +45 -0
  28. package/src/lib/app-utils/tenant-apex.js +48 -0
  29. package/src/lib/app-utils/tenant-apex.js.map +1 -0
  30. package/src/lib/app-utils/wire-nodes.d.ts +64 -0
  31. package/src/lib/app-utils/wire-nodes.js +112 -0
  32. package/src/lib/app-utils/wire-nodes.js.map +1 -0
  33. package/src/lib/foundation/definitions/org-billing.types.d.ts +12 -0
  34. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  35. package/src/lib/plugin-manager/plugin-contributions.d.ts +8 -3
  36. package/src/lib/plugin-manager/plugin-contributions.js +5 -3
  37. package/src/lib/plugin-manager/plugin-contributions.js.map +1 -1
  38. package/src/lib/plugin-manager/realm-plugins.d.ts +70 -0
  39. package/src/lib/plugin-manager/realm-plugins.js +143 -29
  40. package/src/lib/plugin-manager/realm-plugins.js.map +1 -1
@@ -85,10 +85,14 @@ export declare function isFromEnabledPlugin(item: {
85
85
  * the difference. Asking the rendered site's plugin set as well makes the
86
86
  * server and the browser give one answer.
87
87
  *
88
- * Only a FIRST-PARTY id is asked about. A marketplace bundle's components name
89
- * the id in its manifest, which is not the listing id the site's set carries,
90
- * and a component naming no plugin belongs to none. Both render as registered,
91
- * as does the base library, which no site can switch off.
88
+ * A FIRST-PARTY id is asked about, and so is a marketplace IDENTITY
89
+ * (`<publisher handle>.<manifest id>`, AGL-3390), which the site's set carries
90
+ * for each signed plugin the page runs. Those are the ones a server renders,
91
+ * so they are the ones another site's render could otherwise show. A plugin
92
+ * published before identities (its manifest id, with no `.`) only ever
93
+ * registers in the browser of a site that runs it, and a component naming no
94
+ * plugin belongs to none: both render as registered, as does the base
95
+ * library, which no site can switch off.
92
96
  *
93
97
  * An EMPTY set is no answer. Every set a site resolves carries the base
94
98
  * library, so an empty one is a surface saying it has no site to answer for —
@@ -93,10 +93,14 @@ export function isFromEnabledPlugin(item, enabledPluginIds) {
93
93
  * the difference. Asking the rendered site's plugin set as well makes the
94
94
  * server and the browser give one answer.
95
95
  *
96
- * Only a FIRST-PARTY id is asked about. A marketplace bundle's components name
97
- * the id in its manifest, which is not the listing id the site's set carries,
98
- * and a component naming no plugin belongs to none. Both render as registered,
99
- * as does the base library, which no site can switch off.
96
+ * A FIRST-PARTY id is asked about, and so is a marketplace IDENTITY
97
+ * (`<publisher handle>.<manifest id>`, AGL-3390), which the site's set carries
98
+ * for each signed plugin the page runs. Those are the ones a server renders,
99
+ * so they are the ones another site's render could otherwise show. A plugin
100
+ * published before identities (its manifest id, with no `.`) only ever
101
+ * registers in the browser of a site that runs it, and a component naming no
102
+ * plugin belongs to none: both render as registered, as does the base
103
+ * library, which no site can switch off.
100
104
  *
101
105
  * An EMPTY set is no answer. Every set a site resolves carries the base
102
106
  * library, so an empty one is a surface saying it has no site to answer for —
@@ -106,6 +110,7 @@ export function isFromEnabledPlugin(item, enabledPluginIds) {
106
110
  */ export function isSwitchedOffForRenderedSite(pluginId, enabledPluginIds) {
107
111
  if (!pluginId || !(enabledPluginIds == null ? void 0 : enabledPluginIds.length)) return false;
108
112
  if (isLockedOnForSite(pluginId)) return false;
113
+ if (pluginId.includes('.')) return !enabledPluginIds.includes(pluginId);
109
114
  return isFirstPartyPlugin(pluginId) && !enabledPluginIds.includes(pluginId);
110
115
  }
111
116
  export default EnabledPluginsContext;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/enabled-plugins-context.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 { createContext, useContext } from 'react'\nimport { ComponentCategory } from '../foundation/constants/components'\nimport {\n ACCOUNTS_PLUGIN_ID,\n isFirstPartyPlugin,\n isLockedOnForSite,\n} from '../plugin-manager/enabled-plugins'\n\n/**\n * The EDITOR's view of per-site plugin enablement (AGL-1014).\n *\n * `enabledPlugins` is a boundary, not a preference, and the issue names four\n * surfaces that must enforce it identically: console navigation, the editor,\n * published sites, and API dispatch. The other three read\n * `resolveHostEnabledPlugins` on the server or subtract the deny-list in the\n * console shell. This context is how the same answer reaches the besigner —\n * the host app resolves it (only it can read an org and a host) and publishes\n * it; the designer stays storage-agnostic.\n *\n * It has to be a READ-TIME filter rather than a narrower load. The preset\n * registry is a module-global union that only ever grows: `consolePluginLoader`\n * never unloads a bundle, and a plugin whose site components registered while\n * editing site A goes on offering palette entries on site B. Loading less\n * cannot un-register what is already there, so the component drawer filters\n * every entry against this set instead.\n *\n * `undefined` means no set was supplied and leaves consumers unfiltered — the\n * same opt-in shape `listConsoleExtensions` uses, so surfaces with no host\n * (the platform email editor, tests) behave exactly as before.\n */\nexport const EnabledPluginsContext = createContext<\n readonly string[] | undefined\n>(undefined)\nEnabledPluginsContext.displayName = 'EnabledPluginsContext'\n\n/** The edited site's effective plugin ids, or undefined where none is set. */\nexport function useEnabledPlugins(): readonly string[] | undefined {\n return useContext(EnabledPluginsContext)\n}\n\n/**\n * Whether a registry entry's owning plugin runs on the site being edited.\n *\n * An entry with no `pluginId` belongs to no plugin, so no deny-list entry can\n * name it and it passes; so does everything when no set was supplied.\n */\n/**\n * Component categories whose entries follow a per-site CAPABILITY rather\n * than the bundle that ships them (AGL-2486).\n *\n * The Members blocks — Member sign-in, Member sign-up, Password recovery —\n * are registered by the COMMERCE bundle, so `pluginId` alone said \"commerce\n * is on, offer them\" on sites whose `/signin`, `/signup` and `/recover`\n * return 404. An author drops a sign-in block on a page, publishes it, and\n * finds out from a visitor.\n *\n * Attribution by category rather than by re-registering the components\n * under the `accounts` id: re-registering moves where they LOAD from and\n * would change what sites already using those blocks serve. This map is\n * read-time only — it decides what the picker OFFERS, never what a page\n * that already contains one renders.\n */\nexport const CATEGORY_REQUIRED_CAPABILITY: Readonly<Record<string, string>> = {\n [ComponentCategory.MEMBERS]: ACCOUNTS_PLUGIN_ID,\n}\n\n/**\n * Whether a category's required capability (if it has one) runs on the site\n * being edited. Categories with no requirement always pass, as does\n * everything when no set was supplied.\n */\nexport function isCategoryCapabilityEnabled(\n category: string | undefined,\n enabledPluginIds: readonly string[] | undefined,\n): boolean {\n if (!enabledPluginIds) return true\n const required = category ? CATEGORY_REQUIRED_CAPABILITY[category] : undefined\n return !required || enabledPluginIds.includes(required)\n}\n\nexport function isFromEnabledPlugin(\n item: { pluginId?: string } | undefined,\n enabledPluginIds: readonly string[] | undefined,\n): boolean {\n if (!enabledPluginIds) return true\n const pluginId = item?.pluginId\n return !pluginId || enabledPluginIds.includes(pluginId)\n}\n\n/**\n * Whether a REGISTERED component must be drawn as though it were not\n * registered at all, because its first-party plugin does not run on the site\n * being rendered (AGL-3033).\n *\n * The component registry is process-global and only grows. On a server that\n * serves many sites, a bundle one site's page loaded stays registered for\n * every later render, so asking the registry alone draws a switched-off\n * plugin's elements in full on a site that switched it off — while that\n * site's browser, which never loads the bundle, draws the unregistered\n * fallback. The HTML shows what the site switched off, and hydration fails on\n * the difference. Asking the rendered site's plugin set as well makes the\n * server and the browser give one answer.\n *\n * Only a FIRST-PARTY id is asked about. A marketplace bundle's components name\n * the id in its manifest, which is not the listing id the site's set carries,\n * and a component naming no plugin belongs to none. Both render as registered,\n * as does the base library, which no site can switch off.\n *\n * An EMPTY set is no answer. Every set a site resolves carries the base\n * library, so an empty one is a surface saying it has no site to answer for —\n * the console's editor gate publishes `[]` while the URL names no workspace it\n * has resolved — and drawing every element there as unregistered would blank\n * the canvas. It renders as though no set was supplied.\n */\nexport function isSwitchedOffForRenderedSite(\n pluginId: string | undefined,\n enabledPluginIds: readonly string[] | undefined,\n): boolean {\n if (!pluginId || !enabledPluginIds?.length) return false\n if (isLockedOnForSite(pluginId)) return false\n return isFirstPartyPlugin(pluginId) && !enabledPluginIds.includes(pluginId)\n}\n\nexport default EnabledPluginsContext\n"],"names":["createContext","useContext","ComponentCategory","ACCOUNTS_PLUGIN_ID","isFirstPartyPlugin","isLockedOnForSite","EnabledPluginsContext","undefined","displayName","useEnabledPlugins","CATEGORY_REQUIRED_CAPABILITY","MEMBERS","isCategoryCapabilityEnabled","category","enabledPluginIds","required","includes","isFromEnabledPlugin","item","pluginId","isSwitchedOffForRenderedSite","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,EAAEC,UAAU,QAAQ,QAAO;AACjD,SAASC,iBAAiB,QAAQ,wCAAoC;AACtE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,iBAAiB,QACZ,uCAAmC;AAE1C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,MAAMC,wBAAwBN,cAEnCO,WAAU;AACZD,sBAAsBE,WAAW,GAAG;AAEpC,4EAA4E,GAC5E,OAAO,SAASC;IACd,OAAOR,WAAWK;AACpB;AAEA;;;;;CAKC,GACD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,+BAAiE;IAC5E,CAACR,kBAAkBS,OAAO,CAAC,EAAER;AAC/B,EAAC;AAED;;;;CAIC,GACD,OAAO,SAASS,4BACdC,QAA4B,EAC5BC,gBAA+C;IAE/C,IAAI,CAACA,kBAAkB,OAAO;IAC9B,MAAMC,WAAWF,WAAWH,4BAA4B,CAACG,SAAS,GAAGN;IACrE,OAAO,CAACQ,YAAYD,iBAAiBE,QAAQ,CAACD;AAChD;AAEA,OAAO,SAASE,oBACdC,IAAuC,EACvCJ,gBAA+C;IAE/C,IAAI,CAACA,kBAAkB,OAAO;IAC9B,MAAMK,WAAWD,wBAAAA,KAAMC,QAAQ;IAC/B,OAAO,CAACA,YAAYL,iBAAiBE,QAAQ,CAACG;AAChD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,6BACdD,QAA4B,EAC5BL,gBAA+C;IAE/C,IAAI,CAACK,YAAY,EAACL,oCAAAA,iBAAkBO,MAAM,GAAE,OAAO;IACnD,IAAIhB,kBAAkBc,WAAW,OAAO;IACxC,OAAOf,mBAAmBe,aAAa,CAACL,iBAAiBE,QAAQ,CAACG;AACpE;AAEA,eAAeb,sBAAqB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/enabled-plugins-context.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 { createContext, useContext } from 'react'\nimport { ComponentCategory } from '../foundation/constants/components'\nimport {\n ACCOUNTS_PLUGIN_ID,\n isFirstPartyPlugin,\n isLockedOnForSite,\n} from '../plugin-manager/enabled-plugins'\n\n/**\n * The EDITOR's view of per-site plugin enablement (AGL-1014).\n *\n * `enabledPlugins` is a boundary, not a preference, and the issue names four\n * surfaces that must enforce it identically: console navigation, the editor,\n * published sites, and API dispatch. The other three read\n * `resolveHostEnabledPlugins` on the server or subtract the deny-list in the\n * console shell. This context is how the same answer reaches the besigner —\n * the host app resolves it (only it can read an org and a host) and publishes\n * it; the designer stays storage-agnostic.\n *\n * It has to be a READ-TIME filter rather than a narrower load. The preset\n * registry is a module-global union that only ever grows: `consolePluginLoader`\n * never unloads a bundle, and a plugin whose site components registered while\n * editing site A goes on offering palette entries on site B. Loading less\n * cannot un-register what is already there, so the component drawer filters\n * every entry against this set instead.\n *\n * `undefined` means no set was supplied and leaves consumers unfiltered — the\n * same opt-in shape `listConsoleExtensions` uses, so surfaces with no host\n * (the platform email editor, tests) behave exactly as before.\n */\nexport const EnabledPluginsContext = createContext<\n readonly string[] | undefined\n>(undefined)\nEnabledPluginsContext.displayName = 'EnabledPluginsContext'\n\n/** The edited site's effective plugin ids, or undefined where none is set. */\nexport function useEnabledPlugins(): readonly string[] | undefined {\n return useContext(EnabledPluginsContext)\n}\n\n/**\n * Whether a registry entry's owning plugin runs on the site being edited.\n *\n * An entry with no `pluginId` belongs to no plugin, so no deny-list entry can\n * name it and it passes; so does everything when no set was supplied.\n */\n/**\n * Component categories whose entries follow a per-site CAPABILITY rather\n * than the bundle that ships them (AGL-2486).\n *\n * The Members blocks — Member sign-in, Member sign-up, Password recovery —\n * are registered by the COMMERCE bundle, so `pluginId` alone said \"commerce\n * is on, offer them\" on sites whose `/signin`, `/signup` and `/recover`\n * return 404. An author drops a sign-in block on a page, publishes it, and\n * finds out from a visitor.\n *\n * Attribution by category rather than by re-registering the components\n * under the `accounts` id: re-registering moves where they LOAD from and\n * would change what sites already using those blocks serve. This map is\n * read-time only — it decides what the picker OFFERS, never what a page\n * that already contains one renders.\n */\nexport const CATEGORY_REQUIRED_CAPABILITY: Readonly<Record<string, string>> = {\n [ComponentCategory.MEMBERS]: ACCOUNTS_PLUGIN_ID,\n}\n\n/**\n * Whether a category's required capability (if it has one) runs on the site\n * being edited. Categories with no requirement always pass, as does\n * everything when no set was supplied.\n */\nexport function isCategoryCapabilityEnabled(\n category: string | undefined,\n enabledPluginIds: readonly string[] | undefined,\n): boolean {\n if (!enabledPluginIds) return true\n const required = category ? CATEGORY_REQUIRED_CAPABILITY[category] : undefined\n return !required || enabledPluginIds.includes(required)\n}\n\nexport function isFromEnabledPlugin(\n item: { pluginId?: string } | undefined,\n enabledPluginIds: readonly string[] | undefined,\n): boolean {\n if (!enabledPluginIds) return true\n const pluginId = item?.pluginId\n return !pluginId || enabledPluginIds.includes(pluginId)\n}\n\n/**\n * Whether a REGISTERED component must be drawn as though it were not\n * registered at all, because its first-party plugin does not run on the site\n * being rendered (AGL-3033).\n *\n * The component registry is process-global and only grows. On a server that\n * serves many sites, a bundle one site's page loaded stays registered for\n * every later render, so asking the registry alone draws a switched-off\n * plugin's elements in full on a site that switched it off — while that\n * site's browser, which never loads the bundle, draws the unregistered\n * fallback. The HTML shows what the site switched off, and hydration fails on\n * the difference. Asking the rendered site's plugin set as well makes the\n * server and the browser give one answer.\n *\n * A FIRST-PARTY id is asked about, and so is a marketplace IDENTITY\n * (`<publisher handle>.<manifest id>`, AGL-3390), which the site's set carries\n * for each signed plugin the page runs. Those are the ones a server renders,\n * so they are the ones another site's render could otherwise show. A plugin\n * published before identities (its manifest id, with no `.`) only ever\n * registers in the browser of a site that runs it, and a component naming no\n * plugin belongs to none: both render as registered, as does the base\n * library, which no site can switch off.\n *\n * An EMPTY set is no answer. Every set a site resolves carries the base\n * library, so an empty one is a surface saying it has no site to answer for —\n * the console's editor gate publishes `[]` while the URL names no workspace it\n * has resolved — and drawing every element there as unregistered would blank\n * the canvas. It renders as though no set was supplied.\n */\nexport function isSwitchedOffForRenderedSite(\n pluginId: string | undefined,\n enabledPluginIds: readonly string[] | undefined,\n): boolean {\n if (!pluginId || !enabledPluginIds?.length) return false\n if (isLockedOnForSite(pluginId)) return false\n if (pluginId.includes('.')) return !enabledPluginIds.includes(pluginId)\n return isFirstPartyPlugin(pluginId) && !enabledPluginIds.includes(pluginId)\n}\n\nexport default EnabledPluginsContext\n"],"names":["createContext","useContext","ComponentCategory","ACCOUNTS_PLUGIN_ID","isFirstPartyPlugin","isLockedOnForSite","EnabledPluginsContext","undefined","displayName","useEnabledPlugins","CATEGORY_REQUIRED_CAPABILITY","MEMBERS","isCategoryCapabilityEnabled","category","enabledPluginIds","required","includes","isFromEnabledPlugin","item","pluginId","isSwitchedOffForRenderedSite","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,EAAEC,UAAU,QAAQ,QAAO;AACjD,SAASC,iBAAiB,QAAQ,wCAAoC;AACtE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,iBAAiB,QACZ,uCAAmC;AAE1C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,MAAMC,wBAAwBN,cAEnCO,WAAU;AACZD,sBAAsBE,WAAW,GAAG;AAEpC,4EAA4E,GAC5E,OAAO,SAASC;IACd,OAAOR,WAAWK;AACpB;AAEA;;;;;CAKC,GACD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,+BAAiE;IAC5E,CAACR,kBAAkBS,OAAO,CAAC,EAAER;AAC/B,EAAC;AAED;;;;CAIC,GACD,OAAO,SAASS,4BACdC,QAA4B,EAC5BC,gBAA+C;IAE/C,IAAI,CAACA,kBAAkB,OAAO;IAC9B,MAAMC,WAAWF,WAAWH,4BAA4B,CAACG,SAAS,GAAGN;IACrE,OAAO,CAACQ,YAAYD,iBAAiBE,QAAQ,CAACD;AAChD;AAEA,OAAO,SAASE,oBACdC,IAAuC,EACvCJ,gBAA+C;IAE/C,IAAI,CAACA,kBAAkB,OAAO;IAC9B,MAAMK,WAAWD,wBAAAA,KAAMC,QAAQ;IAC/B,OAAO,CAACA,YAAYL,iBAAiBE,QAAQ,CAACG;AAChD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,6BACdD,QAA4B,EAC5BL,gBAA+C;IAE/C,IAAI,CAACK,YAAY,EAACL,oCAAAA,iBAAkBO,MAAM,GAAE,OAAO;IACnD,IAAIhB,kBAAkBc,WAAW,OAAO;IACxC,IAAIA,SAASH,QAAQ,CAAC,MAAM,OAAO,CAACF,iBAAiBE,QAAQ,CAACG;IAC9D,OAAOf,mBAAmBe,aAAa,CAACL,iBAAiBE,QAAQ,CAACG;AACpE;AAEA,eAAeb,sBAAqB"}
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { TENANT_APEX } from './tenant-apex';
17
18
  /** 3–30 chars, lowercase alphanumeric + dashes, no leading dash. */
18
19
  export declare const SUBDOMAIN_PATTERN: RegExp;
19
20
  /** Platform/system names a tenant must not squat. */
@@ -58,35 +59,7 @@ export declare function generateSubdomain(displayName: string): string;
58
59
  * Availability is the caller's job (needs a Firestore query).
59
60
  */
60
61
  export declare function suggestSubdomains(base: string, year?: number): string[];
61
- /**
62
- * The apex tenant sites are served from. Console lives on `app.aglyn.com`.
63
- *
64
- * CONFIGURABLE, not a literal (AGL-2121). AGL-2022 moved the console's
65
- * *display* links onto `NEXT_PUBLIC_TENANT_DOMAIN` and shipped a green spec
66
- * for it; this constant — which every OTHER consumer reads — stayed a bare
67
- * `'aglyn.app'`. So a self-hoster who set the variable got correct "View live"
68
- * links in their own console while the software went on emitting our apex
69
- * everywhere that actually reaches the public: `<link rel="canonical">`,
70
- * `/api/sitemap`, `/api/robots`, `/api/collections-rss` item links,
71
- * `/api/manifest`, `og:image`, and the origin inbox-bound `<img src>` resolves
72
- * against. Their customers' sites told Google, every feed reader and every
73
- * inbox that they lived at a name the operator does not control and we do not
74
- * serve for them.
75
- *
76
- * Same variable and same default as `tenant-links.ts` — one value, one name
77
- * (AGL-733). Dot notation is load-bearing: this module reaches client bundles,
78
- * and Next inlines `NEXT_PUBLIC_*` by substituting `process.env.NAME`
79
- * textually; the bracket form is never substituted and would read `undefined`
80
- * in the browser. Trimmed so a half-finished `.env` line reads as absent
81
- * rather than as a whitespace apex.
82
- *
83
- * The asymmetry with `operator-identity.ts` — which refuses to default at all
84
- * — is deliberate. A wrong apex is visible to the operator on the first click
85
- * in their own console, so a default they can see and correct is a default. A
86
- * wrong contact address fails silently and off-site, which is why that one
87
- * refuses to guess.
88
- */
89
- export declare const TENANT_APEX: string;
62
+ export { TENANT_APEX };
90
63
  /**
91
64
  * The absolute origin a published site is reachable at (AGL-1224).
92
65
  *
@@ -21,6 +21,7 @@
21
21
  */ // The leaf, not the barrel: the brand list and lookalike logic every
22
22
  // phishing surface reads (AGL-3356), and nothing else of the email library.
23
23
  import { brandForSubdomainLabel } from "@aglyn/shared-util-email/outbound-phishing-screen";
24
+ import { TENANT_APEX } from "./tenant-apex.js";
24
25
  /** 3–30 chars, lowercase alphanumeric + dashes, no leading dash. */ export const SUBDOMAIN_PATTERN = /^[a-z0-9][a-z0-9-]{2,29}$/;
25
26
  /** Platform/system names a tenant must not squat. */ export const RESERVED_SUBDOMAINS = new Set([
26
27
  'www',
@@ -136,34 +137,10 @@ import { brandForSubdomainLabel } from "@aglyn/shared-util-email/outbound-phishi
136
137
  ...new Set(candidates.filter((candidate)=>SUBDOMAIN_PATTERN.test(candidate) && !isBlockedSubdomain(candidate)))
137
138
  ];
138
139
  }
139
- /**
140
- * The apex tenant sites are served from. Console lives on `app.aglyn.com`.
141
- *
142
- * CONFIGURABLE, not a literal (AGL-2121). AGL-2022 moved the console's
143
- * *display* links onto `NEXT_PUBLIC_TENANT_DOMAIN` and shipped a green spec
144
- * for it; this constant — which every OTHER consumer reads — stayed a bare
145
- * `'aglyn.app'`. So a self-hoster who set the variable got correct "View live"
146
- * links in their own console while the software went on emitting our apex
147
- * everywhere that actually reaches the public: `<link rel="canonical">`,
148
- * `/api/sitemap`, `/api/robots`, `/api/collections-rss` item links,
149
- * `/api/manifest`, `og:image`, and the origin inbox-bound `<img src>` resolves
150
- * against. Their customers' sites told Google, every feed reader and every
151
- * inbox that they lived at a name the operator does not control and we do not
152
- * serve for them.
153
- *
154
- * Same variable and same default as `tenant-links.ts` — one value, one name
155
- * (AGL-733). Dot notation is load-bearing: this module reaches client bundles,
156
- * and Next inlines `NEXT_PUBLIC_*` by substituting `process.env.NAME`
157
- * textually; the bracket form is never substituted and would read `undefined`
158
- * in the browser. Trimmed so a half-finished `.env` line reads as absent
159
- * rather than as a whitespace apex.
160
- *
161
- * The asymmetry with `operator-identity.ts` — which refuses to default at all
162
- * — is deliberate. A wrong apex is visible to the operator on the first click
163
- * in their own console, so a default they can see and correct is a default. A
164
- * wrong contact address fails silently and off-site, which is why that one
165
- * refuses to guess.
166
- */ export const TENANT_APEX = (process.env.NEXT_PUBLIC_TENANT_DOMAIN || '').trim() || 'aglyn.app';
140
+ // The apex tenant sites are served from lives in its own leaf, so a published
141
+ // page can read it without the subdomain policy above (AGL-3401). Re-exported
142
+ // here so every importer of it keeps working.
143
+ export { TENANT_APEX };
167
144
  /**
168
145
  * The absolute origin a published site is reachable at (AGL-1224).
169
146
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/host-naming.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 * Host naming guards (AGL-147): one shared subdomain policy for the create\n * API, the rename/validate API, and the console dialogs — pattern, reserved\n * + profanity blocklists, a display-name → subdomain generator, and\n * taken-name suggestions.\n */\n\n// The leaf, not the barrel: the brand list and lookalike logic every\n// phishing surface reads (AGL-3356), and nothing else of the email library.\nimport { brandForSubdomainLabel } from '@aglyn/shared-util-email/outbound-phishing-screen'\n\n/** 3–30 chars, lowercase alphanumeric + dashes, no leading dash. */\nexport const SUBDOMAIN_PATTERN = /^[a-z0-9][a-z0-9-]{2,29}$/\n\n/** Platform/system names a tenant must not squat. */\nexport const RESERVED_SUBDOMAINS = new Set([\n 'www',\n 'app',\n 'api',\n 'admin',\n 'console',\n 'mail',\n 'demo',\n 'staging',\n 'dev',\n 'test',\n 'docs',\n 'blog',\n 'help',\n 'support',\n 'status',\n 'cdn',\n 'assets',\n 'static',\n 'ftp',\n 'smtp',\n 'ns1',\n 'ns2',\n 'aglyn',\n 'billing',\n 'account',\n 'login',\n 'signup',\n 'auth',\n])\n\n/**\n * Profanity fragments blocked as substrings in generated or typed\n * subdomains. Deliberately short: obvious slurs and vulgarity only —\n * substring matching is aggressive, so entries must be unambiguous.\n */\nconst BLOCKED_FRAGMENTS = [\n 'fuck',\n 'shit',\n 'cunt',\n 'nigger',\n 'faggot',\n 'bitch',\n 'porn',\n 'nazi',\n]\n\n/**\n * The one label a console URL occupies directly under a workspace domain\n * (AGL-3295): `studio` for `https://studio.example.com` under `example.com`.\n *\n * `null` when either setting is missing or unparseable, and when the console\n * is the apex itself, sits deeper, or lies outside the domain — none of those\n * can collide with a workspace address, so none needs reserving.\n *\n * Pure, and in this dependency-free module, so the console's host gate and the\n * organization-slug blocklist derive the same label from the same two\n * settings. An install whose console is not at `app.` or `console.` is the\n * case it exists for: without it, that label is read as a workspace nobody\n * owns.\n */\nexport function consoleLabelUnder(\n consoleUrl: string | null | undefined,\n workspaceDomain: string | null | undefined,\n): string | null {\n const domain = String(workspaceDomain ?? '')\n .trim()\n .toLowerCase()\n const raw = String(consoleUrl ?? '').trim()\n if (!domain || !raw) return null\n let hostname: string\n try {\n hostname = new URL(raw).hostname.toLowerCase()\n } catch {\n return null\n }\n if (!hostname.endsWith(`.${domain}`)) return null\n const label = hostname.slice(0, -(domain.length + 1))\n return label && !label.includes('.') ? label : null\n}\n\n/**\n * True when the subdomain is reserved, contains a blocked fragment, or wears\n * a brand a phishing kit impersonates (AGL-3356).\n *\n * The brand check is the phishing screen's own ({@link brandForSubdomainLabel}):\n * a brand's name or a lookalike of it (`poshmark`, `paypal-secure`,\n * `paypa1`, `appleid`), or a brand's word joined to an account word\n * (`booking-review`, `apple-support`). Refused for every workspace at create\n * and at rename, since a brand's name on our apex is a disguise that\n * outlives every later screen — while a real business's name that merely\n * contains a common word (`tanyas-booking`, `apple-pie-co`) is not.\n */\nexport function isBlockedSubdomain(subdomain: string): boolean {\n const normalized = subdomain.toLowerCase()\n if (RESERVED_SUBDOMAINS.has(normalized)) return true\n const collapsed = normalized.replace(/[^a-z0-9]/g, '')\n if (BLOCKED_FRAGMENTS.some((fragment) => collapsed.includes(fragment))) return true\n return brandForSubdomainLabel(normalized) !== null\n}\n\n/**\n * Best-effort subdomain from a display name: lowercase, spaces/symbols to\n * dashes, collapsed, trimmed to the pattern's 30-char cap. Returns '' when\n * nothing usable remains (caller falls back to manual entry).\n */\nexport function generateSubdomain(displayName: string): string {\n const slug = displayName\n .toLowerCase()\n .normalize('NFKD')\n .replace(/[̀-ͯ]/g, '')\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/-{2,}/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 30)\n .replace(/-+$/, '')\n if (!SUBDOMAIN_PATTERN.test(slug) || isBlockedSubdomain(slug)) return ''\n return slug\n}\n\n/**\n * Alternatives for a taken subdomain: `name-2`, `name-<year>`, `name-site`.\n * Candidates are truncated to fit the pattern; blocked names are dropped.\n * Availability is the caller's job (needs a Firestore query).\n */\nexport function suggestSubdomains(base: string, year = new Date().getFullYear()): string[] {\n const stem = base.toLowerCase().replace(/-+$/, '')\n const withSuffix = (suffix: string) =>\n `${stem.slice(0, 30 - suffix.length - 1)}-${suffix}`.replace(/-{2,}/g, '-')\n const candidates = [withSuffix('2'), withSuffix(String(year)), withSuffix('site')]\n return [\n ...new Set(\n candidates.filter(\n (candidate) =>\n SUBDOMAIN_PATTERN.test(candidate) && !isBlockedSubdomain(candidate),\n ),\n ),\n ]\n}\n\n/**\n * The apex tenant sites are served from. Console lives on `app.aglyn.com`.\n *\n * CONFIGURABLE, not a literal (AGL-2121). AGL-2022 moved the console's\n * *display* links onto `NEXT_PUBLIC_TENANT_DOMAIN` and shipped a green spec\n * for it; this constant — which every OTHER consumer reads — stayed a bare\n * `'aglyn.app'`. So a self-hoster who set the variable got correct \"View live\"\n * links in their own console while the software went on emitting our apex\n * everywhere that actually reaches the public: `<link rel=\"canonical\">`,\n * `/api/sitemap`, `/api/robots`, `/api/collections-rss` item links,\n * `/api/manifest`, `og:image`, and the origin inbox-bound `<img src>` resolves\n * against. Their customers' sites told Google, every feed reader and every\n * inbox that they lived at a name the operator does not control and we do not\n * serve for them.\n *\n * Same variable and same default as `tenant-links.ts` — one value, one name\n * (AGL-733). Dot notation is load-bearing: this module reaches client bundles,\n * and Next inlines `NEXT_PUBLIC_*` by substituting `process.env.NAME`\n * textually; the bracket form is never substituted and would read `undefined`\n * in the browser. Trimmed so a half-finished `.env` line reads as absent\n * rather than as a whitespace apex.\n *\n * The asymmetry with `operator-identity.ts` — which refuses to default at all\n * — is deliberate. A wrong apex is visible to the operator on the first click\n * in their own console, so a default they can see and correct is a default. A\n * wrong contact address fails silently and off-site, which is why that one\n * refuses to guess.\n */\nexport const TENANT_APEX =\n (process.env.NEXT_PUBLIC_TENANT_DOMAIN || '').trim() || 'aglyn.app'\n\n/**\n * The absolute origin a published site is reachable at (AGL-1224).\n *\n * A custom domain wins over the platform subdomain, matching what the tenant\n * renderer already emits as `<link rel=\"canonical\">` — a site on a custom\n * domain that advertised its `.aglyn.app` origin would be describing itself\n * by a name its visitors never see.\n *\n * Returns undefined rather than a half-built URL when a host has neither, so\n * a caller that needs an absolute URL can tell it does not have one. Emails\n * are the sharp case: an image `src` that is site-relative resolves against\n * nothing in an inbox, so \"no origin\" has to be answerable.\n */\nexport function hostPublicOrigin(\n host:\n | { cname?: string | null; subdomain?: string | null }\n | null\n | undefined,\n): string | undefined {\n if (host?.cname) return `https://${host.cname}`\n if (host?.subdomain) return `https://${host.subdomain}.${TENANT_APEX}`\n return undefined\n}\n\n/**\n * The custom-domain lifecycle as it is actually persisted on the host doc.\n *\n * There is no `verifiedAt`, no `status`, no `sslReady` — the console's\n * `/api/domains/*` routes write exactly these three fields, so this is the\n * whole vocabulary anything downstream has to reason from.\n */\nexport interface HostCustomDomainState {\n cname?: string | null\n /** Vercel attach failed or was unconfigured — the domain does NOT serve. */\n cnameAttachmentPending?: boolean | null\n /** Vercel detach failed — the customer is mid-disconnect. */\n cnameDetachmentPending?: boolean | null\n subdomain?: string | null\n}\n\n/** A bare hostname: labels of a-z0-9/dash, at least two of them. */\nconst CUSTOM_DOMAIN_PATTERN = /^(?!-)[a-z0-9-]{1,63}(\\.(?!-)[a-z0-9-]{1,63})+$/\n\n/**\n * The custom domain a host is actually **serving on**, or undefined (AGL-1272).\n *\n * Deliberately stricter than `hostPublicOrigin`, which answers \"what name does\n * this site call itself\" and is right to prefer `cname` the moment it exists.\n * This answers a load-bearing question instead — \"is it safe to send visitors\n * away from the platform subdomain and onto this name\" — and the cost of\n * getting it wrong is the whole site, not a wrong URL in an email.\n *\n * So every reason to doubt the domain is a refusal:\n *\n * - `cnameAttachmentPending` means the Vercel attach never landed (missing\n * env, or a 5xx). The domain is claimed in Firestore and serves nothing:\n * no certificate, no routing. This is precisely the \"attached but not\n * live\" state, and redirecting to it strands the site on a dead host.\n * - `cnameDetachmentPending` means the customer asked to disconnect and the\n * platform release failed. The name may still resolve, but the customer's\n * stated intent is to leave it, so we stop advertising it.\n * - A name inside `aglyn.app` is refused outright. `{sub}.aglyn.app` is the\n * very origin we redirect FROM, and the tenant middleware resolves that\n * space by `subdomain` before it ever considers `cname` — so a host whose\n * cname pointed back into it would redirect to itself forever.\n * - A name that is not hostname-shaped is refused. `/api/domains/attach`\n * lowercases and trims what the wizard sends but never pattern-checks it\n * (only `/api/domains/verify` does), so a junk value can reach Firestore.\n * A junk value reaching a `Location:` header is a different class of\n * problem again.\n *\n * Note what this CANNOT tell you: DNS was verified once, at connect time, and\n * nothing re-checks it. A domain whose DNS is later repointed or whose\n * registration lapses still reads as live here. That residual risk is why the\n * redirect built on this is temporary and revocable rather than permanent.\n */\nexport function liveCustomDomain(\n host: HostCustomDomainState | null | undefined,\n): string | undefined {\n if (!host) return undefined\n if (host.cnameAttachmentPending === true) return undefined\n if (host.cnameDetachmentPending === true) return undefined\n const domain = String(host.cname ?? '')\n .trim()\n .toLowerCase()\n if (!CUSTOM_DOMAIN_PATTERN.test(domain)) return undefined\n if (domain === TENANT_APEX || domain.endsWith(`.${TENANT_APEX}`)) {\n return undefined\n }\n return domain\n}\n"],"names":["brandForSubdomainLabel","SUBDOMAIN_PATTERN","RESERVED_SUBDOMAINS","Set","BLOCKED_FRAGMENTS","consoleLabelUnder","consoleUrl","workspaceDomain","domain","String","trim","toLowerCase","raw","hostname","URL","endsWith","label","slice","length","includes","isBlockedSubdomain","subdomain","normalized","has","collapsed","replace","some","fragment","generateSubdomain","displayName","slug","normalize","test","suggestSubdomains","base","year","Date","getFullYear","stem","withSuffix","suffix","candidates","filter","candidate","TENANT_APEX","process","env","NEXT_PUBLIC_TENANT_DOMAIN","hostPublicOrigin","host","cname","undefined","CUSTOM_DOMAIN_PATTERN","liveCustomDomain","cnameAttachmentPending","cnameDetachmentPending"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/host-naming.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 * Host naming guards (AGL-147): one shared subdomain policy for the create\n * API, the rename/validate API, and the console dialogs — pattern, reserved\n * + profanity blocklists, a display-name → subdomain generator, and\n * taken-name suggestions.\n */\n\n// The leaf, not the barrel: the brand list and lookalike logic every\n// phishing surface reads (AGL-3356), and nothing else of the email library.\nimport { brandForSubdomainLabel } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { TENANT_APEX } from './tenant-apex'\n\n/** 3–30 chars, lowercase alphanumeric + dashes, no leading dash. */\nexport const SUBDOMAIN_PATTERN = /^[a-z0-9][a-z0-9-]{2,29}$/\n\n/** Platform/system names a tenant must not squat. */\nexport const RESERVED_SUBDOMAINS = new Set([\n 'www',\n 'app',\n 'api',\n 'admin',\n 'console',\n 'mail',\n 'demo',\n 'staging',\n 'dev',\n 'test',\n 'docs',\n 'blog',\n 'help',\n 'support',\n 'status',\n 'cdn',\n 'assets',\n 'static',\n 'ftp',\n 'smtp',\n 'ns1',\n 'ns2',\n 'aglyn',\n 'billing',\n 'account',\n 'login',\n 'signup',\n 'auth',\n])\n\n/**\n * Profanity fragments blocked as substrings in generated or typed\n * subdomains. Deliberately short: obvious slurs and vulgarity only —\n * substring matching is aggressive, so entries must be unambiguous.\n */\nconst BLOCKED_FRAGMENTS = [\n 'fuck',\n 'shit',\n 'cunt',\n 'nigger',\n 'faggot',\n 'bitch',\n 'porn',\n 'nazi',\n]\n\n/**\n * The one label a console URL occupies directly under a workspace domain\n * (AGL-3295): `studio` for `https://studio.example.com` under `example.com`.\n *\n * `null` when either setting is missing or unparseable, and when the console\n * is the apex itself, sits deeper, or lies outside the domain — none of those\n * can collide with a workspace address, so none needs reserving.\n *\n * Pure, and in this dependency-free module, so the console's host gate and the\n * organization-slug blocklist derive the same label from the same two\n * settings. An install whose console is not at `app.` or `console.` is the\n * case it exists for: without it, that label is read as a workspace nobody\n * owns.\n */\nexport function consoleLabelUnder(\n consoleUrl: string | null | undefined,\n workspaceDomain: string | null | undefined,\n): string | null {\n const domain = String(workspaceDomain ?? '')\n .trim()\n .toLowerCase()\n const raw = String(consoleUrl ?? '').trim()\n if (!domain || !raw) return null\n let hostname: string\n try {\n hostname = new URL(raw).hostname.toLowerCase()\n } catch {\n return null\n }\n if (!hostname.endsWith(`.${domain}`)) return null\n const label = hostname.slice(0, -(domain.length + 1))\n return label && !label.includes('.') ? label : null\n}\n\n/**\n * True when the subdomain is reserved, contains a blocked fragment, or wears\n * a brand a phishing kit impersonates (AGL-3356).\n *\n * The brand check is the phishing screen's own ({@link brandForSubdomainLabel}):\n * a brand's name or a lookalike of it (`poshmark`, `paypal-secure`,\n * `paypa1`, `appleid`), or a brand's word joined to an account word\n * (`booking-review`, `apple-support`). Refused for every workspace at create\n * and at rename, since a brand's name on our apex is a disguise that\n * outlives every later screen — while a real business's name that merely\n * contains a common word (`tanyas-booking`, `apple-pie-co`) is not.\n */\nexport function isBlockedSubdomain(subdomain: string): boolean {\n const normalized = subdomain.toLowerCase()\n if (RESERVED_SUBDOMAINS.has(normalized)) return true\n const collapsed = normalized.replace(/[^a-z0-9]/g, '')\n if (BLOCKED_FRAGMENTS.some((fragment) => collapsed.includes(fragment))) return true\n return brandForSubdomainLabel(normalized) !== null\n}\n\n/**\n * Best-effort subdomain from a display name: lowercase, spaces/symbols to\n * dashes, collapsed, trimmed to the pattern's 30-char cap. Returns '' when\n * nothing usable remains (caller falls back to manual entry).\n */\nexport function generateSubdomain(displayName: string): string {\n const slug = displayName\n .toLowerCase()\n .normalize('NFKD')\n .replace(/[̀-ͯ]/g, '')\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/-{2,}/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 30)\n .replace(/-+$/, '')\n if (!SUBDOMAIN_PATTERN.test(slug) || isBlockedSubdomain(slug)) return ''\n return slug\n}\n\n/**\n * Alternatives for a taken subdomain: `name-2`, `name-<year>`, `name-site`.\n * Candidates are truncated to fit the pattern; blocked names are dropped.\n * Availability is the caller's job (needs a Firestore query).\n */\nexport function suggestSubdomains(base: string, year = new Date().getFullYear()): string[] {\n const stem = base.toLowerCase().replace(/-+$/, '')\n const withSuffix = (suffix: string) =>\n `${stem.slice(0, 30 - suffix.length - 1)}-${suffix}`.replace(/-{2,}/g, '-')\n const candidates = [withSuffix('2'), withSuffix(String(year)), withSuffix('site')]\n return [\n ...new Set(\n candidates.filter(\n (candidate) =>\n SUBDOMAIN_PATTERN.test(candidate) && !isBlockedSubdomain(candidate),\n ),\n ),\n ]\n}\n\n// The apex tenant sites are served from lives in its own leaf, so a published\n// page can read it without the subdomain policy above (AGL-3401). Re-exported\n// here so every importer of it keeps working.\nexport { TENANT_APEX }\n\n/**\n * The absolute origin a published site is reachable at (AGL-1224).\n *\n * A custom domain wins over the platform subdomain, matching what the tenant\n * renderer already emits as `<link rel=\"canonical\">` — a site on a custom\n * domain that advertised its `.aglyn.app` origin would be describing itself\n * by a name its visitors never see.\n *\n * Returns undefined rather than a half-built URL when a host has neither, so\n * a caller that needs an absolute URL can tell it does not have one. Emails\n * are the sharp case: an image `src` that is site-relative resolves against\n * nothing in an inbox, so \"no origin\" has to be answerable.\n */\nexport function hostPublicOrigin(\n host:\n | { cname?: string | null; subdomain?: string | null }\n | null\n | undefined,\n): string | undefined {\n if (host?.cname) return `https://${host.cname}`\n if (host?.subdomain) return `https://${host.subdomain}.${TENANT_APEX}`\n return undefined\n}\n\n/**\n * The custom-domain lifecycle as it is actually persisted on the host doc.\n *\n * There is no `verifiedAt`, no `status`, no `sslReady` — the console's\n * `/api/domains/*` routes write exactly these three fields, so this is the\n * whole vocabulary anything downstream has to reason from.\n */\nexport interface HostCustomDomainState {\n cname?: string | null\n /** Vercel attach failed or was unconfigured — the domain does NOT serve. */\n cnameAttachmentPending?: boolean | null\n /** Vercel detach failed — the customer is mid-disconnect. */\n cnameDetachmentPending?: boolean | null\n subdomain?: string | null\n}\n\n/** A bare hostname: labels of a-z0-9/dash, at least two of them. */\nconst CUSTOM_DOMAIN_PATTERN = /^(?!-)[a-z0-9-]{1,63}(\\.(?!-)[a-z0-9-]{1,63})+$/\n\n/**\n * The custom domain a host is actually **serving on**, or undefined (AGL-1272).\n *\n * Deliberately stricter than `hostPublicOrigin`, which answers \"what name does\n * this site call itself\" and is right to prefer `cname` the moment it exists.\n * This answers a load-bearing question instead — \"is it safe to send visitors\n * away from the platform subdomain and onto this name\" — and the cost of\n * getting it wrong is the whole site, not a wrong URL in an email.\n *\n * So every reason to doubt the domain is a refusal:\n *\n * - `cnameAttachmentPending` means the Vercel attach never landed (missing\n * env, or a 5xx). The domain is claimed in Firestore and serves nothing:\n * no certificate, no routing. This is precisely the \"attached but not\n * live\" state, and redirecting to it strands the site on a dead host.\n * - `cnameDetachmentPending` means the customer asked to disconnect and the\n * platform release failed. The name may still resolve, but the customer's\n * stated intent is to leave it, so we stop advertising it.\n * - A name inside `aglyn.app` is refused outright. `{sub}.aglyn.app` is the\n * very origin we redirect FROM, and the tenant middleware resolves that\n * space by `subdomain` before it ever considers `cname` — so a host whose\n * cname pointed back into it would redirect to itself forever.\n * - A name that is not hostname-shaped is refused. `/api/domains/attach`\n * lowercases and trims what the wizard sends but never pattern-checks it\n * (only `/api/domains/verify` does), so a junk value can reach Firestore.\n * A junk value reaching a `Location:` header is a different class of\n * problem again.\n *\n * Note what this CANNOT tell you: DNS was verified once, at connect time, and\n * nothing re-checks it. A domain whose DNS is later repointed or whose\n * registration lapses still reads as live here. That residual risk is why the\n * redirect built on this is temporary and revocable rather than permanent.\n */\nexport function liveCustomDomain(\n host: HostCustomDomainState | null | undefined,\n): string | undefined {\n if (!host) return undefined\n if (host.cnameAttachmentPending === true) return undefined\n if (host.cnameDetachmentPending === true) return undefined\n const domain = String(host.cname ?? '')\n .trim()\n .toLowerCase()\n if (!CUSTOM_DOMAIN_PATTERN.test(domain)) return undefined\n if (domain === TENANT_APEX || domain.endsWith(`.${TENANT_APEX}`)) {\n return undefined\n }\n return domain\n}\n"],"names":["brandForSubdomainLabel","TENANT_APEX","SUBDOMAIN_PATTERN","RESERVED_SUBDOMAINS","Set","BLOCKED_FRAGMENTS","consoleLabelUnder","consoleUrl","workspaceDomain","domain","String","trim","toLowerCase","raw","hostname","URL","endsWith","label","slice","length","includes","isBlockedSubdomain","subdomain","normalized","has","collapsed","replace","some","fragment","generateSubdomain","displayName","slug","normalize","test","suggestSubdomains","base","year","Date","getFullYear","stem","withSuffix","suffix","candidates","filter","candidate","hostPublicOrigin","host","cname","undefined","CUSTOM_DOMAIN_PATTERN","liveCustomDomain","cnameAttachmentPending","cnameDetachmentPending"],"mappings":"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"}
@@ -103,7 +103,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
103
103
  * everything else derives from it. Kept in the framework package rather than
104
104
  * next to the handler because the handler lives in a server-only lib that a
105
105
  * plugin component cannot import.
106
- */ import { TENANT_APEX } from "./host-naming.js";
106
+ */ import { TENANT_APEX } from "./tenant-apex.js";
107
107
  export const MEDIA_CDN_ROUTE = '/api/media/cdn';
108
108
  /**
109
109
  * The WebP variant widths generated at upload (AGL-175), and the widths a