@nospt/plugin-dev-ai-hub-backend 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/config.d.ts +22 -0
  2. package/dist/database/TelemetryStore.cjs.js +84 -0
  3. package/dist/database/TelemetryStore.cjs.js.map +1 -0
  4. package/dist/plugin.cjs.js +24 -45
  5. package/dist/plugin.cjs.js.map +1 -1
  6. package/dist/router.cjs.js +147 -202
  7. package/dist/router.cjs.js.map +1 -1
  8. package/dist/service/bodyResolver.cjs.js +56 -0
  9. package/dist/service/bodyResolver.cjs.js.map +1 -0
  10. package/dist/service/telemetryHash.cjs.js +14 -0
  11. package/dist/service/telemetryHash.cjs.js.map +1 -0
  12. package/dist/service/toResourceSummary.cjs.js +31 -0
  13. package/dist/service/toResourceSummary.cjs.js.map +1 -0
  14. package/migrations/001_init.js +13 -42
  15. package/migrations/002_install_count.js +7 -12
  16. package/migrations/003_install_paths.js +7 -14
  17. package/migrations/004_label.js +7 -12
  18. package/migrations/005_remove_unused_fields.js +6 -16
  19. package/migrations/006_resources_content.js +10 -13
  20. package/migrations/007_telemetry.js +28 -0
  21. package/migrations/008_drop_legacy_tables.js +24 -0
  22. package/migrations/009_telemetry_settings.js +25 -0
  23. package/package.json +18 -8
  24. package/.eslintrc.js +0 -1
  25. package/dev/index.ts +0 -8
  26. package/dist/database/AiAssetStore.cjs.js +0 -225
  27. package/dist/database/AiAssetStore.cjs.js.map +0 -1
  28. package/dist/service/AiAssetSyncService.cjs.js +0 -235
  29. package/dist/service/AiAssetSyncService.cjs.js.map +0 -1
  30. package/dist/service/AssetParser.cjs.js +0 -74
  31. package/dist/service/AssetParser.cjs.js.map +0 -1
  32. package/dist/service/McpServerService.cjs.js +0 -366
  33. package/dist/service/McpServerService.cjs.js.map +0 -1
  34. package/package.json-prepack +0 -71
  35. package/src/database/AiAssetStore.test.ts +0 -294
  36. package/src/database/AiAssetStore.ts +0 -256
  37. package/src/index.ts +0 -2
  38. package/src/plugin.ts +0 -84
  39. package/src/router.smoke.test.ts +0 -262
  40. package/src/router.ts +0 -338
  41. package/src/service/AiAssetSyncService.ts +0 -298
  42. package/src/service/AssetParser.test.ts +0 -329
  43. package/src/service/AssetParser.ts +0 -103
  44. package/src/service/McpServerService.ts +0 -464
  45. package/src/types.ts +0 -31
  46. package/tsconfig.json +0 -8
package/config.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface Config {
2
+ devAiHub?: {
3
+ /**
4
+ * Install telemetry (ADR-0007). The write path is always active — there
5
+ * is no feature flag — so a salt is always in play; the only question is
6
+ * where it comes from.
7
+ */
8
+ telemetry?: {
9
+ /**
10
+ * Stable salt used to hash the caller identity recorded on telemetry
11
+ * events. Optional: when unset, the plugin generates a salt on first
12
+ * start and persists it in its own database, which keeps hashes stable
13
+ * across restarts. Set it explicitly to keep the salt outside the
14
+ * database it protects — and then it must not change across
15
+ * restarts/deploys, or historical per-day dedup keys become
16
+ * unrecoverable.
17
+ * @visibility secret
18
+ */
19
+ salt?: string;
20
+ };
21
+ };
22
+ }
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ var node_crypto = require('node:crypto');
4
+ var backendPluginApi = require('@backstage/backend-plugin-api');
5
+
6
+ const SALT_KEY = "actor_salt";
7
+ const ZERO_COUNTS = {
8
+ install: 0,
9
+ copy: 0,
10
+ download: 0,
11
+ view: 0
12
+ };
13
+ class TelemetryStore {
14
+ constructor(db) {
15
+ this.db = db;
16
+ }
17
+ db;
18
+ static async create(options) {
19
+ const db = await options.database.getClient();
20
+ await db.migrate.latest({
21
+ directory: backendPluginApi.resolvePackagePath(
22
+ "@nospt/plugin-dev-ai-hub-backend",
23
+ "migrations"
24
+ ),
25
+ loadExtensions: [".js"]
26
+ });
27
+ return new TelemetryStore(db);
28
+ }
29
+ /**
30
+ * The salt used to hash actor identity (ADR-0007). A configured value wins
31
+ * and is never persisted, so a deployment can keep the salt outside the
32
+ * database it protects. Otherwise the salt is generated once and stored,
33
+ * which is what makes it survive restarts — the property per-day dedup
34
+ * actually depends on. It is deliberately not required from config: a
35
+ * missing value used to fail the plugin's init and take the whole backend
36
+ * down with it.
37
+ *
38
+ * Concurrent replicas starting together race on the insert; the loser reads
39
+ * back the winner's value, so every replica ends up on the same salt.
40
+ */
41
+ async resolveSalt(configured) {
42
+ if (configured) {
43
+ return configured;
44
+ }
45
+ const existing = await this.readSalt();
46
+ if (existing) {
47
+ return existing;
48
+ }
49
+ await this.db("telemetry_settings").insert({ key: SALT_KEY, value: node_crypto.randomBytes(32).toString("hex") }).onConflict("key").ignore();
50
+ const stored = await this.readSalt();
51
+ if (!stored) {
52
+ throw new Error("Failed to persist the telemetry actor salt");
53
+ }
54
+ return stored;
55
+ }
56
+ async readSalt() {
57
+ const row = await this.db("telemetry_settings").where("key", SALT_KEY).first("value");
58
+ return row?.value;
59
+ }
60
+ async record(event) {
61
+ const now = /* @__PURE__ */ new Date();
62
+ await this.db("telemetry_events").insert({
63
+ entity_ref: event.entityRef,
64
+ action: event.action,
65
+ actor_hash: event.actorHash,
66
+ tool: event.tool ?? null,
67
+ day: now.toISOString().slice(0, 10),
68
+ occurred_at: now.toISOString()
69
+ });
70
+ }
71
+ async getCounts(entityRef) {
72
+ const rows = await this.db("telemetry_events").where("entity_ref", entityRef).groupBy("action").select("action").count(
73
+ "id as count"
74
+ );
75
+ const counts = { ...ZERO_COUNTS };
76
+ for (const row of rows) {
77
+ counts[row.action] = Number(row.count);
78
+ }
79
+ return counts;
80
+ }
81
+ }
82
+
83
+ exports.TelemetryStore = TelemetryStore;
84
+ //# sourceMappingURL=TelemetryStore.cjs.js.map
@@ -0,0 +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 counts on read for\n * this slice — per-(hash, day)-distinct dedup for `view` is a follow-up.\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 — the property per-day dedup\n * actually depends on. 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 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":"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"}
@@ -1,66 +1,45 @@
1
1
  'use strict';
2
2
 
3
3
  var backendPluginApi = require('@backstage/backend-plugin-api');
4
- var pluginDevAiHubNode = require('@nospt/plugin-dev-ai-hub-node');
5
- var AiAssetStore = require('./database/AiAssetStore.cjs.js');
6
- var AiAssetSyncService = require('./service/AiAssetSyncService.cjs.js');
4
+ var pluginCatalogNode = require('@backstage/plugin-catalog-node');
5
+ var TelemetryStore = require('./database/TelemetryStore.cjs.js');
7
6
  var router = require('./router.cjs.js');
8
7
 
9
8
  const devAiHubPlugin = backendPluginApi.createBackendPlugin({
10
9
  pluginId: "dev-ai-hub",
11
10
  register(env) {
12
- const externalProviders = [];
13
- env.registerExtensionPoint(pluginDevAiHubNode.devAiHubProviderExtensionPoint, {
14
- addProvider(provider) {
15
- externalProviders.push(provider);
16
- }
17
- });
18
11
  env.registerInit({
19
12
  deps: {
20
13
  config: backendPluginApi.coreServices.rootConfig,
21
14
  logger: backendPluginApi.coreServices.logger,
22
15
  database: backendPluginApi.coreServices.database,
23
- scheduler: backendPluginApi.coreServices.scheduler,
24
16
  httpRouter: backendPluginApi.coreServices.httpRouter,
25
- urlReader: backendPluginApi.coreServices.urlReader
17
+ urlReader: backendPluginApi.coreServices.urlReader,
18
+ httpAuth: backendPluginApi.coreServices.httpAuth,
19
+ catalog: pluginCatalogNode.catalogServiceRef
26
20
  },
27
- async init({ config, logger, database, scheduler, httpRouter, urlReader }) {
28
- const store = await AiAssetStore.AiAssetStore.create({ database });
29
- const providers = (config.getOptionalConfigArray("devAiHub.providers") ?? []).map((p) => ({
30
- id: p.getString("id"),
31
- type: p.getString("type"),
32
- target: p.getString("target"),
33
- branch: p.getOptionalString("branch") ?? "main",
34
- schedule: {
35
- frequency: {
36
- minutes: p.getOptionalNumber("schedule.frequency.minutes"),
37
- hours: p.getOptionalNumber("schedule.frequency.hours")
38
- },
39
- timeout: {
40
- minutes: p.getOptionalNumber("schedule.timeout.minutes"),
41
- hours: p.getOptionalNumber("schedule.timeout.hours")
42
- }
43
- },
44
- filters: p.has("filters") ? {
45
- tools: p.getOptionalStringArray("filters.tools"),
46
- types: p.getOptionalStringArray("filters.types")
47
- } : void 0
48
- }));
49
- const syncService = new AiAssetSyncService.AiAssetSyncService({
21
+ async init({
22
+ config,
23
+ logger,
24
+ database,
25
+ httpRouter,
26
+ urlReader,
27
+ httpAuth,
28
+ catalog
29
+ }) {
30
+ const telemetryStore = await TelemetryStore.TelemetryStore.create({ database });
31
+ const telemetrySalt = await telemetryStore.resolveSalt(
32
+ config.getOptionalString("devAiHub.telemetry.salt")
33
+ );
34
+ const router$1 = router.createRouter({
50
35
  logger,
51
- store,
52
- scheduler,
53
- urlReader,
54
- providers,
55
- externalProviders
36
+ telemetryStore,
37
+ telemetrySalt,
38
+ catalog,
39
+ httpAuth,
40
+ reader: urlReader
56
41
  });
57
- await syncService.start();
58
- const router$1 = router.createRouter({ logger, store, syncService, providers });
59
42
  httpRouter.use(router$1);
60
- httpRouter.addAuthPolicy({ path: "/assets", allow: "unauthenticated" });
61
- httpRouter.addAuthPolicy({ path: "/providers", allow: "unauthenticated" });
62
- httpRouter.addAuthPolicy({ path: "/stats", allow: "unauthenticated" });
63
- httpRouter.addAuthPolicy({ path: "/mcp", allow: "unauthenticated" });
64
43
  }
65
44
  });
66
45
  }
@@ -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 {\n devAiHubProviderExtensionPoint,\n type AiAssetProvider,\n} from '@nospt/plugin-dev-ai-hub-node';\nimport { AiAssetStore } from './database/AiAssetStore';\nimport { AiAssetSyncService } from './service/AiAssetSyncService';\nimport { createRouter } from './router';\nimport type { ProviderConfig } from './types';\n\nexport const devAiHubPlugin = createBackendPlugin({\n pluginId: 'dev-ai-hub',\n register(env) {\n const externalProviders: AiAssetProvider[] = [];\n\n env.registerExtensionPoint(devAiHubProviderExtensionPoint, {\n addProvider(provider) {\n externalProviders.push(provider);\n },\n });\n\n env.registerInit({\n deps: {\n config: coreServices.rootConfig,\n logger: coreServices.logger,\n database: coreServices.database,\n scheduler: coreServices.scheduler,\n httpRouter: coreServices.httpRouter,\n urlReader: coreServices.urlReader,\n },\n async init({ config, logger, database, scheduler, httpRouter, urlReader }) {\n const store = await AiAssetStore.create({ database });\n\n const providers: ProviderConfig[] = (\n config.getOptionalConfigArray('devAiHub.providers') ?? []\n ).map(p => ({\n id: p.getString('id'),\n type: p.getString('type') as ProviderConfig['type'],\n target: p.getString('target'),\n branch: p.getOptionalString('branch') ?? 'main',\n schedule: {\n frequency: {\n minutes: p.getOptionalNumber('schedule.frequency.minutes'),\n hours: p.getOptionalNumber('schedule.frequency.hours'),\n },\n timeout: {\n minutes: p.getOptionalNumber('schedule.timeout.minutes'),\n hours: p.getOptionalNumber('schedule.timeout.hours'),\n },\n },\n filters: p.has('filters')\n ? {\n tools: p.getOptionalStringArray('filters.tools'),\n types: p.getOptionalStringArray('filters.types'),\n }\n : undefined,\n }));\n\n const syncService = new AiAssetSyncService({\n logger,\n store,\n scheduler,\n urlReader,\n providers,\n externalProviders,\n });\n\n await syncService.start();\n\n const router = createRouter({ logger, store, syncService, providers });\n\n httpRouter.use(router);\n\n httpRouter.addAuthPolicy({ path: '/assets', allow: 'unauthenticated' });\n httpRouter.addAuthPolicy({ path: '/providers', allow: 'unauthenticated' });\n httpRouter.addAuthPolicy({ path: '/stats', allow: 'unauthenticated' });\n httpRouter.addAuthPolicy({ path: '/mcp', allow: 'unauthenticated' });\n },\n });\n },\n});\n"],"names":["createBackendPlugin","devAiHubProviderExtensionPoint","coreServices","AiAssetStore","AiAssetSyncService","router","createRouter"],"mappings":"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"}
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 });\n\n httpRouter.use(router);\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","catalogServiceRef","TelemetryStore","router","createRouter"],"mappings":";;;;;;;AAQO,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,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;AAAA,OACF,EAAG;AACD,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;AAEA,QAAA,MAAMC,WAASC,mBAAA,CAAa;AAAA,UAC1B,MAAA;AAAA,UACA,cAAA;AAAA,UACA,aAAA;AAAA,UACA,OAAA;AAAA,UACA,QAAA;AAAA,UACA,MAAA,EAAQ;AAAA,SACT,CAAA;AAED,QAAA,UAAA,CAAW,IAAID,QAAM,CAAA;AAAA,MACvB;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;;;"}
@@ -1,10 +1,11 @@
1
1
  'use strict';
2
2
 
3
- var crypto = require('crypto');
4
3
  var express = require('express');
5
4
  var archiver = require('archiver');
6
- var streamableHttp_js = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
7
- var McpServerService = require('./service/McpServerService.cjs.js');
5
+ var pluginDevAiHubCommon = require('@nospt/plugin-dev-ai-hub-common');
6
+ var toResourceSummary = require('./service/toResourceSummary.cjs.js');
7
+ var telemetryHash = require('./service/telemetryHash.cjs.js');
8
+ var bodyResolver = require('./service/bodyResolver.cjs.js');
8
9
 
9
10
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
10
11
 
@@ -12,232 +13,176 @@ var express__default = /*#__PURE__*/_interopDefaultCompat(express);
12
13
  var archiver__default = /*#__PURE__*/_interopDefaultCompat(archiver);
13
14
 
14
15
  function createRouter(options) {
15
- const { store, syncService, providers } = options;
16
+ const { telemetryStore, telemetrySalt, catalog, httpAuth, reader } = options;
16
17
  const router = express__default.default.Router();
17
- const mcpSessions = /* @__PURE__ */ new Map();
18
18
  router.use(express__default.default.json());
19
- router.get("/assets", async (req, res) => {
19
+ router.get("/resources", async (req, res) => {
20
20
  try {
21
- const filter = {
22
- type: req.query.type,
23
- tool: req.query.tool,
24
- providerId: req.query.provider,
25
- search: req.query.search,
26
- tags: req.query.tags ? req.query.tags.split(",").filter(Boolean) : void 0,
27
- page: req.query.page ? Number(req.query.page) : 1,
28
- pageSize: req.query.pageSize ? Number(req.query.pageSize) : 20
29
- };
30
- const { items, totalCount } = await store.listAssets(filter);
31
- res.json({
32
- items,
33
- totalCount,
34
- page: filter.page,
35
- pageSize: filter.pageSize
36
- });
37
- } catch (err) {
38
- options.logger.error("GET /assets failed", err);
39
- res.status(500).json({ error: "Internal server error" });
40
- }
41
- });
42
- router.get("/assets/:id", async (req, res) => {
43
- try {
44
- const asset = await store.getAsset(req.params.id);
45
- if (!asset) return res.status(404).json({ error: "Asset not found" });
46
- return res.json(asset);
47
- } catch (err) {
48
- options.logger.error("GET /assets/:id failed", err);
49
- return res.status(500).json({ error: "Internal server error" });
50
- }
51
- });
52
- router.get("/assets/:id/raw", async (req, res) => {
53
- try {
54
- const asset = await store.getAsset(req.params.id);
55
- if (!asset) return res.status(404).json({ error: "Asset not found" });
56
- res.setHeader("Content-Type", "text/markdown; charset=utf-8");
57
- return res.send(asset.content);
58
- } catch (err) {
59
- options.logger.error("GET /assets/:id/raw failed", err);
60
- return res.status(500).json({ error: "Internal server error" });
61
- }
62
- });
63
- router.get("/assets/:id/download", async (req, res) => {
64
- try {
65
- const asset = await store.getAsset(req.params.id);
66
- if (!asset) return res.status(404).json({ error: "Asset not found" });
67
- const filename = `${asset.name.replace(/\s+/g, "-").toLowerCase()}`;
68
- if (asset.type === "skill") {
69
- res.setHeader("Content-Type", "application/zip");
70
- res.setHeader(
71
- "Content-Disposition",
72
- `attachment; filename="${filename}.zip"`
73
- );
74
- const archive = archiver__default.default("zip");
75
- archive.pipe(res);
76
- archive.append(asset.content, { name: "SKILL.md" });
77
- if (asset.resourcesContent) {
78
- for (const [resourcePath, resourceContent] of Object.entries(asset.resourcesContent)) {
79
- archive.append(resourceContent, { name: resourcePath });
80
- }
81
- }
82
- await archive.finalize();
83
- } else {
84
- res.setHeader("Content-Type", "text/markdown; charset=utf-8");
85
- res.setHeader(
86
- "Content-Disposition",
87
- `attachment; filename="${filename}.md"`
88
- );
89
- return res.send(asset.content);
21
+ const credentials = await httpAuth.credentials(req);
22
+ const { items: entities } = await catalog.getEntities(
23
+ { filter: { kind: "AiResource" } },
24
+ { credentials }
25
+ );
26
+ const items = entities.map(toResourceSummary.toResourceSummary).filter((s) => s !== void 0);
27
+ res.json({ items });
28
+ } catch (error) {
29
+ if (error.name === "AuthenticationError") {
30
+ res.status(401).json({ error: "Authentication required" });
31
+ return;
90
32
  }
91
- return void 0;
92
- } catch (err) {
93
- options.logger.error("GET /assets/:id/download failed", err);
94
- return res.status(500).json({ error: "Internal server error" });
33
+ options.logger.error(`Failed to list resources: ${error}`);
34
+ res.status(500).json({ error: "Failed to list resources" });
95
35
  }
96
36
  });
97
- router.post("/assets/:id/track-install", async (req, res) => {
98
- try {
99
- const asset = await store.getAsset(req.params.id);
100
- if (!asset) return res.status(404).json({ error: "Asset not found" });
101
- await store.incrementInstallCount(req.params.id);
102
- return res.status(204).send();
103
- } catch (err) {
104
- options.logger.error("POST /assets/:id/track-install failed", err);
105
- return res.status(500).json({ error: "Internal server error" });
37
+ const safeFilename = (name) => name.replace(/[^\w.-]+/g, "_");
38
+ const sendBodyFile = (res, name, content, download) => {
39
+ res.setHeader("Content-Type", bodyResolver.contentTypeFor(name));
40
+ if (download) {
41
+ res.setHeader(
42
+ "Content-Disposition",
43
+ `attachment; filename="${safeFilename(name.split("/").pop() ?? name)}"`
44
+ );
106
45
  }
107
- });
108
- router.get("/providers", async (_req, res) => {
46
+ res.send(content);
47
+ };
48
+ const handleBodyRequest = async (req, res, filename) => {
109
49
  try {
110
- const statuses = await store.getAllSyncStatuses();
111
- const statusMap = new Map(statuses.map((s) => [s.providerId, s]));
112
- const result = providers.map((p) => {
113
- const status = statusMap.get(p.id);
114
- return {
115
- id: p.id,
116
- type: p.type,
117
- target: p.target,
118
- branch: p.branch,
119
- lastSync: status?.lastSync,
120
- lastCommit: status?.lastCommit,
121
- status: status?.status ?? "idle",
122
- error: status?.error,
123
- assetCount: status?.assetCount ?? 0
124
- };
50
+ const credentials = await httpAuth.credentials(req);
51
+ const entity = await catalog.getEntityByRef(req.params.ref, {
52
+ credentials
125
53
  });
126
- res.json(result);
127
- } catch (err) {
128
- options.logger.error("GET /providers failed", err);
129
- res.status(500).json({ error: "Internal server error" });
130
- }
131
- });
132
- router.get("/providers/:id/status", async (req, res) => {
133
- try {
134
- const provider = providers.find((p) => p.id === req.params.id);
135
- if (!provider) return res.status(404).json({ error: "Provider not found" });
136
- const status = await store.getSyncStatus(req.params.id);
137
- return res.json(
138
- status ?? {
139
- providerId: req.params.id,
140
- status: "idle",
141
- assetCount: 0
54
+ if (!entity) {
55
+ res.status(404).json({ error: "Resource not found" });
56
+ return;
57
+ }
58
+ const sourceLocation = entity.metadata.annotations?.[bodyResolver.SOURCE_LOCATION_ANNOTATION];
59
+ const target = sourceLocation && bodyResolver.parseUrlTarget(sourceLocation);
60
+ if (!target) {
61
+ res.status(404).json({ error: "Resource has no content location" });
62
+ return;
63
+ }
64
+ let body;
65
+ try {
66
+ body = await bodyResolver.resolveBody(reader, target);
67
+ } catch (error) {
68
+ if (error.name === "NotFoundError") {
69
+ res.status(404).json({ error: "Resource content not found" });
70
+ return;
142
71
  }
143
- );
144
- } catch (err) {
145
- options.logger.error("GET /providers/:id/status failed", err);
146
- return res.status(500).json({ error: "Internal server error" });
147
- }
148
- });
149
- router.post("/providers/:id/sync", async (req, res) => {
150
- try {
151
- const provider = providers.find((p) => p.id === req.params.id);
152
- if (!provider) return res.status(404).json({ error: "Provider not found" });
153
- setImmediate(() => syncService.syncProvider(provider));
154
- return res.json({ message: "Sync triggered", providerId: provider.id });
155
- } catch (err) {
156
- options.logger.error("POST /providers/:id/sync failed", err);
157
- return res.status(500).json({ error: "Internal server error" });
158
- }
159
- });
160
- router.get("/stats", async (_req, res) => {
161
- try {
162
- const stats = await store.getStats();
163
- res.json(stats);
164
- } catch (err) {
165
- options.logger.error("GET /stats failed", err);
166
- res.status(500).json({ error: "Internal server error" });
167
- }
168
- });
169
- router.post("/mcp", async (req, res) => {
170
- try {
171
- const sessionId = req.headers["mcp-session-id"];
172
- if (sessionId) {
173
- const transport2 = mcpSessions.get(sessionId);
174
- if (!transport2) {
175
- res.status(404).json({ error: "MCP session not found" });
72
+ options.logger.error(
73
+ `Body fetch failed for ${req.params.ref}: ${error}`
74
+ );
75
+ res.status(502).json({ error: "Failed to fetch resource content from source" });
76
+ return;
77
+ }
78
+ const download = req.query.download === "true";
79
+ if (filename !== void 0) {
80
+ const file = body.kind === "tree" ? body.files.find((f) => f.path === filename) : void 0;
81
+ if (!file) {
82
+ res.status(404).json({ error: "File not found in resource body" });
176
83
  return;
177
84
  }
178
- await transport2.handleRequest(req, res, req.body);
85
+ sendBodyFile(res, filename, await file.content(), download);
86
+ return;
87
+ }
88
+ if (body.kind === "file") {
89
+ sendBodyFile(res, body.name, body.content, download);
179
90
  return;
180
91
  }
181
- const toolFilter = req.query.tool ?? req.headers["x-ai-hub-tool"] ?? "";
182
- const providerFilter = req.query.provider ?? req.headers["x-ai-hub-provider"] ?? "";
183
- const proactiveEnabled = (req.query.proactive ?? req.headers["x-ai-hub-proactive"] ?? "false") === "true";
184
- const newSessionId = crypto.randomUUID();
185
- const transport = new streamableHttp_js.StreamableHTTPServerTransport({
186
- sessionIdGenerator: () => newSessionId,
187
- onsessioninitialized: (id) => {
188
- mcpSessions.set(id, transport);
189
- options.logger.debug(
190
- `dev-ai-hub MCP: session ${id} opened (tool="${toolFilter || "all"}", provider="${providerFilter || "all"}", proactive=${proactiveEnabled})`
191
- );
92
+ if (!download) {
93
+ if (!body.entryPath) {
94
+ res.status(404).json({ error: "Resource body has no entry file" });
95
+ return;
192
96
  }
97
+ const entry = body.files.find((f) => f.path === body.entryPath);
98
+ sendBodyFile(res, body.entryPath, await entry.content(), false);
99
+ return;
100
+ }
101
+ if (body.files.length === 1) {
102
+ sendBodyFile(
103
+ res,
104
+ body.files[0].path,
105
+ await body.files[0].content(),
106
+ true
107
+ );
108
+ return;
109
+ }
110
+ res.setHeader("Content-Type", "application/zip");
111
+ res.setHeader(
112
+ "Content-Disposition",
113
+ `attachment; filename="${safeFilename(entity.metadata.name)}.zip"`
114
+ );
115
+ const archive = archiver__default.default("zip");
116
+ archive.on("error", (error) => {
117
+ options.logger.error(
118
+ `Zip assembly failed for ${req.params.ref}: ${error}`
119
+ );
120
+ res.destroy(error);
193
121
  });
194
- transport.onclose = () => {
195
- mcpSessions.delete(newSessionId);
196
- options.logger.debug(`dev-ai-hub MCP: session ${newSessionId} closed`);
197
- };
198
- const server = McpServerService.createMcpServer(store, toolFilter, providerFilter, providers, proactiveEnabled);
199
- await server.connect(transport);
200
- await transport.handleRequest(req, res, req.body);
201
- } catch (err) {
202
- options.logger.error("POST /mcp failed", err);
203
- if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
122
+ archive.pipe(res);
123
+ for (const file of body.files) {
124
+ archive.append(await file.content(), { name: file.path });
125
+ }
126
+ await archive.finalize();
127
+ } catch (error) {
128
+ if (error.name === "AuthenticationError") {
129
+ res.status(401).json({ error: "Authentication required" });
130
+ return;
131
+ }
132
+ options.logger.error(
133
+ `Body resolution failed for ${req.params.ref}: ${error}`
134
+ );
135
+ res.status(500).json({ error: "Failed to resolve resource body" });
204
136
  }
205
- });
206
- router.get("/mcp", async (req, res) => {
137
+ };
138
+ router.get("/entity/:ref/raw", (req, res) => handleBodyRequest(req, res));
139
+ router.get(
140
+ "/entity/:ref/raw/:filename",
141
+ (req, res) => handleBodyRequest(req, res, req.params.filename)
142
+ );
143
+ router.post("/telemetry", async (req, res) => {
207
144
  try {
208
- const sessionId = req.headers["mcp-session-id"];
209
- if (!sessionId) {
210
- res.status(400).json({ error: "mcp-session-id header required" });
145
+ const parsed = pluginDevAiHubCommon.TelemetryEventInputSchema.safeParse(req.body);
146
+ if (!parsed.success) {
147
+ res.status(400).json({ error: parsed.error.message });
211
148
  return;
212
149
  }
213
- const transport = mcpSessions.get(sessionId);
214
- if (!transport) {
215
- res.status(404).json({ error: "MCP session not found" });
150
+ const credentials = await httpAuth.credentials(req);
151
+ const entity = await catalog.getEntityByRef(parsed.data.ref, {
152
+ credentials
153
+ });
154
+ if (!entity) {
155
+ res.status(404).json({ error: "Resource not found" });
216
156
  return;
217
157
  }
218
- await transport.handleRequest(req, res);
219
- } catch (err) {
220
- options.logger.error("GET /mcp failed", err);
221
- if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
158
+ await telemetryStore.record({
159
+ entityRef: parsed.data.ref,
160
+ action: parsed.data.action,
161
+ actorHash: telemetryHash.hashActor(credentials, telemetrySalt),
162
+ tool: parsed.data.tool
163
+ });
164
+ res.sendStatus(204);
165
+ } catch (error) {
166
+ if (error.name === "AuthenticationError") {
167
+ res.status(401).json({ error: "Authentication required" });
168
+ return;
169
+ }
170
+ options.logger.error(`POST /telemetry failed: ${error}`);
171
+ res.status(500).json({ error: "Failed to record telemetry event" });
222
172
  }
223
173
  });
224
- router.delete("/mcp", async (req, res) => {
174
+ router.get("/telemetry/:ref", async (req, res) => {
225
175
  try {
226
- const sessionId = req.headers["mcp-session-id"];
227
- if (!sessionId) {
228
- res.status(400).json({ error: "mcp-session-id header required" });
229
- return;
230
- }
231
- const transport = mcpSessions.get(sessionId);
232
- if (!transport) {
233
- res.status(404).json({ error: "MCP session not found" });
176
+ await httpAuth.credentials(req);
177
+ const counts = await telemetryStore.getCounts(req.params.ref);
178
+ res.json(counts);
179
+ } catch (error) {
180
+ if (error.name === "AuthenticationError") {
181
+ res.status(401).json({ error: "Authentication required" });
234
182
  return;
235
183
  }
236
- await transport.handleRequest(req, res);
237
- mcpSessions.delete(sessionId);
238
- } catch (err) {
239
- options.logger.error("DELETE /mcp failed", err);
240
- if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
184
+ options.logger.error(`GET /telemetry/${req.params.ref} failed: ${error}`);
185
+ res.status(500).json({ error: "Failed to fetch telemetry counts" });
241
186
  }
242
187
  });
243
188
  return router;