@aglyn/aglyn 1.0.0-beta.149 → 1.0.0-beta.151
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.
- package/package.json +10 -10
- package/src/lib/app-utils/console-routes.d.ts +0 -40
- package/src/lib/app-utils/console-routes.js +15 -13
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/contexts.d.ts +1 -0
- package/src/lib/app-utils/contexts.js +1 -0
- package/src/lib/app-utils/contexts.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +24 -0
- package/src/lib/app-utils/docs-help.generated.js +61 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/index.d.ts +1 -0
- package/src/lib/app-utils/index.js +1 -0
- package/src/lib/app-utils/index.js.map +1 -1
- package/src/lib/app-utils/media-src.d.ts +93 -0
- package/src/lib/app-utils/media-src.js +114 -0
- package/src/lib/app-utils/media-src.js.map +1 -0
- package/src/lib/app-utils/page-header-help-context.d.ts +83 -0
- package/src/lib/app-utils/page-header-help-context.js +56 -0
- package/src/lib/app-utils/page-header-help-context.js.map +1 -0
- package/src/lib/app-utils/release-flags.js +5 -4
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/staff-role-gate.d.ts +56 -0
- package/src/lib/app-utils/staff-role-gate.js +46 -0
- package/src/lib/app-utils/staff-role-gate.js.map +1 -0
- package/src/lib/plugin-manager/feature-plugins.d.ts +113 -23
- package/src/lib/plugin-manager/feature-plugins.js +15 -19
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/plugin-media-publish.d.ts +88 -0
- package/src/lib/plugin-manager/plugin-media-publish.js +118 -0
- package/src/lib/plugin-manager/plugin-media-publish.js.map +1 -0
- package/src/lib/plugin-manager/plugin-route-metadata.d.ts +141 -0
- package/src/lib/plugin-manager/plugin-route-metadata.js +140 -0
- package/src/lib/plugin-manager/plugin-route-metadata.js.map +1 -0
- package/src/lib/plugin-manager/plugin-site-cache.d.ts +78 -0
- package/src/lib/plugin-manager/plugin-site-cache.js +125 -0
- package/src/lib/plugin-manager/plugin-site-cache.js.map +1 -0
|
@@ -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,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[];
|
|
@@ -0,0 +1,140 @@
|
|
|
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 a plugin's page says about itself in the `<head>` (AGL-3080).
|
|
18
|
+
*
|
|
19
|
+
* A console page that a plugin draws has its head built by the SHELL: the
|
|
20
|
+
* page is a client component and cannot export metadata, so the nearest
|
|
21
|
+
* server layout does it, and for every plugin route that layout is the
|
|
22
|
+
* generic one. It can title a page from the slugs in the URL and nothing
|
|
23
|
+
* more — which is correct for a hub and wrong for a record. A marketplace
|
|
24
|
+
* listing shared into Slack unfurled as the same generic console card as
|
|
25
|
+
* every other listing until the app grew a hand-written route with a layout
|
|
26
|
+
* of its own, and that route is one of the ~20 that cannot move onto the
|
|
27
|
+
* generic one while this is the only way to have a card.
|
|
28
|
+
*
|
|
29
|
+
* So the plugin declares how to answer for an address under its own route,
|
|
30
|
+
* and the shell asks.
|
|
31
|
+
*
|
|
32
|
+
* ## The head is not a framework's
|
|
33
|
+
*
|
|
34
|
+
* A declaration answers a {@link PluginRouteHead} — a title, a description,
|
|
35
|
+
* an image — and never Next's `Metadata`. Core does not depend on a web
|
|
36
|
+
* framework and a plugin must not have to; more usefully, how a title,
|
|
37
|
+
* description and image become `openGraph` and `twitter` tags is the SHELL's
|
|
38
|
+
* knowledge and the same for every route: which card size degrades without
|
|
39
|
+
* an image, that defining `openGraph` replaces the root layout's wholesale
|
|
40
|
+
* so the site name has to be restated. A plugin that wrote those tags itself
|
|
41
|
+
* would get a different answer per plugin, and the two that got it wrong
|
|
42
|
+
* would look exactly like the two that got it right until somebody shared a
|
|
43
|
+
* link.
|
|
44
|
+
*
|
|
45
|
+
* ## Absent is a real answer, and so is silence
|
|
46
|
+
*
|
|
47
|
+
* `resolve` answers `null` for an address it has nothing to add about — a
|
|
48
|
+
* hub, a tab, a record it must not describe — and the shell keeps the title
|
|
49
|
+
* it would have built. That is the same answer as a plugin that declares
|
|
50
|
+
* nothing at all, deliberately: a head is an enrichment, and a route with no
|
|
51
|
+
* declaration is the ordinary case rather than a degraded one.
|
|
52
|
+
*
|
|
53
|
+
* ⛔ WHICH MAKES THE LOADING ORDER THE WHOLE RISK. This is a runtime
|
|
54
|
+
* registry, and an unfilled one answers "nothing to add" for every address —
|
|
55
|
+
* silently, and indistinguishably from the honest answer. A layout that asked
|
|
56
|
+
* before loading the plugin's server surface would drop every card it was
|
|
57
|
+
* built to produce and stay green. A registry is nonetheless the right shape
|
|
58
|
+
* here, and a compiled table is not: the answer depends on the DOCUMENT
|
|
59
|
+
* behind the address, so it cannot be compiled, and the reader is a server
|
|
60
|
+
* layout that already awaits — so it can load the surface first, which is
|
|
61
|
+
* what `pluginRouteHead` in the console does and what the app's
|
|
62
|
+
* `*-is-registered` spec holds it to.
|
|
63
|
+
*
|
|
64
|
+
* ## One route, one owner
|
|
65
|
+
*
|
|
66
|
+
* A route belongs to the plugin that serves it, so a second plugin's
|
|
67
|
+
* declaration for the same route is refused naming both and the incumbent
|
|
68
|
+
* keeps answering. The same plugin declaring again — a hot reload, a second
|
|
69
|
+
* surface — replaces its own.
|
|
70
|
+
*
|
|
71
|
+
* Server-side: reached by its own subpath, never through
|
|
72
|
+
* `plugin-manager/index.ts`. What a published page does not need, it must
|
|
73
|
+
* not import.
|
|
74
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
75
|
+
import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
76
|
+
import { definePluginServiceContract, registerPluginService, resolvePluginServices } from "./plugin-services.js";
|
|
77
|
+
export const PLUGIN_ROUTE_METADATA = definePluginServiceContract('core.route-metadata', {
|
|
78
|
+
multiple: true
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Declares how a plugin answers for addresses under one of its routes. The
|
|
82
|
+
* owner is the loader's marker when a register fn is running, else
|
|
83
|
+
* `options.pluginId`.
|
|
84
|
+
*/ export function registerPluginRouteMetadata(declaration, options) {
|
|
85
|
+
var _ref, _ref1, _getRegisteringPluginId;
|
|
86
|
+
var _declaration_route;
|
|
87
|
+
const key = (_ref = (_declaration_route = declaration.route) == null ? void 0 : _declaration_route.trim().replace(/^\/+/, '')) != null ? _ref : '';
|
|
88
|
+
if (!key) throw new Error('a plugin route metadata declaration needs a route');
|
|
89
|
+
if (typeof declaration.resolve !== 'function') {
|
|
90
|
+
throw new Error(`route metadata for "${key}" needs a resolve function`);
|
|
91
|
+
}
|
|
92
|
+
const pluginId = ((_ref1 = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref1 : '').trim();
|
|
93
|
+
const incumbent = resolvePluginServices(PLUGIN_ROUTE_METADATA).find((entry)=>entry.key === key);
|
|
94
|
+
if (incumbent && pluginId && incumbent.pluginId !== pluginId) {
|
|
95
|
+
throw new Error(`route metadata for "${key}" is already declared by ` + `"${incumbent.pluginId}"; refused "${pluginId}"`);
|
|
96
|
+
}
|
|
97
|
+
registerPluginService(PLUGIN_ROUTE_METADATA, _extends({}, declaration, {
|
|
98
|
+
route: key
|
|
99
|
+
}), _extends({}, (options == null ? void 0 : options.pluginId) ? {
|
|
100
|
+
pluginId: options.pluginId
|
|
101
|
+
} : {}, {
|
|
102
|
+
key
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
/** The declaration for one route, or `null` when nobody declared one. */ export function pluginRouteMetadata(route) {
|
|
106
|
+
const key = String(route != null ? route : '').trim().replace(/^\/+/, '');
|
|
107
|
+
const entry = resolvePluginServices(PLUGIN_ROUTE_METADATA).find((one)=>one.key === key);
|
|
108
|
+
return entry ? _extends({}, entry.impl, {
|
|
109
|
+
pluginId: entry.pluginId
|
|
110
|
+
}) : null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What one address says about itself, or `null` when nothing does.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ A DECLARATION THAT THROWS COSTS THE PAGE NOTHING. The head is an
|
|
116
|
+
* enrichment of a page that renders without it, and a layout's metadata
|
|
117
|
+
* rejecting fails the route — so the failure is logged and the shell falls
|
|
118
|
+
* back to the title it built itself.
|
|
119
|
+
*
|
|
120
|
+
* ⛔ It asks the registry as it stands. A caller that has not loaded the
|
|
121
|
+
* plugins' server surfaces gets `null` for every address, which is the same
|
|
122
|
+
* answer as a route nobody declared — see the module docblock.
|
|
123
|
+
*/ export async function resolvePluginRouteHead(route, segments) {
|
|
124
|
+
const declared = pluginRouteMetadata(route);
|
|
125
|
+
if (!declared) return null;
|
|
126
|
+
try {
|
|
127
|
+
var _ref;
|
|
128
|
+
return (_ref = await declared.resolve(segments)) != null ? _ref : null;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(`[route-metadata] "${declared.pluginId}" failed to answer for ` + `${route}/${segments.join('/')}`, error);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Every declared route, with its plugin, in registration order. */ export function listPluginRouteMetadata() {
|
|
135
|
+
return resolvePluginServices(PLUGIN_ROUTE_METADATA).map((entry)=>_extends({}, entry.impl, {
|
|
136
|
+
pluginId: entry.pluginId
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
//# sourceMappingURL=plugin-route-metadata.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-route-metadata.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 * What a plugin's page says about itself in the `<head>` (AGL-3080).\n *\n * A console page that a plugin draws has its head built by the SHELL: the\n * page is a client component and cannot export metadata, so the nearest\n * server layout does it, and for every plugin route that layout is the\n * generic one. It can title a page from the slugs in the URL and nothing\n * more — which is correct for a hub and wrong for a record. A marketplace\n * listing shared into Slack unfurled as the same generic console card as\n * every other listing until the app grew a hand-written route with a layout\n * of its own, and that route is one of the ~20 that cannot move onto the\n * generic one while this is the only way to have a card.\n *\n * So the plugin declares how to answer for an address under its own route,\n * and the shell asks.\n *\n * ## The head is not a framework's\n *\n * A declaration answers a {@link PluginRouteHead} — a title, a description,\n * an image — and never Next's `Metadata`. Core does not depend on a web\n * framework and a plugin must not have to; more usefully, how a title,\n * description and image become `openGraph` and `twitter` tags is the SHELL's\n * knowledge and the same for every route: which card size degrades without\n * an image, that defining `openGraph` replaces the root layout's wholesale\n * so the site name has to be restated. A plugin that wrote those tags itself\n * would get a different answer per plugin, and the two that got it wrong\n * would look exactly like the two that got it right until somebody shared a\n * link.\n *\n * ## Absent is a real answer, and so is silence\n *\n * `resolve` answers `null` for an address it has nothing to add about — a\n * hub, a tab, a record it must not describe — and the shell keeps the title\n * it would have built. That is the same answer as a plugin that declares\n * nothing at all, deliberately: a head is an enrichment, and a route with no\n * declaration is the ordinary case rather than a degraded one.\n *\n * ⛔ WHICH MAKES THE LOADING ORDER THE WHOLE RISK. This is a runtime\n * registry, and an unfilled one answers \"nothing to add\" for every address —\n * silently, and indistinguishably from the honest answer. A layout that asked\n * before loading the plugin's server surface would drop every card it was\n * built to produce and stay green. A registry is nonetheless the right shape\n * here, and a compiled table is not: the answer depends on the DOCUMENT\n * behind the address, so it cannot be compiled, and the reader is a server\n * layout that already awaits — so it can load the surface first, which is\n * what `pluginRouteHead` in the console does and what the app's\n * `*-is-registered` spec holds it to.\n *\n * ## One route, one owner\n *\n * A route belongs to the plugin that serves it, so a second plugin's\n * declaration for the same route is refused naming both and the incumbent\n * keeps answering. The same plugin declaring again — a hot reload, a second\n * surface — replaces its own.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`. What a published page does not need, it must\n * not import.\n */\n\nimport type { ResolvedSocialImage } from '../app-utils/social-image'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/** What one address under a plugin's route says about itself. */\nexport interface PluginRouteHead {\n /**\n * The page's own title, before the shell's scope and brand are affixed.\n * Absent leaves the title the shell built from the URL.\n */\n title?: string\n /** One or two sentences. Already cut to whatever budget the owner keeps. */\n description?: string\n /**\n * The card image, already resolved and absolute —\n * `resolveSocialImage` answers exactly this shape. A crawler reads it out\n * of band, so a relative URL is not a card, it is a broken one.\n */\n image?: ResolvedSocialImage\n}\n\nexport interface PluginRouteMetadataDeclaration {\n /**\n * The plugin's own console route, as its `contributes.console.routes`\n * spells it and without the leading slash: `marketplace`.\n */\n route: string\n /**\n * Answers for one address under that route. The segments are what follows\n * the route itself — `['listing-1']` for `/acme/marketplace/listing-1` —\n * so a declaration reads the address it understands and answers `null` for\n * every other, including the route's own hub.\n *\n * ⚠️ IT RUNS ON THE RENDER PATH and must never throw: an unhandled\n * rejection in a layout's metadata fails the whole route, and this route's\n * job is to serve its page whether or not the card resolves. The shell\n * catches anyway, because a plugin's mistake must not cost a page.\n */\n resolve(segments: readonly string[]): Promise<PluginRouteHead | null>\n}\n\n/** A declaration with the plugin that made it. */\nexport type ResolvedPluginRouteMetadata = PluginRouteMetadataDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_ROUTE_METADATA =\n definePluginServiceContract<PluginRouteMetadataDeclaration>(\n 'core.route-metadata',\n { multiple: true },\n )\n\n/**\n * Declares how a plugin answers for addresses under one of its routes. The\n * owner is the loader's marker when a register fn is running, else\n * `options.pluginId`.\n */\nexport function registerPluginRouteMetadata(\n declaration: PluginRouteMetadataDeclaration,\n options?: { pluginId?: string },\n): void {\n const key = declaration.route?.trim().replace(/^\\/+/, '') ?? ''\n if (!key) throw new Error('a plugin route metadata declaration needs a route')\n if (typeof declaration.resolve !== 'function') {\n throw new Error(`route metadata for \"${key}\" needs a resolve function`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const incumbent = resolvePluginServices(PLUGIN_ROUTE_METADATA).find(\n (entry) => entry.key === key,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `route metadata for \"${key}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(\n PLUGIN_ROUTE_METADATA,\n { ...declaration, route: key },\n {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key,\n },\n )\n}\n\n/** The declaration for one route, or `null` when nobody declared one. */\nexport function pluginRouteMetadata(\n route: string,\n): ResolvedPluginRouteMetadata | null {\n const key = String(route ?? '')\n .trim()\n .replace(/^\\/+/, '')\n const entry = resolvePluginServices(PLUGIN_ROUTE_METADATA).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n\n/**\n * What one address says about itself, or `null` when nothing does.\n *\n * ⚠️ A DECLARATION THAT THROWS COSTS THE PAGE NOTHING. The head is an\n * enrichment of a page that renders without it, and a layout's metadata\n * rejecting fails the route — so the failure is logged and the shell falls\n * back to the title it built itself.\n *\n * ⛔ It asks the registry as it stands. A caller that has not loaded the\n * plugins' server surfaces gets `null` for every address, which is the same\n * answer as a route nobody declared — see the module docblock.\n */\nexport async function resolvePluginRouteHead(\n route: string,\n segments: readonly string[],\n): Promise<PluginRouteHead | null> {\n const declared = pluginRouteMetadata(route)\n if (!declared) return null\n try {\n return (await declared.resolve(segments)) ?? null\n } catch (error) {\n console.error(\n `[route-metadata] \"${declared.pluginId}\" failed to answer for ` +\n `${route}/${segments.join('/')}`,\n error,\n )\n return null\n }\n}\n\n/** Every declared route, with its plugin, in registration order. */\nexport function listPluginRouteMetadata(): ResolvedPluginRouteMetadata[] {\n return resolvePluginServices(PLUGIN_ROUTE_METADATA).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_ROUTE_METADATA","multiple","registerPluginRouteMetadata","declaration","options","key","route","trim","replace","Error","resolve","pluginId","incumbent","find","entry","pluginRouteMetadata","String","one","impl","resolvePluginRouteHead","segments","declared","error","console","join","listPluginRouteMetadata","map"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0DC;AAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA4C1B,OAAO,MAAMC,wBACXH,4BACE,uBACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;CAIC,GACD,OAAO,SAASC,4BACdC,WAA2C,EAC3CC,OAA+B;cAObR,OAAAA;QALNO;IAAZ,MAAME,eAAMF,qBAAAA,YAAYG,KAAK,qBAAjBH,mBAAmBI,IAAI,GAAGC,OAAO,CAAC,QAAQ,sBAAO;IAC7D,IAAI,CAACH,KAAK,MAAM,IAAII,MAAM;IAC1B,IAAI,OAAON,YAAYO,OAAO,KAAK,YAAY;QAC7C,MAAM,IAAID,MAAM,CAAC,oBAAoB,EAAEJ,IAAI,0BAA0B,CAAC;IACxE;IACA,MAAMM,WAAW,EAACf,SAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASO,QAAQ,YAA7Cf,QAAiD,IAAIW,IAAI;IAC3E,MAAMK,YAAYb,sBAAsBC,uBAAuBa,IAAI,CACjE,CAACC,QAAUA,MAAMT,GAAG,KAAKA;IAE3B,IAAIO,aAAaD,YAAYC,UAAUD,QAAQ,KAAKA,UAAU;QAC5D,MAAM,IAAIF,MACR,CAAC,oBAAoB,EAAEJ,IAAI,yBAAyB,CAAC,GACnD,CAAC,CAAC,EAAEO,UAAUD,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAEtD;IACAb,sBACEE,uBACA,aAAKG;QAAaG,OAAOD;QACzB,aACMD,CAAAA,2BAAAA,QAASO,QAAQ,IAAG;QAAEA,UAAUP,QAAQO,QAAQ;IAAC,IAAI,CAAC;QAC1DN;;AAGN;AAEA,uEAAuE,GACvE,OAAO,SAASU,oBACdT,KAAa;IAEb,MAAMD,MAAMW,OAAOV,gBAAAA,QAAS,IACzBC,IAAI,GACJC,OAAO,CAAC,QAAQ;IACnB,MAAMM,QAAQf,sBAAsBC,uBAAuBa,IAAI,CAC7D,CAACI,MAAQA,IAAIZ,GAAG,KAAKA;IAEvB,OAAOS,QAAQ,aAAKA,MAAMI,IAAI;QAAEP,UAAUG,MAAMH,QAAQ;SAAK;AAC/D;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,eAAeQ,uBACpBb,KAAa,EACbc,QAA2B;IAE3B,MAAMC,WAAWN,oBAAoBT;IACrC,IAAI,CAACe,UAAU,OAAO;IACtB,IAAI;YACM;QAAR,QAAQ,OAAA,MAAMA,SAASX,OAAO,CAACU,qBAAvB,OAAqC;IAC/C,EAAE,OAAOE,OAAO;QACdC,QAAQD,KAAK,CACX,CAAC,kBAAkB,EAAED,SAASV,QAAQ,CAAC,uBAAuB,CAAC,GAC7D,GAAGL,MAAM,CAAC,EAAEc,SAASI,IAAI,CAAC,MAAM,EAClCF;QAEF,OAAO;IACT;AACF;AAEA,kEAAkE,GAClE,OAAO,SAASG;IACd,OAAO1B,sBAAsBC,uBAAuB0B,GAAG,CAAC,CAACZ,QAAW,aAC/DA,MAAMI,IAAI;YACbP,UAAUG,MAAMH,QAAQ;;AAE5B"}
|
|
@@ -0,0 +1,78 @@
|
|
|
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 the caller wants dropped, and why. */
|
|
18
|
+
export interface PluginSiteCacheRequest {
|
|
19
|
+
/**
|
|
20
|
+
* The sites whose rendered pages must go. Duplicates are the caller's to
|
|
21
|
+
* avoid or not — the implementation is free to collapse them.
|
|
22
|
+
*/
|
|
23
|
+
hostIds: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Why, in a few words, for the fan-out's own logging: a truncated purge
|
|
26
|
+
* is only debuggable if the record says what it was purging for.
|
|
27
|
+
* `'marketplace listing revoked'`, not `'revoke'`.
|
|
28
|
+
*/
|
|
29
|
+
reason: string;
|
|
30
|
+
}
|
|
31
|
+
export interface PluginSiteCacheResult {
|
|
32
|
+
/** Sites whose cached pages were dropped. */
|
|
33
|
+
dropped: number;
|
|
34
|
+
/**
|
|
35
|
+
* Sites deliberately left holding cached pages because the fan-out hit
|
|
36
|
+
* its cap. Not an error — a decision the shell made and logged — but a
|
|
37
|
+
* number a caller may want to surface, because those sites are still
|
|
38
|
+
* serving what was just stopped.
|
|
39
|
+
*/
|
|
40
|
+
skipped: number;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the drop certainly happened. FALSE when no implementation is
|
|
43
|
+
* registered or one threw, and a caller must not read `dropped: 0` as
|
|
44
|
+
* "there was nothing to drop" without checking this first.
|
|
45
|
+
*/
|
|
46
|
+
complete: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PluginSiteCache {
|
|
49
|
+
drop(request: PluginSiteCacheRequest): Promise<PluginSiteCacheResult>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One implementation: the app's. Two would be two cache layers disagreeing
|
|
53
|
+
* about whether a page is gone, and the second to register would be the one
|
|
54
|
+
* nobody knew about.
|
|
55
|
+
*/
|
|
56
|
+
export declare const PLUGIN_SITE_CACHE: import("./plugin-services").PluginServiceContract<PluginSiteCache>;
|
|
57
|
+
/**
|
|
58
|
+
* Installs the app's implementation. Called once, at server boot, by the app
|
|
59
|
+
* that owns the tenant's cache.
|
|
60
|
+
*
|
|
61
|
+
* `pluginId` is required from outside a plugin's register fn, as it is for
|
|
62
|
+
* every service: the app passes its own marker, and the contract refuses an
|
|
63
|
+
* anonymous registration rather than accepting one nothing can be traced to.
|
|
64
|
+
*/
|
|
65
|
+
export declare function registerPluginSiteCache(cache: PluginSiteCache, options?: {
|
|
66
|
+
pluginId?: string;
|
|
67
|
+
}): void;
|
|
68
|
+
/** Whether an implementation is installed — for a caller that wants to say so. */
|
|
69
|
+
export declare function hasPluginSiteCache(): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Drops the cached pages of the named sites.
|
|
72
|
+
*
|
|
73
|
+
* NEVER THROWS and never silently succeeds: a failure comes back as
|
|
74
|
+
* `complete: false` with the reason logged, because the callers are stop
|
|
75
|
+
* paths that must complete their own work either way. Read `complete`
|
|
76
|
+
* before reading `dropped`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function dropPluginSiteCache(request: PluginSiteCacheRequest): Promise<PluginSiteCacheResult>;
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* HOW A PLUGIN DROPS A SITE'S CACHED PAGES (AGL-3080).
|
|
19
|
+
*
|
|
20
|
+
* The shell hands this one down rather than up. Every other contract in this
|
|
21
|
+
* directory is a plugin telling the shell something; this is the shell
|
|
22
|
+
* telling plugins it can do something — purge the rendered HTML of a site —
|
|
23
|
+
* because purging it takes the tenant's cache tags and revalidation paths,
|
|
24
|
+
* which live in the app and are not a plugin's to know.
|
|
25
|
+
*
|
|
26
|
+
* ## The case, and why it is a kill switch rather than a nicety
|
|
27
|
+
*
|
|
28
|
+
* A marketplace plugin can be REVOKED. Revoking writes a stop on the
|
|
29
|
+
* version, and the tenant refuses revoked bytes at render time — but a page
|
|
30
|
+
* already rendered and cached does not re-render to find that out. So the
|
|
31
|
+
* revoke has a second half: drop the cached pages of every site running it.
|
|
32
|
+
* Without it a revoked plugin keeps executing in visitors' browsers for as
|
|
33
|
+
* long as the cache holds, which is the whole failure the stop exists to
|
|
34
|
+
* prevent.
|
|
35
|
+
*
|
|
36
|
+
* ## ⛔ AN ABSENT IMPLEMENTATION IS AN ANSWER, AND IT IS `complete: false`
|
|
37
|
+
*
|
|
38
|
+
* The registry is filled by the APP at boot. A process that has not filled
|
|
39
|
+
* it resolves "no cache to drop" — indistinguishable, from the inside, from
|
|
40
|
+
* a workspace whose sites were all already fresh. That is the AGL-3025 shape
|
|
41
|
+
* and on this path it means a revoked plugin quietly kept serving.
|
|
42
|
+
*
|
|
43
|
+
* So there is no silent branch. {@link dropPluginSiteCache} always answers a
|
|
44
|
+
* {@link PluginSiteCacheResult}, and `complete` is false whenever the drop
|
|
45
|
+
* did not certainly happen — nothing registered, or the implementation
|
|
46
|
+
* threw. A caller acts on that: the revoke itself must still stand (a plugin
|
|
47
|
+
* left un-revoked because a cache purge failed is strictly worse), but the
|
|
48
|
+
* reader has to be told the purge did not run, and the log has to carry it.
|
|
49
|
+
*
|
|
50
|
+
* ## The split, in one line
|
|
51
|
+
*
|
|
52
|
+
* The plugin knows WHICH sites — that is its own install pins, its own
|
|
53
|
+
* collection. The shell knows HOW to drop one, and owns the fan-out policy:
|
|
54
|
+
* the cap, the concurrency, and saying out loud when a cap truncated the
|
|
55
|
+
* list. Neither half is guessable from the other side.
|
|
56
|
+
*/ import { definePluginServiceContract, registerPluginService, resolvePluginService } from "./plugin-services.js";
|
|
57
|
+
/**
|
|
58
|
+
* One implementation: the app's. Two would be two cache layers disagreeing
|
|
59
|
+
* about whether a page is gone, and the second to register would be the one
|
|
60
|
+
* nobody knew about.
|
|
61
|
+
*/ export const PLUGIN_SITE_CACHE = definePluginServiceContract('core.site-cache', {
|
|
62
|
+
multiple: false
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* Installs the app's implementation. Called once, at server boot, by the app
|
|
66
|
+
* that owns the tenant's cache.
|
|
67
|
+
*
|
|
68
|
+
* `pluginId` is required from outside a plugin's register fn, as it is for
|
|
69
|
+
* every service: the app passes its own marker, and the contract refuses an
|
|
70
|
+
* anonymous registration rather than accepting one nothing can be traced to.
|
|
71
|
+
*/ export function registerPluginSiteCache(cache, options) {
|
|
72
|
+
if (typeof (cache == null ? void 0 : cache.drop) !== 'function') {
|
|
73
|
+
throw new Error('a site cache needs a drop function');
|
|
74
|
+
}
|
|
75
|
+
registerPluginService(PLUGIN_SITE_CACHE, cache, _extends({}, (options == null ? void 0 : options.pluginId) ? {
|
|
76
|
+
pluginId: options.pluginId
|
|
77
|
+
} : {}));
|
|
78
|
+
}
|
|
79
|
+
/** Whether an implementation is installed — for a caller that wants to say so. */ export function hasPluginSiteCache() {
|
|
80
|
+
return Boolean(resolvePluginService(PLUGIN_SITE_CACHE));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Drops the cached pages of the named sites.
|
|
84
|
+
*
|
|
85
|
+
* NEVER THROWS and never silently succeeds: a failure comes back as
|
|
86
|
+
* `complete: false` with the reason logged, because the callers are stop
|
|
87
|
+
* paths that must complete their own work either way. Read `complete`
|
|
88
|
+
* before reading `dropped`.
|
|
89
|
+
*/ export async function dropPluginSiteCache(request) {
|
|
90
|
+
const hostIds = [
|
|
91
|
+
...new Set(request.hostIds.filter(Boolean))
|
|
92
|
+
];
|
|
93
|
+
if (!hostIds.length) {
|
|
94
|
+
// Nothing asked for is the one case where "nothing dropped" is complete
|
|
95
|
+
// on its own terms, whether or not anything is registered.
|
|
96
|
+
return {
|
|
97
|
+
dropped: 0,
|
|
98
|
+
skipped: 0,
|
|
99
|
+
complete: true
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const cache = resolvePluginService(PLUGIN_SITE_CACHE);
|
|
103
|
+
if (!cache) {
|
|
104
|
+
console.error(`[site-cache] no implementation is installed, so ${hostIds.length} ` + `site(s) keep their cached pages after: ${request.reason}. The app ` + 'registers this at boot; a process that has not is serving stopped ' + 'content.');
|
|
105
|
+
return {
|
|
106
|
+
dropped: 0,
|
|
107
|
+
skipped: 0,
|
|
108
|
+
complete: false
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return await cache.drop(_extends({}, request, {
|
|
113
|
+
hostIds
|
|
114
|
+
}));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`[site-cache] the drop failed after: ${request.reason}`, error);
|
|
117
|
+
return {
|
|
118
|
+
dropped: 0,
|
|
119
|
+
skipped: 0,
|
|
120
|
+
complete: false
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//# sourceMappingURL=plugin-site-cache.js.map
|