bro-framework 2.4.4 → 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 +91 -129
- 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,17 +1,30 @@
|
|
|
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
|
-
let globalRedis = null;
|
|
14
21
|
let globalLocale = null;
|
|
22
|
+
const routeRegistry = new RouteRegistry();
|
|
23
|
+
|
|
24
|
+
const globalStore = globalThis;
|
|
25
|
+
globalStore.__broRedis = globalStore.__broRedis || null;
|
|
26
|
+
globalStore.__broMemoryCache = globalStore.__broMemoryCache || new Map();
|
|
27
|
+
globalStore.__broMemoryRateLimit = globalStore.__broMemoryRateLimit || new Map();
|
|
15
28
|
|
|
16
29
|
async function ensureInitialized() {
|
|
17
30
|
if (isInitialized) return;
|
|
@@ -19,6 +32,10 @@ export function createBro(globalConfig = {}) {
|
|
|
19
32
|
|
|
20
33
|
initPromise = (async () => {
|
|
21
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
|
+
|
|
22
39
|
if (globalConfig.env) {
|
|
23
40
|
try {
|
|
24
41
|
globalConfig.env.parse(process.env);
|
|
@@ -38,10 +55,10 @@ export function createBro(globalConfig = {}) {
|
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
if (globalConfig.redisUrl) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
await
|
|
58
|
+
globalStore.__broRedis = createClient({ url: globalConfig.redisUrl });
|
|
59
|
+
globalStore.__broRedis.on('error', (err) => console.error('[bro.js/next] Redis Error:', err));
|
|
60
|
+
if (!globalStore.__broRedis.isOpen) {
|
|
61
|
+
await globalStore.__broRedis.connect().catch(err => {
|
|
45
62
|
if (!err.message.includes('already connecting') && !err.message.includes('already connected')) {
|
|
46
63
|
throw err;
|
|
47
64
|
}
|
|
@@ -85,7 +102,6 @@ export function createBro(globalConfig = {}) {
|
|
|
85
102
|
let messages = globalConfig.locales?.[locale] || globalConfig.locales?.[globalConfig.defaultLocale || 'en'];
|
|
86
103
|
if (!messages) return key;
|
|
87
104
|
|
|
88
|
-
// Handle Webpack / ES module JSON interop where the object is under .default
|
|
89
105
|
if (messages.default && typeof messages.default === 'object') {
|
|
90
106
|
messages = messages.default;
|
|
91
107
|
}
|
|
@@ -105,89 +121,15 @@ export function createBro(globalConfig = {}) {
|
|
|
105
121
|
};
|
|
106
122
|
|
|
107
123
|
function defineRoute(config) {
|
|
124
|
+
// Register route for OpenAPI in Next environments
|
|
125
|
+
routeRegistry.register(config);
|
|
126
|
+
|
|
108
127
|
return async function (req, context) {
|
|
109
128
|
try {
|
|
110
129
|
await ensureInitialized();
|
|
111
130
|
|
|
112
131
|
const resolvedLocale = resolveLocale(Object.fromEntries(req.headers.entries()));
|
|
113
|
-
|
|
114
|
-
// Auth extraction early for Identity caching
|
|
115
|
-
let user = null;
|
|
116
|
-
let apiKeyUsed = null;
|
|
117
|
-
if (config.auth) {
|
|
118
|
-
if (config.auth === 'api-key') {
|
|
119
|
-
const apiKey = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace('Bearer ', '');
|
|
120
|
-
const configuredKey = globalConfig?.auth?.apiKey || process.env.API_KEY;
|
|
121
|
-
|
|
122
|
-
let isValid = false;
|
|
123
|
-
if (configuredKey) {
|
|
124
|
-
const keys = (Array.isArray(configuredKey) ? configuredKey : configuredKey.split(',')).map(k => String(k).trim());
|
|
125
|
-
isValid = keys.includes(apiKey);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (!isValid) {
|
|
129
|
-
return NextResponse.json({ error: 'Unauthorized', details: 'Missing or invalid API key' }, { status: 401 });
|
|
130
|
-
}
|
|
131
|
-
apiKeyUsed = apiKey;
|
|
132
|
-
} else {
|
|
133
|
-
const authHeader = req.headers.get('authorization');
|
|
134
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
135
|
-
return NextResponse.json({ error: 'Unauthorized', details: 'Missing Bearer token' }, { status: 401 });
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const token = authHeader.split(' ')[1];
|
|
139
|
-
const secret = globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET;
|
|
140
|
-
|
|
141
|
-
if (!secret) {
|
|
142
|
-
return NextResponse.json({ error: 'Internal Server Error', details: 'JWT_SECRET is not configured' }, { status: 500 });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const decoded = verifyJwt(token, secret);
|
|
146
|
-
if (!decoded.valid) {
|
|
147
|
-
return NextResponse.json({ error: 'Unauthorized', details: decoded.error }, { status: 401 });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
user = decoded.payload;
|
|
151
|
-
|
|
152
|
-
if (Array.isArray(config.auth) && config.auth.length > 0) {
|
|
153
|
-
if (!user.role || !config.auth.includes(user.role)) {
|
|
154
|
-
return NextResponse.json({ error: 'Forbidden', details: 'Insufficient permissions' }, { status: 403 });
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Rate Limiting
|
|
161
|
-
const activeRateLimit = config.rateLimit === false ? null : (config.rateLimit || globalConfig.rateLimit);
|
|
162
|
-
if (activeRateLimit && globalRedis) {
|
|
163
|
-
const ip = req.headers.get('x-forwarded-for') || 'ip';
|
|
164
|
-
const urlObj = new URL(req.url);
|
|
165
|
-
const rlKey = `rate-limit:${urlObj.pathname}:${ip}`;
|
|
166
|
-
const currentCount = await globalRedis.incr(rlKey);
|
|
167
|
-
if (currentCount === 1) {
|
|
168
|
-
await globalRedis.expire(rlKey, Math.ceil(activeRateLimit.windowMs / 1000));
|
|
169
|
-
}
|
|
170
|
-
if (currentCount > activeRateLimit.max) {
|
|
171
|
-
return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Caching
|
|
176
|
-
let cacheKey = null;
|
|
177
|
-
if (config.cache && globalRedis && req.method === 'GET') {
|
|
178
|
-
const urlObj = new URL(req.url);
|
|
179
|
-
const identity = user ? (user.id || user.role || 'user') : (apiKeyUsed || 'anon');
|
|
180
|
-
cacheKey = `cache:${urlObj.pathname}${urlObj.search}:${resolvedLocale}:${identity}`;
|
|
181
|
-
|
|
182
|
-
const cachedData = await globalRedis.get(cacheKey);
|
|
183
|
-
if (cachedData) {
|
|
184
|
-
return NextResponse.json(JSON.parse(cachedData), { status: 200 });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const rawParams = context?.params ? await context.params : {};
|
|
189
132
|
const url = new URL(req.url);
|
|
190
|
-
const rawQuery = Object.fromEntries(url.searchParams.entries());
|
|
191
133
|
|
|
192
134
|
let rawBody = {};
|
|
193
135
|
const parsedFiles = {};
|
|
@@ -216,26 +158,6 @@ export function createBro(globalConfig = {}) {
|
|
|
216
158
|
}
|
|
217
159
|
}
|
|
218
160
|
|
|
219
|
-
let body, params, query;
|
|
220
|
-
try {
|
|
221
|
-
if (config.body) body = await config.body.parseAsync(rawBody);
|
|
222
|
-
} catch (err) {
|
|
223
|
-
if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Request Body', details: err.issues }, { status: 400 });
|
|
224
|
-
throw err;
|
|
225
|
-
}
|
|
226
|
-
try {
|
|
227
|
-
if (config.params) params = await config.params.parseAsync(rawParams);
|
|
228
|
-
} catch (err) {
|
|
229
|
-
if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid URL Parameters', details: err.issues }, { status: 400 });
|
|
230
|
-
throw err;
|
|
231
|
-
}
|
|
232
|
-
try {
|
|
233
|
-
if (config.query) query = await config.query.parseAsync(rawQuery);
|
|
234
|
-
} catch (err) {
|
|
235
|
-
if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Query Parameters', details: err.issues }, { status: 400 });
|
|
236
|
-
throw err;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
161
|
let ctxFile = undefined;
|
|
240
162
|
let ctxFiles = undefined;
|
|
241
163
|
if (totalFiles === 1) {
|
|
@@ -246,42 +168,82 @@ export function createBro(globalConfig = {}) {
|
|
|
246
168
|
ctxFiles = parsedFiles;
|
|
247
169
|
}
|
|
248
170
|
|
|
249
|
-
const
|
|
250
|
-
req,
|
|
251
|
-
|
|
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
|
|
252
219
|
db: globalDb,
|
|
253
|
-
redis:
|
|
220
|
+
redis: redisFallback,
|
|
254
221
|
io: { emit: () => console.warn('[bro.js/next] WebSockets require standard bro.js server.') },
|
|
255
|
-
body,
|
|
256
|
-
params,
|
|
257
|
-
query,
|
|
258
|
-
file: ctxFile,
|
|
259
|
-
files: ctxFiles,
|
|
260
|
-
locale: resolvedLocale,
|
|
261
222
|
t: (key, values) => translate(resolvedLocale, key, values),
|
|
262
|
-
|
|
263
|
-
jwt: {
|
|
264
|
-
sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, Object.assign({ expiresIn: globalConfig?.auth?.expiresIn || '1d' }, opts || {}))
|
|
265
|
-
},
|
|
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 || {})) },
|
|
266
225
|
error: errorHelper
|
|
267
226
|
};
|
|
268
227
|
|
|
269
|
-
const
|
|
228
|
+
const response = await executeRequest(config, requestData, globalConfig, ctxExtras);
|
|
270
229
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return NextResponse.json(result, { status: 200 });
|
|
230
|
+
return NextResponse.json(response.body, {
|
|
231
|
+
status: response.status,
|
|
232
|
+
headers: response.headers
|
|
233
|
+
});
|
|
276
234
|
|
|
277
235
|
} catch (error) {
|
|
278
|
-
if (error.status) {
|
|
279
|
-
return NextResponse.json({ error: error.message }, { status: error.status });
|
|
280
|
-
}
|
|
281
236
|
console.error('[bro.js/next] Unhandled Error:', error);
|
|
282
237
|
return NextResponse.json(
|
|
283
|
-
{
|
|
284
|
-
|
|
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' } }
|
|
285
247
|
);
|
|
286
248
|
}
|
|
287
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 = [];
|