@bsb/registry 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +133 -0
  2. package/bsb-plugin.json +47 -0
  3. package/lib/.bsb/clients/service-bsb-registry.d.ts +1118 -0
  4. package/lib/.bsb/clients/service-bsb-registry.d.ts.map +1 -0
  5. package/lib/.bsb/clients/service-bsb-registry.js +393 -0
  6. package/lib/.bsb/clients/service-bsb-registry.js.map +1 -0
  7. package/lib/plugins/service-bsb-registry/auth.d.ts +87 -0
  8. package/lib/plugins/service-bsb-registry/auth.d.ts.map +1 -0
  9. package/lib/plugins/service-bsb-registry/auth.js +197 -0
  10. package/lib/plugins/service-bsb-registry/auth.js.map +1 -0
  11. package/lib/plugins/service-bsb-registry/db/file.d.ts +73 -0
  12. package/lib/plugins/service-bsb-registry/db/file.d.ts.map +1 -0
  13. package/lib/plugins/service-bsb-registry/db/file.js +588 -0
  14. package/lib/plugins/service-bsb-registry/db/file.js.map +1 -0
  15. package/lib/plugins/service-bsb-registry/db/index.d.ts +75 -0
  16. package/lib/plugins/service-bsb-registry/db/index.d.ts.map +1 -0
  17. package/lib/plugins/service-bsb-registry/db/index.js +24 -0
  18. package/lib/plugins/service-bsb-registry/db/index.js.map +1 -0
  19. package/lib/plugins/service-bsb-registry/index.d.ts +1228 -0
  20. package/lib/plugins/service-bsb-registry/index.d.ts.map +1 -0
  21. package/lib/plugins/service-bsb-registry/index.js +661 -0
  22. package/lib/plugins/service-bsb-registry/index.js.map +1 -0
  23. package/lib/plugins/service-bsb-registry/types.d.ts +559 -0
  24. package/lib/plugins/service-bsb-registry/types.d.ts.map +1 -0
  25. package/lib/plugins/service-bsb-registry/types.js +235 -0
  26. package/lib/plugins/service-bsb-registry/types.js.map +1 -0
  27. package/lib/plugins/service-bsb-registry-ui/http-server.d.ts +138 -0
  28. package/lib/plugins/service-bsb-registry-ui/http-server.d.ts.map +1 -0
  29. package/lib/plugins/service-bsb-registry-ui/http-server.js +1660 -0
  30. package/lib/plugins/service-bsb-registry-ui/http-server.js.map +1 -0
  31. package/lib/plugins/service-bsb-registry-ui/index.d.ts +62 -0
  32. package/lib/plugins/service-bsb-registry-ui/index.d.ts.map +1 -0
  33. package/lib/plugins/service-bsb-registry-ui/index.js +101 -0
  34. package/lib/plugins/service-bsb-registry-ui/index.js.map +1 -0
  35. package/lib/plugins/service-bsb-registry-ui/static/assets/images/apple-touch-icon.png +0 -0
  36. package/lib/plugins/service-bsb-registry-ui/static/assets/images/favicon-16x16.png +0 -0
  37. package/lib/plugins/service-bsb-registry-ui/static/assets/images/favicon-32x32.png +0 -0
  38. package/lib/plugins/service-bsb-registry-ui/static/assets/images/favicon.ico +0 -0
  39. package/lib/plugins/service-bsb-registry-ui/static/css/style.css +1849 -0
  40. package/lib/plugins/service-bsb-registry-ui/static/js/app.js +336 -0
  41. package/lib/plugins/service-bsb-registry-ui/templates/layouts/main.hbs +39 -0
  42. package/lib/plugins/service-bsb-registry-ui/templates/pages/error.hbs +13 -0
  43. package/lib/plugins/service-bsb-registry-ui/templates/pages/home.hbs +62 -0
  44. package/lib/plugins/service-bsb-registry-ui/templates/pages/not-found.hbs +13 -0
  45. package/lib/plugins/service-bsb-registry-ui/templates/pages/plugin-detail.hbs +537 -0
  46. package/lib/plugins/service-bsb-registry-ui/templates/pages/plugins.hbs +40 -0
  47. package/lib/plugins/service-bsb-registry-ui/templates/partials/pagination.hbs +41 -0
  48. package/lib/plugins/service-bsb-registry-ui/templates/partials/plugin-card.hbs +40 -0
  49. package/lib/plugins/service-bsb-registry-ui/templates/partials/search-form.hbs +31 -0
  50. package/lib/schemas/service-bsb-registry-ui.json +57 -0
  51. package/lib/schemas/service-bsb-registry-ui.plugin.json +73 -0
  52. package/lib/schemas/service-bsb-registry.json +1883 -0
  53. package/lib/schemas/service-bsb-registry.plugin.json +68 -0
  54. package/package.json +60 -0
@@ -0,0 +1,1660 @@
1
+ "use strict";
2
+ /**
3
+ * Registry UI & API HTTP Server (Event-Driven with Handlebars)
4
+ *
5
+ * Serves both the web UI (HTML via Handlebars) and the REST API (JSON)
6
+ * using content negotiation (Accept header).
7
+ * Communicates with registry core via typed BsbRegistryClient.
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ var __importDefault = (this && this.__importDefault) || function (mod) {
43
+ return (mod && mod.__esModule) ? mod : { "default": mod };
44
+ };
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.RegistryUIServer = void 0;
47
+ const path = __importStar(require("path"));
48
+ const fs = __importStar(require("node:fs"));
49
+ const fsp = __importStar(require("node:fs/promises"));
50
+ const promises_1 = require("node:stream/promises");
51
+ const fastify_1 = __importDefault(require("fastify"));
52
+ const cors_1 = __importDefault(require("@fastify/cors"));
53
+ const multipart_1 = __importDefault(require("@fastify/multipart"));
54
+ const static_1 = __importDefault(require("@fastify/static"));
55
+ const view_1 = __importDefault(require("@fastify/view"));
56
+ const handlebars_1 = __importDefault(require("handlebars"));
57
+ const marked_1 = require("marked");
58
+ const zod_1 = require("zod");
59
+ // ============================================================================
60
+ // Zod Validation Schemas — all external input validated at the boundary
61
+ // ============================================================================
62
+ // ---- Reusable field schemas ----
63
+ // ASCII-only printable, no control chars or unicode exploits
64
+ const safeAscii = /^[\x20-\x7E]*$/;
65
+ // Slug: alphanumeric, dash, underscore, dot, @, /
66
+ const slugPattern = /^[a-zA-Z0-9_@.\-/]+$/;
67
+ // Semver: strict major.minor.patch with optional pre-release
68
+ const semverPattern = /^\d{1,5}\.\d{1,5}\.\d{1,5}(-[a-zA-Z0-9.]+)?$/;
69
+ // Major.minor only
70
+ const majorMinorPattern = /^\d{1,5}\.\d{1,5}$/;
71
+ // Package name: npm-style scoped or unscoped
72
+ const packageNamePattern = /^(@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9._-]+$/;
73
+ const slugField = zod_1.z.string().min(1).max(100).regex(slugPattern, 'Only alphanumeric, dash, underscore, dot, @, /');
74
+ const semverField = zod_1.z.string().min(5).max(50).regex(semverPattern, 'Must be semver (e.g. 1.0.0)');
75
+ const languageEnum = zod_1.z.enum(['nodejs', 'csharp', 'go', 'java', 'python']);
76
+ const categoryEnum = zod_1.z.enum(['service', 'observable', 'events', 'config']);
77
+ const visibilityEnum = zod_1.z.enum(['public', 'private']);
78
+ const safeString = (max) => zod_1.z.string().max(max).regex(safeAscii, 'ASCII printable characters only');
79
+ const safeStringRequired = (min, max) => zod_1.z.string().min(min).max(max).regex(safeAscii, 'ASCII printable characters only');
80
+ const optionalNonEmpty = (schema) => zod_1.z.preprocess((value) => value === '' ? undefined : value, schema.optional());
81
+ // ---- Route param schemas ----
82
+ const OrgParamsSchema = zod_1.z.object({
83
+ org: slugField,
84
+ });
85
+ const PluginDetailParamsSchema = zod_1.z.object({
86
+ org: slugField,
87
+ name: slugField,
88
+ });
89
+ const PluginVersionParamsSchema = zod_1.z.object({
90
+ org: slugField,
91
+ name: slugField,
92
+ version: semverField,
93
+ });
94
+ const PluginTypesParamsSchema = zod_1.z.object({
95
+ org: slugField,
96
+ name: slugField,
97
+ version: semverField,
98
+ language: languageEnum,
99
+ });
100
+ // ---- Query string schemas ----
101
+ // Note: z.coerce handles string-to-number conversion for query params
102
+ const BrowseQuerySchema = zod_1.z.object({
103
+ page: zod_1.z.coerce.number().int().min(1).max(10000).optional(),
104
+ query: optionalNonEmpty(safeString(200)),
105
+ category: optionalNonEmpty(categoryEnum),
106
+ language: optionalNonEmpty(languageEnum),
107
+ limit: zod_1.z.coerce.number().int().min(1).max(100).optional(),
108
+ offset: zod_1.z.coerce.number().int().min(0).max(100000).optional(),
109
+ }).passthrough(); // ignore unexpected query params (referrer tracking etc.)
110
+ const VersionsQuerySchema = zod_1.z.object({
111
+ majorMinor: zod_1.z.string().max(11).regex(majorMinorPattern, 'Must be major.minor (e.g. 1.0)').optional(),
112
+ }).passthrough();
113
+ const MatchQuerySchema = zod_1.z.object({
114
+ version: zod_1.z.string().min(1).max(20).regex(safeAscii, 'ASCII only'),
115
+ }).passthrough();
116
+ const DocsQuerySchema = zod_1.z.object({
117
+ index: zod_1.z.coerce.number().int().min(0).max(100).optional(),
118
+ }).passthrough();
119
+ // ---- EventSchemaExport validation (parsed from the JSON string clients send) ----
120
+ const EventExportEntryZod = zod_1.z.object({
121
+ type: zod_1.z.enum(['fire-and-forget', 'returnable', 'broadcast']),
122
+ category: zod_1.z.enum([
123
+ 'emitEvents', 'onEvents',
124
+ 'emitReturnableEvents', 'onReturnableEvents',
125
+ 'emitBroadcast', 'onBroadcast',
126
+ ]),
127
+ description: zod_1.z.string().max(1000).optional(),
128
+ defaultTimeout: zod_1.z.number().int().min(0).max(300).optional(),
129
+ inputSchema: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
130
+ outputSchema: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).nullable(),
131
+ }).passthrough();
132
+ const EventSchemaExportZod = zod_1.z.object({
133
+ pluginName: safeStringRequired(1, 200),
134
+ version: semverField,
135
+ events: zod_1.z.record(zod_1.z.string(), EventExportEntryZod).default({}),
136
+ capabilities: zod_1.z.unknown().optional(),
137
+ dependencies: zod_1.z.array(zod_1.z.object({
138
+ id: zod_1.z.string().min(1).max(200),
139
+ version: safeStringRequired(1, 50),
140
+ })).max(100).optional(),
141
+ }).passthrough();
142
+ // ---- Publish body schema ----
143
+ const AuthorSchema = zod_1.z.union([
144
+ safeString(200),
145
+ zod_1.z.object({
146
+ name: safeStringRequired(1, 200),
147
+ email: zod_1.z.string().max(200).email().optional(),
148
+ url: zod_1.z.string().max(500).url().optional(),
149
+ }).strict(),
150
+ ]);
151
+ const PublishBodySchema = zod_1.z.object({
152
+ org: zod_1.z.string().min(1).max(100).regex(slugPattern, 'Invalid org name'),
153
+ name: zod_1.z.string().min(1).max(100).regex(packageNamePattern, 'Invalid plugin name'),
154
+ version: semverField,
155
+ language: languageEnum,
156
+ metadata: zod_1.z.object({
157
+ displayName: safeStringRequired(1, 200),
158
+ description: zod_1.z.string().min(1).max(1000),
159
+ category: categoryEnum,
160
+ tags: zod_1.z.array(safeString(50)).max(30),
161
+ author: AuthorSchema.optional(),
162
+ license: safeString(50).optional(),
163
+ homepage: zod_1.z.string().max(500).url().optional(),
164
+ repository: zod_1.z.string().max(500).url().optional(),
165
+ }).strict(),
166
+ eventSchema: EventSchemaExportZod, // parsed object, validated at HTTP boundary
167
+ capabilities: zod_1.z.unknown().optional(),
168
+ configSchema: zod_1.z.object({
169
+ type: zod_1.z.literal('object'),
170
+ properties: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
171
+ required: zod_1.z.array(zod_1.z.string()).optional(),
172
+ description: zod_1.z.string().optional(),
173
+ }).passthrough().optional(),
174
+ typeDefinitions: zod_1.z.object({
175
+ nodejs: zod_1.z.string().max(5_000_000).optional(),
176
+ csharp: zod_1.z.string().max(5_000_000).optional(),
177
+ go: zod_1.z.string().max(5_000_000).optional(),
178
+ java: zod_1.z.string().max(5_000_000).optional(),
179
+ }).strict().optional(),
180
+ documentation: zod_1.z.array(zod_1.z.string().max(1_000_000)).min(1).max(20),
181
+ dependencies: zod_1.z.array(zod_1.z.object({
182
+ id: zod_1.z.string().min(1).max(200).regex(slugPattern, 'Invalid plugin ID'),
183
+ version: safeStringRequired(1, 50),
184
+ }).strict()).max(100).optional(),
185
+ package: zod_1.z.object({
186
+ nodejs: safeString(200).optional(),
187
+ csharp: safeString(200).optional(),
188
+ go: safeString(200).optional(),
189
+ java: safeString(200).optional(),
190
+ python: safeString(200).optional(),
191
+ }).strict().optional(),
192
+ runtime: zod_1.z.object({
193
+ nodejs: safeString(50).optional(),
194
+ dotnet: safeString(50).optional(),
195
+ go: safeString(50).optional(),
196
+ java: safeString(50).optional(),
197
+ python: safeString(50).optional(),
198
+ }).strict().optional(),
199
+ visibility: visibilityEnum.optional(),
200
+ }).strict();
201
+ class RegistryUIServer {
202
+ app;
203
+ port;
204
+ host;
205
+ pageSize;
206
+ uploadDir;
207
+ badgesFile;
208
+ maxImageUploadBytes;
209
+ imageIndexPath;
210
+ imageIndex = {};
211
+ badgeMap = {};
212
+ registryClient;
213
+ createTrace;
214
+ constructor(port, host, pageSize, uploadDir, badgesFile, maxImageUploadMb) {
215
+ this.port = port;
216
+ this.host = host;
217
+ this.pageSize = pageSize;
218
+ this.uploadDir = path.resolve(uploadDir);
219
+ this.badgesFile = path.resolve(badgesFile);
220
+ this.maxImageUploadBytes = maxImageUploadMb * 1024 * 1024;
221
+ this.imageIndexPath = path.join(this.uploadDir, 'images.json');
222
+ this.app = (0, fastify_1.default)({
223
+ logger: false,
224
+ disableRequestLogging: true,
225
+ bodyLimit: 10 * 1024 * 1024, // 10MB max request body
226
+ });
227
+ }
228
+ getHandlebarsHelpers() {
229
+ return {
230
+ eq: (a, b) => a === b,
231
+ neq: (a, b) => a !== b,
232
+ gt: (a, b) => a > b,
233
+ lt: (a, b) => a < b,
234
+ add: (a, b) => a + b,
235
+ subtract: (a, b) => a - b,
236
+ // Display plugin ID without the "_/" prefix for unaffiliated plugins
237
+ pluginDisplayId: (id) => {
238
+ const str = id;
239
+ if (str && str.startsWith('_/')) {
240
+ return str.substring(2);
241
+ }
242
+ return str;
243
+ },
244
+ range: (start, end, max) => {
245
+ const actualStart = Math.max(1, start);
246
+ const actualEnd = Math.min(max, end);
247
+ const result = [];
248
+ for (let i = actualStart; i <= actualEnd; i++) {
249
+ result.push(i);
250
+ }
251
+ return result;
252
+ },
253
+ // Display author - handles both string and { name, email?, url? } formats
254
+ formatAuthor: (author) => {
255
+ if (!author)
256
+ return '';
257
+ if (typeof author === 'string')
258
+ return author;
259
+ if (typeof author === 'object' && author !== null) {
260
+ const obj = author;
261
+ return obj.name || '';
262
+ }
263
+ return String(author);
264
+ },
265
+ // Check if an array has at least one item
266
+ hasItems: (arr) => {
267
+ return Array.isArray(arr) && arr.length > 0;
268
+ },
269
+ // Build the correct /plugins/:org/:name URL for a dependency ID.
270
+ // Handles both "org/name" and bare "name" (assumes _ org).
271
+ dependencyHref: (depId) => {
272
+ const str = depId;
273
+ if (!str)
274
+ return '/plugins';
275
+ if (str.includes('/')) {
276
+ return `/plugins/${str}`;
277
+ }
278
+ return `/plugins/_/${str}`;
279
+ },
280
+ // Build query string preserving existing params
281
+ queryString: (context) => {
282
+ const params = new URLSearchParams();
283
+ for (const [key, value] of Object.entries(context)) {
284
+ if (value !== undefined && value !== null && value !== '') {
285
+ params.set(key, String(value));
286
+ }
287
+ }
288
+ const qs = params.toString();
289
+ return qs ? `?${qs}` : '';
290
+ },
291
+ };
292
+ }
293
+ registerHandlebarsHelpers() {
294
+ const helpers = this.getHandlebarsHelpers();
295
+ for (const [name, fn] of Object.entries(helpers)) {
296
+ handlebars_1.default.registerHelper(name, fn);
297
+ }
298
+ }
299
+ async init(obs, plugin) {
300
+ const span = obs.startSpan('RegistryUIServer.init');
301
+ try {
302
+ // Bind plugin context to this server instance
303
+ this.registryClient = plugin.registryClient;
304
+ this.createTrace = plugin.createTrace.bind(plugin);
305
+ // Register CORS
306
+ const corsSpan = obs.startSpan('register.cors');
307
+ await this.app.register(cors_1.default, {
308
+ origin: '*',
309
+ methods: ['GET', 'POST', 'OPTIONS'],
310
+ allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
311
+ });
312
+ corsSpan.end();
313
+ // Register multipart upload handling for plugin images
314
+ const multipartSpan = obs.startSpan('register.multipart');
315
+ await this.app.register(multipart_1.default, {
316
+ limits: {
317
+ fileSize: this.maxImageUploadBytes,
318
+ files: 1,
319
+ },
320
+ });
321
+ multipartSpan.end();
322
+ // Register Handlebars helpers
323
+ const helpersSpan = obs.startSpan('register.helpers');
324
+ this.registerHandlebarsHelpers();
325
+ helpersSpan.end();
326
+ // Register static file serving
327
+ const staticSpan = obs.startSpan('register.static');
328
+ const staticPath = path.join(plugin.pluginCwd, 'static');
329
+ await this.app.register(static_1.default, {
330
+ root: staticPath,
331
+ prefix: '/static/',
332
+ index: false,
333
+ });
334
+ await fsp.mkdir(this.uploadDir, { recursive: true });
335
+ await this.app.register(static_1.default, {
336
+ root: this.uploadDir,
337
+ prefix: '/images/',
338
+ decorateReply: false,
339
+ });
340
+ staticSpan.end();
341
+ await this.loadImageIndex(obs);
342
+ await this.loadBadgeMap(obs);
343
+ // Register Handlebars view engine
344
+ const viewSpan = obs.startSpan('register.handlebars');
345
+ const templatesPath = path.join(plugin.pluginCwd, 'templates');
346
+ obs.log.debug('Registering Handlebars templates from {path}', { path: templatesPath });
347
+ await this.app.register(view_1.default, {
348
+ engine: {
349
+ handlebars: handlebars_1.default,
350
+ },
351
+ root: templatesPath,
352
+ layout: 'layouts/main.hbs',
353
+ options: {
354
+ partials: {
355
+ 'plugin-card': 'partials/plugin-card.hbs',
356
+ 'pagination': 'partials/pagination.hbs',
357
+ 'search-form': 'partials/search-form.hbs',
358
+ },
359
+ helpers: this.getHandlebarsHelpers(),
360
+ },
361
+ });
362
+ viewSpan.end();
363
+ // Register global error handler — catches anything that slips through
364
+ // route-level try/catch (template errors, unexpected throws, etc.)
365
+ // This MUST NOT use reply.view() since the template engine itself may
366
+ // be the source of the error.
367
+ const errorSpan = obs.startSpan('register.errorHandler');
368
+ this.app.setErrorHandler((error, request, reply) => {
369
+ const statusCode = error.statusCode ?? 500;
370
+ // Log server errors
371
+ if (statusCode >= 500) {
372
+ obs.log.error('Unhandled server error on {method} {url}: {error}', {
373
+ error: error.message,
374
+ method: request.method,
375
+ url: request.url,
376
+ });
377
+ }
378
+ if (request.headers.accept?.includes('application/json')) {
379
+ reply.code(statusCode).send({
380
+ statusCode,
381
+ error: 'Internal Server Error',
382
+ });
383
+ }
384
+ else {
385
+ reply
386
+ .code(statusCode)
387
+ .type('text/html; charset=utf-8')
388
+ .send(this.fallbackErrorHtml(statusCode));
389
+ }
390
+ });
391
+ errorSpan.end();
392
+ // Register routes
393
+ const routesSpan = obs.startSpan('register.routes');
394
+ this.registerRoutes();
395
+ routesSpan.end();
396
+ obs.log.info('Registry UI & API server initialized successfully');
397
+ }
398
+ catch (error) {
399
+ obs.log.error('Failed to initialize Registry UI server: {error}', { error: error.message });
400
+ throw error;
401
+ }
402
+ finally {
403
+ span.end();
404
+ }
405
+ }
406
+ // ============================================================================
407
+ // Route Registration
408
+ // ============================================================================
409
+ registerRoutes() {
410
+ // --- Pages (HTML + JSON content negotiation) ---
411
+ // Homepage
412
+ this.app.get('/', async (request, reply) => {
413
+ return this.handleHome(request, reply);
414
+ });
415
+ // Browse + search plugins (combined list/search)
416
+ this.app.get('/plugins', async (request, reply) => {
417
+ return this.handleBrowse(request, reply);
418
+ });
419
+ // Org-scoped browse + search
420
+ this.app.get('/plugins/:org', async (request, reply) => {
421
+ return this.handleOrgBrowse(request, reply);
422
+ });
423
+ // Plugin details page
424
+ this.app.get('/plugins/:org/:name', async (request, reply) => {
425
+ return this.handlePluginDetail(request, reply);
426
+ });
427
+ // --- API-only routes (JSON) ---
428
+ // Registry stats
429
+ this.app.get('/stats', async (request, reply) => {
430
+ return this.handleStats(request, reply);
431
+ });
432
+ // Plugin versions
433
+ this.app.get('/plugins/:org/:name/versions', async (request, reply) => {
434
+ return this.handleVersions(request, reply);
435
+ });
436
+ // Version matching
437
+ this.app.get('/plugins/:org/:name/match', async (request, reply) => {
438
+ return this.handleMatchVersion(request, reply);
439
+ });
440
+ // Plugin event schema
441
+ this.app.get('/plugins/:org/:name/:version/schema', async (request, reply) => {
442
+ return this.handleSchema(request, reply);
443
+ });
444
+ // Plugin documentation
445
+ this.app.get('/plugins/:org/:name/:version/docs', async (request, reply) => {
446
+ return this.handleDocs(request, reply);
447
+ });
448
+ // Plugin type definitions
449
+ this.app.get('/plugins/:org/:name/:version/types/:language', async (request, reply) => {
450
+ return this.handleTypes(request, reply);
451
+ });
452
+ // --- Write routes (require auth) ---
453
+ // Publish plugin (immutable versions)
454
+ this.app.post('/plugins', async (request, reply) => {
455
+ return this.handlePublish(request, reply);
456
+ });
457
+ // Upload/replace plugin image
458
+ this.app.post('/plugins/:org/:name/image', async (request, reply) => {
459
+ return this.handleImageUpload(request, reply);
460
+ });
461
+ // Health check
462
+ this.app.get('/health', async (_request, _reply) => {
463
+ return { status: 'ok' };
464
+ });
465
+ }
466
+ // ============================================================================
467
+ // Auth
468
+ // ============================================================================
469
+ /**
470
+ * Authenticate request via Bearer token.
471
+ * Returns the userId on success, or null if not authenticated (also sends 401 reply).
472
+ */
473
+ async authenticateRequest(request, reply, trace) {
474
+ const authHeader = request.headers.authorization;
475
+ if (!authHeader) {
476
+ trace.log.warn('Missing Authorization header');
477
+ reply.code(401).send({ error: 'Unauthorized', code: 'MISSING_TOKEN' });
478
+ return null;
479
+ }
480
+ const match = authHeader.match(/^Bearer (.+)$/);
481
+ if (!match) {
482
+ trace.log.warn('Invalid Authorization header format');
483
+ reply.code(401).send({ error: 'Unauthorized', code: 'INVALID_TOKEN_FORMAT' });
484
+ return null;
485
+ }
486
+ const token = match[1];
487
+ const authSpan = trace.startSpan('auth.verify');
488
+ try {
489
+ const result = await this.registryClient.registryAuthVerify(trace, { token });
490
+ authSpan.end();
491
+ if (!result.valid) {
492
+ trace.log.warn('Token verification failed');
493
+ reply.code(401).send({ error: 'Unauthorized', code: 'INVALID_TOKEN' });
494
+ return null;
495
+ }
496
+ return result.userId || 'unknown';
497
+ }
498
+ catch (error) {
499
+ authSpan.end();
500
+ trace.log.error('Auth verification error: {error}', { error: error.message });
501
+ reply.code(401).send({ error: 'Unauthorized', code: 'AUTH_ERROR' });
502
+ return null;
503
+ }
504
+ }
505
+ // ============================================================================
506
+ // Helpers
507
+ // ============================================================================
508
+ /** Check if request accepts JSON */
509
+ wantsJson(request) {
510
+ return request.headers.accept?.includes('application/json') === true;
511
+ }
512
+ /**
513
+ * Validate input against a Zod schema. Returns parsed data on success,
514
+ * or sends a 400 response and returns null on failure.
515
+ */
516
+ validateInput(schema, data, reply) {
517
+ const result = schema.safeParse(data);
518
+ if (!result.success) {
519
+ const issues = result.error.issues.map((issue) => ({
520
+ path: issue.path.join('.'),
521
+ message: issue.message,
522
+ }));
523
+ reply.code(400).send({
524
+ error: 'Validation Error',
525
+ code: 'INVALID_INPUT',
526
+ details: issues,
527
+ });
528
+ return null;
529
+ }
530
+ return result.data;
531
+ }
532
+ /** Render an error page (HTML) or send JSON error depending on Accept header */
533
+ async renderError(request, reply, statusCode, title, message) {
534
+ if (this.wantsJson(request)) {
535
+ reply.code(statusCode).send({ error: title, message });
536
+ }
537
+ else {
538
+ await reply.code(statusCode).view('pages/error.hbs', {
539
+ title: `${statusCode} - ${title}`,
540
+ statusCode,
541
+ message,
542
+ });
543
+ }
544
+ }
545
+ async loadImageIndex(obs) {
546
+ try {
547
+ const raw = await fsp.readFile(this.imageIndexPath, 'utf-8');
548
+ const parsed = JSON.parse(raw);
549
+ if (parsed && typeof parsed === 'object') {
550
+ this.imageIndex = parsed;
551
+ }
552
+ }
553
+ catch {
554
+ this.imageIndex = {};
555
+ }
556
+ obs.log.debug('Loaded image index with {count} entries', { count: Object.keys(this.imageIndex).length });
557
+ }
558
+ async saveImageIndex() {
559
+ await fsp.mkdir(this.uploadDir, { recursive: true });
560
+ await fsp.writeFile(this.imageIndexPath, JSON.stringify(this.imageIndex, null, 2), 'utf-8');
561
+ }
562
+ async loadBadgeMap(obs) {
563
+ try {
564
+ const raw = await fsp.readFile(this.badgesFile, 'utf-8');
565
+ const parsed = JSON.parse(raw);
566
+ this.badgeMap = parsed && typeof parsed === 'object'
567
+ ? parsed
568
+ : {};
569
+ }
570
+ catch {
571
+ this.badgeMap = {};
572
+ }
573
+ obs.log.debug('Loaded badge map with {count} entries', { count: Object.keys(this.badgeMap).length });
574
+ }
575
+ resolvePluginImageUrl(pluginId) {
576
+ const filename = this.imageIndex[pluginId];
577
+ if (!filename)
578
+ return null;
579
+ const filePath = path.join(this.uploadDir, filename);
580
+ if (!fs.existsSync(filePath)) {
581
+ delete this.imageIndex[pluginId];
582
+ return null;
583
+ }
584
+ return `/images/${filename}`;
585
+ }
586
+ normalizeBadgeLabel(raw) {
587
+ return raw.trim().toUpperCase();
588
+ }
589
+ resolvePluginBadges(plugin) {
590
+ const id = String(plugin.id || '');
591
+ const org = String(plugin.org || '').trim();
592
+ const mapped = this.badgeMap[id];
593
+ if (typeof mapped === 'string' && mapped.trim()) {
594
+ const label = this.normalizeBadgeLabel(mapped);
595
+ const type = label === 'CORE' ? 'core' : label === 'OFFICIAL' ? 'official' : 'custom';
596
+ return [{ label, type }];
597
+ }
598
+ if (Array.isArray(mapped) && mapped.length > 0) {
599
+ return mapped
600
+ .filter((item) => typeof item === 'string' && item.trim().length > 0)
601
+ .map((item) => {
602
+ const label = this.normalizeBadgeLabel(item);
603
+ const type = label === 'CORE' ? 'core' : label === 'OFFICIAL' ? 'official' : 'custom';
604
+ return { label, type };
605
+ });
606
+ }
607
+ if (org && org !== '_') {
608
+ return [{ label: org.toUpperCase(), type: 'org' }];
609
+ }
610
+ return [{ label: 'COMMUNITY', type: 'community' }];
611
+ }
612
+ enrichPlugin(plugin) {
613
+ const id = String(plugin.id || '');
614
+ return {
615
+ ...plugin,
616
+ imageUrl: this.resolvePluginImageUrl(id),
617
+ badges: this.resolvePluginBadges(plugin),
618
+ };
619
+ }
620
+ enrichPluginList(plugins) {
621
+ return plugins.map((plugin) => this.enrichPlugin(plugin));
622
+ }
623
+ getImageExtension(mimeType) {
624
+ const map = {
625
+ 'image/png': '.png',
626
+ 'image/jpeg': '.jpg',
627
+ 'image/webp': '.webp',
628
+ 'image/gif': '.gif',
629
+ 'image/svg+xml': '.svg',
630
+ };
631
+ return map[mimeType] || null;
632
+ }
633
+ /**
634
+ * Self-contained HTML error page that never touches the template engine.
635
+ * Used by the global error handler as a last-resort fallback.
636
+ */
637
+ fallbackErrorHtml(statusCode) {
638
+ return `<!DOCTYPE html>
639
+ <html lang="en">
640
+ <head>
641
+ <meta charset="utf-8">
642
+ <meta name="viewport" content="width=device-width,initial-scale=1">
643
+ <title>${statusCode} - BSB Registry</title>
644
+ <style>
645
+ *{margin:0;padding:0;box-sizing:border-box}
646
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh}
647
+ .c{text-align:center;padding:2rem}
648
+ h1{font-size:6rem;font-weight:800;color:#FB8C00;line-height:1;margin-bottom:1rem}
649
+ h2{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
650
+ p{color:#a0a0a0;margin-bottom:2rem}
651
+ a{display:inline-block;padding:.75rem 1.5rem;background:#FB8C00;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;margin:0 .5rem}
652
+ a:hover{background:#e65100}
653
+ a.s{background:#2a2a2a;border:1px solid #3a3a3a}
654
+ a.s:hover{background:#333;border-color:#FB8C00}
655
+ </style>
656
+ </head>
657
+ <body>
658
+ <div class="c">
659
+ <h1>${statusCode}</h1>
660
+ <h2>Internal Server Error</h2>
661
+ <p>Something went wrong. Please try again.</p>
662
+ <a href="/">Go Home</a>
663
+ <a class="s" href="/plugins">Browse Plugins</a>
664
+ </div>
665
+ </body>
666
+ </html>`;
667
+ }
668
+ /**
669
+ * Count events by type from an events map (Record<string, EventExportEntry>).
670
+ * The stored `eventSchema` field is the events map directly (not the full export wrapper).
671
+ */
672
+ countEvents(eventsMap) {
673
+ const counts = { total: 0, emit: 0, on: 0, returnable: 0, broadcast: 0 };
674
+ if (!eventsMap || typeof eventsMap !== 'object')
675
+ return counts;
676
+ for (const def of Object.values(eventsMap)) {
677
+ switch (def.category) {
678
+ case 'emitEvents':
679
+ counts.emit++;
680
+ break;
681
+ case 'onEvents':
682
+ counts.on++;
683
+ break;
684
+ case 'emitReturnableEvents':
685
+ case 'onReturnableEvents':
686
+ counts.returnable++;
687
+ break;
688
+ case 'emitBroadcast':
689
+ case 'onBroadcast':
690
+ counts.broadcast++;
691
+ break;
692
+ }
693
+ }
694
+ counts.total = counts.emit + counts.on + counts.returnable + counts.broadcast;
695
+ return counts;
696
+ }
697
+ /**
698
+ * Transform the flat events map into client-perspective grouped format for Handlebars.
699
+ *
700
+ * The stored schema records the SERVICE perspective (onReturnableEvents = the service
701
+ * listens). But the registry shows the CLIENT perspective: if the service listens,
702
+ * the client emits-and-returns.
703
+ *
704
+ * Mapping (service -> client perspective):
705
+ * onReturnableEvents -> emitReturnableEvents (client calls, service responds)
706
+ * emitReturnableEvents -> onReturnableEvents (service calls, client responds)
707
+ * onEvents -> emitEvents (client fires, service handles)
708
+ * emitEvents -> onEvents (service fires, client handles)
709
+ * onBroadcast -> emitBroadcast (client broadcasts, service receives)
710
+ * emitBroadcast -> onBroadcast (service broadcasts, client receives)
711
+ *
712
+ * Also extracts input/output schema property names for display.
713
+ */
714
+ groupEventsByCategory(eventsMap) {
715
+ // Flip map: service perspective -> client perspective
716
+ const flipMap = {
717
+ onReturnableEvents: 'emitReturnableEvents',
718
+ emitReturnableEvents: 'onReturnableEvents',
719
+ onEvents: 'emitEvents',
720
+ emitEvents: 'onEvents',
721
+ onBroadcast: 'emitBroadcast',
722
+ emitBroadcast: 'onBroadcast',
723
+ };
724
+ const grouped = {};
725
+ if (!eventsMap || typeof eventsMap !== 'object')
726
+ return grouped;
727
+ for (const [name, def] of Object.entries(eventsMap)) {
728
+ const d = def;
729
+ const serviceCat = d.category || 'onEvents';
730
+ const clientCat = flipMap[serviceCat] || serviceCat;
731
+ if (!grouped[clientCat])
732
+ grouped[clientCat] = [];
733
+ // Extract property names from JSON Schema for input/output display
734
+ const inputProps = this.extractSchemaProps(d.inputSchema);
735
+ const outputProps = this.extractSchemaProps(d.outputSchema);
736
+ grouped[clientCat].push({
737
+ name,
738
+ description: d.description,
739
+ type: d.type,
740
+ inputProps,
741
+ outputProps,
742
+ });
743
+ }
744
+ return grouped;
745
+ }
746
+ /**
747
+ * Extract property names and types from a JSON Schema object for display.
748
+ * Returns an array of { name, type, required, description } for each property.
749
+ */
750
+ extractSchemaProps(schema) {
751
+ if (!schema || typeof schema !== 'object' || schema.type !== 'object')
752
+ return [];
753
+ const props = schema.properties;
754
+ if (!props || typeof props !== 'object')
755
+ return [];
756
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
757
+ const result = [];
758
+ for (const [name, def] of Object.entries(props)) {
759
+ const d = def;
760
+ let typeLabel = d.type || 'unknown';
761
+ // Enhance type label for common patterns
762
+ if (d.enum) {
763
+ typeLabel = d.enum.map((v) => `"${v}"`).join(' | ');
764
+ }
765
+ else if (d.type === 'array' && d.items) {
766
+ const itemType = d.items.type || 'unknown';
767
+ typeLabel = `${itemType}[]`;
768
+ }
769
+ else if (d.type === 'object') {
770
+ typeLabel = 'object';
771
+ }
772
+ else if (d.format) {
773
+ typeLabel = d.format;
774
+ }
775
+ result.push({
776
+ name,
777
+ type: typeLabel,
778
+ required: required.has(name),
779
+ description: d.description || '',
780
+ });
781
+ }
782
+ return result;
783
+ }
784
+ /**
785
+ * Extract config properties from a JSON Schema for template display.
786
+ * Builds a tree-like flattened node list preserving hierarchy depth.
787
+ */
788
+ extractConfigProps(schema) {
789
+ if (!schema || schema.type !== 'object' || !schema.properties)
790
+ return [];
791
+ const nodes = [];
792
+ const toStringSet = (values) => {
793
+ if (!Array.isArray(values))
794
+ return new Set();
795
+ return new Set(values.filter((v) => typeof v === 'string'));
796
+ };
797
+ const walk = (current, parentPath, required, level) => {
798
+ const props = current?.properties;
799
+ if (!props || typeof props !== 'object')
800
+ return;
801
+ for (const [name, def] of Object.entries(props)) {
802
+ const d = def;
803
+ const fullPath = parentPath ? `${parentPath}.${name}` : name;
804
+ if (d.type === 'object' && d.properties && typeof d.properties === 'object') {
805
+ nodes.push({
806
+ name,
807
+ fullPath,
808
+ level,
809
+ isObject: true,
810
+ required: required.has(name),
811
+ description: d.description || '',
812
+ });
813
+ const nestedRequired = toStringSet(d.required);
814
+ walk(d, fullPath, nestedRequired, level + 1);
815
+ continue;
816
+ }
817
+ nodes.push({
818
+ name,
819
+ fullPath,
820
+ level,
821
+ isObject: false,
822
+ type: this.configTypeLabel(d),
823
+ required: required.has(name),
824
+ description: d.description || '',
825
+ defaultValue: d.default !== undefined ? JSON.stringify(d.default) : null,
826
+ });
827
+ }
828
+ };
829
+ walk(schema, '', toStringSet(schema.required), 0);
830
+ return nodes;
831
+ }
832
+ /** Get a human-readable type label from a JSON Schema property definition */
833
+ configTypeLabel(def) {
834
+ if (def.enum)
835
+ return def.enum.map((v) => JSON.stringify(v)).join(' | ');
836
+ if (def.type === 'array' && def.items)
837
+ return `${def.items.type || 'unknown'}[]`;
838
+ if (def.format)
839
+ return def.format;
840
+ return def.type || 'unknown';
841
+ }
842
+ extractObservableFeatureGroups(capabilities) {
843
+ const groups = [
844
+ {
845
+ title: 'Logging',
846
+ key: 'logging',
847
+ labels: { debug: 'debug', info: 'info', warn: 'warn', error: 'error' },
848
+ },
849
+ {
850
+ title: 'Metrics',
851
+ key: 'metrics',
852
+ labels: {
853
+ createCounter: 'createCounter',
854
+ createGauge: 'createGauge',
855
+ createHistogram: 'createHistogram',
856
+ incrementCounter: 'incrementCounter',
857
+ setGauge: 'setGauge',
858
+ observeHistogram: 'observeHistogram',
859
+ },
860
+ },
861
+ {
862
+ title: 'Tracing',
863
+ key: 'tracing',
864
+ labels: { spanStart: 'spanStart', spanEnd: 'spanEnd', spanError: 'spanError' },
865
+ },
866
+ ];
867
+ if (!capabilities || typeof capabilities !== 'object') {
868
+ return [];
869
+ }
870
+ return groups
871
+ .map((group) => {
872
+ const source = capabilities[group.key];
873
+ if (!source || typeof source !== 'object') {
874
+ return null;
875
+ }
876
+ const items = Object.entries(group.labels).map(([key, label]) => ({
877
+ name: label,
878
+ supported: source[key] === true,
879
+ }));
880
+ return { title: group.title, items };
881
+ })
882
+ .filter((g) => g !== null);
883
+ }
884
+ /**
885
+ * Build documentation tabs from an array of markdown strings.
886
+ * Extracts the title from the first # heading in each document.
887
+ * Returns an array of { id, title, html, active } for the template.
888
+ */
889
+ buildDocTabs(docs) {
890
+ if (!Array.isArray(docs) || docs.length === 0) {
891
+ // Legacy format: object with { readme, changelog?, ... }
892
+ if (docs && typeof docs === 'object' && !Array.isArray(docs)) {
893
+ const legacy = docs;
894
+ if (typeof legacy.readme === 'string') {
895
+ const title = this.extractMarkdownTitle(legacy.readme) || 'README';
896
+ try {
897
+ return [{ id: 'doc-0', title, html: this.renderMarkdown(legacy.readme), active: true }];
898
+ }
899
+ catch {
900
+ return null;
901
+ }
902
+ }
903
+ }
904
+ return null;
905
+ }
906
+ const tabs = [];
907
+ for (let i = 0; i < docs.length; i++) {
908
+ const md = docs[i];
909
+ if (typeof md !== 'string' || !md.trim())
910
+ continue;
911
+ const title = this.extractMarkdownTitle(md) || `Document ${i + 1}`;
912
+ try {
913
+ tabs.push({
914
+ id: `doc-${i}`,
915
+ title,
916
+ html: this.renderMarkdown(md),
917
+ active: i === 0,
918
+ });
919
+ }
920
+ catch {
921
+ // Skip docs that fail to render
922
+ }
923
+ }
924
+ return tabs.length > 0 ? tabs : null;
925
+ }
926
+ /**
927
+ * Extract the title from the first # heading in a markdown string.
928
+ * Returns null if no heading is found.
929
+ */
930
+ extractMarkdownTitle(md) {
931
+ // Match the first line that starts with # (h1)
932
+ const match = md.match(/^#\s+(.+)$/m);
933
+ return match ? match[1].trim() : null;
934
+ }
935
+ /** Extract major.minor from semantic version */
936
+ extractMajorMinor(version) {
937
+ const parts = version.split('.');
938
+ if (parts.length < 2) {
939
+ throw new Error(`Invalid semantic version: ${version}`);
940
+ }
941
+ return `${parts[0]}.${parts[1]}`;
942
+ }
943
+ /**
944
+ * Render a markdown string to sanitized HTML.
945
+ * External links open in a new tab. Raw HTML in the source is escaped.
946
+ */
947
+ renderMarkdown(md) {
948
+ const renderer = new marked_1.marked.Renderer();
949
+ // Open external links in new tab with noopener
950
+ renderer.link = ({ href, title, text }) => {
951
+ const titleAttr = title ? ` title="${title}"` : '';
952
+ return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
953
+ };
954
+ return marked_1.marked.parse(md, {
955
+ renderer,
956
+ gfm: true,
957
+ breaks: false,
958
+ });
959
+ }
960
+ // ============================================================================
961
+ // Page Handlers (HTML + JSON content negotiation)
962
+ // ============================================================================
963
+ async handleHome(request, reply) {
964
+ const trace = this.createTrace('ui.home', {
965
+ url: request.url,
966
+ method: request.method,
967
+ accept: request.headers.accept || 'text/html',
968
+ });
969
+ const span = trace.startSpan('render.home');
970
+ try {
971
+ const statsSpan = trace.startSpan('events.registry.stats.get');
972
+ const stats = await this.registryClient.registryStatsGet(trace, {});
973
+ statsSpan.end();
974
+ const listSpan = trace.startSpan('events.registry.plugin.list');
975
+ const listResult = await this.registryClient.registryPluginList(trace, { limit: 12, offset: 0 });
976
+ listSpan.end();
977
+ const plugins = this.enrichPluginList(listResult.results);
978
+ if (this.wantsJson(request)) {
979
+ trace.log.debug('Returned home data as JSON');
980
+ reply.send({
981
+ stats,
982
+ plugins,
983
+ total: listResult.total,
984
+ });
985
+ }
986
+ else {
987
+ const renderSpan = trace.startSpan('handlebars.render');
988
+ await reply.view('pages/home.hbs', {
989
+ title: 'BSB Plugin Registry',
990
+ activePage: 'home',
991
+ stats,
992
+ plugins,
993
+ pageSize: this.pageSize,
994
+ });
995
+ renderSpan.end();
996
+ trace.log.debug('Rendered home page as HTML');
997
+ }
998
+ }
999
+ catch (error) {
1000
+ trace.log.error('Failed to render home page: {error}', { error: error.message });
1001
+ await this.renderError(request, reply, 500, 'Internal Server Error', 'Something went wrong loading the home page. Please try again.');
1002
+ }
1003
+ finally {
1004
+ span.end();
1005
+ }
1006
+ }
1007
+ async handleBrowse(request, reply) {
1008
+ return this._handleBrowseInternal(request, reply);
1009
+ }
1010
+ async handleOrgBrowse(request, reply) {
1011
+ const params = this.validateInput(OrgParamsSchema, request.params, reply);
1012
+ if (!params)
1013
+ return;
1014
+ return this._handleBrowseInternal(request, reply, params.org);
1015
+ }
1016
+ /**
1017
+ * Internal browse handler shared by /plugins and /plugins/:org.
1018
+ * Supports list, search, pagination, and content negotiation.
1019
+ */
1020
+ async _handleBrowseInternal(request, reply, orgFilter) {
1021
+ const query = this.validateInput(BrowseQuerySchema, request.query, reply);
1022
+ if (!query)
1023
+ return;
1024
+ const trace = this.createTrace('ui.browse', {
1025
+ url: request.url,
1026
+ method: request.method,
1027
+ accept: request.headers.accept || 'text/html',
1028
+ ...(orgFilter && { org: orgFilter }),
1029
+ });
1030
+ const span = trace.startSpan('render.browse');
1031
+ try {
1032
+ const searchQuery = query.query || '';
1033
+ const isJson = this.wantsJson(request);
1034
+ const limit = query.limit ?? this.pageSize;
1035
+ const page = query.page ?? 1;
1036
+ const offset = query.offset ?? (page - 1) * limit;
1037
+ const categoryFilter = query.category;
1038
+ const languageFilter = query.language;
1039
+ let plugins = [];
1040
+ let total = 0;
1041
+ if (searchQuery) {
1042
+ // Search mode
1043
+ const searchSpan = trace.startSpan('events.registry.plugin.search');
1044
+ const searchResult = await this.registryClient.registryPluginSearch(trace, {
1045
+ query: searchQuery,
1046
+ limit,
1047
+ offset,
1048
+ category: categoryFilter,
1049
+ language: languageFilter,
1050
+ });
1051
+ searchSpan.end();
1052
+ plugins = searchResult.results;
1053
+ total = searchResult.total;
1054
+ }
1055
+ else {
1056
+ // Browse mode
1057
+ const listSpan = trace.startSpan('events.registry.plugin.list');
1058
+ const listResult = await this.registryClient.registryPluginList(trace, {
1059
+ limit,
1060
+ offset,
1061
+ org: orgFilter,
1062
+ category: categoryFilter,
1063
+ language: languageFilter,
1064
+ });
1065
+ listSpan.end();
1066
+ plugins = listResult.results;
1067
+ total = listResult.total;
1068
+ }
1069
+ const enrichedPlugins = this.enrichPluginList(plugins);
1070
+ const totalPages = Math.ceil(total / limit);
1071
+ if (isJson) {
1072
+ trace.log.debug('Returned browse data as JSON');
1073
+ reply.send({
1074
+ query: searchQuery || undefined,
1075
+ plugins: enrichedPlugins,
1076
+ total,
1077
+ page: Math.floor(offset / limit) + 1,
1078
+ totalPages,
1079
+ pageSize: limit,
1080
+ filters: {
1081
+ org: orgFilter,
1082
+ category: categoryFilter,
1083
+ language: languageFilter,
1084
+ },
1085
+ });
1086
+ }
1087
+ else {
1088
+ const renderSpan = trace.startSpan('handlebars.render');
1089
+ await reply.view('pages/plugins.hbs', {
1090
+ title: searchQuery
1091
+ ? `Search: ${searchQuery}`
1092
+ : orgFilter
1093
+ ? `Plugins by ${orgFilter}`
1094
+ : 'Browse Plugins',
1095
+ activePage: 'browse',
1096
+ searchQuery,
1097
+ plugins: enrichedPlugins,
1098
+ pagination: {
1099
+ currentPage: page,
1100
+ totalPages,
1101
+ total,
1102
+ pageSize: limit,
1103
+ },
1104
+ filters: {
1105
+ org: orgFilter,
1106
+ category: categoryFilter,
1107
+ language: languageFilter,
1108
+ },
1109
+ });
1110
+ renderSpan.end();
1111
+ trace.log.debug('Rendered browse page as HTML');
1112
+ }
1113
+ }
1114
+ catch (error) {
1115
+ trace.log.error('Failed to render browse page: {error}', { error: error.message });
1116
+ await this.renderError(request, reply, 500, 'Internal Server Error', 'Something went wrong loading plugins. Please try again.');
1117
+ }
1118
+ finally {
1119
+ span.end();
1120
+ }
1121
+ }
1122
+ async handlePluginDetail(request, reply) {
1123
+ const params = this.validateInput(PluginDetailParamsSchema, request.params, reply);
1124
+ if (!params)
1125
+ return;
1126
+ const trace = this.createTrace('ui.plugin.detail', {
1127
+ url: request.url,
1128
+ method: request.method,
1129
+ accept: request.headers.accept || 'text/html',
1130
+ });
1131
+ const span = trace.startSpan('render.plugin-detail');
1132
+ try {
1133
+ const pluginId = `${params.org}/${params.name}`;
1134
+ const getSpan = trace.startSpan('events.registry.plugin.get');
1135
+ let plugin;
1136
+ try {
1137
+ plugin = await this.registryClient.registryPluginGet(trace, { org: params.org, name: params.name });
1138
+ }
1139
+ catch {
1140
+ plugin = null;
1141
+ }
1142
+ getSpan.end();
1143
+ if (!plugin) {
1144
+ await this.renderError(request, reply, 404, 'Plugin Not Found', `Plugin "${pluginId}" could not be found in the registry.`);
1145
+ return;
1146
+ }
1147
+ const versionsSpan = trace.startSpan('events.registry.plugin.versions');
1148
+ const versions = await this.registryClient.registryPluginVersions(trace, { org: params.org, name: params.name });
1149
+ versionsSpan.end();
1150
+ if (this.wantsJson(request)) {
1151
+ const category = String(plugin.category || '');
1152
+ const pluginView = {
1153
+ ...this.enrichPlugin(plugin),
1154
+ showEventsCard: category === 'service',
1155
+ showDependenciesCard: category === 'service',
1156
+ showSupportedFeaturesCard: category === 'observable',
1157
+ showConfigCard: category === 'service' || category === 'config' || category === 'events' || category === 'observable',
1158
+ };
1159
+ trace.log.debug('Returned plugin detail as JSON for {id}', { id: pluginId });
1160
+ reply.send({
1161
+ plugin: pluginView,
1162
+ versions: versions.versions,
1163
+ });
1164
+ }
1165
+ else {
1166
+ // eventSchema is stored as the events map directly.
1167
+ // Handle legacy string format (old data before migration).
1168
+ let eventsMap = plugin.eventSchema;
1169
+ if (typeof eventsMap === 'string') {
1170
+ try {
1171
+ const parsed = JSON.parse(eventsMap);
1172
+ // Legacy: full export wrapper with .events key
1173
+ eventsMap = parsed.events ?? parsed;
1174
+ }
1175
+ catch {
1176
+ eventsMap = null;
1177
+ }
1178
+ }
1179
+ // Group events by category for the Handlebars template
1180
+ const groupedEvents = eventsMap
1181
+ ? this.groupEventsByCategory(eventsMap)
1182
+ : null;
1183
+ const category = String(plugin.category || '');
1184
+ const showEventsCard = category === 'service' && !!groupedEvents && Object.keys(groupedEvents).length > 0;
1185
+ const showDependenciesCard = category === 'service';
1186
+ const showSupportedFeaturesCard = category === 'observable';
1187
+ // Build documentation tabs from the array of markdown strings.
1188
+ // Each doc's title is extracted from the first # heading.
1189
+ // First doc is the active tab by default.
1190
+ const docTabs = this.buildDocTabs(plugin.documentation);
1191
+ // Extract config schema properties for display
1192
+ const configProps = plugin.configSchema
1193
+ ? this.extractConfigProps(plugin.configSchema)
1194
+ : null;
1195
+ const hasNestedConfigProps = !!configProps && configProps.some((node) => Number(node.level || 0) > 0);
1196
+ const showConfigCard = category === 'service' || category === 'config' || category === 'events' || category === 'observable';
1197
+ const configDescription = category === 'config'
1198
+ ? 'Configuration for config plugins is provided through environment variables:'
1199
+ : 'Configuration options for this plugin:';
1200
+ const observableFeatureGroups = category === 'observable'
1201
+ ? this.extractObservableFeatureGroups(plugin.capabilities)
1202
+ : [];
1203
+ const pluginView = {
1204
+ ...this.enrichPlugin(plugin),
1205
+ eventSchema: groupedEvents,
1206
+ configProps,
1207
+ hasNestedConfigProps,
1208
+ configDescription,
1209
+ showConfigCard,
1210
+ showEventsCard,
1211
+ showDependenciesCard,
1212
+ showSupportedFeaturesCard,
1213
+ observableFeatureGroups,
1214
+ docTabs,
1215
+ };
1216
+ const renderSpan = trace.startSpan('handlebars.render');
1217
+ await reply.view('pages/plugin-detail.hbs', {
1218
+ title: `${plugin.displayName || plugin.name} - BSB Registry`,
1219
+ plugin: pluginView,
1220
+ versions: versions.versions,
1221
+ });
1222
+ renderSpan.end();
1223
+ trace.log.debug('Rendered plugin detail page as HTML for {id}', { id: pluginId });
1224
+ }
1225
+ }
1226
+ catch (error) {
1227
+ trace.log.error('Failed to render plugin detail: {error}', { error: error.message });
1228
+ await this.renderError(request, reply, 500, 'Internal Server Error', 'Something went wrong loading plugin details. Please try again.');
1229
+ }
1230
+ finally {
1231
+ span.end();
1232
+ }
1233
+ }
1234
+ // ============================================================================
1235
+ // API-only Handlers (JSON responses)
1236
+ // ============================================================================
1237
+ /** GET /stats — Registry statistics */
1238
+ async handleStats(request, reply) {
1239
+ const trace = this.createTrace('api.stats', {
1240
+ url: request.url,
1241
+ method: request.method,
1242
+ });
1243
+ try {
1244
+ const statsSpan = trace.startSpan('events.registry.stats.get');
1245
+ const stats = await this.registryClient.registryStatsGet(trace, {});
1246
+ statsSpan.end();
1247
+ reply.send(stats);
1248
+ }
1249
+ catch (error) {
1250
+ trace.log.error('Failed to get stats: {error}', { error: error.message });
1251
+ reply.code(500).send({ error: 'Internal Server Error' });
1252
+ }
1253
+ }
1254
+ /** GET /plugins/:org/:name/versions — Plugin version list */
1255
+ async handleVersions(request, reply) {
1256
+ const params = this.validateInput(PluginDetailParamsSchema, request.params, reply);
1257
+ if (!params)
1258
+ return;
1259
+ const query = this.validateInput(VersionsQuerySchema, request.query, reply);
1260
+ if (!query)
1261
+ return;
1262
+ const trace = this.createTrace('api.versions', {
1263
+ url: request.url,
1264
+ method: request.method,
1265
+ });
1266
+ try {
1267
+ const versionsSpan = trace.startSpan('events.registry.plugin.versions');
1268
+ const result = await this.registryClient.registryPluginVersions(trace, { org: params.org, name: params.name, majorMinor: query.majorMinor });
1269
+ versionsSpan.end();
1270
+ if (result.versions.length === 0) {
1271
+ reply.code(404).send({
1272
+ error: `Plugin not found: ${params.org}/${params.name}`,
1273
+ code: 'PLUGIN_NOT_FOUND',
1274
+ });
1275
+ return;
1276
+ }
1277
+ reply.send(result);
1278
+ }
1279
+ catch (error) {
1280
+ trace.log.error('Failed to get versions: {error}', { error: error.message });
1281
+ reply.code(500).send({ error: 'Internal Server Error' });
1282
+ }
1283
+ }
1284
+ /** GET /plugins/:org/:name/match?version=1.0 — Find latest patch for major.minor */
1285
+ async handleMatchVersion(request, reply) {
1286
+ const params = this.validateInput(PluginDetailParamsSchema, request.params, reply);
1287
+ if (!params)
1288
+ return;
1289
+ const query = this.validateInput(MatchQuerySchema, request.query, reply);
1290
+ if (!query)
1291
+ return;
1292
+ const trace = this.createTrace('api.match', {
1293
+ url: request.url,
1294
+ method: request.method,
1295
+ });
1296
+ try {
1297
+ const requested = query.version;
1298
+ // Get all versions and find the latest patch for the requested major.minor
1299
+ const versionsSpan = trace.startSpan('events.registry.plugin.versions');
1300
+ const result = await this.registryClient.registryPluginVersions(trace, { org: params.org, name: params.name, majorMinor: requested });
1301
+ versionsSpan.end();
1302
+ if (result.versions.length === 0) {
1303
+ reply.code(404).send({
1304
+ error: `No version found matching ${requested} for ${params.org}/${params.name}`,
1305
+ code: 'VERSION_NOT_FOUND',
1306
+ });
1307
+ return;
1308
+ }
1309
+ // Latest patch is the first version in the filtered list
1310
+ const matched = result.versions[0].version;
1311
+ // Check if there's a newer major.minor available
1312
+ let alert;
1313
+ if (result.latest) {
1314
+ const latestMajorMinor = this.extractMajorMinor(result.latest);
1315
+ if (latestMajorMinor !== requested) {
1316
+ alert = `Newer major.minor available: ${latestMajorMinor}`;
1317
+ }
1318
+ }
1319
+ reply.send({
1320
+ requested,
1321
+ matched,
1322
+ latest: result.latest,
1323
+ alert,
1324
+ });
1325
+ }
1326
+ catch (error) {
1327
+ trace.log.error('Failed to match version: {error}', { error: error.message });
1328
+ reply.code(500).send({ error: 'Internal Server Error' });
1329
+ }
1330
+ }
1331
+ /** GET /plugins/:org/:name/:version/schema — Plugin event schema (JSON) */
1332
+ async handleSchema(request, reply) {
1333
+ const params = this.validateInput(PluginVersionParamsSchema, request.params, reply);
1334
+ if (!params)
1335
+ return;
1336
+ const trace = this.createTrace('api.schema', {
1337
+ url: request.url,
1338
+ method: request.method,
1339
+ });
1340
+ try {
1341
+ const getSpan = trace.startSpan('events.registry.plugin.get');
1342
+ let plugin;
1343
+ try {
1344
+ plugin = await this.registryClient.registryPluginGet(trace, { org: params.org, name: params.name, version: params.version });
1345
+ }
1346
+ catch {
1347
+ plugin = null;
1348
+ }
1349
+ getSpan.end();
1350
+ if (!plugin) {
1351
+ reply.code(404).send({
1352
+ error: `Plugin not found: ${params.org}/${params.name}@${params.version}`,
1353
+ code: 'PLUGIN_NOT_FOUND',
1354
+ });
1355
+ return;
1356
+ }
1357
+ // eventSchema is stored as the events map only.
1358
+ // Reconstruct the full EventSchemaExport for the API response
1359
+ // using name/version from the root entry.
1360
+ let eventsMap = plugin.eventSchema;
1361
+ if (typeof eventsMap === 'string') {
1362
+ try {
1363
+ const parsed = JSON.parse(eventsMap);
1364
+ eventsMap = parsed.events ?? parsed;
1365
+ }
1366
+ catch {
1367
+ eventsMap = {};
1368
+ }
1369
+ }
1370
+ reply.send({
1371
+ pluginName: plugin.displayName || plugin.name,
1372
+ version: plugin.version,
1373
+ events: eventsMap || {},
1374
+ ...(plugin.capabilities ? { capabilities: plugin.capabilities } : {}),
1375
+ ...(plugin.configSchema ? { configSchema: plugin.configSchema } : {}),
1376
+ });
1377
+ }
1378
+ catch (error) {
1379
+ trace.log.error('Failed to get schema: {error}', { error: error.message });
1380
+ reply.code(500).send({ error: 'Internal Server Error' });
1381
+ }
1382
+ }
1383
+ /** GET /plugins/:org/:name/:version/docs?index=0 — Plugin documentation by index */
1384
+ async handleDocs(request, reply) {
1385
+ const params = this.validateInput(PluginVersionParamsSchema, request.params, reply);
1386
+ if (!params)
1387
+ return;
1388
+ const query = this.validateInput(DocsQuerySchema, request.query, reply);
1389
+ if (!query)
1390
+ return;
1391
+ const trace = this.createTrace('api.docs', {
1392
+ url: request.url,
1393
+ method: request.method,
1394
+ });
1395
+ try {
1396
+ const getSpan = trace.startSpan('events.registry.plugin.get');
1397
+ let plugin;
1398
+ try {
1399
+ plugin = await this.registryClient.registryPluginGet(trace, { org: params.org, name: params.name, version: params.version });
1400
+ }
1401
+ catch {
1402
+ plugin = null;
1403
+ }
1404
+ getSpan.end();
1405
+ if (!plugin) {
1406
+ reply.code(404).send({
1407
+ error: `Plugin not found: ${params.org}/${params.name}@${params.version}`,
1408
+ code: 'PLUGIN_NOT_FOUND',
1409
+ });
1410
+ return;
1411
+ }
1412
+ const docs = plugin.documentation;
1413
+ // Handle both array (new) and legacy object format
1414
+ if (Array.isArray(docs)) {
1415
+ const idx = query.index ?? 0;
1416
+ if (idx >= docs.length) {
1417
+ reply.code(404).send({
1418
+ error: `Documentation index ${idx} out of range (${docs.length} docs available)`,
1419
+ code: 'DOC_NOT_FOUND',
1420
+ });
1421
+ return;
1422
+ }
1423
+ reply.send({ content: docs[idx], format: 'markdown', index: idx, total: docs.length });
1424
+ }
1425
+ else if (docs && typeof docs === 'object') {
1426
+ // Legacy object format
1427
+ const legacy = docs;
1428
+ if (typeof legacy.readme === 'string') {
1429
+ reply.send({ content: legacy.readme, format: 'markdown', index: 0, total: 1 });
1430
+ }
1431
+ else {
1432
+ reply.code(404).send({ error: 'Documentation not available', code: 'DOCS_NOT_FOUND' });
1433
+ }
1434
+ }
1435
+ else {
1436
+ reply.code(404).send({ error: 'Documentation not available for this plugin', code: 'DOCS_NOT_FOUND' });
1437
+ }
1438
+ }
1439
+ catch (error) {
1440
+ trace.log.error('Failed to get docs: {error}', { error: error.message });
1441
+ reply.code(500).send({ error: 'Internal Server Error' });
1442
+ }
1443
+ }
1444
+ /** GET /plugins/:org/:name/:version/types/:language — Type definitions (text/plain) */
1445
+ async handleTypes(request, reply) {
1446
+ const params = this.validateInput(PluginTypesParamsSchema, request.params, reply);
1447
+ if (!params)
1448
+ return;
1449
+ const trace = this.createTrace('api.types', {
1450
+ url: request.url,
1451
+ method: request.method,
1452
+ });
1453
+ try {
1454
+ const getSpan = trace.startSpan('events.registry.plugin.get');
1455
+ let plugin;
1456
+ try {
1457
+ plugin = await this.registryClient.registryPluginGet(trace, { org: params.org, name: params.name, version: params.version });
1458
+ }
1459
+ catch {
1460
+ plugin = null;
1461
+ }
1462
+ getSpan.end();
1463
+ if (!plugin) {
1464
+ reply.code(404).send({
1465
+ error: `Plugin not found: ${params.org}/${params.name}@${params.version}`,
1466
+ code: 'PLUGIN_NOT_FOUND',
1467
+ });
1468
+ return;
1469
+ }
1470
+ if (!plugin.typeDefinitions || !plugin.typeDefinitions[params.language]) {
1471
+ reply.code(404).send({
1472
+ error: `Type definitions not available for language: ${params.language}`,
1473
+ code: 'TYPES_NOT_FOUND',
1474
+ });
1475
+ return;
1476
+ }
1477
+ reply.type('text/plain').send(plugin.typeDefinitions[params.language]);
1478
+ }
1479
+ catch (error) {
1480
+ trace.log.error('Failed to get types: {error}', { error: error.message });
1481
+ reply.code(500).send({ error: 'Internal Server Error' });
1482
+ }
1483
+ }
1484
+ // ============================================================================
1485
+ // Write Handlers (require auth)
1486
+ // ============================================================================
1487
+ /** POST /plugins/:org/:name/image — Upload or replace plugin image */
1488
+ async handleImageUpload(request, reply) {
1489
+ const params = this.validateInput(PluginDetailParamsSchema, request.params, reply);
1490
+ if (!params)
1491
+ return;
1492
+ const trace = this.createTrace('api.image.upload', {
1493
+ url: request.url,
1494
+ method: request.method,
1495
+ });
1496
+ try {
1497
+ const userId = await this.authenticateRequest(request, reply, trace);
1498
+ if (!userId)
1499
+ return;
1500
+ const getSpan = trace.startSpan('events.registry.plugin.get');
1501
+ let plugin;
1502
+ try {
1503
+ plugin = await this.registryClient.registryPluginGet(trace, { org: params.org, name: params.name });
1504
+ }
1505
+ catch {
1506
+ plugin = null;
1507
+ }
1508
+ getSpan.end();
1509
+ if (!plugin) {
1510
+ reply.code(404).send({
1511
+ error: `Plugin not found: ${params.org}/${params.name}`,
1512
+ code: 'PLUGIN_NOT_FOUND',
1513
+ });
1514
+ return;
1515
+ }
1516
+ const parts = request.parts?.();
1517
+ if (!parts) {
1518
+ reply.code(400).send({ error: 'Expected multipart/form-data body', code: 'INVALID_UPLOAD' });
1519
+ return;
1520
+ }
1521
+ let filePart = null;
1522
+ for await (const part of parts) {
1523
+ if (part.type === 'file') {
1524
+ filePart = part;
1525
+ break;
1526
+ }
1527
+ }
1528
+ if (!filePart) {
1529
+ reply.code(400).send({ error: 'No image file provided', code: 'MISSING_FILE' });
1530
+ return;
1531
+ }
1532
+ const ext = this.getImageExtension(filePart.mimetype);
1533
+ if (!ext) {
1534
+ reply.code(400).send({
1535
+ error: `Unsupported image type: ${filePart.mimetype}`,
1536
+ code: 'UNSUPPORTED_IMAGE_TYPE',
1537
+ });
1538
+ return;
1539
+ }
1540
+ const pluginId = `${params.org}/${params.name}`;
1541
+ const safeFileName = `${params.org}__${params.name}${ext}`;
1542
+ const outputPath = path.join(this.uploadDir, safeFileName);
1543
+ await fsp.mkdir(this.uploadDir, { recursive: true });
1544
+ await (0, promises_1.pipeline)(filePart.file, fs.createWriteStream(outputPath));
1545
+ if (filePart.file.truncated) {
1546
+ await fsp.unlink(outputPath).catch(() => { });
1547
+ reply.code(413).send({
1548
+ error: 'Image exceeds configured upload size limit',
1549
+ code: 'IMAGE_TOO_LARGE',
1550
+ });
1551
+ return;
1552
+ }
1553
+ const previousFile = this.imageIndex[pluginId];
1554
+ this.imageIndex[pluginId] = safeFileName;
1555
+ await this.saveImageIndex();
1556
+ if (previousFile && previousFile !== safeFileName) {
1557
+ await fsp.unlink(path.join(this.uploadDir, previousFile)).catch(() => { });
1558
+ }
1559
+ trace.log.info('Plugin image updated for {id} by {userId}', { id: pluginId, userId });
1560
+ reply.send({
1561
+ success: true,
1562
+ pluginId,
1563
+ imageUrl: `/images/${safeFileName}`,
1564
+ });
1565
+ }
1566
+ catch (error) {
1567
+ trace.log.error('Failed to upload plugin image: {error}', { error: error.message });
1568
+ reply.code(500).send({ error: 'Internal Server Error' });
1569
+ }
1570
+ }
1571
+ /** POST /plugins — Publish a plugin (immutable versions) */
1572
+ async handlePublish(request, reply) {
1573
+ // Validate body first, before any auth or tracing
1574
+ const body = this.validateInput(PublishBodySchema, request.body, reply);
1575
+ if (!body)
1576
+ return;
1577
+ const trace = this.createTrace('api.publish', {
1578
+ url: request.url,
1579
+ method: request.method,
1580
+ });
1581
+ try {
1582
+ // Authenticate (returns userId on success)
1583
+ const userId = await this.authenticateRequest(request, reply, trace);
1584
+ if (!userId)
1585
+ return;
1586
+ // eventSchema is already a validated object (Zod parsed it from the JSON body).
1587
+ // configSchema is also already validated by Zod if present.
1588
+ // Extract dependencies from eventSchema if not provided at top level
1589
+ const dependencies = body.dependencies ?? body.eventSchema.dependencies ?? [];
1590
+ // Publish via event (immutable versions - rejects if version exists)
1591
+ const publishSpan = trace.startSpan('events.registry.plugin.publish');
1592
+ const result = await this.registryClient.registryPluginPublish(trace, {
1593
+ org: body.org,
1594
+ name: body.name,
1595
+ version: body.version,
1596
+ language: body.language,
1597
+ metadata: body.metadata,
1598
+ eventSchema: body.eventSchema,
1599
+ configSchema: body.configSchema,
1600
+ typeDefinitions: body.typeDefinitions,
1601
+ documentation: body.documentation,
1602
+ dependencies,
1603
+ package: body.package,
1604
+ runtime: body.runtime,
1605
+ visibility: body.visibility || 'public',
1606
+ publishedBy: userId,
1607
+ });
1608
+ publishSpan.end();
1609
+ // Check if the core rejected the publish (version already exists)
1610
+ if (!result.success) {
1611
+ trace.log.warn('Publish rejected: {org}/{name}@{version} - {message}', {
1612
+ org: body.org,
1613
+ name: body.name,
1614
+ version: body.version,
1615
+ message: result.message || 'Version already exists',
1616
+ });
1617
+ reply.code(409).send(result);
1618
+ return;
1619
+ }
1620
+ trace.log.info('Plugin published: {org}/{name}@{version}', {
1621
+ org: body.org,
1622
+ name: body.name,
1623
+ version: body.version,
1624
+ });
1625
+ reply.send(result);
1626
+ }
1627
+ catch (error) {
1628
+ trace.log.error('Failed to publish plugin: {error}', { error: error.message });
1629
+ reply.code(500).send({ error: 'Internal Server Error' });
1630
+ }
1631
+ }
1632
+ // ============================================================================
1633
+ // Server Lifecycle
1634
+ // ============================================================================
1635
+ async start(obs) {
1636
+ const span = obs.startSpan('RegistryUIServer.start');
1637
+ try {
1638
+ await this.app.listen({
1639
+ port: this.port,
1640
+ host: this.host,
1641
+ });
1642
+ obs.log.info('Registry UI & API server started on {host}:{port}', {
1643
+ host: this.host,
1644
+ port: this.port,
1645
+ });
1646
+ }
1647
+ catch (error) {
1648
+ obs.log.error('Failed to start Registry UI server: {error}', { error: error.message });
1649
+ throw error;
1650
+ }
1651
+ finally {
1652
+ span.end();
1653
+ }
1654
+ }
1655
+ close() {
1656
+ this.app.close();
1657
+ }
1658
+ }
1659
+ exports.RegistryUIServer = RegistryUIServer;
1660
+ //# sourceMappingURL=http-server.js.map