@nospt/plugin-dev-ai-hub-common 1.1.3 → 1.2.0-rc1

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/README.md CHANGED
@@ -35,16 +35,18 @@ interface ResourceSummary {
35
35
  childCount?: number; // = children.length
36
36
  helpText?: string;
37
37
  annotations: Record<string, string>;
38
+ recommended?: boolean; // curation overlay, set by the backend router — never by toResourceSummary
38
39
  }
39
40
  ```
40
41
 
41
- `toResourceSummary(entity)` maps one `Entity`; `toResourceSummaries(entities)` maps a whole catalog read and resolves containment in both directions (ADR-0015). Both are exported here so the backend router and the test fixtures cannot disagree about them.
42
+ `toResourceSummary(entity)` maps one `Entity`; `toResourceSummaries(entities)` maps a whole catalog read and resolves containment in both directions (ADR-0015). Both are exported here so the backend router and the test fixtures cannot disagree about them. Neither ever sets `recommended` — that is plugin-owned curation state, merged onto the summary by the backend router after mapping (ADR-0017), not derived from the catalog entity.
42
43
 
43
44
  ## Also in here
44
45
 
45
46
  - **The `ResourceType` vocabulary** — `skill`, `agent`, `hook`, `mcp-config`, `plugin`, `marketplace` — and the rules derived from it: which body shape a type renders as (`mcp-config` is JSON, the rest markdown), whether its body is a downloadable artifact or merely a pointer, and its per-framework install paths.
46
47
  - **Framework tokens and normalisation**, so `claude-code` means the same thing on both sides.
47
48
  - **Telemetry schemas** for the event contract.
49
+ - **The permission contract** — `devAiHubBackofficeReadPermission` (read the backoffice) and `devAiHubBackofficeCuratePermission` (write curation state), plus the `RecommendationInput` schema validated on the write path.
48
50
 
49
51
  ## Full documentation
50
52
 
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "devAiHub": {
6
+ "type": "object",
7
+ "properties": {
8
+ "frameworks": {
9
+ "type": "object",
10
+ "properties": {
11
+ "custom": {
12
+ "type": "object",
13
+ "additionalProperties": {
14
+ "type": "object",
15
+ "properties": {
16
+ "displayName": {
17
+ "type": "string",
18
+ "description": "Display label. Defaults to the key when omitted."
19
+ },
20
+ "icon": {
21
+ "type": "string",
22
+ "description": "Icon shown beside the label. Any browser-loadable source — an absolute URL, an app-served static path, or a data URI. Rendered as an `<img>`, so it is not theme-inverted the way the built-in monochrome brand marks are: supply an image that reads on both light and dark backgrounds. A neutral icon is used when omitted."
23
+ }
24
+ }
25
+ },
26
+ "description": "Frameworks this deployment recognises beyond the ones built into the plugin — an internal assistant, a tool too new or too niche to ship in code. Keyed by the token authors write in an AiResource's `spec.agents` (or the `devaihub.io/compatible-frameworks` annotation); keys are normalised (trimmed, lowercased) before matching.\n\nRegistering a custom framework **activates the allow-list** (`devAiHub.frameworks.allowed`, declared by `@nospt/plugin-dev-ai-hub-backend`) even when `allowed` is omitted: the effective set becomes the plugin's built-in frameworks plus these keys, and any other token is dropped. With `allowed` also set, the effective set is `allowed` plus these keys.\n\nA key that collides with a built-in token or one of its aliases (`claude-code`, `claude`, …) is ignored with a logged warning — built-ins stay authoritative so their install paths cannot be lost.\n\nCustom frameworks are discovery-only: they appear as badges, as \"Works with\" entries and in the AI Tool filter, but never produce a generated install path or launcher, because the plugin knows no filesystem convention for them. The `all` wildcard on a resource never expands into them — a resource must name the token explicitly (ADR-0018).\n\nDeclared here rather than in the backend or frontend plugin's own `config.d.ts`: the backend needs the names to gate the served payload, the frontend needs `displayName`/`icon` to render, and `backstage-cli`'s config schema collector only walks a package's actual `dependencies`/`devDependencies` — since neither plugin package depends on the other, a declaration in just one of them would be invisible to the other. Both already depend on this common package, so declaring it here is what makes one declaration serve both (ADR-0018).",
27
+ "deepVisibility": "frontend"
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ var zod = require('zod');
4
+
5
+ const RECOMMENDED_TAG = "recommended";
6
+ const RecommendationInputSchema = zod.z.object({
7
+ recommended: zod.z.boolean()
8
+ });
9
+ const ExclusionInputSchema = zod.z.object({
10
+ excluded: zod.z.boolean()
11
+ });
12
+
13
+ exports.ExclusionInputSchema = ExclusionInputSchema;
14
+ exports.RECOMMENDED_TAG = RECOMMENDED_TAG;
15
+ exports.RecommendationInputSchema = RecommendationInputSchema;
16
+ //# sourceMappingURL=curation.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"curation.cjs.js","sources":["../src/curation.ts"],"sourcesContent":["import { z } from 'zod';\n\n/**\n * The catalog tag that recommends a resource from its own definition\n * (ADR-0020). A resource carrying it is recommended regardless of the\n * `curation` table, and the backoffice cannot un-recommend it — the author\n * removes the tag instead.\n */\nexport const RECOMMENDED_TAG = 'recommended';\n\n/**\n * The write body for `PUT /curation/:ref/recommendation` (ADR-0017).\n *\n * `recommended: false` clears the flag rather than deleting the row, so\n * `curated_by`/`curated_at` still say who last touched this resource even\n * after it is un-recommended — the same \"never forget who curated this\"\n * requirement #68 states for demote and hide.\n */\nexport const RecommendationInputSchema = z.object({\n recommended: z.boolean(),\n});\nexport type RecommendationInput = z.infer<typeof RecommendationInputSchema>;\n\n/**\n * The write body for `PUT /curation/:ref/exclusion` (#68, ADR-0019).\n *\n * `excluded: false` restores the resource to the hub but keeps the row, so\n * `excluded_by`/`excluded_at` still record the most recent exclusion — the\n * same \"never forget who curated this\" requirement that shapes\n * `RecommendationInputSchema`.\n */\nexport const ExclusionInputSchema = z.object({\n excluded: z.boolean(),\n});\nexport type ExclusionInput = z.infer<typeof ExclusionInputSchema>;\n\n/** One resource's curation record, as stored by `CurationStore`. */\nexport interface CurationRecord {\n entityRef: string;\n recommended: boolean;\n curatedBy?: string;\n curatedAt: string;\n /**\n * Excluded resources are dropped from `GET /resources` entirely rather\n * than flagged for the frontend to skip (ADR-0019), so this is only ever\n * observed by the backoffice, which asks for them explicitly.\n */\n excluded: boolean;\n /**\n * Who last changed this resource's exclusion state, and when. Tracked\n * separately from `curatedBy`/`curatedAt` so a later recommendation cannot\n * overwrite the exclusion's attribution. Both are `undefined` until the\n * first exclusion write; while `excluded` is `true` they name whoever\n * excluded it.\n */\n excludedBy?: string;\n excludedAt?: string;\n}\n"],"names":["z"],"mappings":";;;;AAQO,MAAM,eAAA,GAAkB;AAUxB,MAAM,yBAAA,GAA4BA,MAAE,MAAA,CAAO;AAAA,EAChD,WAAA,EAAaA,MAAE,OAAA;AACjB,CAAC;AAWM,MAAM,oBAAA,GAAuBA,MAAE,MAAA,CAAO;AAAA,EAC3C,QAAA,EAAUA,MAAE,OAAA;AACd,CAAC;;;;;;"}
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+
3
+ const RECOMMENDED_TAG = "recommended";
4
+ const RecommendationInputSchema = z.object({
5
+ recommended: z.boolean()
6
+ });
7
+ const ExclusionInputSchema = z.object({
8
+ excluded: z.boolean()
9
+ });
10
+
11
+ export { ExclusionInputSchema, RECOMMENDED_TAG, RecommendationInputSchema };
12
+ //# sourceMappingURL=curation.esm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"curation.esm.js","sources":["../src/curation.ts"],"sourcesContent":["import { z } from 'zod';\n\n/**\n * The catalog tag that recommends a resource from its own definition\n * (ADR-0020). A resource carrying it is recommended regardless of the\n * `curation` table, and the backoffice cannot un-recommend it — the author\n * removes the tag instead.\n */\nexport const RECOMMENDED_TAG = 'recommended';\n\n/**\n * The write body for `PUT /curation/:ref/recommendation` (ADR-0017).\n *\n * `recommended: false` clears the flag rather than deleting the row, so\n * `curated_by`/`curated_at` still say who last touched this resource even\n * after it is un-recommended — the same \"never forget who curated this\"\n * requirement #68 states for demote and hide.\n */\nexport const RecommendationInputSchema = z.object({\n recommended: z.boolean(),\n});\nexport type RecommendationInput = z.infer<typeof RecommendationInputSchema>;\n\n/**\n * The write body for `PUT /curation/:ref/exclusion` (#68, ADR-0019).\n *\n * `excluded: false` restores the resource to the hub but keeps the row, so\n * `excluded_by`/`excluded_at` still record the most recent exclusion — the\n * same \"never forget who curated this\" requirement that shapes\n * `RecommendationInputSchema`.\n */\nexport const ExclusionInputSchema = z.object({\n excluded: z.boolean(),\n});\nexport type ExclusionInput = z.infer<typeof ExclusionInputSchema>;\n\n/** One resource's curation record, as stored by `CurationStore`. */\nexport interface CurationRecord {\n entityRef: string;\n recommended: boolean;\n curatedBy?: string;\n curatedAt: string;\n /**\n * Excluded resources are dropped from `GET /resources` entirely rather\n * than flagged for the frontend to skip (ADR-0019), so this is only ever\n * observed by the backoffice, which asks for them explicitly.\n */\n excluded: boolean;\n /**\n * Who last changed this resource's exclusion state, and when. Tracked\n * separately from `curatedBy`/`curatedAt` so a later recommendation cannot\n * overwrite the exclusion's attribution. Both are `undefined` until the\n * first exclusion write; while `excluded` is `true` they name whoever\n * excluded it.\n */\n excludedBy?: string;\n excludedAt?: string;\n}\n"],"names":[],"mappings":";;AAQO,MAAM,eAAA,GAAkB;AAUxB,MAAM,yBAAA,GAA4B,EAAE,MAAA,CAAO;AAAA,EAChD,WAAA,EAAa,EAAE,OAAA;AACjB,CAAC;AAWM,MAAM,oBAAA,GAAuB,EAAE,MAAA,CAAO;AAAA,EAC3C,QAAA,EAAU,EAAE,OAAA;AACd,CAAC;;;;"}
package/dist/index.cjs.js CHANGED
@@ -1,11 +1,20 @@
1
1
  'use strict';
2
2
 
3
+ var curation = require('./curation.cjs.js');
4
+ var permissions = require('./permissions.cjs.js');
3
5
  var resources = require('./resources.cjs.js');
6
+ var sorting = require('./sorting.cjs.js');
4
7
  var telemetry = require('./telemetry.cjs.js');
5
8
  var toResourceSummary = require('./toResourceSummary.cjs.js');
6
9
 
7
10
 
8
11
 
12
+ exports.ExclusionInputSchema = curation.ExclusionInputSchema;
13
+ exports.RECOMMENDED_TAG = curation.RECOMMENDED_TAG;
14
+ exports.RecommendationInputSchema = curation.RecommendationInputSchema;
15
+ exports.devAiHubBackofficeCuratePermission = permissions.devAiHubBackofficeCuratePermission;
16
+ exports.devAiHubBackofficeReadPermission = permissions.devAiHubBackofficeReadPermission;
17
+ exports.devAiHubPermissions = permissions.devAiHubPermissions;
9
18
  exports.AGENT_LINK_CAPABLE_FRAMEWORKS = resources.AGENT_LINK_CAPABLE_FRAMEWORKS;
10
19
  exports.ANNOTATION_COMPATIBLE_FRAMEWORKS = resources.ANNOTATION_COMPATIBLE_FRAMEWORKS;
11
20
  exports.ANNOTATION_HELP = resources.ANNOTATION_HELP;
@@ -37,8 +46,17 @@ exports.hasDownloadableArtifact = resources.hasDownloadableArtifact;
37
46
  exports.isRenderableContainment = resources.isRenderableContainment;
38
47
  exports.isResourceType = resources.isResourceType;
39
48
  exports.normalizeFramework = resources.normalizeFramework;
49
+ exports.parseCustomFrameworks = resources.parseCustomFrameworks;
50
+ exports.resolveAllowedFrameworks = resources.resolveAllowedFrameworks;
40
51
  exports.resolveCapableFrameworks = resources.resolveCapableFrameworks;
41
52
  exports.showsContentSection = resources.showsContentSection;
53
+ exports.DEFAULT_SORT_MODE = sorting.DEFAULT_SORT_MODE;
54
+ exports.NATURAL_SORT_DIRECTION = sorting.NATURAL_SORT_DIRECTION;
55
+ exports.SORT_MODES = sorting.SORT_MODES;
56
+ exports.SortModeEnum = sorting.SortModeEnum;
57
+ exports.isSortMode = sorting.isSortMode;
58
+ exports.sortResources = sorting.sortResources;
59
+ exports.usageScore = sorting.usageScore;
42
60
  exports.TelemetryActionEnum = telemetry.TelemetryActionEnum;
43
61
  exports.TelemetryEventInputSchema = telemetry.TelemetryEventInputSchema;
44
62
  exports.isParseableEntityRef = toResourceSummary.isParseableEntityRef;
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,100 @@
1
1
  import { z } from 'zod';
2
+ import * as _backstage_plugin_permission_common from '@backstage/plugin-permission-common';
2
3
  import { Entity } from '@backstage/catalog-model';
3
4
 
5
+ /**
6
+ * The catalog tag that recommends a resource from its own definition
7
+ * (ADR-0020). A resource carrying it is recommended regardless of the
8
+ * `curation` table, and the backoffice cannot un-recommend it — the author
9
+ * removes the tag instead.
10
+ */
11
+ declare const RECOMMENDED_TAG = "recommended";
12
+ /**
13
+ * The write body for `PUT /curation/:ref/recommendation` (ADR-0017).
14
+ *
15
+ * `recommended: false` clears the flag rather than deleting the row, so
16
+ * `curated_by`/`curated_at` still say who last touched this resource even
17
+ * after it is un-recommended — the same "never forget who curated this"
18
+ * requirement #68 states for demote and hide.
19
+ */
20
+ declare const RecommendationInputSchema: z.ZodObject<{
21
+ recommended: z.ZodBoolean;
22
+ }, "strip", z.ZodTypeAny, {
23
+ recommended: boolean;
24
+ }, {
25
+ recommended: boolean;
26
+ }>;
27
+ type RecommendationInput = z.infer<typeof RecommendationInputSchema>;
28
+ /**
29
+ * The write body for `PUT /curation/:ref/exclusion` (#68, ADR-0019).
30
+ *
31
+ * `excluded: false` restores the resource to the hub but keeps the row, so
32
+ * `excluded_by`/`excluded_at` still record the most recent exclusion — the
33
+ * same "never forget who curated this" requirement that shapes
34
+ * `RecommendationInputSchema`.
35
+ */
36
+ declare const ExclusionInputSchema: z.ZodObject<{
37
+ excluded: z.ZodBoolean;
38
+ }, "strip", z.ZodTypeAny, {
39
+ excluded: boolean;
40
+ }, {
41
+ excluded: boolean;
42
+ }>;
43
+ type ExclusionInput = z.infer<typeof ExclusionInputSchema>;
44
+ /** One resource's curation record, as stored by `CurationStore`. */
45
+ interface CurationRecord {
46
+ entityRef: string;
47
+ recommended: boolean;
48
+ curatedBy?: string;
49
+ curatedAt: string;
50
+ /**
51
+ * Excluded resources are dropped from `GET /resources` entirely rather
52
+ * than flagged for the frontend to skip (ADR-0019), so this is only ever
53
+ * observed by the backoffice, which asks for them explicitly.
54
+ */
55
+ excluded: boolean;
56
+ /**
57
+ * Who last changed this resource's exclusion state, and when. Tracked
58
+ * separately from `curatedBy`/`curatedAt` so a later recommendation cannot
59
+ * overwrite the exclusion's attribution. Both are `undefined` until the
60
+ * first exclusion write; while `excluded` is `true` they name whoever
61
+ * excluded it.
62
+ */
63
+ excludedBy?: string;
64
+ excludedAt?: string;
65
+ }
66
+
67
+ /**
68
+ * Opening the Dev AI Hub backoffice (ADR-0016).
69
+ *
70
+ * Held in `-common` because both sides need the same identity: the backend
71
+ * registers it so the Backstage RBAC plugin can list and grant it, and the
72
+ * frontend checks it to decide whether the surface exists for this user at all.
73
+ *
74
+ * Read-only on purpose. Every governance capability that writes — curation
75
+ * (#68, ADR-0017), tool visibility (#85) — gets its own permission rather
76
+ * than riding on this one, so "can see the backoffice" never silently
77
+ * becomes "can change the catalog's presentation".
78
+ */
79
+ declare const devAiHubBackofficeReadPermission: _backstage_plugin_permission_common.BasicPermission;
80
+ /**
81
+ * Changing a resource's curation state — currently just "recommended"
82
+ * (ADR-0017), later also demote/hide (#68) — from the backoffice.
83
+ *
84
+ * Scoped to the capability, not to one action: a future demote or hide
85
+ * reuses this permission rather than minting one each, the same way granting
86
+ * someone curation trust once covers whichever curation actions the backoffice
87
+ * exposes. Separate from `dev-ai-hub.backoffice.read` so seeing the backoffice
88
+ * never implies being able to write to it.
89
+ */
90
+ declare const devAiHubBackofficeCuratePermission: _backstage_plugin_permission_common.BasicPermission;
91
+ /**
92
+ * Every permission this plugin owns, for bulk registration with the backend's
93
+ * permissions registry. New permissions must be added here or the RBAC plugin
94
+ * will not offer them.
95
+ */
96
+ declare const devAiHubPermissions: _backstage_plugin_permission_common.BasicPermission[];
97
+
4
98
  /**
5
99
  * The v2 read contract: AiResource vocabulary, the flat ResourceSummary
6
100
  * returned by the backend, and the shared framework resolver (ADR-0003).
@@ -53,16 +147,74 @@ type FrameworkToken = (typeof KNOWN_FRAMEWORKS)[number];
53
147
  declare function normalizeFramework(token: string): string;
54
148
  /**
55
149
  * Narrow a resource's frameworks to a deployment-level allow-list
56
- * (`devAiHub.frameworks.allowed`). Omitting the config key (`allowed` is
57
- * `undefined`) permits every framework; an explicit empty list permits none,
58
- * so a deployment can suppress framework exposure entirely. Both sides are
59
- * normalised (aliases resolved) before comparison.
150
+ * (`devAiHub.frameworks.allowed`, unioned with the `custom` keys). Omitting
151
+ * the config key (`allowed` is `undefined`) permits every framework; an
152
+ * explicit empty list permits none, so a deployment can suppress framework
153
+ * exposure entirely. Both sides are normalised (aliases resolved) before
154
+ * comparison.
60
155
  *
61
156
  * The `all` wildcard is expanded to the allow-list itself before filtering, so
62
157
  * a resource that declares `all` degrades to "every permitted host" rather
63
- * than disappearing entirely.
158
+ * than disappearing entirely — minus `custom`, which `all` never reaches
159
+ * (ADR-0018): the wildcard means "every host the plugin knows", and claiming
160
+ * an internal assistant on every universal resource would be an assertion the
161
+ * author never made.
64
162
  */
65
- declare function filterAllowedFrameworks(frameworks: string[], allowed: string[] | undefined): string[];
163
+ declare function filterAllowedFrameworks(frameworks: string[], allowed: string[] | undefined, custom?: readonly string[]): string[];
164
+ /**
165
+ * A deployment-registered framework (`devAiHub.frameworks.custom`) — a tool
166
+ * the plugin does not ship knowledge of, named by the token its authors write
167
+ * in `spec.agents` (ADR-0018).
168
+ */
169
+ interface CustomFramework {
170
+ /** Normalised token matched against a resource's declared frameworks. */
171
+ name: string;
172
+ /** Display label. Falls back to `name` when the config omits `displayName`. */
173
+ displayName: string;
174
+ /** Browser-loadable icon source; `undefined` selects the neutral fallback. */
175
+ icon?: string;
176
+ }
177
+ /** The raw per-token shape of `devAiHub.frameworks.custom`. */
178
+ interface CustomFrameworkConfig {
179
+ displayName?: string;
180
+ icon?: string;
181
+ }
182
+ interface ParsedCustomFrameworks {
183
+ frameworks: CustomFramework[];
184
+ /**
185
+ * Human-readable reasons entries were dropped. The backend logs these; the
186
+ * frontend ignores them, but both run the same parse so they can never
187
+ * disagree about which tokens exist.
188
+ */
189
+ warnings: string[];
190
+ }
191
+ /**
192
+ * Parse `devAiHub.frameworks.custom` into the registry both sides read.
193
+ *
194
+ * Entries are dropped rather than rejected wholesale — one malformed entry
195
+ * must not take out a deployment's whole framework registry — and each drop
196
+ * carries a reason:
197
+ *
198
+ * - a key that normalises to empty has nothing to match against;
199
+ * - a key that collides with a built-in token or alias (`claude-code`,
200
+ * `claude`) is ignored so built-ins stay authoritative: custom frameworks
201
+ * are install-suppressed, and letting one shadow `claude-code` would
202
+ * silently strip Claude's install paths;
203
+ * - a duplicate after normalisation keeps the first entry, since the second
204
+ * would otherwise win non-deterministically.
205
+ */
206
+ declare function parseCustomFrameworks(raw: unknown): ParsedCustomFrameworks;
207
+ /**
208
+ * The allow-list actually applied, once `custom` is taken into account
209
+ * (ADR-0018). Registering a custom framework activates the gate even when
210
+ * `allowed` is omitted — otherwise the deployment would have registered a
211
+ * token that nothing distinguishes from the unknown tokens beside it.
212
+ *
213
+ * - no custom frameworks → `allowed` unchanged (including `undefined`);
214
+ * - `allowed` set → `allowed` ∪ custom;
215
+ * - `allowed` omitted → every built-in ∪ custom.
216
+ */
217
+ declare function resolveAllowedFrameworks(allowed: string[] | undefined, custom: readonly string[]): string[] | undefined;
66
218
  /**
67
219
  * The minimal structural shape of an AiResource entity that the read helpers
68
220
  * need. A real `@backstage/catalog-model` `Entity` satisfies it, but keeping
@@ -133,22 +285,24 @@ interface ResourceSummary {
133
285
  sourceLocation?: string;
134
286
  frameworks: string[];
135
287
  /**
136
- * True when an active deployment allow-list (`devAiHub.frameworks.allowed`)
137
- * left this resource with no frameworks — either it removed the ones the
138
- * author declared, or the allow-list itself is empty. Lets the UI tell
139
- * "author declared nothing" (`frameworks: []`, show neutral install
140
- * defaults) apart from "the deployment suppressed them" (hide generated
141
- * install paths and launchers; Copy/Download still work).
288
+ * True when this resource has no generated install path to offer — either
289
+ * an active deployment allow-list (`devAiHub.frameworks.allowed`) left it
290
+ * with no frameworks, or every framework it does have is a custom,
291
+ * discovery-only one (ADR-0018). Lets the UI tell "author declared nothing"
292
+ * (`frameworks: []`, show neutral install defaults) apart from "nothing
293
+ * installable remains" (hide generated install paths and launchers;
294
+ * Copy/Download still work).
142
295
  */
143
296
  frameworksRestricted?: boolean;
144
297
  /**
145
298
  * The framework set that install-path and launcher generation must be
146
- * scoped to, when it differs from `frameworks`. Only set for a resource
147
- * that declared no frameworks (which otherwise means "every host") under an
148
- * active allow-list: it holds the allow-list itself, so a bare resource
149
- * cannot produce launchers for tools the deployment disallowed. Kept
150
- * separate from `frameworks` so this inferred set never renders as a
151
- * compatibility badge the author never claimed.
299
+ * scoped to, when it differs from `frameworks`. Two cases set it: a
300
+ * resource that declared no frameworks (which otherwise means "every host")
301
+ * under an active allow-list, where it holds the allow-list itself so a
302
+ * bare resource cannot produce launchers for disallowed tools; and a
303
+ * resource carrying custom frameworks, where it holds the remainder once
304
+ * those are removed (ADR-0018). Kept separate from `frameworks` so neither
305
+ * inferred set renders as a compatibility badge the author never claimed.
152
306
  */
153
307
  installFrameworks?: string[];
154
308
  version?: string;
@@ -170,6 +324,47 @@ interface ResourceSummary {
170
324
  childCount?: number;
171
325
  helpText?: string;
172
326
  annotations: Record<string, string>;
327
+ /**
328
+ * True when the resource is recommended from either source: its catalog
329
+ * tags carry `RECOMMENDED_TAG` (set by `toResourceSummary`, ADR-0020), or
330
+ * the backend router overlaid it from the `curation` table (ADR-0017).
331
+ * `undefined` and `false` both mean "not recommended"; only `true` renders
332
+ * the badge.
333
+ */
334
+ recommended?: boolean;
335
+ /**
336
+ * True when `recommended` comes from the entity's own `RECOMMENDED_TAG`
337
+ * (ADR-0020). Such a recommendation is owned by the resource's author, so
338
+ * the backoffice cannot remove it — only removing the tag can.
339
+ */
340
+ recommendedByTag?: boolean;
341
+ /**
342
+ * True when a curator recommended this resource in the backoffice — a
343
+ * `curation` row (ADR-0017). Can be true alongside `recommendedByTag`, for
344
+ * a row recorded before the author added the tag; the backoffice then
345
+ * offers to clear it, so that removing the tag really un-recommends.
346
+ */
347
+ recommendedByCuration?: boolean;
348
+ /**
349
+ * When the plugin first saw this resource, as an ISO timestamp — set by the
350
+ * backend router from its own `resource_first_seen` table (ADR-0021), since
351
+ * the catalog exposes no creation date. `undefined` for a resource that
352
+ * already existed when tracking began; the "Newest" sort ranks those as
353
+ * the oldest.
354
+ */
355
+ firstSeenAt?: string;
356
+ /**
357
+ * Set by the backend router from the `curation` table (ADR-0019), and only
358
+ * ever present when the caller explicitly asked for excluded resources via
359
+ * `?includeExcluded=true`. The default `GET /resources` response omits
360
+ * excluded resources entirely rather than flagging them, so on the browse
361
+ * page this field is always `undefined`.
362
+ */
363
+ excluded?: boolean;
364
+ /** Who last excluded this resource, for the backoffice's exclusion list. */
365
+ excludedBy?: string;
366
+ /** When it was last excluded, ISO-8601. */
367
+ excludedAt?: string;
173
368
  }
174
369
  interface ResourceListResponse {
175
370
  items: ResourceSummary[];
@@ -413,6 +608,64 @@ declare const TelemetryEventInputSchema: z.ZodObject<{
413
608
  type TelemetryEventInput = z.infer<typeof TelemetryEventInputSchema>;
414
609
  /** Raw per-action counts for one resource, as returned by `GET /telemetry/:ref`. */
415
610
  type TelemetryCounts = Record<TelemetryAction, number>;
611
+ /**
612
+ * Raw per-action counts for every resource that has recorded at least one
613
+ * telemetry event, keyed by `entityRef`, as returned by `GET /telemetry`
614
+ * (#83 backoffice overview). A resource with no events at all is simply
615
+ * absent from the map — the caller already has the full resource list and
616
+ * can treat a missing key as zero, so the response only carries entries
617
+ * that mean something.
618
+ */
619
+ type TelemetrySummary = Record<string, TelemetryCounts>;
620
+
621
+ /**
622
+ * The named sort modes exposed on the browse page (issue #102). `usage` is
623
+ * the composite "most popular" score; `newest` orders by when the plugin
624
+ * first saw each resource (ADR-0021); `name` is the alphabetical fallback
625
+ * every list needs.
626
+ *
627
+ * The raw per-counter modes (installs, downloads, views) and "Recommended
628
+ * first" were dropped: `usage` already folds the counters into one ranking,
629
+ * and recommended resources are pinned in their own section above the grid,
630
+ * so sorting by the flag would only reorder a list it no longer contains.
631
+ *
632
+ * Deliberately no `framework` mode: `frameworks` is a multi-value array per
633
+ * resource, so a resource compatible with several tools has no single sort
634
+ * position — that dimension is a filter (the AI Tool dropdown), not a sort.
635
+ */
636
+ declare const SortModeEnum: z.ZodEnum<["usage", "newest", "name"]>;
637
+ type SortMode = z.infer<typeof SortModeEnum>;
638
+ declare const SORT_MODES: readonly SortMode[];
639
+ declare function isSortMode(value: unknown): value is SortMode;
640
+ type SortDirection = 'asc' | 'desc';
641
+ /**
642
+ * The default sort mode when no configuration is set and the mode a deployer
643
+ * gets on an unrecognised `devAiHub.ui.defaultSort` value (soft fallback —
644
+ * a typo'd config value must never blank the page).
645
+ */
646
+ declare const DEFAULT_SORT_MODE: SortMode;
647
+ /**
648
+ * Each mode's "obvious" direction — usage and newest rank highest (most
649
+ * popular, most recent) first, name reads A-Z. The dropdown and direction toggle reset to this whenever the user
650
+ * switches mode.
651
+ */
652
+ declare const NATURAL_SORT_DIRECTION: Record<SortMode, SortDirection>;
653
+ declare function usageScore(counts: TelemetryCounts | undefined): number;
654
+ /**
655
+ * Sort resources by the given mode and direction. `counts` is the shared
656
+ * page-level telemetry summary map (entityRef -> raw counts) — a resource
657
+ * absent from the map is treated as all-zero, per `TelemetrySummary`'s
658
+ * contract.
659
+ *
660
+ * Every mode tie-breaks on Name (A-Z) so equal-scoring resources — the
661
+ * common case for a catalog with little or no telemetry yet — render in a
662
+ * predictable, stable order rather than arbitrary fetch order. The `name`
663
+ * mode itself has no primary key to break ties on, so it tie-breaks on
664
+ * `entityRef` instead (see `compareNames`).
665
+ *
666
+ * Pure and allocation-light: does not mutate `resources`.
667
+ */
668
+ declare function sortResources(resources: readonly ResourceSummary[], mode: SortMode, direction: SortDirection, counts: Map<string, TelemetryCounts>): ResourceSummary[];
416
669
 
417
670
  /**
418
671
  * Whether a string is a parseable entity ref. The frontend links `owner` to
@@ -444,13 +697,16 @@ declare function toResourceSummary(entity: Entity): ResourceSummary | undefined;
444
697
  *
445
698
  * `opts.allowedFrameworks` (from `devAiHub.frameworks.allowed`) narrows every
446
699
  * summary's `frameworks` to the permitted set — undefined/empty allows all.
447
- * Applied here so the served payload is the single choke point: badges, the
448
- * "Works with" section, the framework filter and install-path generation all
449
- * read the already-filtered list.
700
+ * `opts.customFrameworks` (the keys of `devAiHub.frameworks.custom`) joins
701
+ * that set and, on its own, activates it (ADR-0018). Applied here so the
702
+ * served payload is the single choke point: badges, the "Works with" section,
703
+ * the framework filter and install-path generation all read the
704
+ * already-filtered list.
450
705
  */
451
706
  declare function toResourceSummaries(entities: Entity[], opts?: {
452
707
  allowedFrameworks?: string[];
708
+ customFrameworks?: string[];
453
709
  }): ResourceSummary[];
454
710
 
455
- export { AGENT_LINK_CAPABLE_FRAMEWORKS, ANNOTATION_COMPATIBLE_FRAMEWORKS, ANNOTATION_HELP, DEVAIHUB_ANNOTATION_PREFIX, INSTALLABLE_FRAMEWORKS, KNOWN_FRAMEWORKS, MARKETPLACE_CAPABLE_FRAMEWORKS, MARKETPLACE_MEMBER_TYPES, MCP_LINK_CAPABLE_FRAMEWORKS, PLUGIN_MEMBER_TYPES, PROMPT_LINK_CAPABLE_FRAMEWORKS, RESOURCE_TYPES, RESOURCE_TYPE_REGISTRY, TelemetryActionEnum, TelemetryEventInputSchema, expandInstallFrameworks, filterAllowedFrameworks, getAgentInstallLinks, getBodyShape, getFrameworks, getInstallSteps, getMarketplaceAddCommands, getMarketplaceInstallTemplate, getMarketplaceRepoSlug, getMarketplaceTeamSnippet, getMcpInstallLinks, getPromptInstallLinks, getResourceInstallTarget, hasCopyableBody, hasDownloadableArtifact, isParseableEntityRef, isRenderableContainment, isResourceType, normalizeFramework, resolveCapableFrameworks, showsContentSection, toResourceSummaries, toResourceSummary };
456
- export type { AiResourceEntityLike, BodyShape, FrameworkToken, InstallMode, MarketplaceAddCommand, ResourceDeepLink, ResourceInstallStep, ResourceInstallTarget, ResourceListResponse, ResourceSummary, ResourceType, ResourceTypeIcon, ResourceTypeInfo, TelemetryAction, TelemetryCounts, TelemetryEventInput };
711
+ export { AGENT_LINK_CAPABLE_FRAMEWORKS, ANNOTATION_COMPATIBLE_FRAMEWORKS, ANNOTATION_HELP, DEFAULT_SORT_MODE, DEVAIHUB_ANNOTATION_PREFIX, ExclusionInputSchema, INSTALLABLE_FRAMEWORKS, KNOWN_FRAMEWORKS, MARKETPLACE_CAPABLE_FRAMEWORKS, MARKETPLACE_MEMBER_TYPES, MCP_LINK_CAPABLE_FRAMEWORKS, NATURAL_SORT_DIRECTION, PLUGIN_MEMBER_TYPES, PROMPT_LINK_CAPABLE_FRAMEWORKS, RECOMMENDED_TAG, RESOURCE_TYPES, RESOURCE_TYPE_REGISTRY, RecommendationInputSchema, SORT_MODES, SortModeEnum, TelemetryActionEnum, TelemetryEventInputSchema, devAiHubBackofficeCuratePermission, devAiHubBackofficeReadPermission, devAiHubPermissions, expandInstallFrameworks, filterAllowedFrameworks, getAgentInstallLinks, getBodyShape, getFrameworks, getInstallSteps, getMarketplaceAddCommands, getMarketplaceInstallTemplate, getMarketplaceRepoSlug, getMarketplaceTeamSnippet, getMcpInstallLinks, getPromptInstallLinks, getResourceInstallTarget, hasCopyableBody, hasDownloadableArtifact, isParseableEntityRef, isRenderableContainment, isResourceType, isSortMode, normalizeFramework, parseCustomFrameworks, resolveAllowedFrameworks, resolveCapableFrameworks, showsContentSection, sortResources, toResourceSummaries, toResourceSummary, usageScore };
712
+ export type { AiResourceEntityLike, BodyShape, CurationRecord, CustomFramework, CustomFrameworkConfig, ExclusionInput, FrameworkToken, InstallMode, MarketplaceAddCommand, ParsedCustomFrameworks, RecommendationInput, ResourceDeepLink, ResourceInstallStep, ResourceInstallTarget, ResourceListResponse, ResourceSummary, ResourceType, ResourceTypeIcon, ResourceTypeInfo, SortDirection, SortMode, TelemetryAction, TelemetryCounts, TelemetryEventInput, TelemetrySummary };
package/dist/index.esm.js CHANGED
@@ -1,4 +1,7 @@
1
- export { AGENT_LINK_CAPABLE_FRAMEWORKS, ANNOTATION_COMPATIBLE_FRAMEWORKS, ANNOTATION_HELP, DEVAIHUB_ANNOTATION_PREFIX, INSTALLABLE_FRAMEWORKS, KNOWN_FRAMEWORKS, MARKETPLACE_CAPABLE_FRAMEWORKS, MARKETPLACE_MEMBER_TYPES, MCP_LINK_CAPABLE_FRAMEWORKS, PLUGIN_MEMBER_TYPES, PROMPT_LINK_CAPABLE_FRAMEWORKS, RESOURCE_TYPES, RESOURCE_TYPE_REGISTRY, expandInstallFrameworks, filterAllowedFrameworks, getAgentInstallLinks, getBodyShape, getFrameworks, getInstallSteps, getMarketplaceAddCommands, getMarketplaceInstallTemplate, getMarketplaceRepoSlug, getMarketplaceTeamSnippet, getMcpInstallLinks, getPromptInstallLinks, getResourceInstallTarget, hasCopyableBody, hasDownloadableArtifact, isRenderableContainment, isResourceType, normalizeFramework, resolveCapableFrameworks, showsContentSection } from './resources.esm.js';
1
+ export { ExclusionInputSchema, RECOMMENDED_TAG, RecommendationInputSchema } from './curation.esm.js';
2
+ export { devAiHubBackofficeCuratePermission, devAiHubBackofficeReadPermission, devAiHubPermissions } from './permissions.esm.js';
3
+ export { AGENT_LINK_CAPABLE_FRAMEWORKS, ANNOTATION_COMPATIBLE_FRAMEWORKS, ANNOTATION_HELP, DEVAIHUB_ANNOTATION_PREFIX, INSTALLABLE_FRAMEWORKS, KNOWN_FRAMEWORKS, MARKETPLACE_CAPABLE_FRAMEWORKS, MARKETPLACE_MEMBER_TYPES, MCP_LINK_CAPABLE_FRAMEWORKS, PLUGIN_MEMBER_TYPES, PROMPT_LINK_CAPABLE_FRAMEWORKS, RESOURCE_TYPES, RESOURCE_TYPE_REGISTRY, expandInstallFrameworks, filterAllowedFrameworks, getAgentInstallLinks, getBodyShape, getFrameworks, getInstallSteps, getMarketplaceAddCommands, getMarketplaceInstallTemplate, getMarketplaceRepoSlug, getMarketplaceTeamSnippet, getMcpInstallLinks, getPromptInstallLinks, getResourceInstallTarget, hasCopyableBody, hasDownloadableArtifact, isRenderableContainment, isResourceType, normalizeFramework, parseCustomFrameworks, resolveAllowedFrameworks, resolveCapableFrameworks, showsContentSection } from './resources.esm.js';
4
+ export { DEFAULT_SORT_MODE, NATURAL_SORT_DIRECTION, SORT_MODES, SortModeEnum, isSortMode, sortResources, usageScore } from './sorting.esm.js';
2
5
  export { TelemetryActionEnum, TelemetryEventInputSchema } from './telemetry.esm.js';
3
6
  export { isParseableEntityRef, toResourceSummaries, toResourceSummary } from './toResourceSummary.esm.js';
4
7
  //# sourceMappingURL=index.esm.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.esm.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;"}
1
+ {"version":3,"file":"index.esm.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;"}
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ var pluginPermissionCommon = require('@backstage/plugin-permission-common');
4
+
5
+ const devAiHubBackofficeReadPermission = pluginPermissionCommon.createPermission({
6
+ name: "dev-ai-hub.backoffice.read",
7
+ attributes: { action: "read" }
8
+ });
9
+ const devAiHubBackofficeCuratePermission = pluginPermissionCommon.createPermission({
10
+ name: "dev-ai-hub.backoffice.curate",
11
+ attributes: { action: "update" }
12
+ });
13
+ const devAiHubPermissions = [
14
+ devAiHubBackofficeReadPermission,
15
+ devAiHubBackofficeCuratePermission
16
+ ];
17
+
18
+ exports.devAiHubBackofficeCuratePermission = devAiHubBackofficeCuratePermission;
19
+ exports.devAiHubBackofficeReadPermission = devAiHubBackofficeReadPermission;
20
+ exports.devAiHubPermissions = devAiHubPermissions;
21
+ //# sourceMappingURL=permissions.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissions.cjs.js","sources":["../src/permissions.ts"],"sourcesContent":["import { createPermission } from '@backstage/plugin-permission-common';\n\n/**\n * Opening the Dev AI Hub backoffice (ADR-0016).\n *\n * Held in `-common` because both sides need the same identity: the backend\n * registers it so the Backstage RBAC plugin can list and grant it, and the\n * frontend checks it to decide whether the surface exists for this user at all.\n *\n * Read-only on purpose. Every governance capability that writes — curation\n * (#68, ADR-0017), tool visibility (#85) — gets its own permission rather\n * than riding on this one, so \"can see the backoffice\" never silently\n * becomes \"can change the catalog's presentation\".\n */\nexport const devAiHubBackofficeReadPermission = createPermission({\n name: 'dev-ai-hub.backoffice.read',\n attributes: { action: 'read' },\n});\n\n/**\n * Changing a resource's curation state — currently just \"recommended\"\n * (ADR-0017), later also demote/hide (#68) — from the backoffice.\n *\n * Scoped to the capability, not to one action: a future demote or hide\n * reuses this permission rather than minting one each, the same way granting\n * someone curation trust once covers whichever curation actions the backoffice\n * exposes. Separate from `dev-ai-hub.backoffice.read` so seeing the backoffice\n * never implies being able to write to it.\n */\nexport const devAiHubBackofficeCuratePermission = createPermission({\n name: 'dev-ai-hub.backoffice.curate',\n attributes: { action: 'update' },\n});\n\n/**\n * Every permission this plugin owns, for bulk registration with the backend's\n * permissions registry. New permissions must be added here or the RBAC plugin\n * will not offer them.\n */\nexport const devAiHubPermissions = [\n devAiHubBackofficeReadPermission,\n devAiHubBackofficeCuratePermission,\n];\n"],"names":["createPermission"],"mappings":";;;;AAcO,MAAM,mCAAmCA,uCAAA,CAAiB;AAAA,EAC/D,IAAA,EAAM,4BAAA;AAAA,EACN,UAAA,EAAY,EAAE,MAAA,EAAQ,MAAA;AACxB,CAAC;AAYM,MAAM,qCAAqCA,uCAAA,CAAiB;AAAA,EACjE,IAAA,EAAM,8BAAA;AAAA,EACN,UAAA,EAAY,EAAE,MAAA,EAAQ,QAAA;AACxB,CAAC;AAOM,MAAM,mBAAA,GAAsB;AAAA,EACjC,gCAAA;AAAA,EACA;AACF;;;;;;"}
@@ -0,0 +1,17 @@
1
+ import { createPermission } from '@backstage/plugin-permission-common';
2
+
3
+ const devAiHubBackofficeReadPermission = createPermission({
4
+ name: "dev-ai-hub.backoffice.read",
5
+ attributes: { action: "read" }
6
+ });
7
+ const devAiHubBackofficeCuratePermission = createPermission({
8
+ name: "dev-ai-hub.backoffice.curate",
9
+ attributes: { action: "update" }
10
+ });
11
+ const devAiHubPermissions = [
12
+ devAiHubBackofficeReadPermission,
13
+ devAiHubBackofficeCuratePermission
14
+ ];
15
+
16
+ export { devAiHubBackofficeCuratePermission, devAiHubBackofficeReadPermission, devAiHubPermissions };
17
+ //# sourceMappingURL=permissions.esm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissions.esm.js","sources":["../src/permissions.ts"],"sourcesContent":["import { createPermission } from '@backstage/plugin-permission-common';\n\n/**\n * Opening the Dev AI Hub backoffice (ADR-0016).\n *\n * Held in `-common` because both sides need the same identity: the backend\n * registers it so the Backstage RBAC plugin can list and grant it, and the\n * frontend checks it to decide whether the surface exists for this user at all.\n *\n * Read-only on purpose. Every governance capability that writes — curation\n * (#68, ADR-0017), tool visibility (#85) — gets its own permission rather\n * than riding on this one, so \"can see the backoffice\" never silently\n * becomes \"can change the catalog's presentation\".\n */\nexport const devAiHubBackofficeReadPermission = createPermission({\n name: 'dev-ai-hub.backoffice.read',\n attributes: { action: 'read' },\n});\n\n/**\n * Changing a resource's curation state — currently just \"recommended\"\n * (ADR-0017), later also demote/hide (#68) — from the backoffice.\n *\n * Scoped to the capability, not to one action: a future demote or hide\n * reuses this permission rather than minting one each, the same way granting\n * someone curation trust once covers whichever curation actions the backoffice\n * exposes. Separate from `dev-ai-hub.backoffice.read` so seeing the backoffice\n * never implies being able to write to it.\n */\nexport const devAiHubBackofficeCuratePermission = createPermission({\n name: 'dev-ai-hub.backoffice.curate',\n attributes: { action: 'update' },\n});\n\n/**\n * Every permission this plugin owns, for bulk registration with the backend's\n * permissions registry. New permissions must be added here or the RBAC plugin\n * will not offer them.\n */\nexport const devAiHubPermissions = [\n devAiHubBackofficeReadPermission,\n devAiHubBackofficeCuratePermission,\n];\n"],"names":[],"mappings":";;AAcO,MAAM,mCAAmC,gBAAA,CAAiB;AAAA,EAC/D,IAAA,EAAM,4BAAA;AAAA,EACN,UAAA,EAAY,EAAE,MAAA,EAAQ,MAAA;AACxB,CAAC;AAYM,MAAM,qCAAqC,gBAAA,CAAiB;AAAA,EACjE,IAAA,EAAM,8BAAA;AAAA,EACN,UAAA,EAAY,EAAE,MAAA,EAAQ,QAAA;AACxB,CAAC;AAOM,MAAM,mBAAA,GAAsB;AAAA,EACjC,gCAAA;AAAA,EACA;AACF;;;;"}