bro-framework 2.4.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/bin/bro.js +194 -292
- package/package.json +138 -103
- package/src/dashboard.js +80 -0
- package/src/database.js +105 -0
- package/src/edge.js +244 -0
- package/src/engine.js +245 -0
- package/src/index.d.ts +31 -11
- package/src/index.js +10 -0
- package/src/logger.js +47 -0
- package/src/next.d.ts +9 -9
- package/src/next.js +82 -156
- package/src/observability.js +81 -0
- package/src/plugins.js +135 -0
- package/src/policy-auth.js +88 -0
- package/src/router.js +1 -1
- package/src/sdk.js +229 -169
- package/src/server.js +181 -170
- package/src/studio.js +162 -0
- package/src/task-engine.js +88 -0
- package/src/testing.js +142 -0
- package/src/uploads.js +129 -0
package/src/next.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
1
|
+
import { NextResponse } from 'next/server.js';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { verifyJwt, signJwt } from './auth.js';
|
|
4
5
|
import { createClient } from 'redis';
|
|
6
|
+
import { createLogger } from './logger.js';
|
|
7
|
+
import { executeRequest, RouteRegistry, resolveIdentity } from './engine.js';
|
|
8
|
+
export function hashIdentity(identity) { return crypto.createHash('sha256').update(String(identity)).digest('hex'); }
|
|
9
|
+
export function generateRateLimitKey(req, config, prefix = 'route') { const identity = resolveIdentity(req, config); return `bro:rate_limit:${prefix}:${originalUrl}:${hashIdentity(identity)}`; }
|
|
10
|
+
export function generateCacheKey(req, config, locale) { const identity = resolveIdentity(req, config); return `bro:cache:${method}:${originalUrl}:${locale}:${hashIdentity(identity)}`; }
|
|
5
11
|
|
|
6
12
|
export { z };
|
|
7
13
|
|
|
8
14
|
export function createBro(globalConfig = {}) {
|
|
15
|
+
const globalLogger = createLogger(globalConfig.logger || { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' });
|
|
16
|
+
|
|
9
17
|
let isInitialized = false;
|
|
10
18
|
let initPromise = null;
|
|
11
19
|
|
|
12
20
|
let globalDb = null;
|
|
13
21
|
let globalLocale = null;
|
|
22
|
+
const routeRegistry = new RouteRegistry();
|
|
14
23
|
|
|
15
24
|
const globalStore = globalThis;
|
|
16
25
|
globalStore.__broRedis = globalStore.__broRedis || null;
|
|
@@ -23,6 +32,10 @@ export function createBro(globalConfig = {}) {
|
|
|
23
32
|
|
|
24
33
|
initPromise = (async () => {
|
|
25
34
|
try {
|
|
35
|
+
if (process.env.NODE_ENV === 'production' && ['dev_secret_please_change', 'bro_default_secret_key', 'your_jwt_secret_here'].includes(globalConfig.auth?.jwtSecret)) {
|
|
36
|
+
throw new Error('CRITICAL SECURITY ERROR: You are running in production with a default JWT secret!');
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
if (globalConfig.env) {
|
|
27
40
|
try {
|
|
28
41
|
globalConfig.env.parse(process.env);
|
|
@@ -89,7 +102,6 @@ export function createBro(globalConfig = {}) {
|
|
|
89
102
|
let messages = globalConfig.locales?.[locale] || globalConfig.locales?.[globalConfig.defaultLocale || 'en'];
|
|
90
103
|
if (!messages) return key;
|
|
91
104
|
|
|
92
|
-
// Handle Webpack / ES module JSON interop where the object is under .default
|
|
93
105
|
if (messages.default && typeof messages.default === 'object') {
|
|
94
106
|
messages = messages.default;
|
|
95
107
|
}
|
|
@@ -109,117 +121,15 @@ export function createBro(globalConfig = {}) {
|
|
|
109
121
|
};
|
|
110
122
|
|
|
111
123
|
function defineRoute(config) {
|
|
124
|
+
// Register route for OpenAPI in Next environments
|
|
125
|
+
routeRegistry.register(config);
|
|
126
|
+
|
|
112
127
|
return async function (req, context) {
|
|
113
128
|
try {
|
|
114
129
|
await ensureInitialized();
|
|
115
130
|
|
|
116
131
|
const resolvedLocale = resolveLocale(Object.fromEntries(req.headers.entries()));
|
|
117
|
-
|
|
118
|
-
// Auth extraction early for Identity caching
|
|
119
|
-
let user = null;
|
|
120
|
-
let apiKeyUsed = null;
|
|
121
|
-
if (config.auth) {
|
|
122
|
-
if (config.auth === 'api-key') {
|
|
123
|
-
const apiKey = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace('Bearer ', '');
|
|
124
|
-
const configuredKey = globalConfig?.auth?.apiKey || process.env.API_KEY;
|
|
125
|
-
|
|
126
|
-
let isValid = false;
|
|
127
|
-
if (configuredKey) {
|
|
128
|
-
const keys = (Array.isArray(configuredKey) ? configuredKey : configuredKey.split(',')).map(k => String(k).trim());
|
|
129
|
-
isValid = keys.includes(apiKey);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!isValid) {
|
|
133
|
-
return NextResponse.json({ error: 'Unauthorized', details: 'Missing or invalid API key' }, { status: 401 });
|
|
134
|
-
}
|
|
135
|
-
apiKeyUsed = apiKey;
|
|
136
|
-
} else {
|
|
137
|
-
const authHeader = req.headers.get('authorization');
|
|
138
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
139
|
-
return NextResponse.json({ error: 'Unauthorized', details: 'Missing Bearer token' }, { status: 401 });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const token = authHeader.split(' ')[1];
|
|
143
|
-
const secret = globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET;
|
|
144
|
-
|
|
145
|
-
if (!secret) {
|
|
146
|
-
return NextResponse.json({ error: 'Internal Server Error', details: 'JWT_SECRET is not configured' }, { status: 500 });
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const decoded = verifyJwt(token, secret);
|
|
150
|
-
if (!decoded.valid) {
|
|
151
|
-
return NextResponse.json({ error: 'Unauthorized', details: decoded.error }, { status: 401 });
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
user = decoded.payload;
|
|
155
|
-
|
|
156
|
-
if (Array.isArray(config.auth) && config.auth.length > 0) {
|
|
157
|
-
if (!user.role || !config.auth.includes(user.role)) {
|
|
158
|
-
return NextResponse.json({ error: 'Forbidden', details: 'Insufficient permissions' }, { status: 403 });
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Rate Limiting
|
|
165
|
-
const activeRateLimit = config.rateLimit === false ? null : (config.rateLimit || globalConfig.rateLimit);
|
|
166
|
-
if (activeRateLimit) {
|
|
167
|
-
const ip = req.headers.get('x-forwarded-for') || 'ip';
|
|
168
|
-
const urlObj = new URL(req.url);
|
|
169
|
-
const rlKey = `rate-limit:${urlObj.pathname}:${ip}`;
|
|
170
|
-
|
|
171
|
-
if (globalStore.__broRedis) {
|
|
172
|
-
const currentCount = await globalStore.__broRedis.incr(rlKey);
|
|
173
|
-
if (currentCount === 1) {
|
|
174
|
-
await globalStore.__broRedis.expire(rlKey, Math.ceil(activeRateLimit.windowMs / 1000));
|
|
175
|
-
}
|
|
176
|
-
if (currentCount > activeRateLimit.max) {
|
|
177
|
-
return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
|
|
178
|
-
}
|
|
179
|
-
} else {
|
|
180
|
-
const now = Date.now();
|
|
181
|
-
let record = globalStore.__broMemoryRateLimit.get(rlKey);
|
|
182
|
-
|
|
183
|
-
if (!record || now > record.expires) {
|
|
184
|
-
record = { count: 0, expires: now + activeRateLimit.windowMs };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
record.count++;
|
|
188
|
-
globalStore.__broMemoryRateLimit.set(rlKey, record);
|
|
189
|
-
|
|
190
|
-
if (record.count > activeRateLimit.max) {
|
|
191
|
-
return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Caching
|
|
197
|
-
let cacheKey = null;
|
|
198
|
-
if (config.cache && req.method === 'GET') {
|
|
199
|
-
const urlObj = new URL(req.url);
|
|
200
|
-
const identity = user ? (user.id || user.role || 'user') : (apiKeyUsed || 'anon');
|
|
201
|
-
cacheKey = `cache:${urlObj.pathname}${urlObj.search}:${resolvedLocale}:${identity}`;
|
|
202
|
-
|
|
203
|
-
if (globalStore.__broRedis) {
|
|
204
|
-
const cachedData = await globalStore.__broRedis.get(cacheKey);
|
|
205
|
-
if (cachedData) {
|
|
206
|
-
return NextResponse.json(JSON.parse(cachedData), { status: 200 });
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
const cached = globalStore.__broMemoryCache.get(cacheKey);
|
|
210
|
-
if (cached) {
|
|
211
|
-
if (Date.now() < cached.expires) {
|
|
212
|
-
return NextResponse.json(cached.data, { status: 200 });
|
|
213
|
-
} else {
|
|
214
|
-
globalStore.__broMemoryCache.delete(cacheKey);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const rawParams = context?.params ? await context.params : {};
|
|
221
132
|
const url = new URL(req.url);
|
|
222
|
-
const rawQuery = Object.fromEntries(url.searchParams.entries());
|
|
223
133
|
|
|
224
134
|
let rawBody = {};
|
|
225
135
|
const parsedFiles = {};
|
|
@@ -248,26 +158,6 @@ export function createBro(globalConfig = {}) {
|
|
|
248
158
|
}
|
|
249
159
|
}
|
|
250
160
|
|
|
251
|
-
let body, params, query;
|
|
252
|
-
try {
|
|
253
|
-
if (config.body) body = await config.body.parseAsync(rawBody);
|
|
254
|
-
} catch (err) {
|
|
255
|
-
if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Request Body', details: err.issues }, { status: 400 });
|
|
256
|
-
throw err;
|
|
257
|
-
}
|
|
258
|
-
try {
|
|
259
|
-
if (config.params) params = await config.params.parseAsync(rawParams);
|
|
260
|
-
} catch (err) {
|
|
261
|
-
if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid URL Parameters', details: err.issues }, { status: 400 });
|
|
262
|
-
throw err;
|
|
263
|
-
}
|
|
264
|
-
try {
|
|
265
|
-
if (config.query) query = await config.query.parseAsync(rawQuery);
|
|
266
|
-
} catch (err) {
|
|
267
|
-
if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Query Parameters', details: err.issues }, { status: 400 });
|
|
268
|
-
throw err;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
161
|
let ctxFile = undefined;
|
|
272
162
|
let ctxFiles = undefined;
|
|
273
163
|
if (totalFiles === 1) {
|
|
@@ -278,46 +168,82 @@ export function createBro(globalConfig = {}) {
|
|
|
278
168
|
ctxFiles = parsedFiles;
|
|
279
169
|
}
|
|
280
170
|
|
|
281
|
-
const
|
|
282
|
-
req,
|
|
283
|
-
|
|
171
|
+
const requestData = {
|
|
172
|
+
method: req.method,
|
|
173
|
+
originalUrl: url.pathname + url.search,
|
|
174
|
+
headers: Object.fromEntries(req.headers.entries()),
|
|
175
|
+
body: rawBody,
|
|
176
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
177
|
+
params: context?.params ? await context.params : {},
|
|
178
|
+
files: ctxFiles || ctxFile,
|
|
179
|
+
ip: req.headers.get('x-forwarded-for') || 'ip',
|
|
180
|
+
locale: resolvedLocale
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
let redisFallback = globalStore.__broRedis;
|
|
184
|
+
if (!redisFallback) {
|
|
185
|
+
redisFallback = {
|
|
186
|
+
async get(key) {
|
|
187
|
+
const cached = globalStore.__broMemoryCache.get(key);
|
|
188
|
+
if (cached && Date.now() < cached.expires) return JSON.stringify(cached.data);
|
|
189
|
+
return null;
|
|
190
|
+
},
|
|
191
|
+
async setEx(key, ex, val) {
|
|
192
|
+
globalStore.__broMemoryCache.set(key, { data: JSON.parse(val), expires: Date.now() + (ex * 1000) });
|
|
193
|
+
},
|
|
194
|
+
async del(key) { globalStore.__broMemoryCache.delete(key); },
|
|
195
|
+
async incr(key) {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
let record = globalStore.__broMemoryRateLimit.get(key);
|
|
198
|
+
if (!record || now > record.expires) record = { count: 0, expires: now + 60000 };
|
|
199
|
+
record.count++;
|
|
200
|
+
globalStore.__broMemoryRateLimit.set(key, record);
|
|
201
|
+
return record.count;
|
|
202
|
+
},
|
|
203
|
+
async expire(key, ex) {
|
|
204
|
+
let record = globalStore.__broMemoryRateLimit.get(key);
|
|
205
|
+
if (record) {
|
|
206
|
+
record.expires = Date.now() + (ex * 1000);
|
|
207
|
+
globalStore.__broMemoryRateLimit.set(key, record);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const ctxExtras = {
|
|
214
|
+
verifyJwt,
|
|
215
|
+
generateRateLimitKey,
|
|
216
|
+
generateCacheKey,
|
|
217
|
+
generateId: () => crypto.randomUUID(),
|
|
218
|
+
req, // Native NextRequest
|
|
284
219
|
db: globalDb,
|
|
285
|
-
redis:
|
|
220
|
+
redis: redisFallback,
|
|
286
221
|
io: { emit: () => console.warn('[bro.js/next] WebSockets require standard bro.js server.') },
|
|
287
|
-
body,
|
|
288
|
-
params,
|
|
289
|
-
query,
|
|
290
|
-
file: ctxFile,
|
|
291
|
-
files: ctxFiles,
|
|
292
|
-
locale: resolvedLocale,
|
|
293
222
|
t: (key, values) => translate(resolvedLocale, key, values),
|
|
294
|
-
|
|
295
|
-
jwt: {
|
|
296
|
-
sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, Object.assign({ expiresIn: globalConfig?.auth?.expiresIn || '1d' }, opts || {}))
|
|
297
|
-
},
|
|
223
|
+
logger: globalLogger,
|
|
224
|
+
jwt: { sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, Object.assign({ expiresIn: globalConfig?.auth?.expiresIn || '1d' }, opts || {})) },
|
|
298
225
|
error: errorHelper
|
|
299
226
|
};
|
|
300
227
|
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
if (cacheKey) {
|
|
304
|
-
if (globalStore.__broRedis) {
|
|
305
|
-
await globalStore.__broRedis.set(cacheKey, JSON.stringify(result), { EX: config.cache });
|
|
306
|
-
} else {
|
|
307
|
-
globalStore.__broMemoryCache.set(cacheKey, { data: result, expires: Date.now() + (config.cache * 1000) });
|
|
308
|
-
}
|
|
309
|
-
}
|
|
228
|
+
const response = await executeRequest(config, requestData, globalConfig, ctxExtras);
|
|
310
229
|
|
|
311
|
-
return NextResponse.json(
|
|
230
|
+
return NextResponse.json(response.body, {
|
|
231
|
+
status: response.status,
|
|
232
|
+
headers: response.headers
|
|
233
|
+
});
|
|
312
234
|
|
|
313
235
|
} catch (error) {
|
|
314
|
-
if (error.status) {
|
|
315
|
-
return NextResponse.json({ error: error.message }, { status: error.status });
|
|
316
|
-
}
|
|
317
236
|
console.error('[bro.js/next] Unhandled Error:', error);
|
|
318
237
|
return NextResponse.json(
|
|
319
|
-
{
|
|
320
|
-
|
|
238
|
+
{
|
|
239
|
+
type: 'https://brojs.dev/errors/internal_server_error',
|
|
240
|
+
title: 'Internal Server Error',
|
|
241
|
+
status: 500,
|
|
242
|
+
instance: req.url,
|
|
243
|
+
requestId: req.headers.get('x-request-id') || 'unknown',
|
|
244
|
+
detail: error.message || 'An unexpected error occurred'
|
|
245
|
+
},
|
|
246
|
+
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } }
|
|
321
247
|
);
|
|
322
248
|
}
|
|
323
249
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pino Logger Adapter for bro.js plugins
|
|
5
|
+
*/
|
|
6
|
+
export function createPinoAdapter(pinoInstance, options = {}) {
|
|
7
|
+
const redactPaths = options.redact || ['req.headers.authorization', 'req.headers.cookie'];
|
|
8
|
+
if (!pinoInstance.redact) {
|
|
9
|
+
pinoInstance.redact = redactPaths;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: 'bro-pino-adapter',
|
|
14
|
+
order: 5,
|
|
15
|
+
onContext: (ctx) => {
|
|
16
|
+
return {
|
|
17
|
+
log: pinoInstance.child({ reqId: ctx.env?.TRACE_ID || crypto.randomUUID() })
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
onRequest: (req, res) => {
|
|
21
|
+
pinoInstance.info({ req: { method: req.method, url: req.url, headers: req.headers } }, 'Request received');
|
|
22
|
+
},
|
|
23
|
+
onError: (err, req, res) => {
|
|
24
|
+
pinoInstance.error({ err, reqId: req.id || req.headers['x-request-id'] }, 'Request failed');
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* OpenTelemetry Instrumentation Setup Helper
|
|
31
|
+
*/
|
|
32
|
+
export function setupOpenTelemetry(sdkConfig) {
|
|
33
|
+
return {
|
|
34
|
+
name: 'bro-otel-instrumentation',
|
|
35
|
+
order: 1,
|
|
36
|
+
onInit: async (globalConfig) => {
|
|
37
|
+
console.log('[bro.js] OpenTelemetry initialized for service:', sdkConfig.serviceName);
|
|
38
|
+
},
|
|
39
|
+
onContext: (ctx) => {
|
|
40
|
+
// W3C Trace Propagation
|
|
41
|
+
const traceparent = ctx.req?.headers['traceparent'];
|
|
42
|
+
return {
|
|
43
|
+
traceId: traceparent ? traceparent.split('-')[1] : crypto.randomUUID()
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
onRequest: (req, res) => {
|
|
47
|
+
// In a real implementation, we would start an OTel span here
|
|
48
|
+
req.__otelStartTime = Date.now();
|
|
49
|
+
},
|
|
50
|
+
onShutdown: () => {
|
|
51
|
+
console.log('[bro.js] OpenTelemetry shutting down gracefully...');
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Dashboard Event Bus Plugin
|
|
58
|
+
*/
|
|
59
|
+
export function createDashboardEventBus(busConfig = {}) {
|
|
60
|
+
const metrics = { requests: 0, errors: 0, latencies: [] };
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: 'bro-dashboard-bus',
|
|
64
|
+
order: 90,
|
|
65
|
+
onRequest: (req, res) => {
|
|
66
|
+
metrics.requests++;
|
|
67
|
+
req.__busStartTime = Date.now();
|
|
68
|
+
},
|
|
69
|
+
onError: (err) => {
|
|
70
|
+
metrics.errors++;
|
|
71
|
+
if (busConfig.io) {
|
|
72
|
+
busConfig.io.emit('metrics:error', { error: err.message, time: Date.now() });
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onShutdown: () => {
|
|
76
|
+
if (busConfig.io) {
|
|
77
|
+
busConfig.io.emit('system:shutdown', { metrics });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/plugins.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Manager for bro.js
|
|
3
|
+
* Handles lifecycle hooks, context extension, and typed plugins.
|
|
4
|
+
*/
|
|
5
|
+
export class PluginManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.plugins = [];
|
|
8
|
+
this.hooks = {
|
|
9
|
+
onInit: [],
|
|
10
|
+
onContext: [],
|
|
11
|
+
onRequest: [],
|
|
12
|
+
onError: [],
|
|
13
|
+
onShutdown: []
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Registers a plugin with the framework.
|
|
19
|
+
* @param {Object} plugin
|
|
20
|
+
*/
|
|
21
|
+
register(plugin) {
|
|
22
|
+
if (!plugin.name) throw new Error('[bro.js] Plugin must have a \'name\'.');
|
|
23
|
+
if (!plugin.version) throw new Error(`[bro.js] Plugin '${plugin.name}' must specify a 'version' (e.g., '3.0.0').`);
|
|
24
|
+
|
|
25
|
+
// Enforce v3 compatibility
|
|
26
|
+
if (!plugin.version.startsWith('3.')) {
|
|
27
|
+
throw new Error(`[bro.js] Plugin '${plugin.name}' (v${plugin.version}) is not compatible with bro.js v3.x.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const allowedKeys = ['name', 'version', 'order', 'onInit', 'onContext', 'onRequest', 'onError', 'onShutdown'];
|
|
31
|
+
for (const key of Object.keys(plugin)) {
|
|
32
|
+
if (!allowedKeys.includes(key)) {
|
|
33
|
+
throw new Error(`[bro.js] Plugin '${plugin.name}' uses undocumented escape hatch / unknown property: '${key}'.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.plugins.push(plugin);
|
|
38
|
+
// Sort plugins if they provide an order, default to 50
|
|
39
|
+
this.plugins.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
|
|
40
|
+
this._rebuildHooks();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_rebuildHooks() {
|
|
44
|
+
for (const key of Object.keys(this.hooks)) {
|
|
45
|
+
this.hooks[key] = [];
|
|
46
|
+
}
|
|
47
|
+
for (const plugin of this.plugins) {
|
|
48
|
+
if (plugin.onInit) this.hooks.onInit.push(plugin.onInit);
|
|
49
|
+
if (plugin.onContext) this.hooks.onContext.push(plugin.onContext);
|
|
50
|
+
if (plugin.onRequest) this.hooks.onRequest.push(plugin.onRequest);
|
|
51
|
+
if (plugin.onError) this.hooks.onError.push(plugin.onError);
|
|
52
|
+
if (plugin.onShutdown) this.hooks.onShutdown.push(plugin.onShutdown);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async runOnInit(globalConfig, app) {
|
|
57
|
+
for (const hook of this.hooks.onInit) {
|
|
58
|
+
await hook(globalConfig, app);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async runOnContext(ctx) {
|
|
63
|
+
for (const hook of this.hooks.onContext) {
|
|
64
|
+
const ext = await hook(ctx);
|
|
65
|
+
if (ext && typeof ext === 'object') {
|
|
66
|
+
Object.assign(ctx, ext);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async runOnShutdown() {
|
|
72
|
+
for (const hook of this.hooks.onShutdown) {
|
|
73
|
+
await hook();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getRequestMiddleware() {
|
|
78
|
+
return async (req, res, next) => {
|
|
79
|
+
try {
|
|
80
|
+
for (const hook of this.hooks.onRequest) {
|
|
81
|
+
await hook(req, res);
|
|
82
|
+
}
|
|
83
|
+
next();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
next(err);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getErrorMiddleware() {
|
|
91
|
+
return async (err, req, res, next) => {
|
|
92
|
+
for (const hook of this.hooks.onError) {
|
|
93
|
+
try {
|
|
94
|
+
await hook(err, req, res);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error('[bro.js] Error in Plugin onError hook:', e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
next(err);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Baseline Observability Plugin
|
|
106
|
+
* Adds performance measuring and correlation ids.
|
|
107
|
+
*/
|
|
108
|
+
export const ObservabilityPlugin = {
|
|
109
|
+
name: 'bro-observability',
|
|
110
|
+
version: '3.0.0',
|
|
111
|
+
order: 10, // Runs early
|
|
112
|
+
onRequest: (req, res) => {
|
|
113
|
+
req.startTime = performance.now();
|
|
114
|
+
},
|
|
115
|
+
onContext: (ctx) => {
|
|
116
|
+
return {
|
|
117
|
+
traceId: ctx.env?.TRACE_ID || crypto.randomUUID()
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Baseline Security Plugin
|
|
124
|
+
* Applies strict security headers beyond the default helmet configuration.
|
|
125
|
+
*/
|
|
126
|
+
export const SecurityPlugin = {
|
|
127
|
+
name: 'bro-security',
|
|
128
|
+
version: '3.0.0',
|
|
129
|
+
order: 20,
|
|
130
|
+
onRequest: (req, res) => {
|
|
131
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
132
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
133
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
134
|
+
}
|
|
135
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class OidcProvider {
|
|
4
|
+
constructor(issuerUrl, clientId) {
|
|
5
|
+
this.issuerUrl = issuerUrl;
|
|
6
|
+
this.clientId = clientId;
|
|
7
|
+
this.jwksUrl = `${issuerUrl}/.well-known/jwks.json`;
|
|
8
|
+
this.keys = null;
|
|
9
|
+
this.lastFetch = 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async _fetchKeys() {
|
|
13
|
+
if (this.keys && (Date.now() - this.lastFetch < 3600000)) return this.keys; // 1 hour cache
|
|
14
|
+
const response = await fetch(this.jwksUrl);
|
|
15
|
+
if (!response.ok) throw new Error('Failed to fetch JWKS');
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
this.keys = data.keys;
|
|
18
|
+
this.lastFetch = Date.now();
|
|
19
|
+
return this.keys;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async verifyIdToken(token) {
|
|
23
|
+
let jose;
|
|
24
|
+
try { jose = await import('jose'); } catch { throw new Error('Install jose package to use OIDC Provider'); }
|
|
25
|
+
const JWKS = jose.createRemoteJWKSet(new URL(this.jwksUrl));
|
|
26
|
+
try {
|
|
27
|
+
const { payload } = await jose.jwtVerify(token, JWKS, {
|
|
28
|
+
issuer: this.issuerUrl,
|
|
29
|
+
audience: this.clientId
|
|
30
|
+
});
|
|
31
|
+
return { valid: true, payload };
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return { valid: false, error: e.message };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PolicyEvaluator {
|
|
39
|
+
constructor() {
|
|
40
|
+
this.policies = new Map();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
definePolicy(action, evaluatorFn) {
|
|
44
|
+
this.policies.set(action, evaluatorFn);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async authorize(user, action, resourceContext = {}) {
|
|
48
|
+
const evaluator = this.policies.get(action);
|
|
49
|
+
if (!evaluator) return false;
|
|
50
|
+
return await evaluator(user, resourceContext);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ApiKeyManager {
|
|
55
|
+
constructor(dbAdapter) {
|
|
56
|
+
this.db = dbAdapter;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hashKey(key) {
|
|
60
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
generateKey(prefix = 'bro') {
|
|
64
|
+
const random = crypto.randomBytes(32).toString('hex');
|
|
65
|
+
const key = `${prefix}_${random}`;
|
|
66
|
+
const hash = this.hashKey(key);
|
|
67
|
+
return { key, hash };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async verifyKey(key) {
|
|
71
|
+
if (!this.db) throw new Error('ApiKeyManager requires a database adapter');
|
|
72
|
+
const hash = this.hashKey(key);
|
|
73
|
+
// Stub implementation for verify. DB adapter needs a specific findKey method.
|
|
74
|
+
return { valid: true, id: hash };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const tenantContextPlugin = {
|
|
79
|
+
name: 'bro-tenant-context',
|
|
80
|
+
order: 10,
|
|
81
|
+
onContext: async (ctx) => {
|
|
82
|
+
const tenantId = ctx.req?.headers['x-tenant-id'];
|
|
83
|
+
if (tenantId) {
|
|
84
|
+
return { tenant: { id: tenantId } };
|
|
85
|
+
}
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
};
|
package/src/router.js
CHANGED
|
@@ -82,7 +82,7 @@ export function parseRouteFile(filePath, routesDir) {
|
|
|
82
82
|
* @param {Object} [openApiSpec] - Optional OpenAPI Spec object to build.
|
|
83
83
|
* @returns {Promise<Array>} Array of loaded route objects.
|
|
84
84
|
*/
|
|
85
|
-
export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
|
|
85
|
+
export async function loadRoutes(app, routesDir, createHandler, openApiSpec, routeRegistry) {
|
|
86
86
|
const files = scanDir(routesDir);
|
|
87
87
|
const loadedRoutes = [];
|
|
88
88
|
const routeModules = [];
|