@nospt/plugin-dev-ai-hub-backend 1.1.2 → 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 +33 -3
- package/config.schema.json +1 -1
- package/dist/database/CurationStore.cjs.js +79 -0
- package/dist/database/CurationStore.cjs.js.map +1 -0
- package/dist/database/FirstSeenStore.cjs.js +85 -0
- package/dist/database/FirstSeenStore.cjs.js.map +1 -0
- package/dist/database/TelemetryStore.cjs.js +20 -0
- package/dist/database/TelemetryStore.cjs.js.map +1 -1
- package/dist/plugin.cjs.js +24 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/router.cjs.js +199 -3
- package/dist/router.cjs.js.map +1 -1
- package/migrations/010_curation.js +28 -0
- package/migrations/011_exclusion.js +38 -0
- package/migrations/012_first_seen.js +32 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -50,10 +50,17 @@ GET /api/dev-ai-hub/entity/:ref/raw Resolved body (markdown or JS
|
|
|
50
50
|
GET /api/dev-ai-hub/entity/:ref/raw/:filename A specific file from a directory-shaped body
|
|
51
51
|
GET /api/dev-ai-hub/entity/:ref/raw?download=true Download the artifact (zipped if multi-file)
|
|
52
52
|
POST /api/dev-ai-hub/telemetry Record an install/copy/download/view event
|
|
53
|
+
GET /api/dev-ai-hub/telemetry Catalog-wide lifetime totals, keyed by entity ref
|
|
53
54
|
GET /api/dev-ai-hub/telemetry/:ref Per-action counts for a resource
|
|
55
|
+
PUT /api/dev-ai-hub/curation/:ref/recommendation Set or clear a resource's "Recommended" state
|
|
56
|
+
PUT /api/dev-ai-hub/curation/:ref/exclusion Set or clear a resource's "Excluded" state
|
|
54
57
|
```
|
|
55
58
|
|
|
56
|
-
Every route requires standard Backstage authentication — there are no unauthenticated endpoints.
|
|
59
|
+
Every route requires standard Backstage authentication — there are no unauthenticated endpoints. `PUT /curation/:ref/recommendation` and `PUT /curation/:ref/exclusion` additionally require the `dev-ai-hub.backoffice.curate` permission (exported as `devAiHubBackofficeCuratePermission` from `@nospt/plugin-dev-ai-hub-common`); they 403 without it and 404 if the target entity doesn't exist or isn't visible to the caller. Recommending an entity already tagged `recommended` returns 409: it is recommended by the catalog, and a `curation` row on top would outlive the tag (ADR-0020). Clearing is always allowed.
|
|
60
|
+
|
|
61
|
+
`GET /resources` omits excluded resources entirely (ADR-0019) — they are absent from the payload, not flagged in it, so nothing downstream has to know the concept. Pass `?includeExcluded=true` to get them back with their `excluded`, `excludedBy` and `excludedAt` fields; that opt-in requires `dev-ai-hub.backoffice.read` and 403s without it. Only the backoffice's "Excluded" page uses it. Body endpoints (`/entity/:ref/raw`) are deliberately unaffected: exclusion is curation, not access control, and the entity is still served by the catalog itself.
|
|
62
|
+
|
|
63
|
+
`GET /telemetry` powers the backoffice's "Usage" section (#83): one grouped query across every resource's events, rather than the N requests `GET /telemetry/:ref` would take fetched once per resource. It has no notion of catalog visibility — the frontend joins the result against the `ResourceSummary` list it already has, so a "most installed" ranking never names something the caller cannot otherwise see.
|
|
57
64
|
|
|
58
65
|
`GET /resources` returns the flat contract, never a raw catalog `Entity`:
|
|
59
66
|
|
|
@@ -105,13 +112,36 @@ devAiHub:
|
|
|
105
112
|
# list. Omit the key to permit every framework; an explicit empty list
|
|
106
113
|
# permits none. Must be a list of strings.
|
|
107
114
|
allowed: [claude-code, github-copilot, cursor, google-gemini]
|
|
115
|
+
# Optional. Frameworks this deployment registers itself (ADR-0018), keyed
|
|
116
|
+
# by the token authors write in `spec.agents`. `displayName` defaults to
|
|
117
|
+
# the key, `icon` to a neutral glyph; `icon` is any browser-loadable
|
|
118
|
+
# source and is read by the frontend, which is why the key is
|
|
119
|
+
# frontend-visible.
|
|
120
|
+
#
|
|
121
|
+
# Registering one activates the allow-list even when `allowed` is omitted —
|
|
122
|
+
# the effective set becomes every built-in framework plus these keys. With
|
|
123
|
+
# `allowed` set, the two are unioned. A key that collides with a built-in
|
|
124
|
+
# token or alias (`claude-code`, `claude`) is ignored with a logged warning.
|
|
125
|
+
#
|
|
126
|
+
# Custom frameworks are discovery-only: the served summary carries them in
|
|
127
|
+
# `frameworks` (so they badge and filter) but excludes them from
|
|
128
|
+
# `installFrameworks`, and a resource left with nothing installable is
|
|
129
|
+
# flagged `frameworksRestricted`.
|
|
130
|
+
custom:
|
|
131
|
+
acme-assistant:
|
|
132
|
+
displayName: Acme Assistant
|
|
133
|
+
icon: https://example.com/acme-assistant.svg
|
|
108
134
|
```
|
|
109
135
|
|
|
110
136
|
## Database
|
|
111
137
|
|
|
112
|
-
The plugin owns
|
|
138
|
+
The plugin owns tables for telemetry events, curation state (the "Recommended" and "Excluded" flags and their attribution), and when it first saw each resource (for the browse page's "Newest" sort, ADR-0021). They are created by its own migrations and applied automatically on start. It runs on both SQLite and PostgreSQL and needs no manual setup.
|
|
139
|
+
|
|
140
|
+
The catalog exposes no creation date, so `GET /resources` records one itself: the first request after upgrading reads every `AiResource` ref with the plugin's own service credentials and records them all as undated ("predates tracking"); after that, any ref it hasn't seen before is recorded with the current time. This is best-effort — a failure is logged and the list is served without dates.
|
|
141
|
+
|
|
142
|
+
Don't roll the package back past a version that added a migration: knex refuses to start when `knex_migrations` names a file the installed version doesn't ship. Roll forward instead.
|
|
113
143
|
|
|
114
|
-
Actor identity is stored as a **one-way hash
|
|
144
|
+
Actor identity is stored as a **one-way hash** for telemetry. Counts are deduplicated per person, but "who installed this" is deliberately unanswerable — not merely unbuilt. Curation is the exception: `curated_by` records the curator's own entity ref (never hashed), since knowing who recommended a resource is the point.
|
|
115
145
|
|
|
116
146
|
## Troubleshooting
|
|
117
147
|
|
package/config.schema.json
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
|
-
"description": "Framework allow-list. When set, any framework declared on an AiResource (via `spec.agents` or the `devaihub.io/compatible-frameworks` annotation) that is not in this list is dropped before the resource is served: no badge, no \"Works with\" entry, no filter option, no generated install path. Tokens are normalised (aliases such as `claude` → `claude-code` are resolved) before comparison, and a resource declaring the `all` wildcard collapses to exactly this list. Omit the key to permit every framework; an explicit empty list permits none. Must be a list of strings."
|
|
29
|
+
"description": "Framework allow-list. When set, any framework declared on an AiResource (via `spec.agents` or the `devaihub.io/compatible-frameworks` annotation) that is not in this list is dropped before the resource is served: no badge, no \"Works with\" entry, no filter option, no generated install path. Tokens are normalised (aliases such as `claude` → `claude-code` are resolved) before comparison, and a resource declaring the `all` wildcard collapses to exactly this list. Omit the key to permit every framework; an explicit empty list permits none. Must be a list of strings.\n\n`devAiHub.frameworks.custom` (declared by `@nospt/plugin-dev-ai-hub-common`, so the frontend can see it too) is unioned into this list, and registering any of its keys activates the allow-list even when `allowed` is omitted (ADR-0018)."
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
|
|
5
|
+
function toRecord(row) {
|
|
6
|
+
return {
|
|
7
|
+
entityRef: row.entity_ref,
|
|
8
|
+
recommended: !!row.recommended,
|
|
9
|
+
curatedBy: row.curated_by ?? void 0,
|
|
10
|
+
// Populated by every write; only null for a row a migration seeded
|
|
11
|
+
// directly, which none of ours do — defaulted defensively all the same.
|
|
12
|
+
curatedAt: row.curated_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
13
|
+
excluded: !!row.excluded,
|
|
14
|
+
// Null for any row written before this resource was ever excluded —
|
|
15
|
+
// including every row a deployment created under migration 010.
|
|
16
|
+
excludedBy: row.excluded_by ?? void 0,
|
|
17
|
+
excludedAt: row.excluded_at ?? void 0
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
class CurationStore {
|
|
21
|
+
constructor(db) {
|
|
22
|
+
this.db = db;
|
|
23
|
+
}
|
|
24
|
+
db;
|
|
25
|
+
static async create(options) {
|
|
26
|
+
const db = await options.database.getClient();
|
|
27
|
+
await db.migrate.latest({
|
|
28
|
+
directory: backendPluginApi.resolvePackagePath(
|
|
29
|
+
"@nospt/plugin-dev-ai-hub-backend",
|
|
30
|
+
"migrations"
|
|
31
|
+
),
|
|
32
|
+
loadExtensions: [".js"]
|
|
33
|
+
});
|
|
34
|
+
return new CurationStore(db);
|
|
35
|
+
}
|
|
36
|
+
async setRecommendation(entityRef, input) {
|
|
37
|
+
await this.db("curation").insert({
|
|
38
|
+
entity_ref: entityRef,
|
|
39
|
+
recommended: input.recommended,
|
|
40
|
+
curated_by: input.curatedBy ?? null,
|
|
41
|
+
curated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
42
|
+
}).onConflict("entity_ref").merge();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Exclude a resource from the hub, or restore it (ADR-0019).
|
|
46
|
+
*
|
|
47
|
+
* Only the exclusion columns are inserted, so `merge()` leaves an existing
|
|
48
|
+
* row's `recommended`/`curated_by`/`curated_at` untouched — the two verbs
|
|
49
|
+
* write to the same row without clobbering each other. A resource excluded
|
|
50
|
+
* before it was ever recommended relies on the column default for
|
|
51
|
+
* `recommended`, which is why `010_curation.js` made it `notNullable`.
|
|
52
|
+
*/
|
|
53
|
+
async setExclusion(entityRef, input) {
|
|
54
|
+
await this.db("curation").insert({
|
|
55
|
+
entity_ref: entityRef,
|
|
56
|
+
excluded: input.excluded,
|
|
57
|
+
...input.excluded ? {
|
|
58
|
+
excluded_by: input.excludedBy ?? null,
|
|
59
|
+
excluded_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
60
|
+
} : {}
|
|
61
|
+
}).onConflict("entity_ref").merge();
|
|
62
|
+
}
|
|
63
|
+
/** One resource's curation record, or `undefined` if it has never been curated. */
|
|
64
|
+
async get(entityRef) {
|
|
65
|
+
const row = await this.db("curation").where("entity_ref", entityRef).first();
|
|
66
|
+
return row && toRecord(row);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Every curated resource, keyed by entity ref, for the `/resources` merge
|
|
70
|
+
* (ADR-0017) — one query per list rather than one per entity.
|
|
71
|
+
*/
|
|
72
|
+
async getAll() {
|
|
73
|
+
const rows = await this.db("curation").select();
|
|
74
|
+
return new Map(rows.map((row) => [row.entity_ref, toRecord(row)]));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
exports.CurationStore = CurationStore;
|
|
79
|
+
//# sourceMappingURL=CurationStore.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CurationStore.cjs.js","sources":["../../src/database/CurationStore.ts"],"sourcesContent":["import type { Knex } from 'knex';\nimport {\n resolvePackagePath,\n type DatabaseService,\n} from '@backstage/backend-plugin-api';\nimport type { CurationRecord } from '@nospt/plugin-dev-ai-hub-common';\n\ninterface CurationRow {\n entity_ref: string;\n recommended: boolean | 0 | 1;\n curated_by: string | null;\n curated_at: string | null;\n excluded: boolean | 0 | 1;\n excluded_by: string | null;\n excluded_at: string | null;\n}\n\nfunction toRecord(row: CurationRow): CurationRecord {\n return {\n entityRef: row.entity_ref,\n recommended: !!row.recommended,\n curatedBy: row.curated_by ?? undefined,\n // Populated by every write; only null for a row a migration seeded\n // directly, which none of ours do — defaulted defensively all the same.\n curatedAt: row.curated_at ?? new Date(0).toISOString(),\n excluded: !!row.excluded,\n // Null for any row written before this resource was ever excluded —\n // including every row a deployment created under migration 010.\n excludedBy: row.excluded_by ?? undefined,\n excludedAt: row.excluded_at ?? undefined,\n };\n}\n\n/**\n * Curation overlay (#68, ADR-0017/0019): plugin-owned presentation state about\n * resources, kept out of the catalog entirely.\n *\n * Two independent verbs share one row per entity ref. `setRecommendation`\n * promotes a resource on the browse page; `setExclusion` removes it from the\n * hub altogether. Neither ever deletes the row, so `curatedBy`/`curatedAt`\n * and `excludedBy`/`excludedAt` still answer \"who last touched this\" after\n * the corresponding flag is cleared.\n */\nexport class CurationStore {\n private constructor(private readonly db: Knex) {}\n\n static async create(options: {\n database: DatabaseService;\n }): Promise<CurationStore> {\n const db = await options.database.getClient();\n await db.migrate.latest({\n directory: resolvePackagePath(\n '@nospt/plugin-dev-ai-hub-backend',\n 'migrations',\n ),\n loadExtensions: ['.js'],\n });\n return new CurationStore(db);\n }\n\n async setRecommendation(\n entityRef: string,\n input: { recommended: boolean; curatedBy?: string },\n ): Promise<void> {\n await this.db('curation')\n .insert({\n entity_ref: entityRef,\n recommended: input.recommended,\n curated_by: input.curatedBy ?? null,\n curated_at: new Date().toISOString(),\n })\n .onConflict('entity_ref')\n .merge();\n }\n\n /**\n * Exclude a resource from the hub, or restore it (ADR-0019).\n *\n * Only the exclusion columns are inserted, so `merge()` leaves an existing\n * row's `recommended`/`curated_by`/`curated_at` untouched — the two verbs\n * write to the same row without clobbering each other. A resource excluded\n * before it was ever recommended relies on the column default for\n * `recommended`, which is why `010_curation.js` made it `notNullable`.\n */\n async setExclusion(\n entityRef: string,\n input: { excluded: boolean; excludedBy?: string },\n ): Promise<void> {\n await this.db('curation')\n .insert({\n entity_ref: entityRef,\n excluded: input.excluded,\n ...(input.excluded\n ? {\n excluded_by: input.excludedBy ?? null,\n excluded_at: new Date().toISOString(),\n }\n : {}),\n })\n .onConflict('entity_ref')\n .merge();\n }\n\n /** One resource's curation record, or `undefined` if it has never been curated. */\n async get(entityRef: string): Promise<CurationRecord | undefined> {\n const row = await this.db<CurationRow>('curation')\n .where('entity_ref', entityRef)\n .first();\n return row && toRecord(row);\n }\n\n /**\n * Every curated resource, keyed by entity ref, for the `/resources` merge\n * (ADR-0017) — one query per list rather than one per entity.\n */\n async getAll(): Promise<Map<string, CurationRecord>> {\n const rows = await this.db<CurationRow>('curation').select();\n return new Map(rows.map(row => [row.entity_ref, toRecord(row)]));\n }\n}\n"],"names":["resolvePackagePath"],"mappings":";;;;AAiBA,SAAS,SAAS,GAAA,EAAkC;AAClD,EAAA,OAAO;AAAA,IACL,WAAW,GAAA,CAAI,UAAA;AAAA,IACf,WAAA,EAAa,CAAC,CAAC,GAAA,CAAI,WAAA;AAAA,IACnB,SAAA,EAAW,IAAI,UAAA,IAAc,MAAA;AAAA;AAAA;AAAA,IAG7B,WAAW,GAAA,CAAI,UAAA,IAAA,qBAAkB,IAAA,CAAK,CAAC,GAAE,WAAA,EAAY;AAAA,IACrD,QAAA,EAAU,CAAC,CAAC,GAAA,CAAI,QAAA;AAAA;AAAA;AAAA,IAGhB,UAAA,EAAY,IAAI,WAAA,IAAe,MAAA;AAAA,IAC/B,UAAA,EAAY,IAAI,WAAA,IAAe;AAAA,GACjC;AACF;AAYO,MAAM,aAAA,CAAc;AAAA,EACjB,YAA6B,EAAA,EAAU;AAAV,IAAA,IAAA,CAAA,EAAA,GAAA,EAAA;AAAA,EAAW;AAAA,EAAX,EAAA;AAAA,EAErC,aAAa,OAAO,OAAA,EAEO;AACzB,IAAA,MAAM,EAAA,GAAK,MAAM,OAAA,CAAQ,QAAA,CAAS,SAAA,EAAU;AAC5C,IAAA,MAAM,EAAA,CAAG,QAAQ,MAAA,CAAO;AAAA,MACtB,SAAA,EAAWA,mCAAA;AAAA,QACT,kCAAA;AAAA,QACA;AAAA,OACF;AAAA,MACA,cAAA,EAAgB,CAAC,KAAK;AAAA,KACvB,CAAA;AACD,IAAA,OAAO,IAAI,cAAc,EAAE,CAAA;AAAA,EAC7B;AAAA,EAEA,MAAM,iBAAA,CACJ,SAAA,EACA,KAAA,EACe;AACf,IAAA,MAAM,IAAA,CAAK,EAAA,CAAG,UAAU,CAAA,CACrB,MAAA,CAAO;AAAA,MACN,UAAA,EAAY,SAAA;AAAA,MACZ,aAAa,KAAA,CAAM,WAAA;AAAA,MACnB,UAAA,EAAY,MAAM,SAAA,IAAa,IAAA;AAAA,MAC/B,UAAA,EAAA,iBAAY,IAAI,IAAA,EAAK,EAAE,WAAA;AAAY,KACpC,CAAA,CACA,UAAA,CAAW,YAAY,EACvB,KAAA,EAAM;AAAA,EACX;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,YAAA,CACJ,SAAA,EACA,KAAA,EACe;AACf,IAAA,MAAM,IAAA,CAAK,EAAA,CAAG,UAAU,CAAA,CACrB,MAAA,CAAO;AAAA,MACN,UAAA,EAAY,SAAA;AAAA,MACZ,UAAU,KAAA,CAAM,QAAA;AAAA,MAChB,GAAI,MAAM,QAAA,GACN;AAAA,QACE,WAAA,EAAa,MAAM,UAAA,IAAc,IAAA;AAAA,QACjC,WAAA,EAAA,iBAAa,IAAI,IAAA,EAAK,EAAE,WAAA;AAAY,UAEtC;AAAC,KACN,CAAA,CACA,UAAA,CAAW,YAAY,EACvB,KAAA,EAAM;AAAA,EACX;AAAA;AAAA,EAGA,MAAM,IAAI,SAAA,EAAwD;AAChE,IAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,EAAA,CAAgB,UAAU,EAC9C,KAAA,CAAM,YAAA,EAAc,SAAS,CAAA,CAC7B,KAAA,EAAM;AACT,IAAA,OAAO,GAAA,IAAO,SAAS,GAAG,CAAA;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,MAAA,GAA+C;AACnD,IAAA,MAAM,OAAO,MAAM,IAAA,CAAK,EAAA,CAAgB,UAAU,EAAE,MAAA,EAAO;AAC3D,IAAA,OAAO,IAAI,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,CAAA,GAAA,KAAO,CAAC,GAAA,CAAI,UAAA,EAAY,QAAA,CAAS,GAAG,CAAC,CAAC,CAAC,CAAA;AAAA,EACjE;AACF;;;;"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
|
|
5
|
+
const TABLE = "resource_first_seen";
|
|
6
|
+
const BASELINE_KEY = "first_seen_baseline_at";
|
|
7
|
+
const CHUNK_SIZE = 400;
|
|
8
|
+
function toIso(value) {
|
|
9
|
+
if (value === null || value === void 0) return void 0;
|
|
10
|
+
return new Date(value).toISOString();
|
|
11
|
+
}
|
|
12
|
+
class FirstSeenStore {
|
|
13
|
+
constructor(db) {
|
|
14
|
+
this.db = db;
|
|
15
|
+
}
|
|
16
|
+
db;
|
|
17
|
+
static async create(options) {
|
|
18
|
+
const db = await options.database.getClient();
|
|
19
|
+
await db.migrate.latest({
|
|
20
|
+
directory: backendPluginApi.resolvePackagePath(
|
|
21
|
+
"@nospt/plugin-dev-ai-hub-backend",
|
|
22
|
+
"migrations"
|
|
23
|
+
),
|
|
24
|
+
loadExtensions: [".js"]
|
|
25
|
+
});
|
|
26
|
+
return new FirstSeenStore(db);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* On the first call ever, records every ref `loadAllRefs` returns as
|
|
30
|
+
* predating tracking, then marks the baseline done; a no-op afterwards.
|
|
31
|
+
* Concurrent first calls are harmless — every insert ignores conflicts.
|
|
32
|
+
*/
|
|
33
|
+
async ensureBaseline(loadAllRefs) {
|
|
34
|
+
const marker = await this.db("telemetry_settings").where("key", BASELINE_KEY).first();
|
|
35
|
+
if (marker) return;
|
|
36
|
+
await this.insert(await loadAllRefs(), null);
|
|
37
|
+
await this.db("telemetry_settings").insert({ key: BASELINE_KEY, value: (/* @__PURE__ */ new Date()).toISOString() }).onConflict("key").ignore();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Records any of `entityRefs` not yet seen as first seen now, and returns
|
|
41
|
+
* the first-seen date of each of them that has one. Refs that predate
|
|
42
|
+
* tracking are absent from the result.
|
|
43
|
+
*
|
|
44
|
+
* Reads only the requested refs, not the whole table. After inserting, the
|
|
45
|
+
* new refs are read back rather than assumed: when two requests race on
|
|
46
|
+
* the same new ref, only one insert wins, and both must report its date.
|
|
47
|
+
*/
|
|
48
|
+
async recordAndGet(entityRefs) {
|
|
49
|
+
const rows = await this.select(entityRefs);
|
|
50
|
+
const known = new Set(rows.map((row) => row.entity_ref));
|
|
51
|
+
const unseen = entityRefs.filter((ref) => !known.has(ref));
|
|
52
|
+
if (unseen.length > 0) {
|
|
53
|
+
await this.insert(unseen, (/* @__PURE__ */ new Date()).toISOString());
|
|
54
|
+
rows.push(...await this.select(unseen));
|
|
55
|
+
}
|
|
56
|
+
const dates = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const iso = toIso(row.first_seen_at);
|
|
59
|
+
if (iso) dates.set(row.entity_ref, iso);
|
|
60
|
+
}
|
|
61
|
+
return dates;
|
|
62
|
+
}
|
|
63
|
+
async select(entityRefs) {
|
|
64
|
+
const rows = [];
|
|
65
|
+
for (let i = 0; i < entityRefs.length; i += CHUNK_SIZE) {
|
|
66
|
+
rows.push(
|
|
67
|
+
...await this.db(TABLE).whereIn("entity_ref", entityRefs.slice(i, i + CHUNK_SIZE)).select()
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
async insert(entityRefs, firstSeenAt) {
|
|
73
|
+
for (let i = 0; i < entityRefs.length; i += CHUNK_SIZE) {
|
|
74
|
+
await this.db(TABLE).insert(
|
|
75
|
+
entityRefs.slice(i, i + CHUNK_SIZE).map((ref) => ({
|
|
76
|
+
entity_ref: ref,
|
|
77
|
+
first_seen_at: firstSeenAt
|
|
78
|
+
}))
|
|
79
|
+
).onConflict("entity_ref").ignore();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
exports.FirstSeenStore = FirstSeenStore;
|
|
85
|
+
//# sourceMappingURL=FirstSeenStore.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FirstSeenStore.cjs.js","sources":["../../src/database/FirstSeenStore.ts"],"sourcesContent":["import type { Knex } from 'knex';\nimport {\n resolvePackagePath,\n type DatabaseService,\n} from '@backstage/backend-plugin-api';\n\nconst TABLE = 'resource_first_seen';\nconst BASELINE_KEY = 'first_seen_baseline_at';\n/** Rows per insert — well under SQLite's bound-parameter limit. */\nconst CHUNK_SIZE = 400;\n\ninterface FirstSeenRow {\n entity_ref: string;\n first_seen_at: string | Date | number | null;\n}\n\n/** ISO string from whatever the driver returns (Date on pg, text on SQLite). */\nfunction toIso(value: FirstSeenRow['first_seen_at']): string | undefined {\n if (value === null || value === undefined) return undefined;\n return new Date(value).toISOString();\n}\n\n/**\n * When the plugin first saw each resource (ADR-0021), for the \"Newest\" sort.\n *\n * Resources present when tracking began are recorded with no date by\n * `ensureBaseline`, so only resources that appear afterwards rank as new.\n * Rows are only ever inserted, never updated or deleted: a resource removed\n * and re-added under the same entity ref keeps its original date.\n */\nexport class FirstSeenStore {\n private constructor(private readonly db: Knex) {}\n\n static async create(options: {\n database: DatabaseService;\n }): Promise<FirstSeenStore> {\n const db = await options.database.getClient();\n await db.migrate.latest({\n directory: resolvePackagePath(\n '@nospt/plugin-dev-ai-hub-backend',\n 'migrations',\n ),\n loadExtensions: ['.js'],\n });\n return new FirstSeenStore(db);\n }\n\n /**\n * On the first call ever, records every ref `loadAllRefs` returns as\n * predating tracking, then marks the baseline done; a no-op afterwards.\n * Concurrent first calls are harmless — every insert ignores conflicts.\n */\n async ensureBaseline(loadAllRefs: () => Promise<string[]>): Promise<void> {\n const marker = await this.db('telemetry_settings')\n .where('key', BASELINE_KEY)\n .first();\n if (marker) return;\n\n await this.insert(await loadAllRefs(), null);\n await this.db('telemetry_settings')\n .insert({ key: BASELINE_KEY, value: new Date().toISOString() })\n .onConflict('key')\n .ignore();\n }\n\n /**\n * Records any of `entityRefs` not yet seen as first seen now, and returns\n * the first-seen date of each of them that has one. Refs that predate\n * tracking are absent from the result.\n *\n * Reads only the requested refs, not the whole table. After inserting, the\n * new refs are read back rather than assumed: when two requests race on\n * the same new ref, only one insert wins, and both must report its date.\n */\n async recordAndGet(entityRefs: string[]): Promise<Map<string, string>> {\n const rows = await this.select(entityRefs);\n const known = new Set(rows.map(row => row.entity_ref));\n const unseen = entityRefs.filter(ref => !known.has(ref));\n if (unseen.length > 0) {\n await this.insert(unseen, new Date().toISOString());\n rows.push(...(await this.select(unseen)));\n }\n\n const dates = new Map<string, string>();\n for (const row of rows) {\n const iso = toIso(row.first_seen_at);\n if (iso) dates.set(row.entity_ref, iso);\n }\n return dates;\n }\n\n private async select(entityRefs: string[]): Promise<FirstSeenRow[]> {\n const rows: FirstSeenRow[] = [];\n for (let i = 0; i < entityRefs.length; i += CHUNK_SIZE) {\n rows.push(\n ...(await this.db<FirstSeenRow>(TABLE)\n .whereIn('entity_ref', entityRefs.slice(i, i + CHUNK_SIZE))\n .select()),\n );\n }\n return rows;\n }\n\n private async insert(entityRefs: string[], firstSeenAt: string | null) {\n for (let i = 0; i < entityRefs.length; i += CHUNK_SIZE) {\n await this.db(TABLE)\n .insert(\n entityRefs.slice(i, i + CHUNK_SIZE).map(ref => ({\n entity_ref: ref,\n first_seen_at: firstSeenAt,\n })),\n )\n .onConflict('entity_ref')\n .ignore();\n }\n }\n}\n"],"names":["resolvePackagePath"],"mappings":"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"}
|
|
@@ -88,6 +88,26 @@ class TelemetryStore {
|
|
|
88
88
|
}
|
|
89
89
|
return counts;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Catalog-wide lifetime totals (#83 backoffice overview), one grouped query
|
|
93
|
+
* rather than one `getCounts` per resource — the backoffice usage rollup
|
|
94
|
+
* needs every resource's counts at once, and N round-trips would not scale
|
|
95
|
+
* with catalog size the way this single `GROUP BY` does.
|
|
96
|
+
*
|
|
97
|
+
* A resource with no recorded events is simply absent from the result; the
|
|
98
|
+
* router has the full resource list already and treats a missing key as
|
|
99
|
+
* `ZERO_COUNTS`, so there is nothing useful this method would add by
|
|
100
|
+
* pre-filling every possible ref with zeroes.
|
|
101
|
+
*/
|
|
102
|
+
async getSummary() {
|
|
103
|
+
const rows = await this.db("telemetry_events").groupBy("entity_ref", "action").select("entity_ref", "action").count("id as count");
|
|
104
|
+
const summary = {};
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
const bucket = summary[row.entity_ref] ??= { ...ZERO_COUNTS };
|
|
107
|
+
bucket[row.action] = Number(row.count);
|
|
108
|
+
}
|
|
109
|
+
return summary;
|
|
110
|
+
}
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
exports.TelemetryStore = TelemetryStore;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TelemetryStore.cjs.js","sources":["../../src/database/TelemetryStore.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport type { Knex } from 'knex';\nimport {\n resolvePackagePath,\n type DatabaseService,\n} from '@backstage/backend-plugin-api';\nimport type {\n TelemetryAction,\n TelemetryCounts,\n} from '@nospt/plugin-dev-ai-hub-common';\n\n/** Settings key holding the salt used to hash telemetry actor identity. */\nconst SALT_KEY = 'actor_salt';\n\nconst ZERO_COUNTS: TelemetryCounts = {\n install: 0,\n copy: 0,\n download: 0,\n view: 0,\n};\n\nexport interface TelemetryEventRecord {\n entityRef: string;\n action: TelemetryAction;\n actorHash: string | null;\n tool?: string;\n}\n\n/**\n * Telemetry event log (ADR-0007): store-all writes, raw lifetime totals on\n * read. Every action counts each occurrence, `view` included.\n */\nexport class TelemetryStore {\n private constructor(private readonly db: Knex) {}\n\n static async create(options: {\n database: DatabaseService;\n }): Promise<TelemetryStore> {\n const db = await options.database.getClient();\n await db.migrate.latest({\n directory: resolvePackagePath(\n '@nospt/plugin-dev-ai-hub-backend',\n 'migrations',\n ),\n loadExtensions: ['.js'],\n });\n return new TelemetryStore(db);\n }\n\n /**\n * The salt used to hash actor identity (ADR-0007). A configured value wins\n * and is never persisted, so a deployment can keep the salt outside the\n * database it protects. Otherwise the salt is generated once and stored,\n * which is what makes it survive restarts. No current query groups by\n * `actor_hash` — counts are raw — but stability is what keeps hashes\n * comparable across restarts, and so keeps distinct-viewer counting possible\n * later without a backfill. It is deliberately not required from config: a\n * missing value used to fail the plugin's init and take the whole backend\n * down with it.\n *\n * Concurrent replicas starting together race on the insert; the loser reads\n * back the winner's value, so every replica ends up on the same salt.\n */\n async resolveSalt(configured?: string): Promise<string> {\n if (configured) {\n return configured;\n }\n const existing = await this.readSalt();\n if (existing) {\n return existing;\n }\n await this.db('telemetry_settings')\n .insert({ key: SALT_KEY, value: randomBytes(32).toString('hex') })\n .onConflict('key')\n .ignore();\n const stored = await this.readSalt();\n if (!stored) {\n throw new Error('Failed to persist the telemetry actor salt');\n }\n return stored;\n }\n\n private async readSalt(): Promise<string | undefined> {\n const row = await this.db('telemetry_settings')\n .where('key', SALT_KEY)\n .first<{ value: string } | undefined>('value');\n return row?.value;\n }\n\n async record(event: TelemetryEventRecord): Promise<void> {\n const now = new Date();\n await this.db('telemetry_events').insert({\n entity_ref: event.entityRef,\n action: event.action,\n actor_hash: event.actorHash,\n tool: event.tool ?? null,\n day: now.toISOString().slice(0, 10),\n occurred_at: now.toISOString(),\n });\n }\n\n /**\n * Popularity for one resource (ADR-0007): every action counted raw, as a\n * lifetime total. `view` included — it is recorded only when a user opens a\n * resource, so each row is already one deliberate look and there is nothing\n * to dedup away. The read-time per-(hash, day) dedup the ADR originally\n * called for was dropped once the render-time trigger it existed to cancel\n * was removed; see the 2026-08-03 amendment.\n */\n async getCounts(entityRef: string): Promise<TelemetryCounts> {\n const rows = await this.db('telemetry_events')\n .where('entity_ref', entityRef)\n .groupBy('action')\n .select('action')\n .count<Array<{ action: TelemetryAction; count: string | number }>>(\n 'id as count',\n );\n\n const counts = { ...ZERO_COUNTS };\n for (const row of rows) {\n counts[row.action] = Number(row.count);\n }\n return counts;\n }\n}\n"],"names":["resolvePackagePath","randomBytes"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"TelemetryStore.cjs.js","sources":["../../src/database/TelemetryStore.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport type { Knex } from 'knex';\nimport {\n resolvePackagePath,\n type DatabaseService,\n} from '@backstage/backend-plugin-api';\nimport type {\n TelemetryAction,\n TelemetryCounts,\n TelemetrySummary,\n} from '@nospt/plugin-dev-ai-hub-common';\n\n/** Settings key holding the salt used to hash telemetry actor identity. */\nconst SALT_KEY = 'actor_salt';\n\nconst ZERO_COUNTS: TelemetryCounts = {\n install: 0,\n copy: 0,\n download: 0,\n view: 0,\n};\n\nexport interface TelemetryEventRecord {\n entityRef: string;\n action: TelemetryAction;\n actorHash: string | null;\n tool?: string;\n}\n\n/**\n * Telemetry event log (ADR-0007): store-all writes, raw lifetime totals on\n * read. Every action counts each occurrence, `view` included.\n */\nexport class TelemetryStore {\n private constructor(private readonly db: Knex) {}\n\n static async create(options: {\n database: DatabaseService;\n }): Promise<TelemetryStore> {\n const db = await options.database.getClient();\n await db.migrate.latest({\n directory: resolvePackagePath(\n '@nospt/plugin-dev-ai-hub-backend',\n 'migrations',\n ),\n loadExtensions: ['.js'],\n });\n return new TelemetryStore(db);\n }\n\n /**\n * The salt used to hash actor identity (ADR-0007). A configured value wins\n * and is never persisted, so a deployment can keep the salt outside the\n * database it protects. Otherwise the salt is generated once and stored,\n * which is what makes it survive restarts. No current query groups by\n * `actor_hash` — counts are raw — but stability is what keeps hashes\n * comparable across restarts, and so keeps distinct-viewer counting possible\n * later without a backfill. It is deliberately not required from config: a\n * missing value used to fail the plugin's init and take the whole backend\n * down with it.\n *\n * Concurrent replicas starting together race on the insert; the loser reads\n * back the winner's value, so every replica ends up on the same salt.\n */\n async resolveSalt(configured?: string): Promise<string> {\n if (configured) {\n return configured;\n }\n const existing = await this.readSalt();\n if (existing) {\n return existing;\n }\n await this.db('telemetry_settings')\n .insert({ key: SALT_KEY, value: randomBytes(32).toString('hex') })\n .onConflict('key')\n .ignore();\n const stored = await this.readSalt();\n if (!stored) {\n throw new Error('Failed to persist the telemetry actor salt');\n }\n return stored;\n }\n\n private async readSalt(): Promise<string | undefined> {\n const row = await this.db('telemetry_settings')\n .where('key', SALT_KEY)\n .first<{ value: string } | undefined>('value');\n return row?.value;\n }\n\n async record(event: TelemetryEventRecord): Promise<void> {\n const now = new Date();\n await this.db('telemetry_events').insert({\n entity_ref: event.entityRef,\n action: event.action,\n actor_hash: event.actorHash,\n tool: event.tool ?? null,\n day: now.toISOString().slice(0, 10),\n occurred_at: now.toISOString(),\n });\n }\n\n /**\n * Popularity for one resource (ADR-0007): every action counted raw, as a\n * lifetime total. `view` included — it is recorded only when a user opens a\n * resource, so each row is already one deliberate look and there is nothing\n * to dedup away. The read-time per-(hash, day) dedup the ADR originally\n * called for was dropped once the render-time trigger it existed to cancel\n * was removed; see the 2026-08-03 amendment.\n */\n async getCounts(entityRef: string): Promise<TelemetryCounts> {\n const rows = await this.db('telemetry_events')\n .where('entity_ref', entityRef)\n .groupBy('action')\n .select('action')\n .count<Array<{ action: TelemetryAction; count: string | number }>>(\n 'id as count',\n );\n\n const counts = { ...ZERO_COUNTS };\n for (const row of rows) {\n counts[row.action] = Number(row.count);\n }\n return counts;\n }\n\n /**\n * Catalog-wide lifetime totals (#83 backoffice overview), one grouped query\n * rather than one `getCounts` per resource — the backoffice usage rollup\n * needs every resource's counts at once, and N round-trips would not scale\n * with catalog size the way this single `GROUP BY` does.\n *\n * A resource with no recorded events is simply absent from the result; the\n * router has the full resource list already and treats a missing key as\n * `ZERO_COUNTS`, so there is nothing useful this method would add by\n * pre-filling every possible ref with zeroes.\n */\n async getSummary(): Promise<TelemetrySummary> {\n const rows = await this.db('telemetry_events')\n .groupBy('entity_ref', 'action')\n .select('entity_ref', 'action')\n .count<\n Array<{\n entity_ref: string;\n action: TelemetryAction;\n count: string | number;\n }>\n >('id as count');\n\n const summary: TelemetrySummary = {};\n for (const row of rows) {\n const bucket = (summary[row.entity_ref] ??= { ...ZERO_COUNTS });\n bucket[row.action] = Number(row.count);\n }\n return summary;\n }\n}\n"],"names":["resolvePackagePath","randomBytes"],"mappings":"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"}
|
package/dist/plugin.cjs.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
4
|
var pluginCatalogNode = require('@backstage/plugin-catalog-node');
|
|
5
|
+
var pluginDevAiHubCommon = require('@nospt/plugin-dev-ai-hub-common');
|
|
5
6
|
var TelemetryStore = require('./database/TelemetryStore.cjs.js');
|
|
7
|
+
var CurationStore = require('./database/CurationStore.cjs.js');
|
|
8
|
+
var FirstSeenStore = require('./database/FirstSeenStore.cjs.js');
|
|
6
9
|
var router = require('./router.cjs.js');
|
|
7
10
|
|
|
8
11
|
const devAiHubPlugin = backendPluginApi.createBackendPlugin({
|
|
@@ -16,6 +19,9 @@ const devAiHubPlugin = backendPluginApi.createBackendPlugin({
|
|
|
16
19
|
httpRouter: backendPluginApi.coreServices.httpRouter,
|
|
17
20
|
urlReader: backendPluginApi.coreServices.urlReader,
|
|
18
21
|
httpAuth: backendPluginApi.coreServices.httpAuth,
|
|
22
|
+
auth: backendPluginApi.coreServices.auth,
|
|
23
|
+
permissions: backendPluginApi.coreServices.permissions,
|
|
24
|
+
permissionsRegistry: backendPluginApi.coreServices.permissionsRegistry,
|
|
19
25
|
catalog: pluginCatalogNode.catalogServiceRef
|
|
20
26
|
},
|
|
21
27
|
async init({
|
|
@@ -25,22 +31,39 @@ const devAiHubPlugin = backendPluginApi.createBackendPlugin({
|
|
|
25
31
|
httpRouter,
|
|
26
32
|
urlReader,
|
|
27
33
|
httpAuth,
|
|
34
|
+
auth,
|
|
35
|
+
permissions,
|
|
36
|
+
permissionsRegistry,
|
|
28
37
|
catalog
|
|
29
38
|
}) {
|
|
39
|
+
permissionsRegistry.addPermissions(pluginDevAiHubCommon.devAiHubPermissions);
|
|
30
40
|
const telemetryStore = await TelemetryStore.TelemetryStore.create({ database });
|
|
31
41
|
const telemetrySalt = await telemetryStore.resolveSalt(
|
|
32
42
|
config.getOptionalString("devAiHub.telemetry.salt")
|
|
33
43
|
);
|
|
44
|
+
const curationStore = await CurationStore.CurationStore.create({ database });
|
|
45
|
+
const firstSeenStore = await FirstSeenStore.FirstSeenStore.create({ database });
|
|
46
|
+
const customFrameworks = pluginDevAiHubCommon.parseCustomFrameworks(
|
|
47
|
+
config.getOptionalConfig("devAiHub.frameworks.custom")?.get()
|
|
48
|
+
);
|
|
49
|
+
for (const warning of customFrameworks.warnings) {
|
|
50
|
+
logger.warn(warning);
|
|
51
|
+
}
|
|
34
52
|
const router$1 = router.createRouter({
|
|
35
53
|
logger,
|
|
36
54
|
telemetryStore,
|
|
37
55
|
telemetrySalt,
|
|
56
|
+
curationStore,
|
|
57
|
+
firstSeenStore,
|
|
58
|
+
permissions,
|
|
38
59
|
catalog,
|
|
39
60
|
httpAuth,
|
|
61
|
+
auth,
|
|
40
62
|
reader: urlReader,
|
|
41
63
|
allowedFrameworks: config.getOptionalStringArray(
|
|
42
64
|
"devAiHub.frameworks.allowed"
|
|
43
|
-
)
|
|
65
|
+
),
|
|
66
|
+
customFrameworks: customFrameworks.frameworks.map((f) => f.name)
|
|
44
67
|
});
|
|
45
68
|
httpRouter.use(router$1);
|
|
46
69
|
}
|
package/dist/plugin.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["import {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { catalogServiceRef } from '@backstage/plugin-catalog-node';\nimport { TelemetryStore } from './database/TelemetryStore';\nimport { createRouter } from './router';\n\nexport const devAiHubPlugin = createBackendPlugin({\n pluginId: 'dev-ai-hub',\n register(env) {\n env.registerInit({\n deps: {\n config: coreServices.rootConfig,\n logger: coreServices.logger,\n database: coreServices.database,\n httpRouter: coreServices.httpRouter,\n urlReader: coreServices.urlReader,\n httpAuth: coreServices.httpAuth,\n catalog: catalogServiceRef,\n },\n async init({\n config,\n logger,\n database,\n httpRouter,\n urlReader,\n httpAuth,\n catalog,\n }) {\n const telemetryStore = await TelemetryStore.create({ database });\n // Optional on purpose (#56): unset means \"generate and persist one\",\n // not \"fail\". Requiring it here took the whole backend down on any\n // deployment that had not configured this plugin.\n const telemetrySalt = await telemetryStore.resolveSalt(\n config.getOptionalString('devAiHub.telemetry.salt'),\n );\n\n const router = createRouter({\n logger,\n telemetryStore,\n telemetrySalt,\n catalog,\n httpAuth,\n reader: urlReader,\n allowedFrameworks: config.getOptionalStringArray(\n 'devAiHub.frameworks.allowed',\n ),\n });\n\n httpRouter.use(router);\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","catalogServiceRef","TelemetryStore","router","createRouter"],"mappings":"
|
|
1
|
+
{"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["import {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { catalogServiceRef } from '@backstage/plugin-catalog-node';\nimport {\n devAiHubPermissions,\n parseCustomFrameworks,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { TelemetryStore } from './database/TelemetryStore';\nimport { CurationStore } from './database/CurationStore';\nimport { FirstSeenStore } from './database/FirstSeenStore';\nimport { createRouter } from './router';\n\nexport const devAiHubPlugin = createBackendPlugin({\n pluginId: 'dev-ai-hub',\n register(env) {\n env.registerInit({\n deps: {\n config: coreServices.rootConfig,\n logger: coreServices.logger,\n database: coreServices.database,\n httpRouter: coreServices.httpRouter,\n urlReader: coreServices.urlReader,\n httpAuth: coreServices.httpAuth,\n auth: coreServices.auth,\n permissions: coreServices.permissions,\n permissionsRegistry: coreServices.permissionsRegistry,\n catalog: catalogServiceRef,\n },\n async init({\n config,\n logger,\n database,\n httpRouter,\n urlReader,\n httpAuth,\n auth,\n permissions,\n permissionsRegistry,\n catalog,\n }) {\n // Registration is what makes the plugin's permissions selectable in the\n // Backstage RBAC plugin's UI (ADR-0016). Without it the permission\n // still exists, but no operator can grant it — so the backoffice would\n // be unreachable for everyone in a permission-enabled deployment.\n permissionsRegistry.addPermissions(devAiHubPermissions);\n\n const telemetryStore = await TelemetryStore.create({ database });\n // Optional on purpose (#56): unset means \"generate and persist one\",\n // not \"fail\". Requiring it here took the whole backend down on any\n // deployment that had not configured this plugin.\n const telemetrySalt = await telemetryStore.resolveSalt(\n config.getOptionalString('devAiHub.telemetry.salt'),\n );\n const curationStore = await CurationStore.create({ database });\n const firstSeenStore = await FirstSeenStore.create({ database });\n\n // Deployment-registered frameworks (ADR-0018). Dropped entries are\n // logged rather than thrown: one bad entry must not take the backend\n // down, and a silent drop would leave an operator staring at a token\n // that never appears in the UI.\n const customFrameworks = parseCustomFrameworks(\n config.getOptionalConfig('devAiHub.frameworks.custom')?.get(),\n );\n for (const warning of customFrameworks.warnings) {\n logger.warn(warning);\n }\n\n const router = createRouter({\n logger,\n telemetryStore,\n telemetrySalt,\n curationStore,\n firstSeenStore,\n permissions,\n catalog,\n httpAuth,\n auth,\n reader: urlReader,\n allowedFrameworks: config.getOptionalStringArray(\n 'devAiHub.frameworks.allowed',\n ),\n customFrameworks: customFrameworks.frameworks.map(f => f.name),\n });\n\n httpRouter.use(router);\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","catalogServiceRef","devAiHubPermissions","TelemetryStore","CurationStore","FirstSeenStore","parseCustomFrameworks","router","createRouter"],"mappings":";;;;;;;;;;AAcO,MAAM,iBAAiBA,oCAAA,CAAoB;AAAA,EAChD,QAAA,EAAU,YAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,QAAQC,6BAAA,CAAa,UAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa,MAAA;AAAA,QACrB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,YAAYA,6BAAA,CAAa,UAAA;AAAA,QACzB,WAAWA,6BAAA,CAAa,SAAA;AAAA,QACxB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,MAAMA,6BAAA,CAAa,IAAA;AAAA,QACnB,aAAaA,6BAAA,CAAa,WAAA;AAAA,QAC1B,qBAAqBA,6BAAA,CAAa,mBAAA;AAAA,QAClC,OAAA,EAASC;AAAA,OACX;AAAA,MACA,MAAM,IAAA,CAAK;AAAA,QACT,MAAA;AAAA,QACA,MAAA;AAAA,QACA,QAAA;AAAA,QACA,UAAA;AAAA,QACA,SAAA;AAAA,QACA,QAAA;AAAA,QACA,IAAA;AAAA,QACA,WAAA;AAAA,QACA,mBAAA;AAAA,QACA;AAAA,OACF,EAAG;AAKD,QAAA,mBAAA,CAAoB,eAAeC,wCAAmB,CAAA;AAEtD,QAAA,MAAM,iBAAiB,MAAMC,6BAAA,CAAe,MAAA,CAAO,EAAE,UAAU,CAAA;AAI/D,QAAA,MAAM,aAAA,GAAgB,MAAM,cAAA,CAAe,WAAA;AAAA,UACzC,MAAA,CAAO,kBAAkB,yBAAyB;AAAA,SACpD;AACA,QAAA,MAAM,gBAAgB,MAAMC,2BAAA,CAAc,MAAA,CAAO,EAAE,UAAU,CAAA;AAC7D,QAAA,MAAM,iBAAiB,MAAMC,6BAAA,CAAe,MAAA,CAAO,EAAE,UAAU,CAAA;AAM/D,QAAA,MAAM,gBAAA,GAAmBC,0CAAA;AAAA,UACvB,MAAA,CAAO,iBAAA,CAAkB,4BAA4B,CAAA,EAAG,GAAA;AAAI,SAC9D;AACA,QAAA,KAAA,MAAW,OAAA,IAAW,iBAAiB,QAAA,EAAU;AAC/C,UAAA,MAAA,CAAO,KAAK,OAAO,CAAA;AAAA,QACrB;AAEA,QAAA,MAAMC,WAASC,mBAAA,CAAa;AAAA,UAC1B,MAAA;AAAA,UACA,cAAA;AAAA,UACA,aAAA;AAAA,UACA,aAAA;AAAA,UACA,cAAA;AAAA,UACA,WAAA;AAAA,UACA,OAAA;AAAA,UACA,QAAA;AAAA,UACA,IAAA;AAAA,UACA,MAAA,EAAQ,SAAA;AAAA,UACR,mBAAmB,MAAA,CAAO,sBAAA;AAAA,YACxB;AAAA,WACF;AAAA,UACA,kBAAkB,gBAAA,CAAiB,UAAA,CAAW,GAAA,CAAI,CAAA,CAAA,KAAK,EAAE,IAAI;AAAA,SAC9D,CAAA;AAED,QAAA,UAAA,CAAW,IAAID,QAAM,CAAA;AAAA,MACvB;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;;;"}
|
package/dist/router.cjs.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var express = require('express');
|
|
4
|
+
var rateLimit = require('express-rate-limit');
|
|
4
5
|
var archiver = require('archiver');
|
|
6
|
+
var pluginPermissionCommon = require('@backstage/plugin-permission-common');
|
|
7
|
+
var catalogModel = require('@backstage/catalog-model');
|
|
5
8
|
var pluginDevAiHubCommon = require('@nospt/plugin-dev-ai-hub-common');
|
|
6
9
|
var telemetryHash = require('./service/telemetryHash.cjs.js');
|
|
7
10
|
var bodyResolver = require('./service/bodyResolver.cjs.js');
|
|
@@ -9,28 +12,87 @@ var bodyResolver = require('./service/bodyResolver.cjs.js');
|
|
|
9
12
|
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
10
13
|
|
|
11
14
|
var express__default = /*#__PURE__*/_interopDefaultCompat(express);
|
|
15
|
+
var rateLimit__default = /*#__PURE__*/_interopDefaultCompat(rateLimit);
|
|
12
16
|
var archiver__default = /*#__PURE__*/_interopDefaultCompat(archiver);
|
|
13
17
|
|
|
14
18
|
function createRouter(options) {
|
|
15
19
|
const {
|
|
16
20
|
telemetryStore,
|
|
17
21
|
telemetrySalt,
|
|
22
|
+
curationStore,
|
|
23
|
+
firstSeenStore,
|
|
24
|
+
permissions,
|
|
18
25
|
catalog,
|
|
19
26
|
httpAuth,
|
|
27
|
+
auth,
|
|
20
28
|
reader,
|
|
21
|
-
allowedFrameworks
|
|
29
|
+
allowedFrameworks,
|
|
30
|
+
customFrameworks
|
|
22
31
|
} = options;
|
|
23
32
|
const router = express__default.default.Router();
|
|
33
|
+
async function overlayFirstSeen(items) {
|
|
34
|
+
try {
|
|
35
|
+
await firstSeenStore.ensureBaseline(async () => {
|
|
36
|
+
const credentials = await auth.getOwnServiceCredentials();
|
|
37
|
+
const { items: entities } = await catalog.getEntities(
|
|
38
|
+
{
|
|
39
|
+
filter: { kind: "AiResource" },
|
|
40
|
+
fields: ["kind", "metadata.name", "metadata.namespace"]
|
|
41
|
+
},
|
|
42
|
+
{ credentials }
|
|
43
|
+
);
|
|
44
|
+
return entities.map((entity) => catalogModel.stringifyEntityRef(entity));
|
|
45
|
+
});
|
|
46
|
+
const dates = await firstSeenStore.recordAndGet(
|
|
47
|
+
items.map((item) => item.entityRef)
|
|
48
|
+
);
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
const firstSeenAt = dates.get(item.entityRef);
|
|
51
|
+
if (firstSeenAt) item.firstSeenAt = firstSeenAt;
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
options.logger.warn(`Failed to record first-seen dates: ${error}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
24
57
|
router.use(express__default.default.json());
|
|
25
58
|
router.get("/resources", async (req, res) => {
|
|
26
59
|
try {
|
|
27
60
|
const credentials = await httpAuth.credentials(req);
|
|
61
|
+
const includeExcluded = req.query.includeExcluded === "true";
|
|
62
|
+
if (includeExcluded) {
|
|
63
|
+
const decision = (await permissions.authorize(
|
|
64
|
+
[{ permission: pluginDevAiHubCommon.devAiHubBackofficeReadPermission }],
|
|
65
|
+
{ credentials }
|
|
66
|
+
))[0];
|
|
67
|
+
if (decision.result !== pluginPermissionCommon.AuthorizeResult.ALLOW) {
|
|
68
|
+
res.status(403).json({ error: "Forbidden" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
28
72
|
const { items: entities } = await catalog.getEntities(
|
|
29
73
|
{ filter: { kind: "AiResource" } },
|
|
30
74
|
{ credentials }
|
|
31
75
|
);
|
|
32
|
-
const items = pluginDevAiHubCommon.toResourceSummaries(entities, {
|
|
33
|
-
|
|
76
|
+
const items = pluginDevAiHubCommon.toResourceSummaries(entities, {
|
|
77
|
+
allowedFrameworks,
|
|
78
|
+
customFrameworks
|
|
79
|
+
});
|
|
80
|
+
const curation = await curationStore.getAll();
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
const record = curation.get(item.entityRef);
|
|
83
|
+
if (record?.recommended) {
|
|
84
|
+
item.recommended = true;
|
|
85
|
+
item.recommendedByCuration = true;
|
|
86
|
+
}
|
|
87
|
+
if (record?.excluded && includeExcluded) {
|
|
88
|
+
item.excluded = true;
|
|
89
|
+
item.excludedBy = record.excludedBy;
|
|
90
|
+
item.excludedAt = record.excludedAt;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const visible = includeExcluded ? items : items.filter((item) => !curation.get(item.entityRef)?.excluded);
|
|
94
|
+
await overlayFirstSeen(visible);
|
|
95
|
+
res.json({ items: visible });
|
|
34
96
|
} catch (error) {
|
|
35
97
|
if (error.name === "AuthenticationError") {
|
|
36
98
|
res.status(401).json({ error: "Authentication required" });
|
|
@@ -40,6 +102,108 @@ function createRouter(options) {
|
|
|
40
102
|
res.status(500).json({ error: "Failed to list resources" });
|
|
41
103
|
}
|
|
42
104
|
});
|
|
105
|
+
const curationWriteRateLimit = rateLimit__default.default({
|
|
106
|
+
windowMs: 15 * 60 * 1e3,
|
|
107
|
+
limit: 100,
|
|
108
|
+
standardHeaders: true,
|
|
109
|
+
legacyHeaders: false
|
|
110
|
+
});
|
|
111
|
+
router.put(
|
|
112
|
+
"/curation/:ref/recommendation",
|
|
113
|
+
curationWriteRateLimit,
|
|
114
|
+
async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = pluginDevAiHubCommon.RecommendationInputSchema.safeParse(req.body);
|
|
117
|
+
if (!parsed.success) {
|
|
118
|
+
res.status(400).json({ error: parsed.error.message });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const credentials = await httpAuth.credentials(req);
|
|
122
|
+
const decision = (await permissions.authorize(
|
|
123
|
+
[{ permission: pluginDevAiHubCommon.devAiHubBackofficeCuratePermission }],
|
|
124
|
+
{ credentials }
|
|
125
|
+
))[0];
|
|
126
|
+
if (decision.result !== pluginPermissionCommon.AuthorizeResult.ALLOW) {
|
|
127
|
+
res.status(403).json({ error: "Forbidden" });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const entity = await catalog.getEntityByRef(req.params.ref, {
|
|
131
|
+
credentials
|
|
132
|
+
});
|
|
133
|
+
if (!entity) {
|
|
134
|
+
res.status(404).json({ error: "Resource not found" });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (parsed.data.recommended && entity.metadata.tags?.includes(pluginDevAiHubCommon.RECOMMENDED_TAG)) {
|
|
138
|
+
res.status(409).json({
|
|
139
|
+
error: `Already recommended by its "${pluginDevAiHubCommon.RECOMMENDED_TAG}" catalog tag; remove the tag from the entity to un-recommend it`
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const principal = credentials.principal;
|
|
144
|
+
const curatedBy = principal.type === "user" ? principal.userEntityRef : void 0;
|
|
145
|
+
await curationStore.setRecommendation(req.params.ref, {
|
|
146
|
+
recommended: parsed.data.recommended,
|
|
147
|
+
curatedBy
|
|
148
|
+
});
|
|
149
|
+
res.sendStatus(204);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error.name === "AuthenticationError") {
|
|
152
|
+
res.status(401).json({ error: "Authentication required" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
options.logger.error(
|
|
156
|
+
`PUT /curation/${req.params.ref}/recommendation failed: ${error}`
|
|
157
|
+
);
|
|
158
|
+
res.status(500).json({ error: "Failed to update recommendation" });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
router.put(
|
|
163
|
+
"/curation/:ref/exclusion",
|
|
164
|
+
curationWriteRateLimit,
|
|
165
|
+
async (req, res) => {
|
|
166
|
+
try {
|
|
167
|
+
const parsed = pluginDevAiHubCommon.ExclusionInputSchema.safeParse(req.body);
|
|
168
|
+
if (!parsed.success) {
|
|
169
|
+
res.status(400).json({ error: parsed.error.message });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const credentials = await httpAuth.credentials(req);
|
|
173
|
+
const decision = (await permissions.authorize(
|
|
174
|
+
[{ permission: pluginDevAiHubCommon.devAiHubBackofficeCuratePermission }],
|
|
175
|
+
{ credentials }
|
|
176
|
+
))[0];
|
|
177
|
+
if (decision.result !== pluginPermissionCommon.AuthorizeResult.ALLOW) {
|
|
178
|
+
res.status(403).json({ error: "Forbidden" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const entity = await catalog.getEntityByRef(req.params.ref, {
|
|
182
|
+
credentials
|
|
183
|
+
});
|
|
184
|
+
if (!entity) {
|
|
185
|
+
res.status(404).json({ error: "Resource not found" });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const principal = credentials.principal;
|
|
189
|
+
const excludedBy = principal.type === "user" ? principal.userEntityRef : void 0;
|
|
190
|
+
await curationStore.setExclusion(req.params.ref, {
|
|
191
|
+
excluded: parsed.data.excluded,
|
|
192
|
+
excludedBy
|
|
193
|
+
});
|
|
194
|
+
res.sendStatus(204);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error.name === "AuthenticationError") {
|
|
197
|
+
res.status(401).json({ error: "Authentication required" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
options.logger.error(
|
|
201
|
+
`PUT /curation/${req.params.ref}/exclusion failed: ${error}`
|
|
202
|
+
);
|
|
203
|
+
res.status(500).json({ error: "Failed to update exclusion" });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
);
|
|
43
207
|
const safeFilename = (name) => name.replace(/[^\w.-]+/g, "_");
|
|
44
208
|
const sendBodyFile = (res, name, content, download) => {
|
|
45
209
|
res.setHeader("Content-Type", bodyResolver.contentTypeFor(name));
|
|
@@ -177,6 +341,38 @@ function createRouter(options) {
|
|
|
177
341
|
res.status(500).json({ error: "Failed to record telemetry event" });
|
|
178
342
|
}
|
|
179
343
|
});
|
|
344
|
+
router.get("/telemetry", async (req, res) => {
|
|
345
|
+
try {
|
|
346
|
+
const credentials = await httpAuth.credentials(req);
|
|
347
|
+
const [{ items: entities }, counts, curation] = await Promise.all([
|
|
348
|
+
catalog.getEntities(
|
|
349
|
+
{ filter: { kind: "AiResource" } },
|
|
350
|
+
{ credentials }
|
|
351
|
+
),
|
|
352
|
+
telemetryStore.getSummary(),
|
|
353
|
+
curationStore.getAll()
|
|
354
|
+
]);
|
|
355
|
+
const visibleRefs = new Set(
|
|
356
|
+
pluginDevAiHubCommon.toResourceSummaries(entities, {
|
|
357
|
+
allowedFrameworks,
|
|
358
|
+
customFrameworks
|
|
359
|
+
}).filter((item) => !curation.get(item.entityRef)?.excluded).map((item) => item.entityRef)
|
|
360
|
+
);
|
|
361
|
+
const visibleCounts = Object.fromEntries(
|
|
362
|
+
Object.entries(counts).filter(
|
|
363
|
+
([entityRef]) => visibleRefs.has(entityRef)
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
res.json({ counts: visibleCounts });
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error.name === "AuthenticationError") {
|
|
369
|
+
res.status(401).json({ error: "Authentication required" });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
options.logger.error(`GET /telemetry failed: ${error}`);
|
|
373
|
+
res.status(500).json({ error: "Failed to fetch telemetry summary" });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
180
376
|
router.get("/telemetry/:ref", async (req, res) => {
|
|
181
377
|
try {
|
|
182
378
|
await httpAuth.credentials(req);
|
package/dist/router.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.cjs.js","sources":["../src/router.ts"],"sourcesContent":["import express from 'express';\nimport archiver from 'archiver';\nimport type {\n HttpAuthService,\n LoggerService,\n UrlReaderService,\n} from '@backstage/backend-plugin-api';\nimport type { CatalogService } from '@backstage/plugin-catalog-node';\nimport type { TelemetryStore } from './database/TelemetryStore';\nimport {\n TelemetryEventInputSchema,\n toResourceSummaries,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { hashActor } from './service/telemetryHash';\nimport {\n SOURCE_LOCATION_ANNOTATION,\n contentTypeFor,\n parseUrlTarget,\n resolveBody,\n} from './service/bodyResolver';\n\ninterface RouterOptions {\n logger: LoggerService;\n telemetryStore: TelemetryStore;\n telemetrySalt: string;\n catalog: CatalogService;\n httpAuth: HttpAuthService;\n reader: UrlReaderService;\n /**\n * `devAiHub.frameworks.allowed`. Omitted (`undefined`) permits every\n * framework; an explicit empty list permits none. Passed straight through to\n * `toResourceSummaries` — do not normalise `[]` to `undefined`.\n */\n allowedFrameworks?: string[];\n}\n\nexport function createRouter(options: RouterOptions): express.Router {\n const {\n telemetryStore,\n telemetrySalt,\n catalog,\n httpAuth,\n reader,\n allowedFrameworks,\n } = options;\n const router = express.Router();\n\n router.use(express.json());\n\n // ── Resources (v2 — catalog-backed, ADR-0001) ─────────────────────────────\n\n router.get('/resources', async (req, res) => {\n try {\n const credentials = await httpAuth.credentials(req);\n const { items: entities } = await catalog.getEntities(\n { filter: { kind: 'AiResource' } },\n { credentials },\n );\n const items = toResourceSummaries(entities, { allowedFrameworks });\n res.json({ items });\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`Failed to list resources: ${error}`);\n res.status(500).json({ error: 'Failed to list resources' });\n }\n });\n\n // ── Body resolver (v2 — ADR-0002/0006/0009) ───────────────────────────────\n\n const safeFilename = (name: string) => name.replace(/[^\\w.-]+/g, '_');\n\n const sendBodyFile = (\n res: express.Response,\n name: string,\n content: Buffer,\n download: boolean,\n ) => {\n res.setHeader('Content-Type', contentTypeFor(name));\n if (download) {\n res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${safeFilename(name.split('/').pop() ?? name)}\"`,\n );\n }\n res.send(content);\n };\n\n const handleBodyRequest = async (\n req: express.Request,\n res: express.Response,\n filename?: string,\n ) => {\n try {\n // Re-fetch as the caller: catalog visibility is the only gate (ADR-0006).\n const credentials = await httpAuth.credentials(req);\n const entity = await catalog.getEntityByRef(req.params.ref, {\n credentials,\n });\n if (!entity) {\n res.status(404).json({ error: 'Resource not found' });\n return;\n }\n\n const sourceLocation =\n entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];\n const target = sourceLocation && parseUrlTarget(sourceLocation);\n if (!target) {\n res.status(404).json({ error: 'Resource has no content location' });\n return;\n }\n\n let body;\n try {\n body = await resolveBody(reader, target);\n } catch (error) {\n if ((error as Error).name === 'NotFoundError') {\n res.status(404).json({ error: 'Resource content not found' });\n return;\n }\n options.logger.error(\n `Body fetch failed for ${req.params.ref}: ${error}`,\n );\n res\n .status(502)\n .json({ error: 'Failed to fetch resource content from source' });\n return;\n }\n\n const download = req.query.download === 'true';\n\n if (filename !== undefined) {\n const file =\n body.kind === 'tree'\n ? body.files.find(f => f.path === filename)\n : undefined;\n if (!file) {\n res.status(404).json({ error: 'File not found in resource body' });\n return;\n }\n sendBodyFile(res, filename, await file.content(), download);\n return;\n }\n\n if (body.kind === 'file') {\n sendBodyFile(res, body.name, body.content, download);\n return;\n }\n\n if (!download) {\n if (!body.entryPath) {\n res.status(404).json({ error: 'Resource body has no entry file' });\n return;\n }\n const entry = body.files.find(f => f.path === body.entryPath)!;\n sendBodyFile(res, body.entryPath, await entry.content(), false);\n return;\n }\n\n // Multi-file artifact: one zip (ADR-0009 — interim, not the golden road).\n if (body.files.length === 1) {\n sendBodyFile(\n res,\n body.files[0].path,\n await body.files[0].content(),\n true,\n );\n return;\n }\n res.setHeader('Content-Type', 'application/zip');\n res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${safeFilename(entity.metadata.name)}.zip\"`,\n );\n const archive = archiver('zip');\n archive.on('error', error => {\n options.logger.error(\n `Zip assembly failed for ${req.params.ref}: ${error}`,\n );\n res.destroy(error);\n });\n archive.pipe(res);\n for (const file of body.files) {\n archive.append(await file.content(), { name: file.path });\n }\n await archive.finalize();\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(\n `Body resolution failed for ${req.params.ref}: ${error}`,\n );\n res.status(500).json({ error: 'Failed to resolve resource body' });\n }\n };\n\n router.get('/entity/:ref/raw', (req, res) => handleBodyRequest(req, res));\n router.get('/entity/:ref/raw/:filename', (req, res) =>\n handleBodyRequest(req, res, req.params.filename),\n );\n\n // ── Telemetry (v2 — ADR-0007) ──────────────────────────────────────────────\n\n router.post('/telemetry', async (req, res) => {\n try {\n const parsed = TelemetryEventInputSchema.safeParse(req.body);\n if (!parsed.success) {\n res.status(400).json({ error: parsed.error.message });\n return;\n }\n\n const credentials = await httpAuth.credentials(req);\n const entity = await catalog.getEntityByRef(parsed.data.ref, {\n credentials,\n });\n if (!entity) {\n res.status(404).json({ error: 'Resource not found' });\n return;\n }\n\n await telemetryStore.record({\n entityRef: parsed.data.ref,\n action: parsed.data.action,\n actorHash: hashActor(credentials, telemetrySalt),\n tool: parsed.data.tool,\n });\n res.sendStatus(204);\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`POST /telemetry failed: ${error}`);\n res.status(500).json({ error: 'Failed to record telemetry event' });\n }\n });\n\n router.get('/telemetry/:ref', async (req, res) => {\n try {\n await httpAuth.credentials(req);\n const counts = await telemetryStore.getCounts(req.params.ref);\n res.json(counts);\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`GET /telemetry/${req.params.ref} failed: ${error}`);\n res.status(500).json({ error: 'Failed to fetch telemetry counts' });\n }\n });\n\n return router;\n}\n"],"names":["express","toResourceSummaries","contentTypeFor","SOURCE_LOCATION_ANNOTATION","parseUrlTarget","resolveBody","archiver","TelemetryEventInputSchema","hashActor"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"router.cjs.js","sources":["../src/router.ts"],"sourcesContent":["import express from 'express';\nimport rateLimit from 'express-rate-limit';\nimport archiver from 'archiver';\nimport type {\n AuthService,\n BackstageUserPrincipal,\n HttpAuthService,\n LoggerService,\n PermissionsService,\n UrlReaderService,\n} from '@backstage/backend-plugin-api';\nimport { AuthorizeResult } from '@backstage/plugin-permission-common';\nimport { stringifyEntityRef } from '@backstage/catalog-model';\nimport type { CatalogService } from '@backstage/plugin-catalog-node';\nimport type { TelemetryStore } from './database/TelemetryStore';\nimport type { CurationStore } from './database/CurationStore';\nimport type { FirstSeenStore } from './database/FirstSeenStore';\nimport {\n ExclusionInputSchema,\n RECOMMENDED_TAG,\n RecommendationInputSchema,\n TelemetryEventInputSchema,\n devAiHubBackofficeCuratePermission,\n devAiHubBackofficeReadPermission,\n toResourceSummaries,\n type ResourceSummary,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { hashActor } from './service/telemetryHash';\nimport {\n SOURCE_LOCATION_ANNOTATION,\n contentTypeFor,\n parseUrlTarget,\n resolveBody,\n} from './service/bodyResolver';\n\ninterface RouterOptions {\n logger: LoggerService;\n telemetryStore: TelemetryStore;\n telemetrySalt: string;\n curationStore: CurationStore;\n firstSeenStore: FirstSeenStore;\n permissions: PermissionsService;\n catalog: CatalogService;\n httpAuth: HttpAuthService;\n /** The plugin's own service credentials, for the first-seen baseline. */\n auth: AuthService;\n reader: UrlReaderService;\n /**\n * `devAiHub.frameworks.allowed`. Omitted (`undefined`) permits every\n * framework; an explicit empty list permits none. Passed straight through to\n * `toResourceSummaries` — do not normalise `[]` to `undefined`.\n */\n allowedFrameworks?: string[];\n /**\n * The normalised keys of `devAiHub.frameworks.custom` (ADR-0018). They join\n * `allowedFrameworks` and, on their own, activate it.\n */\n customFrameworks?: string[];\n}\n\nexport function createRouter(options: RouterOptions): express.Router {\n const {\n telemetryStore,\n telemetrySalt,\n curationStore,\n firstSeenStore,\n permissions,\n catalog,\n httpAuth,\n auth,\n reader,\n allowedFrameworks,\n customFrameworks,\n } = options;\n const router = express.Router();\n\n /**\n * Overlays `firstSeenAt` (ADR-0021). Best-effort: the date only feeds the\n * \"Newest\" sort, so a failure here is logged and the list is served without\n * dates rather than failing the whole request.\n */\n async function overlayFirstSeen(items: ResourceSummary[]): Promise<void> {\n try {\n // The baseline reads the whole catalog with the plugin's own\n // credentials, not the caller's: a first caller who can see only part\n // of the catalog must not leave the rest to be stamped \"new\" later.\n // Only refs are read, and none of them reach the caller.\n await firstSeenStore.ensureBaseline(async () => {\n const credentials = await auth.getOwnServiceCredentials();\n const { items: entities } = await catalog.getEntities(\n {\n filter: { kind: 'AiResource' },\n fields: ['kind', 'metadata.name', 'metadata.namespace'],\n },\n { credentials },\n );\n return entities.map(entity => stringifyEntityRef(entity));\n });\n const dates = await firstSeenStore.recordAndGet(\n items.map(item => item.entityRef),\n );\n for (const item of items) {\n const firstSeenAt = dates.get(item.entityRef);\n if (firstSeenAt) item.firstSeenAt = firstSeenAt;\n }\n } catch (error) {\n options.logger.warn(`Failed to record first-seen dates: ${error}`);\n }\n }\n\n router.use(express.json());\n\n // ── Resources (v2 — catalog-backed, ADR-0001) ─────────────────────────────\n\n router.get('/resources', async (req, res) => {\n try {\n const credentials = await httpAuth.credentials(req);\n\n // Excluded resources are absent from this response by default\n // (ADR-0019), which is what makes them disappear from the grid, the\n // stat tiles, the filters and deep links in one move — none of that\n // code has to learn about exclusion. The backoffice asks for them back\n // explicitly, and only it can: the opt-in costs the backoffice read\n // permission, so the browse page cannot request them by guessing a\n // query string.\n const includeExcluded = req.query.includeExcluded === 'true';\n if (includeExcluded) {\n const decision = (\n await permissions.authorize(\n [{ permission: devAiHubBackofficeReadPermission }],\n { credentials },\n )\n )[0];\n if (decision.result !== AuthorizeResult.ALLOW) {\n res.status(403).json({ error: 'Forbidden' });\n return;\n }\n }\n\n const { items: entities } = await catalog.getEntities(\n { filter: { kind: 'AiResource' } },\n { credentials },\n );\n const items = toResourceSummaries(entities, {\n allowedFrameworks,\n customFrameworks,\n });\n\n // Curated recommendations are plugin-owned presentation, not something\n // the catalog holds (ADR-0017) — overlaid here, after the pure entity\n // mapping, for every caller regardless of curate access. The overlay\n // only ever adds: a tag-declared recommendation (ADR-0020) is already\n // set by `toResourceSummaries` and a curation row cannot clear it.\n const curation = await curationStore.getAll();\n for (const item of items) {\n const record = curation.get(item.entityRef);\n if (record?.recommended) {\n item.recommended = true;\n item.recommendedByCuration = true;\n }\n // Only ever set on the opt-in response, so the browse page can never\n // receive a resource flagged as excluded.\n if (record?.excluded && includeExcluded) {\n item.excluded = true;\n item.excludedBy = record.excludedBy;\n item.excludedAt = record.excludedAt;\n }\n }\n const visible = includeExcluded\n ? items\n : items.filter(item => !curation.get(item.entityRef)?.excluded);\n await overlayFirstSeen(visible);\n\n res.json({ items: visible });\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`Failed to list resources: ${error}`);\n res.status(500).json({ error: 'Failed to list resources' });\n }\n });\n\n // ── Curation (v2 — #68, ADR-0017) ─────────────────────────────────────────\n\n // The only write path this router exposes (CodeQL js/missing-rate-limiting):\n // a permission check alone doesn't bound how many authorization/catalog/DB\n // round trips a single caller can trigger.\n const curationWriteRateLimit = rateLimit({\n windowMs: 15 * 60 * 1000,\n limit: 100,\n standardHeaders: true,\n legacyHeaders: false,\n });\n\n router.put(\n '/curation/:ref/recommendation',\n curationWriteRateLimit,\n async (req, res) => {\n try {\n const parsed = RecommendationInputSchema.safeParse(req.body);\n if (!parsed.success) {\n res.status(400).json({ error: parsed.error.message });\n return;\n }\n\n const credentials = await httpAuth.credentials(req);\n const decision = (\n await permissions.authorize(\n [{ permission: devAiHubBackofficeCuratePermission }],\n { credentials },\n )\n )[0];\n if (decision.result !== AuthorizeResult.ALLOW) {\n res.status(403).json({ error: 'Forbidden' });\n return;\n }\n\n const entity = await catalog.getEntityByRef(req.params.ref, {\n credentials,\n });\n if (!entity) {\n res.status(404).json({ error: 'Resource not found' });\n return;\n }\n\n // A tagged resource is already recommended by its author (ADR-0020).\n // Refuse to add a curation row on top: it would outlive the tag and\n // keep the resource recommended after the author removes it. Clearing\n // is still allowed, so a row recorded before the tag was added can be\n // withdrawn.\n if (\n parsed.data.recommended &&\n entity.metadata.tags?.includes(RECOMMENDED_TAG)\n ) {\n res.status(409).json({\n error: `Already recommended by its \"${RECOMMENDED_TAG}\" catalog tag; remove the tag from the entity to un-recommend it`,\n });\n return;\n }\n\n const principal = credentials.principal as { type: string };\n const curatedBy =\n principal.type === 'user'\n ? (principal as BackstageUserPrincipal).userEntityRef\n : undefined;\n\n await curationStore.setRecommendation(req.params.ref, {\n recommended: parsed.data.recommended,\n curatedBy,\n });\n res.sendStatus(204);\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(\n `PUT /curation/${req.params.ref}/recommendation failed: ${error}`,\n );\n res.status(500).json({ error: 'Failed to update recommendation' });\n }\n },\n );\n\n router.put(\n '/curation/:ref/exclusion',\n curationWriteRateLimit,\n async (req, res) => {\n try {\n const parsed = ExclusionInputSchema.safeParse(req.body);\n if (!parsed.success) {\n res.status(400).json({ error: parsed.error.message });\n return;\n }\n\n const credentials = await httpAuth.credentials(req);\n // Deliberately the same permission as recommending (ADR-0017): the\n // grant is scoped to the curation capability, not to one verb.\n const decision = (\n await permissions.authorize(\n [{ permission: devAiHubBackofficeCuratePermission }],\n { credentials },\n )\n )[0];\n if (decision.result !== AuthorizeResult.ALLOW) {\n res.status(403).json({ error: 'Forbidden' });\n return;\n }\n\n // Checked even when restoring: a ref the caller cannot see must not\n // be probeable through the write path, and a typo'd ref should fail\n // loudly rather than leave an orphan row excluding nothing.\n const entity = await catalog.getEntityByRef(req.params.ref, {\n credentials,\n });\n if (!entity) {\n res.status(404).json({ error: 'Resource not found' });\n return;\n }\n\n const principal = credentials.principal as { type: string };\n const excludedBy =\n principal.type === 'user'\n ? (principal as BackstageUserPrincipal).userEntityRef\n : undefined;\n\n await curationStore.setExclusion(req.params.ref, {\n excluded: parsed.data.excluded,\n excludedBy,\n });\n res.sendStatus(204);\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(\n `PUT /curation/${req.params.ref}/exclusion failed: ${error}`,\n );\n res.status(500).json({ error: 'Failed to update exclusion' });\n }\n },\n );\n\n // ── Body resolver (v2 — ADR-0002/0006/0009) ───────────────────────────────\n\n const safeFilename = (name: string) => name.replace(/[^\\w.-]+/g, '_');\n\n const sendBodyFile = (\n res: express.Response,\n name: string,\n content: Buffer,\n download: boolean,\n ) => {\n res.setHeader('Content-Type', contentTypeFor(name));\n if (download) {\n res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${safeFilename(name.split('/').pop() ?? name)}\"`,\n );\n }\n res.send(content);\n };\n\n const handleBodyRequest = async (\n req: express.Request,\n res: express.Response,\n filename?: string,\n ) => {\n try {\n // Re-fetch as the caller: catalog visibility is the only gate (ADR-0006).\n const credentials = await httpAuth.credentials(req);\n const entity = await catalog.getEntityByRef(req.params.ref, {\n credentials,\n });\n if (!entity) {\n res.status(404).json({ error: 'Resource not found' });\n return;\n }\n\n const sourceLocation =\n entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];\n const target = sourceLocation && parseUrlTarget(sourceLocation);\n if (!target) {\n res.status(404).json({ error: 'Resource has no content location' });\n return;\n }\n\n let body;\n try {\n body = await resolveBody(reader, target);\n } catch (error) {\n if ((error as Error).name === 'NotFoundError') {\n res.status(404).json({ error: 'Resource content not found' });\n return;\n }\n options.logger.error(\n `Body fetch failed for ${req.params.ref}: ${error}`,\n );\n res\n .status(502)\n .json({ error: 'Failed to fetch resource content from source' });\n return;\n }\n\n const download = req.query.download === 'true';\n\n if (filename !== undefined) {\n const file =\n body.kind === 'tree'\n ? body.files.find(f => f.path === filename)\n : undefined;\n if (!file) {\n res.status(404).json({ error: 'File not found in resource body' });\n return;\n }\n sendBodyFile(res, filename, await file.content(), download);\n return;\n }\n\n if (body.kind === 'file') {\n sendBodyFile(res, body.name, body.content, download);\n return;\n }\n\n if (!download) {\n if (!body.entryPath) {\n res.status(404).json({ error: 'Resource body has no entry file' });\n return;\n }\n const entry = body.files.find(f => f.path === body.entryPath)!;\n sendBodyFile(res, body.entryPath, await entry.content(), false);\n return;\n }\n\n // Multi-file artifact: one zip (ADR-0009 — interim, not the golden road).\n if (body.files.length === 1) {\n sendBodyFile(\n res,\n body.files[0].path,\n await body.files[0].content(),\n true,\n );\n return;\n }\n res.setHeader('Content-Type', 'application/zip');\n res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${safeFilename(entity.metadata.name)}.zip\"`,\n );\n const archive = archiver('zip');\n archive.on('error', error => {\n options.logger.error(\n `Zip assembly failed for ${req.params.ref}: ${error}`,\n );\n res.destroy(error);\n });\n archive.pipe(res);\n for (const file of body.files) {\n archive.append(await file.content(), { name: file.path });\n }\n await archive.finalize();\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(\n `Body resolution failed for ${req.params.ref}: ${error}`,\n );\n res.status(500).json({ error: 'Failed to resolve resource body' });\n }\n };\n\n router.get('/entity/:ref/raw', (req, res) => handleBodyRequest(req, res));\n router.get('/entity/:ref/raw/:filename', (req, res) =>\n handleBodyRequest(req, res, req.params.filename),\n );\n\n // ── Telemetry (v2 — ADR-0007) ──────────────────────────────────────────────\n\n router.post('/telemetry', async (req, res) => {\n try {\n const parsed = TelemetryEventInputSchema.safeParse(req.body);\n if (!parsed.success) {\n res.status(400).json({ error: parsed.error.message });\n return;\n }\n\n const credentials = await httpAuth.credentials(req);\n const entity = await catalog.getEntityByRef(parsed.data.ref, {\n credentials,\n });\n if (!entity) {\n res.status(404).json({ error: 'Resource not found' });\n return;\n }\n\n await telemetryStore.record({\n entityRef: parsed.data.ref,\n action: parsed.data.action,\n actorHash: hashActor(credentials, telemetrySalt),\n tool: parsed.data.tool,\n });\n res.sendStatus(204);\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`POST /telemetry failed: ${error}`);\n res.status(500).json({ error: 'Failed to record telemetry event' });\n }\n });\n\n // Catalog-wide usage rollup (#83 backoffice overview). Registered before\n // `/telemetry/:ref` in source order for readability only — Express only\n // matches that route when a segment follows `/telemetry/`, so the two\n // never compete for the same request.\n router.get('/telemetry', async (req, res) => {\n try {\n const credentials = await httpAuth.credentials(req);\n const [{ items: entities }, counts, curation] = await Promise.all([\n catalog.getEntities(\n { filter: { kind: 'AiResource' } },\n { credentials },\n ),\n telemetryStore.getSummary(),\n curationStore.getAll(),\n ]);\n const visibleRefs = new Set(\n toResourceSummaries(entities, {\n allowedFrameworks,\n customFrameworks,\n })\n .filter(item => !curation.get(item.entityRef)?.excluded)\n .map(item => item.entityRef),\n );\n const visibleCounts = Object.fromEntries(\n Object.entries(counts).filter(([entityRef]) =>\n visibleRefs.has(entityRef),\n ),\n );\n res.json({ counts: visibleCounts });\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`GET /telemetry failed: ${error}`);\n res.status(500).json({ error: 'Failed to fetch telemetry summary' });\n }\n });\n\n router.get('/telemetry/:ref', async (req, res) => {\n try {\n await httpAuth.credentials(req);\n const counts = await telemetryStore.getCounts(req.params.ref);\n res.json(counts);\n } catch (error) {\n if ((error as Error).name === 'AuthenticationError') {\n res.status(401).json({ error: 'Authentication required' });\n return;\n }\n options.logger.error(`GET /telemetry/${req.params.ref} failed: ${error}`);\n res.status(500).json({ error: 'Failed to fetch telemetry counts' });\n }\n });\n\n return router;\n}\n"],"names":["express","stringifyEntityRef","devAiHubBackofficeReadPermission","AuthorizeResult","toResourceSummaries","rateLimit","RecommendationInputSchema","devAiHubBackofficeCuratePermission","RECOMMENDED_TAG","ExclusionInputSchema","contentTypeFor","SOURCE_LOCATION_ANNOTATION","parseUrlTarget","resolveBody","archiver","TelemetryEventInputSchema","hashActor"],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curation overlay (#68, ADR-0017): one optional row per entity ref recording
|
|
3
|
+
* an operator's opinion about a resource's presentation, never a write to the
|
|
4
|
+
* catalog. This slice only sets `recommended`; `curated_by`/`curated_at` are
|
|
5
|
+
* already shaped for the demote/hide states #68 still owes, so that
|
|
6
|
+
* extension only adds a `state` value, not a new table.
|
|
7
|
+
*
|
|
8
|
+
* No row means "not recommended" — the only state an empty table can
|
|
9
|
+
* express — so shipping this changes nothing for a deployment that never
|
|
10
|
+
* curates anything.
|
|
11
|
+
*
|
|
12
|
+
* @param {import('knex').Knex} knex
|
|
13
|
+
*/
|
|
14
|
+
exports.up = async function up(knex) {
|
|
15
|
+
await knex.schema.createTable('curation', table => {
|
|
16
|
+
table.string('entity_ref').primary();
|
|
17
|
+
table.boolean('recommended').notNullable().defaultTo(false);
|
|
18
|
+
table.string('curated_by').nullable();
|
|
19
|
+
table.timestamp('curated_at').nullable();
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('knex').Knex} knex
|
|
25
|
+
*/
|
|
26
|
+
exports.down = async function down(knex) {
|
|
27
|
+
await knex.schema.dropTableIfExists('curation');
|
|
28
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exclusion state (#68, ADR-0019): the second curation verb, extending the
|
|
3
|
+
* table `010_curation.js` created rather than replacing it, exactly as
|
|
4
|
+
* ADR-0017 anticipated.
|
|
5
|
+
*
|
|
6
|
+
* Additive on purpose. `excluded` is a flag beside `recommended`, not a
|
|
7
|
+
* `state` column that would make the two mutually exclusive: a resource can
|
|
8
|
+
* carry both opinions, and exclusion simply wins when it does.
|
|
9
|
+
*
|
|
10
|
+
* `excluded_by`/`excluded_at` are separate from `curated_by`/`curated_at`
|
|
11
|
+
* because those record only the row's last touch. Sharing them would let a
|
|
12
|
+
* later recommendation overwrite who excluded a resource, and ADR-0017 asks
|
|
13
|
+
* the exclusion UI to answer "who did this" for exactly as long as the
|
|
14
|
+
* resource stays excluded.
|
|
15
|
+
*
|
|
16
|
+
* No row, and a row with `excluded = false`, both mean "visible" — so this
|
|
17
|
+
* changes nothing for a deployment that never excludes anything.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('knex').Knex} knex
|
|
20
|
+
*/
|
|
21
|
+
exports.up = async function up(knex) {
|
|
22
|
+
await knex.schema.alterTable('curation', table => {
|
|
23
|
+
table.boolean('excluded').notNullable().defaultTo(false);
|
|
24
|
+
table.string('excluded_by').nullable();
|
|
25
|
+
table.timestamp('excluded_at').nullable();
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {import('knex').Knex} knex
|
|
31
|
+
*/
|
|
32
|
+
exports.down = async function down(knex) {
|
|
33
|
+
await knex.schema.alterTable('curation', table => {
|
|
34
|
+
table.dropColumn('excluded');
|
|
35
|
+
table.dropColumn('excluded_by');
|
|
36
|
+
table.dropColumn('excluded_at');
|
|
37
|
+
});
|
|
38
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the plugin first saw each resource (ADR-0021), backing the browse
|
|
3
|
+
* page's "Newest" sort. The catalog exposes no creation timestamp for an
|
|
4
|
+
* entity, so the plugin records its own.
|
|
5
|
+
*
|
|
6
|
+
* `first_seen_at` is nullable on purpose: every resource that already existed
|
|
7
|
+
* when tracking began is stored with `null` — "predates tracking" — rather
|
|
8
|
+
* than the rollout date, which would stamp the whole catalog as new on the
|
|
9
|
+
* same day. The baseline itself is marked by a `telemetry_settings` row
|
|
10
|
+
* (`first_seen_baseline_at`), the key/value table 009 created so a new
|
|
11
|
+
* plugin-level setting needs no migration of its own.
|
|
12
|
+
*
|
|
13
|
+
* A new table and nothing else, so it changes no existing data.
|
|
14
|
+
*
|
|
15
|
+
* @param {import('knex').Knex} knex
|
|
16
|
+
*/
|
|
17
|
+
exports.up = async function up(knex) {
|
|
18
|
+
await knex.schema.createTable('resource_first_seen', table => {
|
|
19
|
+
table.string('entity_ref').primary();
|
|
20
|
+
table.timestamp('first_seen_at').nullable();
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import('knex').Knex} knex
|
|
26
|
+
*/
|
|
27
|
+
exports.down = async function down(knex) {
|
|
28
|
+
await knex.schema.dropTableIfExists('resource_first_seen');
|
|
29
|
+
await knex('telemetry_settings')
|
|
30
|
+
.where('key', 'first_seen_baseline_at')
|
|
31
|
+
.delete();
|
|
32
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nospt/plugin-dev-ai-hub-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-rc1",
|
|
4
4
|
"description": "Backend plugin for Dev AI Hub",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "./dist/index.cjs.js",
|
|
@@ -61,9 +61,11 @@
|
|
|
61
61
|
"@backstage/backend-plugin-api": "^1.10.0",
|
|
62
62
|
"@backstage/catalog-model": "^1.10.0",
|
|
63
63
|
"@backstage/plugin-catalog-node": "^2.2.4",
|
|
64
|
-
"@
|
|
64
|
+
"@backstage/plugin-permission-common": "^0.9.10",
|
|
65
|
+
"@nospt/plugin-dev-ai-hub-common": "^1.2.0-rc1",
|
|
65
66
|
"archiver": "^6.0.0",
|
|
66
67
|
"express": "^5.2.1",
|
|
68
|
+
"express-rate-limit": "^8.2.2",
|
|
67
69
|
"js-yaml": "^4.3.1",
|
|
68
70
|
"zod": "^3.22.0"
|
|
69
71
|
},
|