@aglyn/aglyn 1.0.0-beta.147 → 1.0.0-beta.150

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 (54) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/binding-token-catalog.js +9 -2
  3. package/src/lib/app-utils/binding-token-catalog.js.map +1 -1
  4. package/src/lib/app-utils/collection-entries.d.ts +52 -3
  5. package/src/lib/app-utils/collection-entries.js +98 -9
  6. package/src/lib/app-utils/collection-entries.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +2 -34
  8. package/src/lib/app-utils/console-routes.js +29 -11
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contexts.d.ts +1 -0
  11. package/src/lib/app-utils/contexts.js +1 -0
  12. package/src/lib/app-utils/contexts.js.map +1 -1
  13. package/src/lib/app-utils/docs-help.generated.d.ts +13 -1
  14. package/src/lib/app-utils/docs-help.generated.js +37 -0
  15. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  16. package/src/lib/app-utils/docs-index.generated.js +1 -1
  17. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  18. package/src/lib/app-utils/media-src.d.ts +93 -0
  19. package/src/lib/app-utils/media-src.js +114 -0
  20. package/src/lib/app-utils/media-src.js.map +1 -0
  21. package/src/lib/app-utils/notifications.d.ts +136 -2
  22. package/src/lib/app-utils/notifications.js +139 -3
  23. package/src/lib/app-utils/notifications.js.map +1 -1
  24. package/src/lib/app-utils/page-header-help-context.d.ts +83 -0
  25. package/src/lib/app-utils/page-header-help-context.js +56 -0
  26. package/src/lib/app-utils/page-header-help-context.js.map +1 -0
  27. package/src/lib/app-utils/release-flags.js +5 -4
  28. package/src/lib/app-utils/release-flags.js.map +1 -1
  29. package/src/lib/foundation/definitions/host-content-collections.d.ts +42 -40
  30. package/src/lib/foundation/definitions/host-content-collections.js +47 -109
  31. package/src/lib/foundation/definitions/host-content-collections.js.map +1 -1
  32. package/src/lib/plugin-manager/feature-plugins.d.ts +121 -22
  33. package/src/lib/plugin-manager/feature-plugins.js +41 -18
  34. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  35. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +13 -0
  36. package/src/lib/plugin-manager/first-party-plugins.generated.js +257 -0
  37. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  38. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +16 -0
  39. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  40. package/src/lib/plugin-manager/plugin-host-collections.d.ts +27 -5
  41. package/src/lib/plugin-manager/plugin-host-collections.js +25 -16
  42. package/src/lib/plugin-manager/plugin-host-collections.js.map +1 -1
  43. package/src/lib/plugin-manager/plugin-media-publish.d.ts +88 -0
  44. package/src/lib/plugin-manager/plugin-media-publish.js +118 -0
  45. package/src/lib/plugin-manager/plugin-media-publish.js.map +1 -0
  46. package/src/lib/plugin-manager/plugin-org-capacity.d.ts +57 -0
  47. package/src/lib/plugin-manager/plugin-org-capacity.js +68 -0
  48. package/src/lib/plugin-manager/plugin-org-capacity.js.map +1 -0
  49. package/src/lib/plugin-manager/plugin-route-metadata.d.ts +141 -0
  50. package/src/lib/plugin-manager/plugin-route-metadata.js +140 -0
  51. package/src/lib/plugin-manager/plugin-route-metadata.js.map +1 -0
  52. package/src/lib/plugin-manager/record-captured-contact.d.ts +89 -0
  53. package/src/lib/plugin-manager/record-captured-contact.js +128 -0
  54. package/src/lib/plugin-manager/record-captured-contact.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-host-collections.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 { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * The host subcollections a plugin owns, declared by that plugin (AGL-3124).\n *\n * A first-party plugin writes ordinary documents under `hosts/{hostId}` with\n * the ordinary SDKs, and several core surfaces have to know which plugin owns\n * which of them: the media-usage scan decides what to read before an author\n * deletes an asset, a reference row deep-links to the console page that shows\n * one, and the artifact counters total what a site holds.\n *\n * Core answered all three by listing the collections and switching over plugin\n * ids — `services` and `resources` are bookings', `campaigns` and `experiments`\n * are marketing's, `functions`, `variables` and `actions` are logic's — which\n * is the platform holding a map of its plugins' storage. Every entry is also a\n * thing that can silently fall out of step: a plugin that ships a new\n * collection is invisible to all three until somebody remembers the list.\n *\n * So the plugin declares what it owns, once, and the three readers ask.\n *\n * ## What a declaration says, and what it deliberately does not\n *\n * A declaration names the collection, how the media scan should treat it, the\n * console slug a reference row links to, and whether a document in it is an\n * ARTIFACT the site's counters total beside screens and layouts. It does NOT\n * name fields: the scan flattens a document generically on purpose, and a\n * field list here would be the same staleness trap one level down.\n *\n * The media default is inherited from the scan's own rule and is the whole\n * reason that rule survives this move: **a collection is scanned unless its\n * declaration says why it is not.** A plugin that ships a media-bearing\n * collection is covered the day it lands rather than the day somebody\n * remembers it, and `mediaScan: 'none'` without a reason is refused — \"it\n * probably has no images in it\" is the guess the scan's design exists to\n * avoid making.\n *\n * ## One collection, one owner\n *\n * Two plugins writing one collection under one site would be two schemas in\n * one place, and the scan, the deep link and the counters would each pick a\n * winner by registration order. So a collection another plugin declared is\n * refused naming both, and the incumbent keeps it; the same plugin declaring\n * again replaces its own.\n *\n * Reached by its own subpath rather than through `plugin-manager/index.ts`:\n * the readers are the media scan, the console counters and the reference\n * rows, none of which a published page renders.\n */\n\n/** How the media-usage scan reads a collection. */\nexport type PluginHostCollectionMediaScan =\n /** Flattened and searched generically, with no per-collection decoder. */\n | 'generic'\n /** Read by a pass that knows its shape, the way screens and layouts are. */\n | 'own'\n /** Not read; {@link PluginHostCollectionDeclaration.mediaScanReason} says why. */\n | 'none'\n\nexport interface PluginHostCollectionDeclaration {\n /** The subcollection under `hosts/{hostId}`, spelled as Firestore holds it. */\n name: string\n /**\n * What one of its documents is called in a reference row. Derived from the\n * collection name when absent, so a new collection reads correctly without\n * a second list to keep in step.\n */\n label?: string\n /** Default `generic` — see this module's note on why the default is to scan. */\n mediaScan?: PluginHostCollectionMediaScan\n /**\n * Why the scan does not read it. Required with `mediaScan: 'none'`, and it\n * has to answer what scanning would COST or what it would get WRONG.\n */\n mediaScanReason?: string\n /**\n * The `[pluginSlug]` segment of the plugin's console hub, for a reference\n * row's deep link. Optional on purpose: a row with no destination still\n * renders as text, which is better than the row not existing.\n */\n routeSlug?: string\n /**\n * Whether a document here is a live ARTIFACT — something the site's own\n * counters total beside its screens, layouts, components and templates.\n */\n artifact?: boolean\n}\n\n/** A declaration with the plugin that made it. */\nexport type ResolvedPluginHostCollection = PluginHostCollectionDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_HOST_COLLECTIONS =\n definePluginServiceContract<PluginHostCollectionDeclaration>(\n 'core.host-collections',\n { multiple: true },\n )\n\n/**\n * Declares the host subcollections the registering plugin owns. The owner is\n * the loader's marker when a register fn is running, else `options.pluginId`;\n * with neither the registration throws. A collection another plugin declared\n * throws naming both, and nothing in the same call is registered — a\n * declaration is one plugin's statement about its own storage, so half of it\n * landing would be worse than none.\n */\nexport function registerPluginHostCollections(\n collections: readonly PluginHostCollectionDeclaration[],\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const prepared = collections.map((declaration) => {\n const name = declaration.name?.trim() ?? ''\n if (!name) throw new Error('a host collection needs a name')\n if (declaration.mediaScan === 'none' && !declaration.mediaScanReason?.trim()) {\n throw new Error(\n `host collection \"${name}\" is not scanned for media and says no ` +\n 'reason: mediaScanReason has to name what scanning would cost, or ' +\n 'what it would get wrong',\n )\n }\n const incumbent = resolvePluginServices(PLUGIN_HOST_COLLECTIONS).find(\n (entry) => entry.key === name,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `host collection \"${name}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n return { ...declaration, name }\n })\n for (const declaration of prepared) {\n registerPluginService(PLUGIN_HOST_COLLECTIONS, declaration, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key: declaration.name,\n })\n }\n}\n\n/** Every declared collection, with its owner, in registration order. */\nexport function listPluginHostCollections(): ResolvedPluginHostCollection[] {\n return resolvePluginServices(PLUGIN_HOST_COLLECTIONS).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n\n/** One declared collection by name, with its owner, or `null`. */\nexport function pluginHostCollection(\n name: string,\n): ResolvedPluginHostCollection | null {\n const key = name.trim()\n const entry = resolvePluginServices(PLUGIN_HOST_COLLECTIONS).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n\n/** The plugin that owns a collection, or `undefined` when none declares it. */\nexport function pluginIdForHostCollection(name: string): string | undefined {\n return pluginHostCollection(name)?.pluginId\n}\n\n/**\n * The console hub slug a reference row for this collection links to, or\n * `undefined` when the owner routes none.\n */\nexport function pluginHostCollectionRouteSlug(\n name: string,\n): string | undefined {\n return pluginHostCollection(name)?.routeSlug\n}\n\n/**\n * The declared collections the media scan reads generically — the list the\n * scan walks, in place of one core keeps by hand.\n */\nexport function pluginHostCollectionsScannedGenerically(): string[] {\n return listPluginHostCollections()\n .filter((one) => (one.mediaScan ?? 'generic') === 'generic')\n .map((one) => one.name)\n}\n\n/** The declared collections the scan does not read, with the reason given. */\nexport function pluginHostCollectionsExcludedFromMediaScan(): Array<{\n name: string\n reason: string\n pluginId: string\n}> {\n return listPluginHostCollections()\n .filter((one) => one.mediaScan === 'none')\n .map((one) => ({\n name: one.name,\n reason: one.mediaScanReason ?? '',\n pluginId: one.pluginId,\n }))\n}\n\n/** The declared collections whose documents the site's artifact counters total. */\nexport function pluginHostArtifactCollections(): string[] {\n return listPluginHostCollections()\n .filter((one) => one.artifact === true)\n .map((one) => one.name)\n}\n\n/**\n * What to call one of a collection's documents in a reference row: the\n * declared label, else derived from the name — `productCategories` reads as\n * \"Product category\". Singularized with the two rules English spells\n * consistently enough to automate; anything else keeps its name, which is a\n * cosmetic miss rather than a coverage one.\n */\nexport function pluginHostCollectionLabel(name: string): string {\n const declared = pluginHostCollection(name)?.label?.trim()\n if (declared) return declared\n const singular = /ies$/.test(name)\n ? name.replace(/ies$/, 'y')\n : /(ss|s)$/.test(name)\n ? name.replace(/s$/, '')\n : name\n const spaced = singular.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase()\n return spaced.charAt(0).toUpperCase() + spaced.slice(1)\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_HOST_COLLECTIONS","multiple","registerPluginHostCollections","collections","options","pluginId","trim","prepared","map","declaration","name","Error","mediaScan","mediaScanReason","incumbent","find","entry","key","listPluginHostCollections","impl","pluginHostCollection","one","pluginIdForHostCollection","pluginHostCollectionRouteSlug","routeSlug","pluginHostCollectionsScannedGenerically","filter","pluginHostCollectionsExcludedFromMediaScan","reason","pluginHostArtifactCollections","artifact","pluginHostCollectionLabel","declared","label","singular","test","replace","spaced","toLowerCase","charAt","toUpperCase","slice"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-host-collections.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 { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport { hostContentCollectionLabel } from '../foundation/definitions/host-content-collections'\nimport { PLUGIN_HOST_COLLECTIONS_DECLARED } from './first-party-plugins.generated'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * The host subcollections a plugin owns, declared by that plugin (AGL-3124).\n *\n * A first-party plugin writes ordinary documents under `hosts/{hostId}` with\n * the ordinary SDKs, and several core surfaces have to know which plugin owns\n * which of them: the media-usage scan decides what to read before an author\n * deletes an asset, a reference row deep-links to the console page that shows\n * one, and the artifact counters total what a site holds.\n *\n * Core answered all three by listing the collections and switching over plugin\n * ids — `services` and `resources` are bookings', `campaigns` and `experiments`\n * are marketing's, `functions`, `variables` and `actions` are logic's — which\n * is the platform holding a map of its plugins' storage. Every entry is also a\n * thing that can silently fall out of step: a plugin that ships a new\n * collection is invisible to all three until somebody remembers the list.\n *\n * So the plugin declares what it owns, once, and the three readers ask.\n *\n * ## What a declaration says, and what it deliberately does not\n *\n * A declaration names the collection, how the media scan should treat it, the\n * console slug a reference row links to, and whether a document in it is an\n * ARTIFACT the site's counters total beside screens and layouts. It does NOT\n * name fields: the scan flattens a document generically on purpose, and a\n * field list here would be the same staleness trap one level down.\n *\n * The media default is inherited from the scan's own rule and is the whole\n * reason that rule survives this move: **a collection is scanned unless its\n * declaration says why it is not.** A plugin that ships a media-bearing\n * collection is covered the day it lands rather than the day somebody\n * remembers it, and `mediaScan: 'none'` without a reason is refused — \"it\n * probably has no images in it\" is the guess the scan's design exists to\n * avoid making.\n *\n * ## One collection, one owner\n *\n * Two plugins writing one collection under one site would be two schemas in\n * one place, and the scan, the deep link and the counters would each pick a\n * winner by registration order. So a collection another plugin declared is\n * refused naming both, and the incumbent keeps it; the same plugin declaring\n * again replaces its own.\n *\n * ## Declared in the config, not registered at boot (AGL-3080)\n *\n * A first-party plugin declares its collections in `plugins.config.json` and\n * the generator compiles them, so every reader has the whole list the moment\n * it imports this module. That is not a preference: the media scan answers\n * \"what uses this asset\" in the moment before an author deletes it, every way\n * it can be wrong points at \"unused\", and it runs inside a console request\n * that loads no plugin code. A registry that request had not filled would\n * report every plugin-owned document as holding nothing, with nothing red —\n * the shape of AGL-3025, on the one answer here that somebody acts on.\n *\n * {@link registerPluginHostCollections} is still the door for a plugin the\n * compiler never sees, and the two compose: the compiled rows are a floor a\n * registration cannot lower, and a registration naming a compiled collection\n * is refused the same way a second plugin's would be.\n *\n * Reached by its own subpath rather than through `plugin-manager/index.ts`:\n * the readers are the media scan, the console counters and the reference\n * rows, none of which a published page renders.\n */\n\n/** How the media-usage scan reads a collection. */\nexport type PluginHostCollectionMediaScan =\n /** Flattened and searched generically, with no per-collection decoder. */\n | 'generic'\n /** Read by a pass that knows its shape, the way screens and layouts are. */\n | 'own'\n /** Not read; {@link PluginHostCollectionDeclaration.mediaScanReason} says why. */\n | 'none'\n\nexport interface PluginHostCollectionDeclaration {\n /** The subcollection under `hosts/{hostId}`, spelled as Firestore holds it. */\n name: string\n /**\n * What one of its documents is called in a reference row. Derived from the\n * collection name when absent, so a new collection reads correctly without\n * a second list to keep in step.\n */\n label?: string\n /** Default `generic` — see this module's note on why the default is to scan. */\n mediaScan?: PluginHostCollectionMediaScan\n /**\n * Why the scan does not read it. Required with `mediaScan: 'none'`, and it\n * has to answer what scanning would COST or what it would get WRONG.\n */\n mediaScanReason?: string\n /**\n * The `[pluginSlug]` segment of the plugin's console hub, for a reference\n * row's deep link. Optional on purpose: a row with no destination still\n * renders as text, which is better than the row not existing.\n */\n routeSlug?: string\n /**\n * Whether a document here is a live ARTIFACT — something the site's own\n * counters total beside its screens, layouts, components and templates.\n */\n artifact?: boolean\n}\n\n/** A declaration with the plugin that made it. */\nexport type ResolvedPluginHostCollection = PluginHostCollectionDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_HOST_COLLECTIONS =\n definePluginServiceContract<PluginHostCollectionDeclaration>(\n 'core.host-collections',\n { multiple: true },\n )\n\n/**\n * Declares the host subcollections the registering plugin owns. The owner is\n * the loader's marker when a register fn is running, else `options.pluginId`;\n * with neither the registration throws. A collection another plugin declared\n * throws naming both, and nothing in the same call is registered — a\n * declaration is one plugin's statement about its own storage, so half of it\n * landing would be worse than none.\n */\nexport function registerPluginHostCollections(\n collections: readonly PluginHostCollectionDeclaration[],\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const prepared = collections.map((declaration) => {\n const name = declaration.name?.trim() ?? ''\n if (!name) throw new Error('a host collection needs a name')\n if (declaration.mediaScan === 'none' && !declaration.mediaScanReason?.trim()) {\n throw new Error(\n `host collection \"${name}\" is not scanned for media and says no ` +\n 'reason: mediaScanReason has to name what scanning would cost, or ' +\n 'what it would get wrong',\n )\n }\n const incumbent = pluginHostCollection(name)\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `host collection \"${name}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n return { ...declaration, name }\n })\n for (const declaration of prepared) {\n registerPluginService(PLUGIN_HOST_COLLECTIONS, declaration, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key: declaration.name,\n })\n }\n}\n\n/**\n * Every declared collection, with its owner: the compiled rows first, then\n * anything registered at runtime that they do not already name.\n */\nexport function listPluginHostCollections(): ResolvedPluginHostCollection[] {\n const compiled = PLUGIN_HOST_COLLECTIONS_DECLARED.map((row) => ({ ...row }))\n const named = new Set(compiled.map((row) => row.name))\n const registered = resolvePluginServices(PLUGIN_HOST_COLLECTIONS)\n .map((entry) => ({ ...entry.impl, pluginId: entry.pluginId }))\n .filter((row) => !named.has(row.name))\n return [...compiled, ...registered]\n}\n\n/** One declared collection by name, with its owner, or `null`. */\nexport function pluginHostCollection(\n name: string,\n): ResolvedPluginHostCollection | null {\n const key = name.trim()\n return listPluginHostCollections().find((one) => one.name === key) ?? null\n}\n\n/** The plugin that owns a collection, or `undefined` when none declares it. */\nexport function pluginIdForHostCollection(name: string): string | undefined {\n return pluginHostCollection(name)?.pluginId\n}\n\n/**\n * The console hub slug a reference row for this collection links to, or\n * `undefined` when the owner routes none.\n */\nexport function pluginHostCollectionRouteSlug(\n name: string,\n): string | undefined {\n return pluginHostCollection(name)?.routeSlug\n}\n\n/**\n * The declared collections the media scan reads generically — the list the\n * scan walks, in place of one core keeps by hand.\n */\nexport function pluginHostCollectionsScannedGenerically(): string[] {\n return listPluginHostCollections()\n .filter((one) => (one.mediaScan ?? 'generic') === 'generic')\n .map((one) => one.name)\n}\n\n/** The declared collections the scan does not read, with the reason given. */\nexport function pluginHostCollectionsExcludedFromMediaScan(): Array<{\n name: string\n reason: string\n pluginId: string\n}> {\n return listPluginHostCollections()\n .filter((one) => one.mediaScan === 'none')\n .map((one) => ({\n name: one.name,\n reason: one.mediaScanReason ?? '',\n pluginId: one.pluginId,\n }))\n}\n\n/** The declared collections whose documents the site's artifact counters total. */\nexport function pluginHostArtifactCollections(): string[] {\n return listPluginHostCollections()\n .filter((one) => one.artifact === true)\n .map((one) => one.name)\n}\n\n/**\n * What to call one of a collection's documents in a reference row: the\n * declared label, else core's own derivation from the name.\n *\n * The derivation is `hostContentCollectionLabel` rather than a second copy of\n * it, because a reference row does not know or care whether the collection it\n * names belongs to a plugin — two spellings of \"Product category\" reachable\n * by two paths is the kind of difference nobody notices until a screenshot\n * disagrees with a test.\n */\nexport function pluginHostCollectionLabel(name: string): string {\n const declared = pluginHostCollection(name)?.label?.trim()\n return declared || hostContentCollectionLabel(name)\n}\n"],"names":["getRegisteringPluginId","hostContentCollectionLabel","PLUGIN_HOST_COLLECTIONS_DECLARED","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_HOST_COLLECTIONS","multiple","registerPluginHostCollections","collections","options","pluginId","trim","prepared","map","declaration","name","Error","mediaScan","mediaScanReason","incumbent","pluginHostCollection","key","listPluginHostCollections","compiled","row","named","Set","registered","entry","impl","filter","has","find","one","pluginIdForHostCollection","pluginHostCollectionRouteSlug","routeSlug","pluginHostCollectionsScannedGenerically","pluginHostCollectionsExcludedFromMediaScan","reason","pluginHostArtifactCollections","artifact","pluginHostCollectionLabel","declared","label"],"mappings":"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"}
@@ -0,0 +1,88 @@
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
+ /** The asset a caller is about to make public. */
18
+ export interface PluginMediaPublishRequest {
19
+ /**
20
+ * The library it lives in: `hosts/{hostId}` for a site's own, or
21
+ * `orgs/{orgId}` for one the whole organization shares.
22
+ */
23
+ base: string;
24
+ /** The asset's id in that library. */
25
+ mediaId: string;
26
+ /**
27
+ * The platform's media bucket, so a raw URL from any other bucket is never
28
+ * mistaken for this asset. Absent where the caller has none to give.
29
+ */
30
+ bucket?: string;
31
+ }
32
+ /** One thing a guard found that is still using the asset. */
33
+ export interface PluginMediaPublishBlocker {
34
+ /** What a person reads: a product's name, a page's title. */
35
+ label: string;
36
+ /** The document, for a caller that links back to it. */
37
+ refId?: string;
38
+ /** The site holding it, where the plugin scopes per site. */
39
+ hostId?: string;
40
+ }
41
+ export interface PluginMediaPublishRefusal {
42
+ /**
43
+ * The sentence the person who pressed Publish reads. Customer-safe: the
44
+ * caller may show it exactly as it stands, and it names nothing internal.
45
+ */
46
+ reason: string;
47
+ /** What is holding the asset. May be empty — see {@link complete}. */
48
+ blockers: readonly PluginMediaPublishBlocker[];
49
+ /**
50
+ * False when a ceiling or a failure stopped the guard short. An incomplete
51
+ * "nothing holds this" is not an answer anyone may publish on, so it is a
52
+ * refusal with no blockers rather than a pass.
53
+ */
54
+ complete: boolean;
55
+ }
56
+ export interface PluginMediaPublishGuard {
57
+ /**
58
+ * Answers a refusal, or `null` when nothing this plugin holds objects.
59
+ *
60
+ * ⚠️ A throw is a REFUSAL, handled by the resolver — a guard is not asked
61
+ * to turn its own unreadable state into a sentence.
62
+ */
63
+ check(request: PluginMediaPublishRequest): Promise<PluginMediaPublishRefusal | null>;
64
+ }
65
+ /** A guard with the plugin that registered it. */
66
+ export type ResolvedPluginMediaPublishGuard = {
67
+ pluginId: string;
68
+ guard: PluginMediaPublishGuard;
69
+ };
70
+ export declare const PLUGIN_MEDIA_PUBLISH_GUARDS: import("./plugin-services").PluginServiceContract<PluginMediaPublishGuard>;
71
+ /**
72
+ * Declares that this plugin has a reason an asset may not be made public.
73
+ * The owner is the loader's marker when a register fn is running, else
74
+ * `options.pluginId`.
75
+ */
76
+ export declare function registerPluginMediaPublishGuard(guard: PluginMediaPublishGuard, options?: {
77
+ pluginId?: string;
78
+ }): void;
79
+ /** Every registered guard, with its plugin, in registration order. */
80
+ export declare function listPluginMediaPublishGuards(): ResolvedPluginMediaPublishGuard[];
81
+ /**
82
+ * The first reason this asset may not be made public, or `null` when every
83
+ * guard ran and none objected.
84
+ *
85
+ * ⚠️ Read the module docblock before treating `null` as permission: it is
86
+ * only an answer once the caller has loaded the plugins' server surfaces.
87
+ */
88
+ export declare function resolvePluginMediaPublishRefusal(request: PluginMediaPublishRequest): Promise<PluginMediaPublishRefusal | null>;
@@ -0,0 +1,118 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * WHO REFUSES TO LET A LIBRARY ASSET BE MADE PUBLIC (AGL-3080).
19
+ *
20
+ * "Publish file" in the media library gives an asset its permanent CDN URL
21
+ * back. For most assets that is what the button is for. For a file somebody
22
+ * is SELLING it is a giveaway: the public URL names the same object as every
23
+ * signed link a buyer was handed, so stripping the signature off any of them
24
+ * starts working again.
25
+ *
26
+ * The console knew that rule, which meant it knew what a product is, which
27
+ * two of its fields hold paid media, and how a deleted one still counts. All
28
+ * of that belongs to whatever sells things. So the plugin holding a reason
29
+ * declares it, and the media route asks before it publishes.
30
+ *
31
+ * ## ⛔ A CHECK THAT DID NOT RUN IS NOT PERMISSION
32
+ *
33
+ * This is the money path, and every way it can be wrong points the same way:
34
+ * towards publishing a file somebody paid for. So the shape is the opposite
35
+ * of the usual one —
36
+ *
37
+ * - a guard that THROWS is a refusal, not a guard to skip. Something it
38
+ * needed was unreadable, and "we could not check" and "nothing holds this"
39
+ * are different answers;
40
+ * - a guard that answers `complete: false` is a refusal for the same reason,
41
+ * even with no blocker to name;
42
+ * - and the CALLER must load the plugins' server surfaces before it asks.
43
+ * An unfilled registry answers "nobody refuses", which is the honest
44
+ * answer for a workspace with no store and the catastrophic one for a
45
+ * process that has not loaded commerce yet (AGL-3025). A caller that
46
+ * cannot load them must refuse rather than publish.
47
+ *
48
+ * `null` from {@link resolvePluginMediaPublishRefusal} therefore means only
49
+ * one thing: every guard that exists ran, and none of them objected.
50
+ *
51
+ * ## Many guards, first refusal wins
52
+ *
53
+ * More than one plugin may sell access to a file, and the person reading the
54
+ * refusal needs one sentence, not a list of every plugin's. The guards are
55
+ * asked in registration order and the first refusal is the answer, because a
56
+ * second reason changes nothing about what they have to do next: the asset
57
+ * stays private either way, and the first blocker is as good a place to start
58
+ * as any.
59
+ *
60
+ * Server-side: reached by its own subpath, never through
61
+ * `plugin-manager/index.ts`.
62
+ */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
63
+ import { definePluginServiceContract, registerPluginService, resolvePluginServices } from "./plugin-services.js";
64
+ export const PLUGIN_MEDIA_PUBLISH_GUARDS = definePluginServiceContract('core.media-publish-guard', {
65
+ multiple: true
66
+ });
67
+ /**
68
+ * Declares that this plugin has a reason an asset may not be made public.
69
+ * The owner is the loader's marker when a register fn is running, else
70
+ * `options.pluginId`.
71
+ */ export function registerPluginMediaPublishGuard(guard, options) {
72
+ var _ref, _getRegisteringPluginId;
73
+ if (typeof (guard == null ? void 0 : guard.check) !== 'function') {
74
+ throw new Error('a media publish guard needs a check function');
75
+ }
76
+ const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
77
+ registerPluginService(PLUGIN_MEDIA_PUBLISH_GUARDS, guard, _extends({}, (options == null ? void 0 : options.pluginId) ? {
78
+ pluginId: options.pluginId
79
+ } : {}, pluginId ? {
80
+ key: pluginId
81
+ } : {}));
82
+ }
83
+ /** Every registered guard, with its plugin, in registration order. */ export function listPluginMediaPublishGuards() {
84
+ return resolvePluginServices(PLUGIN_MEDIA_PUBLISH_GUARDS).map((entry)=>({
85
+ pluginId: entry.pluginId,
86
+ guard: entry.impl
87
+ }));
88
+ }
89
+ /**
90
+ * The first reason this asset may not be made public, or `null` when every
91
+ * guard ran and none objected.
92
+ *
93
+ * ⚠️ Read the module docblock before treating `null` as permission: it is
94
+ * only an answer once the caller has loaded the plugins' server surfaces.
95
+ */ export async function resolvePluginMediaPublishRefusal(request) {
96
+ for (const { pluginId, guard } of listPluginMediaPublishGuards()){
97
+ let verdict;
98
+ try {
99
+ verdict = await guard.check(request);
100
+ } catch (error) {
101
+ // Fail closed, loudly. The asset stays private and somebody can find
102
+ // out why; the alternative is a paid file published because a query
103
+ // failed.
104
+ console.error(`[media-publish] "${pluginId}" could not check ${request.base}/${request.mediaId}`, error);
105
+ return {
106
+ reason: 'We could not check everything that might be using this file, so ' + 'it stays private. Try again in a moment.',
107
+ blockers: [],
108
+ complete: false
109
+ };
110
+ }
111
+ if (verdict && (verdict.blockers.length > 0 || !verdict.complete)) {
112
+ return verdict;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ //# sourceMappingURL=plugin-media-publish.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-media-publish.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 * WHO REFUSES TO LET A LIBRARY ASSET BE MADE PUBLIC (AGL-3080).\n *\n * \"Publish file\" in the media library gives an asset its permanent CDN URL\n * back. For most assets that is what the button is for. For a file somebody\n * is SELLING it is a giveaway: the public URL names the same object as every\n * signed link a buyer was handed, so stripping the signature off any of them\n * starts working again.\n *\n * The console knew that rule, which meant it knew what a product is, which\n * two of its fields hold paid media, and how a deleted one still counts. All\n * of that belongs to whatever sells things. So the plugin holding a reason\n * declares it, and the media route asks before it publishes.\n *\n * ## ⛔ A CHECK THAT DID NOT RUN IS NOT PERMISSION\n *\n * This is the money path, and every way it can be wrong points the same way:\n * towards publishing a file somebody paid for. So the shape is the opposite\n * of the usual one —\n *\n * - a guard that THROWS is a refusal, not a guard to skip. Something it\n * needed was unreadable, and \"we could not check\" and \"nothing holds this\"\n * are different answers;\n * - a guard that answers `complete: false` is a refusal for the same reason,\n * even with no blocker to name;\n * - and the CALLER must load the plugins' server surfaces before it asks.\n * An unfilled registry answers \"nobody refuses\", which is the honest\n * answer for a workspace with no store and the catastrophic one for a\n * process that has not loaded commerce yet (AGL-3025). A caller that\n * cannot load them must refuse rather than publish.\n *\n * `null` from {@link resolvePluginMediaPublishRefusal} therefore means only\n * one thing: every guard that exists ran, and none of them objected.\n *\n * ## Many guards, first refusal wins\n *\n * More than one plugin may sell access to a file, and the person reading the\n * refusal needs one sentence, not a list of every plugin's. The guards are\n * asked in registration order and the first refusal is the answer, because a\n * second reason changes nothing about what they have to do next: the asset\n * stays private either way, and the first blocker is as good a place to start\n * as any.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/** The asset a caller is about to make public. */\nexport interface PluginMediaPublishRequest {\n /**\n * The library it lives in: `hosts/{hostId}` for a site's own, or\n * `orgs/{orgId}` for one the whole organization shares.\n */\n base: string\n /** The asset's id in that library. */\n mediaId: string\n /**\n * The platform's media bucket, so a raw URL from any other bucket is never\n * mistaken for this asset. Absent where the caller has none to give.\n */\n bucket?: string\n}\n\n/** One thing a guard found that is still using the asset. */\nexport interface PluginMediaPublishBlocker {\n /** What a person reads: a product's name, a page's title. */\n label: string\n /** The document, for a caller that links back to it. */\n refId?: string\n /** The site holding it, where the plugin scopes per site. */\n hostId?: string\n}\n\nexport interface PluginMediaPublishRefusal {\n /**\n * The sentence the person who pressed Publish reads. Customer-safe: the\n * caller may show it exactly as it stands, and it names nothing internal.\n */\n reason: string\n /** What is holding the asset. May be empty — see {@link complete}. */\n blockers: readonly PluginMediaPublishBlocker[]\n /**\n * False when a ceiling or a failure stopped the guard short. An incomplete\n * \"nothing holds this\" is not an answer anyone may publish on, so it is a\n * refusal with no blockers rather than a pass.\n */\n complete: boolean\n}\n\nexport interface PluginMediaPublishGuard {\n /**\n * Answers a refusal, or `null` when nothing this plugin holds objects.\n *\n * ⚠️ A throw is a REFUSAL, handled by the resolver — a guard is not asked\n * to turn its own unreadable state into a sentence.\n */\n check(\n request: PluginMediaPublishRequest,\n ): Promise<PluginMediaPublishRefusal | null>\n}\n\n/** A guard with the plugin that registered it. */\nexport type ResolvedPluginMediaPublishGuard = {\n pluginId: string\n guard: PluginMediaPublishGuard\n}\n\nexport const PLUGIN_MEDIA_PUBLISH_GUARDS =\n definePluginServiceContract<PluginMediaPublishGuard>(\n 'core.media-publish-guard',\n { multiple: true },\n )\n\n/**\n * Declares that this plugin has a reason an asset may not be made public.\n * The owner is the loader's marker when a register fn is running, else\n * `options.pluginId`.\n */\nexport function registerPluginMediaPublishGuard(\n guard: PluginMediaPublishGuard,\n options?: { pluginId?: string },\n): void {\n if (typeof guard?.check !== 'function') {\n throw new Error('a media publish guard needs a check function')\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n registerPluginService(PLUGIN_MEDIA_PUBLISH_GUARDS, guard, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n // Keyed by owner so a plugin registering twice — a hot reload, a second\n // surface — replaces its own rather than being asked twice.\n ...(pluginId ? { key: pluginId } : {}),\n })\n}\n\n/** Every registered guard, with its plugin, in registration order. */\nexport function listPluginMediaPublishGuards(): ResolvedPluginMediaPublishGuard[] {\n return resolvePluginServices(PLUGIN_MEDIA_PUBLISH_GUARDS).map((entry) => ({\n pluginId: entry.pluginId,\n guard: entry.impl,\n }))\n}\n\n/**\n * The first reason this asset may not be made public, or `null` when every\n * guard ran and none objected.\n *\n * ⚠️ Read the module docblock before treating `null` as permission: it is\n * only an answer once the caller has loaded the plugins' server surfaces.\n */\nexport async function resolvePluginMediaPublishRefusal(\n request: PluginMediaPublishRequest,\n): Promise<PluginMediaPublishRefusal | null> {\n for (const { pluginId, guard } of listPluginMediaPublishGuards()) {\n let verdict: PluginMediaPublishRefusal | null\n try {\n verdict = await guard.check(request)\n } catch (error) {\n // Fail closed, loudly. The asset stays private and somebody can find\n // out why; the alternative is a paid file published because a query\n // failed.\n console.error(\n `[media-publish] \"${pluginId}\" could not check ${request.base}/${request.mediaId}`,\n error,\n )\n return {\n reason:\n 'We could not check everything that might be using this file, so ' +\n 'it stays private. Try again in a moment.',\n blockers: [],\n complete: false,\n }\n }\n if (verdict && (verdict.blockers.length > 0 || !verdict.complete)) {\n return verdict\n }\n }\n return null\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_MEDIA_PUBLISH_GUARDS","multiple","registerPluginMediaPublishGuard","guard","options","check","Error","pluginId","trim","key","listPluginMediaPublishGuards","map","entry","impl","resolvePluginMediaPublishRefusal","request","verdict","error","console","base","mediaId","reason","blockers","complete","length"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA8D1B,OAAO,MAAMC,8BACXH,4BACE,4BACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;CAIC,GACD,OAAO,SAASC,gCACdC,KAA8B,EAC9BC,OAA+B;QAKbR,MAAAA;IAHlB,IAAI,QAAOO,yBAAAA,MAAOE,KAAK,MAAK,YAAY;QACtC,MAAM,IAAIC,MAAM;IAClB;IACA,MAAMC,WAAW,EAACX,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASG,QAAQ,YAA7CX,OAAiD,IAAIY,IAAI;IAC3EV,sBAAsBE,6BAA6BG,OAAO,aACpDC,CAAAA,2BAAAA,QAASG,QAAQ,IAAG;QAAEA,UAAUH,QAAQG,QAAQ;IAAC,IAAI,CAAC,GAGtDA,WAAW;QAAEE,KAAKF;IAAS,IAAI,CAAC;AAExC;AAEA,oEAAoE,GACpE,OAAO,SAASG;IACd,OAAOX,sBAAsBC,6BAA6BW,GAAG,CAAC,CAACC,QAAW,CAAA;YACxEL,UAAUK,MAAML,QAAQ;YACxBJ,OAAOS,MAAMC,IAAI;QACnB,CAAA;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,iCACpBC,OAAkC;IAElC,KAAK,MAAM,EAAER,QAAQ,EAAEJ,KAAK,EAAE,IAAIO,+BAAgC;QAChE,IAAIM;QACJ,IAAI;YACFA,UAAU,MAAMb,MAAME,KAAK,CAACU;QAC9B,EAAE,OAAOE,OAAO;YACd,qEAAqE;YACrE,oEAAoE;YACpE,UAAU;YACVC,QAAQD,KAAK,CACX,CAAC,iBAAiB,EAAEV,SAAS,kBAAkB,EAAEQ,QAAQI,IAAI,CAAC,CAAC,EAAEJ,QAAQK,OAAO,EAAE,EAClFH;YAEF,OAAO;gBACLI,QACE,qEACA;gBACFC,UAAU,EAAE;gBACZC,UAAU;YACZ;QACF;QACA,IAAIP,WAAYA,CAAAA,QAAQM,QAAQ,CAACE,MAAM,GAAG,KAAK,CAACR,QAAQO,QAAQ,AAAD,GAAI;YACjE,OAAOP;QACT;IACF;IACA,OAAO;AACT"}
@@ -0,0 +1,57 @@
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
+ /** What one and many of the thing are called, and what the money buys. */
18
+ export interface PluginOrgCapacityNouns {
19
+ /** "dataset" — used when the quantity is exactly one. */
20
+ one: string;
21
+ /** "datasets" — used for every other quantity, and as the held noun. */
22
+ many: string;
23
+ /**
24
+ * "extra datasets" — what the PURCHASE is called. An org holds "team
25
+ * members" and buys "extra team seats", and a refusal that used one word
26
+ * for both would be telling somebody to delete a seat.
27
+ */
28
+ addon: string;
29
+ }
30
+ export interface PluginOrgCapacityDeclaration {
31
+ /** The capacity's id, as both gates and any stored row spell it. */
32
+ kind: string;
33
+ /** Where it sorts among the capacities; core's own are 10 and 20. */
34
+ order: number;
35
+ /** The subcollection under `orgs/{orgId}` whose documents are counted. */
36
+ collection: string;
37
+ /** The `AddonKind` this capacity is sold as. */
38
+ addonKind: string;
39
+ /**
40
+ * The `PLAN_ENTITLEMENTS` field holding what a plan INCLUDES — never the
41
+ * purchase ceiling. The two are far apart (Starter includes 3 datasets and
42
+ * sells up to 10) and measuring against the ceiling prints a number the
43
+ * word "includes" makes false while clearing an org a plan change strands.
44
+ */
45
+ includedEntitlement: string;
46
+ nouns: PluginOrgCapacityNouns;
47
+ }
48
+ /** A declaration with the plugin that made it. */
49
+ export type ResolvedPluginOrgCapacity = PluginOrgCapacityDeclaration & {
50
+ pluginId: string;
51
+ };
52
+ /** Every declared org capacity, in declared order. */
53
+ export declare function pluginOrgCapacities(): readonly ResolvedPluginOrgCapacity[];
54
+ /** One declared capacity by kind, or `null`. */
55
+ export declare function pluginOrgCapacity(kind: string): ResolvedPluginOrgCapacity | null;
56
+ /** One declared capacity by the add-on kind it is sold as, or `null`. */
57
+ export declare function pluginOrgCapacityForAddon(addonKind: string): ResolvedPluginOrgCapacity | null;
@@ -0,0 +1,68 @@
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
+ * AN ORG CAPACITY A PLUGIN BACKS (AGL-3080).
18
+ *
19
+ * An org holds a fixed number of some things, its plan includes a number of
20
+ * them, and it can buy more on top. Three of those existed and core named all
21
+ * three: sites, team seats and DATASETS. The first two are the platform's own
22
+ * — every workspace has sites and people, plugin or no plugin. A dataset is
23
+ * the data plugin's, and core knew its collection path, its noun, its plural,
24
+ * the words a refusal says about it and which entitlement field to measure it
25
+ * against.
26
+ *
27
+ * That mattered because the same three names were spelled out in three places
28
+ * with two different vocabularies — `OverLimitKind` calls it `datasets` and
29
+ * `CapacityAddonKind` calls it `datasets` but calls a site `hosts` — and each
30
+ * had its own noun table. A fourth capacity would have meant editing all of
31
+ * them, and a plugin that shipped one could not add it at all.
32
+ *
33
+ * So the plugin declares the capacity, once, and core's two gates read it.
34
+ *
35
+ * ## What core keeps
36
+ *
37
+ * The MONEY. Which add-on kinds exist, what they cost, what a plan includes,
38
+ * whether a reduction is refused and what the refusal says structurally are
39
+ * all core's, and none of them move. A declaration names the collection to
40
+ * count, the entitlement field that holds the included figure, the add-on kind
41
+ * it stacks on, and the words for one and many. It decides nothing about
42
+ * whether the customer may do the thing.
43
+ *
44
+ * ## Compiled, and deliberately without a runtime registrar
45
+ *
46
+ * The readers are a downgrade REFUSAL (`/api/billing/subscription`) and the
47
+ * warning the customer reads before choosing, and both must list the same
48
+ * capacities or the refusal contradicts the warning that preceded it. A
49
+ * registry that one of those processes had not filled would drop a capacity
50
+ * from the comparison, and the plan change that strands it would be allowed
51
+ * with nothing red — the AGL-3025 shape, on money. So these are compiled from
52
+ * `plugins.config.json` like the switchboard catalog, and there is no
53
+ * `registerPluginOrgCapacity`: a registrar nothing can safely call is worse
54
+ * than no registrar, because the next reader assumes it works.
55
+ */ import { PLUGIN_ORG_CAPACITIES_DECLARED } from "./first-party-plugins.generated.js";
56
+ /** Every declared org capacity, in declared order. */ export function pluginOrgCapacities() {
57
+ return PLUGIN_ORG_CAPACITIES_DECLARED;
58
+ }
59
+ /** One declared capacity by kind, or `null`. */ export function pluginOrgCapacity(kind) {
60
+ var _PLUGIN_ORG_CAPACITIES_DECLARED_find;
61
+ return (_PLUGIN_ORG_CAPACITIES_DECLARED_find = PLUGIN_ORG_CAPACITIES_DECLARED.find((one)=>one.kind === kind)) != null ? _PLUGIN_ORG_CAPACITIES_DECLARED_find : null;
62
+ }
63
+ /** One declared capacity by the add-on kind it is sold as, or `null`. */ export function pluginOrgCapacityForAddon(addonKind) {
64
+ var _PLUGIN_ORG_CAPACITIES_DECLARED_find;
65
+ return (_PLUGIN_ORG_CAPACITIES_DECLARED_find = PLUGIN_ORG_CAPACITIES_DECLARED.find((one)=>one.addonKind === addonKind)) != null ? _PLUGIN_ORG_CAPACITIES_DECLARED_find : null;
66
+ }
67
+
68
+ //# sourceMappingURL=plugin-org-capacity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-org-capacity.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 * AN ORG CAPACITY A PLUGIN BACKS (AGL-3080).\n *\n * An org holds a fixed number of some things, its plan includes a number of\n * them, and it can buy more on top. Three of those existed and core named all\n * three: sites, team seats and DATASETS. The first two are the platform's own\n * — every workspace has sites and people, plugin or no plugin. A dataset is\n * the data plugin's, and core knew its collection path, its noun, its plural,\n * the words a refusal says about it and which entitlement field to measure it\n * against.\n *\n * That mattered because the same three names were spelled out in three places\n * with two different vocabularies — `OverLimitKind` calls it `datasets` and\n * `CapacityAddonKind` calls it `datasets` but calls a site `hosts` — and each\n * had its own noun table. A fourth capacity would have meant editing all of\n * them, and a plugin that shipped one could not add it at all.\n *\n * So the plugin declares the capacity, once, and core's two gates read it.\n *\n * ## What core keeps\n *\n * The MONEY. Which add-on kinds exist, what they cost, what a plan includes,\n * whether a reduction is refused and what the refusal says structurally are\n * all core's, and none of them move. A declaration names the collection to\n * count, the entitlement field that holds the included figure, the add-on kind\n * it stacks on, and the words for one and many. It decides nothing about\n * whether the customer may do the thing.\n *\n * ## Compiled, and deliberately without a runtime registrar\n *\n * The readers are a downgrade REFUSAL (`/api/billing/subscription`) and the\n * warning the customer reads before choosing, and both must list the same\n * capacities or the refusal contradicts the warning that preceded it. A\n * registry that one of those processes had not filled would drop a capacity\n * from the comparison, and the plan change that strands it would be allowed\n * with nothing red — the AGL-3025 shape, on money. So these are compiled from\n * `plugins.config.json` like the switchboard catalog, and there is no\n * `registerPluginOrgCapacity`: a registrar nothing can safely call is worse\n * than no registrar, because the next reader assumes it works.\n */\n\nimport { PLUGIN_ORG_CAPACITIES_DECLARED } from './first-party-plugins.generated'\n\n/** What one and many of the thing are called, and what the money buys. */\nexport interface PluginOrgCapacityNouns {\n /** \"dataset\" — used when the quantity is exactly one. */\n one: string\n /** \"datasets\" — used for every other quantity, and as the held noun. */\n many: string\n /**\n * \"extra datasets\" — what the PURCHASE is called. An org holds \"team\n * members\" and buys \"extra team seats\", and a refusal that used one word\n * for both would be telling somebody to delete a seat.\n */\n addon: string\n}\n\nexport interface PluginOrgCapacityDeclaration {\n /** The capacity's id, as both gates and any stored row spell it. */\n kind: string\n /** Where it sorts among the capacities; core's own are 10 and 20. */\n order: number\n /** The subcollection under `orgs/{orgId}` whose documents are counted. */\n collection: string\n /** The `AddonKind` this capacity is sold as. */\n addonKind: string\n /**\n * The `PLAN_ENTITLEMENTS` field holding what a plan INCLUDES — never the\n * purchase ceiling. The two are far apart (Starter includes 3 datasets and\n * sells up to 10) and measuring against the ceiling prints a number the\n * word \"includes\" makes false while clearing an org a plan change strands.\n */\n includedEntitlement: string\n nouns: PluginOrgCapacityNouns\n}\n\n/** A declaration with the plugin that made it. */\nexport type ResolvedPluginOrgCapacity = PluginOrgCapacityDeclaration & {\n pluginId: string\n}\n\n/** Every declared org capacity, in declared order. */\nexport function pluginOrgCapacities(): readonly ResolvedPluginOrgCapacity[] {\n return PLUGIN_ORG_CAPACITIES_DECLARED\n}\n\n/** One declared capacity by kind, or `null`. */\nexport function pluginOrgCapacity(\n kind: string,\n): ResolvedPluginOrgCapacity | null {\n return PLUGIN_ORG_CAPACITIES_DECLARED.find((one) => one.kind === kind) ?? null\n}\n\n/** One declared capacity by the add-on kind it is sold as, or `null`. */\nexport function pluginOrgCapacityForAddon(\n addonKind: string,\n): ResolvedPluginOrgCapacity | null {\n return (\n PLUGIN_ORG_CAPACITIES_DECLARED.find((one) => one.addonKind === addonKind) ??\n null\n )\n}\n"],"names":["PLUGIN_ORG_CAPACITIES_DECLARED","pluginOrgCapacities","pluginOrgCapacity","kind","find","one","pluginOrgCapacityForAddon","addonKind"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,SAASA,8BAA8B,QAAQ,qCAAiC;AAwChF,oDAAoD,GACpD,OAAO,SAASC;IACd,OAAOD;AACT;AAEA,8CAA8C,GAC9C,OAAO,SAASE,kBACdC,IAAY;QAELH;IAAP,QAAOA,uCAAAA,+BAA+BI,IAAI,CAAC,CAACC,MAAQA,IAAIF,IAAI,KAAKA,iBAA1DH,uCAAmE;AAC5E;AAEA,uEAAuE,GACvE,OAAO,SAASM,0BACdC,SAAiB;QAGfP;IADF,QACEA,uCAAAA,+BAA+BI,IAAI,CAAC,CAACC,MAAQA,IAAIE,SAAS,KAAKA,sBAA/DP,uCACA;AAEJ"}
@@ -0,0 +1,141 @@
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
+ * What a plugin's page says about itself in the `<head>` (AGL-3080).
19
+ *
20
+ * A console page that a plugin draws has its head built by the SHELL: the
21
+ * page is a client component and cannot export metadata, so the nearest
22
+ * server layout does it, and for every plugin route that layout is the
23
+ * generic one. It can title a page from the slugs in the URL and nothing
24
+ * more — which is correct for a hub and wrong for a record. A marketplace
25
+ * listing shared into Slack unfurled as the same generic console card as
26
+ * every other listing until the app grew a hand-written route with a layout
27
+ * of its own, and that route is one of the ~20 that cannot move onto the
28
+ * generic one while this is the only way to have a card.
29
+ *
30
+ * So the plugin declares how to answer for an address under its own route,
31
+ * and the shell asks.
32
+ *
33
+ * ## The head is not a framework's
34
+ *
35
+ * A declaration answers a {@link PluginRouteHead} — a title, a description,
36
+ * an image — and never Next's `Metadata`. Core does not depend on a web
37
+ * framework and a plugin must not have to; more usefully, how a title,
38
+ * description and image become `openGraph` and `twitter` tags is the SHELL's
39
+ * knowledge and the same for every route: which card size degrades without
40
+ * an image, that defining `openGraph` replaces the root layout's wholesale
41
+ * so the site name has to be restated. A plugin that wrote those tags itself
42
+ * would get a different answer per plugin, and the two that got it wrong
43
+ * would look exactly like the two that got it right until somebody shared a
44
+ * link.
45
+ *
46
+ * ## Absent is a real answer, and so is silence
47
+ *
48
+ * `resolve` answers `null` for an address it has nothing to add about — a
49
+ * hub, a tab, a record it must not describe — and the shell keeps the title
50
+ * it would have built. That is the same answer as a plugin that declares
51
+ * nothing at all, deliberately: a head is an enrichment, and a route with no
52
+ * declaration is the ordinary case rather than a degraded one.
53
+ *
54
+ * ⛔ WHICH MAKES THE LOADING ORDER THE WHOLE RISK. This is a runtime
55
+ * registry, and an unfilled one answers "nothing to add" for every address —
56
+ * silently, and indistinguishably from the honest answer. A layout that asked
57
+ * before loading the plugin's server surface would drop every card it was
58
+ * built to produce and stay green. A registry is nonetheless the right shape
59
+ * here, and a compiled table is not: the answer depends on the DOCUMENT
60
+ * behind the address, so it cannot be compiled, and the reader is a server
61
+ * layout that already awaits — so it can load the surface first, which is
62
+ * what `pluginRouteHead` in the console does and what the app's
63
+ * `*-is-registered` spec holds it to.
64
+ *
65
+ * ## One route, one owner
66
+ *
67
+ * A route belongs to the plugin that serves it, so a second plugin's
68
+ * declaration for the same route is refused naming both and the incumbent
69
+ * keeps answering. The same plugin declaring again — a hot reload, a second
70
+ * surface — replaces its own.
71
+ *
72
+ * Server-side: reached by its own subpath, never through
73
+ * `plugin-manager/index.ts`. What a published page does not need, it must
74
+ * not import.
75
+ */
76
+ import type { ResolvedSocialImage } from '../app-utils/social-image';
77
+ /** What one address under a plugin's route says about itself. */
78
+ export interface PluginRouteHead {
79
+ /**
80
+ * The page's own title, before the shell's scope and brand are affixed.
81
+ * Absent leaves the title the shell built from the URL.
82
+ */
83
+ title?: string;
84
+ /** One or two sentences. Already cut to whatever budget the owner keeps. */
85
+ description?: string;
86
+ /**
87
+ * The card image, already resolved and absolute —
88
+ * `resolveSocialImage` answers exactly this shape. A crawler reads it out
89
+ * of band, so a relative URL is not a card, it is a broken one.
90
+ */
91
+ image?: ResolvedSocialImage;
92
+ }
93
+ export interface PluginRouteMetadataDeclaration {
94
+ /**
95
+ * The plugin's own console route, as its `contributes.console.routes`
96
+ * spells it and without the leading slash: `marketplace`.
97
+ */
98
+ route: string;
99
+ /**
100
+ * Answers for one address under that route. The segments are what follows
101
+ * the route itself — `['listing-1']` for `/acme/marketplace/listing-1` —
102
+ * so a declaration reads the address it understands and answers `null` for
103
+ * every other, including the route's own hub.
104
+ *
105
+ * ⚠️ IT RUNS ON THE RENDER PATH and must never throw: an unhandled
106
+ * rejection in a layout's metadata fails the whole route, and this route's
107
+ * job is to serve its page whether or not the card resolves. The shell
108
+ * catches anyway, because a plugin's mistake must not cost a page.
109
+ */
110
+ resolve(segments: readonly string[]): Promise<PluginRouteHead | null>;
111
+ }
112
+ /** A declaration with the plugin that made it. */
113
+ export type ResolvedPluginRouteMetadata = PluginRouteMetadataDeclaration & {
114
+ pluginId: string;
115
+ };
116
+ export declare const PLUGIN_ROUTE_METADATA: import("./plugin-services").PluginServiceContract<PluginRouteMetadataDeclaration>;
117
+ /**
118
+ * Declares how a plugin answers for addresses under one of its routes. The
119
+ * owner is the loader's marker when a register fn is running, else
120
+ * `options.pluginId`.
121
+ */
122
+ export declare function registerPluginRouteMetadata(declaration: PluginRouteMetadataDeclaration, options?: {
123
+ pluginId?: string;
124
+ }): void;
125
+ /** The declaration for one route, or `null` when nobody declared one. */
126
+ export declare function pluginRouteMetadata(route: string): ResolvedPluginRouteMetadata | null;
127
+ /**
128
+ * What one address says about itself, or `null` when nothing does.
129
+ *
130
+ * ⚠️ A DECLARATION THAT THROWS COSTS THE PAGE NOTHING. The head is an
131
+ * enrichment of a page that renders without it, and a layout's metadata
132
+ * rejecting fails the route — so the failure is logged and the shell falls
133
+ * back to the title it built itself.
134
+ *
135
+ * ⛔ It asks the registry as it stands. A caller that has not loaded the
136
+ * plugins' server surfaces gets `null` for every address, which is the same
137
+ * answer as a route nobody declared — see the module docblock.
138
+ */
139
+ export declare function resolvePluginRouteHead(route: string, segments: readonly string[]): Promise<PluginRouteHead | null>;
140
+ /** Every declared route, with its plugin, in registration order. */
141
+ export declare function listPluginRouteMetadata(): ResolvedPluginRouteMetadata[];