@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.
- package/README.md +133 -0
- package/bsb-plugin.json +47 -0
- package/lib/.bsb/clients/service-bsb-registry.d.ts +1118 -0
- package/lib/.bsb/clients/service-bsb-registry.d.ts.map +1 -0
- package/lib/.bsb/clients/service-bsb-registry.js +393 -0
- package/lib/.bsb/clients/service-bsb-registry.js.map +1 -0
- package/lib/plugins/service-bsb-registry/auth.d.ts +87 -0
- package/lib/plugins/service-bsb-registry/auth.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry/auth.js +197 -0
- package/lib/plugins/service-bsb-registry/auth.js.map +1 -0
- package/lib/plugins/service-bsb-registry/db/file.d.ts +73 -0
- package/lib/plugins/service-bsb-registry/db/file.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry/db/file.js +588 -0
- package/lib/plugins/service-bsb-registry/db/file.js.map +1 -0
- package/lib/plugins/service-bsb-registry/db/index.d.ts +75 -0
- package/lib/plugins/service-bsb-registry/db/index.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry/db/index.js +24 -0
- package/lib/plugins/service-bsb-registry/db/index.js.map +1 -0
- package/lib/plugins/service-bsb-registry/index.d.ts +1228 -0
- package/lib/plugins/service-bsb-registry/index.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry/index.js +661 -0
- package/lib/plugins/service-bsb-registry/index.js.map +1 -0
- package/lib/plugins/service-bsb-registry/types.d.ts +559 -0
- package/lib/plugins/service-bsb-registry/types.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry/types.js +235 -0
- package/lib/plugins/service-bsb-registry/types.js.map +1 -0
- package/lib/plugins/service-bsb-registry-ui/http-server.d.ts +138 -0
- package/lib/plugins/service-bsb-registry-ui/http-server.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry-ui/http-server.js +1660 -0
- package/lib/plugins/service-bsb-registry-ui/http-server.js.map +1 -0
- package/lib/plugins/service-bsb-registry-ui/index.d.ts +62 -0
- package/lib/plugins/service-bsb-registry-ui/index.d.ts.map +1 -0
- package/lib/plugins/service-bsb-registry-ui/index.js +101 -0
- package/lib/plugins/service-bsb-registry-ui/index.js.map +1 -0
- package/lib/plugins/service-bsb-registry-ui/static/assets/images/apple-touch-icon.png +0 -0
- package/lib/plugins/service-bsb-registry-ui/static/assets/images/favicon-16x16.png +0 -0
- package/lib/plugins/service-bsb-registry-ui/static/assets/images/favicon-32x32.png +0 -0
- package/lib/plugins/service-bsb-registry-ui/static/assets/images/favicon.ico +0 -0
- package/lib/plugins/service-bsb-registry-ui/static/css/style.css +1849 -0
- package/lib/plugins/service-bsb-registry-ui/static/js/app.js +336 -0
- package/lib/plugins/service-bsb-registry-ui/templates/layouts/main.hbs +39 -0
- package/lib/plugins/service-bsb-registry-ui/templates/pages/error.hbs +13 -0
- package/lib/plugins/service-bsb-registry-ui/templates/pages/home.hbs +62 -0
- package/lib/plugins/service-bsb-registry-ui/templates/pages/not-found.hbs +13 -0
- package/lib/plugins/service-bsb-registry-ui/templates/pages/plugin-detail.hbs +537 -0
- package/lib/plugins/service-bsb-registry-ui/templates/pages/plugins.hbs +40 -0
- package/lib/plugins/service-bsb-registry-ui/templates/partials/pagination.hbs +41 -0
- package/lib/plugins/service-bsb-registry-ui/templates/partials/plugin-card.hbs +40 -0
- package/lib/plugins/service-bsb-registry-ui/templates/partials/search-form.hbs +31 -0
- package/lib/schemas/service-bsb-registry-ui.json +57 -0
- package/lib/schemas/service-bsb-registry-ui.plugin.json +73 -0
- package/lib/schemas/service-bsb-registry.json +1883 -0
- package/lib/schemas/service-bsb-registry.plugin.json +68 -0
- 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
|