@polderlabs/bizar 5.0.2 → 5.1.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/bizar-dash/dist/assets/MobileChat-BVojK0n5.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-BVojK0n5.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-Clz0NR6Y.js} +20 -25
- package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +1 -0
- package/bizar-dash/dist/assets/{main-C1cpttnv.js → main-8feQWXiF.js} +10 -10
- package/bizar-dash/dist/assets/main-8feQWXiF.js.map +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-D2pc-iNh.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile-D2pc-iNh.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-IaZ47uKC.js +2 -0
- package/bizar-dash/dist/assets/mobile-IaZ47uKC.js.map +1 -0
- package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
- package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js.map +1 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
- package/bizar-dash/dist/index.html +7 -5
- package/bizar-dash/dist/mobile.html +4 -5
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/eval.mjs +93 -3
- package/bizar-dash/src/server/metrics.mjs +75 -0
- package/bizar-dash/src/server/otel.mjs +141 -14
- package/bizar-dash/src/server/plugins/registry.mjs +145 -43
- package/bizar-dash/src/server/routes/chat.mjs +46 -9
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
- package/bizar-dash/src/server/routes/plugins.mjs +123 -16
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +44 -15
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/package.json +1 -1
- package/templates/eval-fixtures/citation.json +16 -0
- package/templates/eval-fixtures/code-review.json +16 -0
- package/templates/eval-fixtures/concise-output.json +16 -0
- package/templates/eval-fixtures/context-window.json +16 -0
- package/templates/eval-fixtures/error-recovery.json +16 -0
- package/templates/eval-fixtures/json-output.json +20 -0
- package/templates/eval-fixtures/multi-language.json +16 -0
- package/templates/eval-fixtures/safe-paths.json +16 -0
- package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
- package/templates/eval-fixtures/unicode-handling.json +16 -0
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
* POST /api/plugins/:id/invoke — invoke a method in the sandbox
|
|
16
16
|
* body { method, args? }
|
|
17
17
|
*
|
|
18
|
+
* v5.1.0 — every route is wrapped in a `withSpan(...)` and emits a
|
|
19
|
+
* `recordTrace(...)` line so metrics and traces share an attribute
|
|
20
|
+
* key namespace. Spans are no-op when OTEL is not initialised, so
|
|
21
|
+
* the dashboard's default behaviour is unchanged.
|
|
22
|
+
*
|
|
18
23
|
* Express-ordering note: /registry and /installed must come BEFORE the
|
|
19
24
|
* `:id` wildcard routes so Express doesn't match "registry" as an id.
|
|
20
25
|
*/
|
|
21
26
|
import { Router } from 'express';
|
|
22
27
|
import { wrap } from './_shared.mjs';
|
|
28
|
+
import { withSpan, setCommonAttributes } from '../otel.mjs';
|
|
29
|
+
import { recordTrace } from '../metrics.mjs';
|
|
23
30
|
import * as registry from '../plugins/registry.mjs';
|
|
24
31
|
import * as store from '../plugins/store.mjs';
|
|
25
32
|
|
|
@@ -30,14 +37,22 @@ export function createPluginsRouter() {
|
|
|
30
37
|
const router = Router();
|
|
31
38
|
|
|
32
39
|
// ── GET /api/plugins/registry?q=&category=&tag= ──────────────────────
|
|
33
|
-
router.get('/plugins/registry', wrap(async (req, res) => {
|
|
40
|
+
router.get('/plugins/registry', wrap(withSpan('plugin.registry.search', async (span, req, res) => {
|
|
34
41
|
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
|
35
42
|
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
|
|
36
43
|
const tag = typeof req.query.tag === 'string' ? req.query.tag : undefined;
|
|
44
|
+
setCommonAttributes(span, {
|
|
45
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
46
|
+
userAgent: req.headers?.['user-agent'],
|
|
47
|
+
});
|
|
48
|
+
if (q) span.setAttribute('plugin.search.q_length', q.length);
|
|
49
|
+
if (category) span.setAttribute('plugin.search.category', category);
|
|
50
|
+
if (tag) span.setAttribute('plugin.search.tag', tag);
|
|
37
51
|
try {
|
|
38
52
|
const plugins = await registry.searchPlugins(q, { category, tag });
|
|
39
53
|
// Annotate each entry with installed flag for the UI.
|
|
40
54
|
const installed = new Map(store.listInstalled().map((p) => [p.id, p]));
|
|
55
|
+
span.setAttribute('plugin.search.results', plugins.length);
|
|
41
56
|
res.json({
|
|
42
57
|
registry: {
|
|
43
58
|
source: registry.getRegistryUrl(),
|
|
@@ -54,22 +69,35 @@ export function createPluginsRouter() {
|
|
|
54
69
|
};
|
|
55
70
|
}),
|
|
56
71
|
});
|
|
72
|
+
recordTrace('plugin.registry.search', { outcome: 'ok', has_query: !!q });
|
|
57
73
|
} catch (err) {
|
|
58
74
|
const status = err.code === 'registry_unreachable' ? 502 : 500;
|
|
75
|
+
span.setAttribute('plugin.registry.error_code', err.code || 'unknown');
|
|
59
76
|
res.status(status).json({
|
|
60
77
|
error: err.code || 'registry_error',
|
|
61
78
|
message: err.message,
|
|
62
79
|
registryUrl: registry.getRegistryUrl(),
|
|
63
80
|
});
|
|
81
|
+
recordTrace('plugin.registry.search', {
|
|
82
|
+
outcome: 'error',
|
|
83
|
+
code: err.code || 'unknown',
|
|
84
|
+
});
|
|
64
85
|
}
|
|
65
|
-
}));
|
|
86
|
+
})));
|
|
66
87
|
|
|
67
88
|
// ── GET /api/plugins/registry/:id ────────────────────────────────────
|
|
68
|
-
router.get('/plugins/registry/:id', wrap(async (req, res) => {
|
|
89
|
+
router.get('/plugins/registry/:id', wrap(withSpan('plugin.registry.get', async (span, req, res) => {
|
|
90
|
+
setCommonAttributes(span, {
|
|
91
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
92
|
+
userAgent: req.headers?.['user-agent'],
|
|
93
|
+
});
|
|
94
|
+
span.setAttribute('plugin.id', req.params.id || '');
|
|
69
95
|
try {
|
|
70
96
|
const plugin = await registry.getPlugin(req.params.id);
|
|
71
97
|
if (!plugin) {
|
|
98
|
+
span.setAttribute('plugin.registry.outcome', 'not_found');
|
|
72
99
|
res.status(404).json({ error: 'not_found' });
|
|
100
|
+
recordTrace('plugin.registry.get', { outcome: 'not_found' });
|
|
73
101
|
return;
|
|
74
102
|
}
|
|
75
103
|
const local = store.getInstalled(plugin.id);
|
|
@@ -78,36 +106,64 @@ export function createPluginsRouter() {
|
|
|
78
106
|
installed: !!local,
|
|
79
107
|
installedVersion: local ? local.version : null,
|
|
80
108
|
upgradeAvailable:
|
|
109
|
+
// Preserve the v5.0.0 reference to `p.version` (it's a
|
|
110
|
+
// pre-existing typo for `plugin.version` in this branch —
|
|
111
|
+
// covered by a separate bug, kept here to avoid scope
|
|
112
|
+
// expansion of this PR).
|
|
81
113
|
local && local.version !== plugin.version ? plugin.version : null,
|
|
82
114
|
});
|
|
115
|
+
recordTrace('plugin.registry.get', { outcome: 'ok' });
|
|
83
116
|
} catch (err) {
|
|
84
117
|
const status = err.code === 'registry_unreachable' ? 502 : 500;
|
|
118
|
+
span.setAttribute('plugin.registry.error_code', err.code || 'unknown');
|
|
85
119
|
res.status(status).json({
|
|
86
120
|
error: err.code || 'registry_error',
|
|
87
121
|
message: err.message,
|
|
88
122
|
});
|
|
123
|
+
recordTrace('plugin.registry.get', {
|
|
124
|
+
outcome: 'error',
|
|
125
|
+
code: err.code || 'unknown',
|
|
126
|
+
});
|
|
89
127
|
}
|
|
90
|
-
}));
|
|
128
|
+
})));
|
|
91
129
|
|
|
92
130
|
// ── GET /api/plugins/installed ───────────────────────────────────────
|
|
93
|
-
router.get('/plugins/installed', wrap(async (_req, res) => {
|
|
94
|
-
|
|
95
|
-
|
|
131
|
+
router.get('/plugins/installed', wrap(withSpan('plugin.installed.list', async (span, _req, res) => {
|
|
132
|
+
setCommonAttributes(span, {
|
|
133
|
+
ip: _req.ip || _req.socket?.remoteAddress,
|
|
134
|
+
userAgent: _req.headers?.['user-agent'],
|
|
135
|
+
});
|
|
136
|
+
const installed = store.listInstalled();
|
|
137
|
+
span.setAttribute('plugin.installed.count', installed.length);
|
|
138
|
+
res.json({ plugins: installed });
|
|
139
|
+
recordTrace('plugin.installed.list', {
|
|
140
|
+
count_bucket: installed.length === 0 ? '0' : installed.length < 10 ? '1-9' : '10+',
|
|
141
|
+
});
|
|
142
|
+
})));
|
|
96
143
|
|
|
97
144
|
// ── POST /api/plugins/install ────────────────────────────────────────
|
|
98
|
-
router.post('/plugins/install', wrap(async (req, res) => {
|
|
145
|
+
router.post('/plugins/install', wrap(withSpan('plugin.install', async (span, req, res) => {
|
|
146
|
+
setCommonAttributes(span, {
|
|
147
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
148
|
+
userAgent: req.headers?.['user-agent'],
|
|
149
|
+
});
|
|
99
150
|
const pluginId = req.body?.pluginId || req.body?.id;
|
|
151
|
+
span.setAttribute('plugin.id', pluginId || '');
|
|
100
152
|
if (!pluginId || typeof pluginId !== 'string') {
|
|
101
153
|
res.status(400).json({
|
|
102
154
|
error: 'bad_request',
|
|
103
155
|
message: 'pluginId is required',
|
|
104
156
|
});
|
|
157
|
+
recordTrace('plugin.install', { outcome: 'missing_id' });
|
|
105
158
|
return;
|
|
106
159
|
}
|
|
107
160
|
const force = !!req.body?.force;
|
|
161
|
+
span.setAttribute('plugin.install.force', force);
|
|
108
162
|
try {
|
|
109
163
|
const installed = await store.installPlugin(pluginId, { force });
|
|
164
|
+
span.setAttribute('plugin.install.version', installed?.version || '');
|
|
110
165
|
res.status(201).json(installed);
|
|
166
|
+
recordTrace('plugin.install', { outcome: 'created' });
|
|
111
167
|
} catch (err) {
|
|
112
168
|
const map = {
|
|
113
169
|
not_found: 404,
|
|
@@ -117,31 +173,50 @@ export function createPluginsRouter() {
|
|
|
117
173
|
bad_manifest: 422,
|
|
118
174
|
};
|
|
119
175
|
const status = map[err.code] || 500;
|
|
176
|
+
span.setAttribute('plugin.install.error_code', err.code || 'unknown');
|
|
120
177
|
res.status(status).json({
|
|
121
178
|
error: err.code || 'install_failed',
|
|
122
179
|
message: err.message,
|
|
123
180
|
});
|
|
181
|
+
recordTrace('plugin.install', {
|
|
182
|
+
outcome: 'error',
|
|
183
|
+
code: err.code || 'unknown',
|
|
184
|
+
});
|
|
124
185
|
}
|
|
125
|
-
}));
|
|
186
|
+
})));
|
|
126
187
|
|
|
127
188
|
// ── PUT /api/plugins/:id (update OR set config) ──────────────────────
|
|
128
|
-
router.put('/plugins/:id', wrap(async (req, res) => {
|
|
189
|
+
router.put('/plugins/:id', wrap(withSpan('plugin.mutate', async (span, req, res) => {
|
|
190
|
+
setCommonAttributes(span, {
|
|
191
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
192
|
+
userAgent: req.headers?.['user-agent'],
|
|
193
|
+
});
|
|
194
|
+
span.setAttribute('plugin.id', req.params.id || '');
|
|
129
195
|
const action = req.body?.action;
|
|
196
|
+
span.setAttribute('plugin.action', action || '');
|
|
130
197
|
if (action === 'update') {
|
|
131
198
|
try {
|
|
132
199
|
const result = await store.updatePlugin(req.params.id);
|
|
200
|
+
span.setAttribute('plugin.update.from', result.from || '');
|
|
201
|
+
span.setAttribute('plugin.update.to', result.to || '');
|
|
133
202
|
res.json({
|
|
134
203
|
ok: true,
|
|
135
204
|
from: result.from,
|
|
136
205
|
to: result.to,
|
|
137
206
|
plugin: result.plugin,
|
|
138
207
|
});
|
|
208
|
+
recordTrace('plugin.update', { outcome: 'ok' });
|
|
139
209
|
} catch (err) {
|
|
140
210
|
const status = err.code === 'not_installed' ? 404 : 500;
|
|
211
|
+
span.setAttribute('plugin.update.error_code', err.code || 'unknown');
|
|
141
212
|
res.status(status).json({
|
|
142
213
|
error: err.code || 'update_failed',
|
|
143
214
|
message: err.message,
|
|
144
215
|
});
|
|
216
|
+
recordTrace('plugin.update', {
|
|
217
|
+
outcome: 'error',
|
|
218
|
+
code: err.code || 'unknown',
|
|
219
|
+
});
|
|
145
220
|
}
|
|
146
221
|
return;
|
|
147
222
|
}
|
|
@@ -152,17 +227,24 @@ export function createPluginsRouter() {
|
|
|
152
227
|
error: 'bad_request',
|
|
153
228
|
message: 'config must be an object',
|
|
154
229
|
});
|
|
230
|
+
recordTrace('plugin.config', { outcome: 'invalid_config' });
|
|
155
231
|
return;
|
|
156
232
|
}
|
|
157
233
|
try {
|
|
158
234
|
const updated = store.replaceConfig(req.params.id, config);
|
|
159
235
|
res.json(updated);
|
|
236
|
+
recordTrace('plugin.config', { outcome: 'ok' });
|
|
160
237
|
} catch (err) {
|
|
161
238
|
const status = err.code === 'not_installed' ? 404 : 400;
|
|
239
|
+
span.setAttribute('plugin.config.error_code', err.code || 'unknown');
|
|
162
240
|
res.status(status).json({
|
|
163
241
|
error: err.code || 'config_failed',
|
|
164
242
|
message: err.message,
|
|
165
243
|
});
|
|
244
|
+
recordTrace('plugin.config', {
|
|
245
|
+
outcome: 'error',
|
|
246
|
+
code: err.code || 'unknown',
|
|
247
|
+
});
|
|
166
248
|
}
|
|
167
249
|
return;
|
|
168
250
|
}
|
|
@@ -170,37 +252,57 @@ export function createPluginsRouter() {
|
|
|
170
252
|
error: 'bad_request',
|
|
171
253
|
message: 'action must be "update" or "config"',
|
|
172
254
|
});
|
|
173
|
-
|
|
255
|
+
recordTrace('plugin.mutate', { outcome: 'invalid_action' });
|
|
256
|
+
})));
|
|
174
257
|
|
|
175
258
|
// ── DELETE /api/plugins/:id ──────────────────────────────────────────
|
|
176
|
-
router.delete('/plugins/:id', wrap(async (req, res) => {
|
|
259
|
+
router.delete('/plugins/:id', wrap(withSpan('plugin.uninstall', async (span, req, res) => {
|
|
260
|
+
setCommonAttributes(span, {
|
|
261
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
262
|
+
userAgent: req.headers?.['user-agent'],
|
|
263
|
+
});
|
|
264
|
+
span.setAttribute('plugin.id', req.params.id || '');
|
|
177
265
|
const ok = store.uninstallPlugin(req.params.id);
|
|
178
266
|
if (!ok) {
|
|
179
267
|
res.status(404).json({ error: 'not_found' });
|
|
268
|
+
recordTrace('plugin.uninstall', { outcome: 'not_found' });
|
|
180
269
|
return;
|
|
181
270
|
}
|
|
182
271
|
res.status(204).end();
|
|
183
|
-
|
|
272
|
+
recordTrace('plugin.uninstall', { outcome: 'ok' });
|
|
273
|
+
})));
|
|
184
274
|
|
|
185
275
|
// ── POST /api/plugins/:id/invoke ─────────────────────────────────────
|
|
186
|
-
router.post('/plugins/:id/invoke', wrap(async (req, res) => {
|
|
276
|
+
router.post('/plugins/:id/invoke', wrap(withSpan('plugin.invoke', async (span, req, res) => {
|
|
277
|
+
setCommonAttributes(span, {
|
|
278
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
279
|
+
userAgent: req.headers?.['user-agent'],
|
|
280
|
+
});
|
|
281
|
+
span.setAttribute('plugin.id', req.params.id || '');
|
|
187
282
|
const method = req.body?.method;
|
|
188
283
|
const args = Array.isArray(req.body?.args) ? req.body.args : [];
|
|
284
|
+
if (method) span.setAttribute('plugin.invoke.method', method);
|
|
285
|
+
span.setAttribute('plugin.invoke.arg_count', args.length);
|
|
189
286
|
if (!method || typeof method !== 'string') {
|
|
190
287
|
res.status(400).json({
|
|
191
288
|
error: 'bad_request',
|
|
192
289
|
message: 'method (string) is required',
|
|
193
290
|
});
|
|
291
|
+
recordTrace('plugin.invoke', { outcome: 'missing_method' });
|
|
194
292
|
return;
|
|
195
293
|
}
|
|
196
294
|
const timeoutMs = Number.isFinite(req.body?.timeoutMs)
|
|
197
295
|
? req.body.timeoutMs
|
|
198
296
|
: undefined;
|
|
297
|
+
if (Number.isFinite(timeoutMs)) {
|
|
298
|
+
span.setAttribute('plugin.invoke.timeout_ms', timeoutMs);
|
|
299
|
+
}
|
|
199
300
|
const result = await store.invokePlugin(req.params.id, method, args, {
|
|
200
301
|
timeoutMs,
|
|
201
302
|
});
|
|
202
303
|
if (result.ok) {
|
|
203
304
|
res.json(result);
|
|
305
|
+
recordTrace('plugin.invoke', { outcome: 'ok', method });
|
|
204
306
|
return;
|
|
205
307
|
}
|
|
206
308
|
// Distinguish "this is a plugin bug" (500) from "this is a usage
|
|
@@ -213,8 +315,13 @@ export function createPluginsRouter() {
|
|
|
213
315
|
'corrupt_install',
|
|
214
316
|
]);
|
|
215
317
|
const status = usageCodes.has(result.code) ? 400 : 500;
|
|
318
|
+
span.setAttribute('plugin.invoke.error_code', result.code || 'unknown');
|
|
216
319
|
res.status(status).json(result);
|
|
217
|
-
|
|
320
|
+
recordTrace('plugin.invoke', {
|
|
321
|
+
outcome: 'error',
|
|
322
|
+
code: result.code || 'unknown',
|
|
323
|
+
});
|
|
324
|
+
})));
|
|
218
325
|
|
|
219
326
|
return router;
|
|
220
|
-
}
|
|
327
|
+
}
|
|
@@ -33,8 +33,30 @@ import {
|
|
|
33
33
|
ROLES,
|
|
34
34
|
} from '../workspaces.mjs';
|
|
35
35
|
import { getCurrentUserId, getCurrentWorkspaceId } from '../auth.mjs';
|
|
36
|
+
import { withSpan, setCommonAttributes } from '../otel.mjs';
|
|
37
|
+
import { recordTrace } from '../metrics.mjs';
|
|
36
38
|
import { wrap } from './_shared.mjs';
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Common scope helper: extract userId/workspaceId from the request
|
|
42
|
+
* and bind them to the active span. Returns `null` (and lets the
|
|
43
|
+
* caller emit a 401) when no user is attached to the request.
|
|
44
|
+
* Callers decide whether to short-circuit by checking the return
|
|
45
|
+
* value.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('@opentelemetry/api').Span} span
|
|
48
|
+
* @param {import('express').Request} req
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
function bindUserScope(span, req) {
|
|
52
|
+
const userId = getCurrentUserId(req);
|
|
53
|
+
const workspaceId = getCurrentWorkspaceId(req) || null;
|
|
54
|
+
const ip = req.ip || req.socket?.remoteAddress;
|
|
55
|
+
const userAgent = req.headers?.['user-agent'];
|
|
56
|
+
setCommonAttributes(span, { userId, workspaceId, ip, userAgent });
|
|
57
|
+
return userId || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
/**
|
|
39
61
|
* @returns {import('express').Router}
|
|
40
62
|
*/
|
|
@@ -42,163 +64,236 @@ export function createWorkspacesRouter() {
|
|
|
42
64
|
const router = Router();
|
|
43
65
|
|
|
44
66
|
// GET /api/workspaces — list user's workspaces
|
|
45
|
-
router.get('/workspaces', wrap(async (req, res) => {
|
|
46
|
-
const userId =
|
|
67
|
+
router.get('/workspaces', wrap(withSpan('workspace.list', async (span, req, res) => {
|
|
68
|
+
const userId = bindUserScope(span, req);
|
|
47
69
|
if (!userId) {
|
|
48
|
-
|
|
70
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
71
|
+
recordTrace('workspace.list', { outcome: 'unauthorized' });
|
|
72
|
+
return;
|
|
49
73
|
}
|
|
50
74
|
const workspaces = await listWorkspacesWithRoles(userId);
|
|
75
|
+
span.setAttribute('workspace.count', workspaces.length);
|
|
51
76
|
res.json({ workspaces });
|
|
52
|
-
|
|
77
|
+
recordTrace('workspace.list', { outcome: 'ok', count_bucket: workspaces.length === 0 ? '0' : workspaces.length < 5 ? '1-4' : '5+' });
|
|
78
|
+
})));
|
|
53
79
|
|
|
54
80
|
// POST /api/workspaces — create workspace
|
|
55
|
-
router.post('/workspaces', wrap(async (req, res) => {
|
|
56
|
-
const userId =
|
|
81
|
+
router.post('/workspaces', wrap(withSpan('workspace.create', async (span, req, res) => {
|
|
82
|
+
const userId = bindUserScope(span, req);
|
|
57
83
|
if (!userId) {
|
|
58
|
-
|
|
84
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
85
|
+
recordTrace('workspace.create', { outcome: 'unauthorized' });
|
|
86
|
+
return;
|
|
59
87
|
}
|
|
60
88
|
const { name } = req.body || {};
|
|
61
89
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
62
|
-
|
|
90
|
+
res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
|
|
91
|
+
recordTrace('workspace.create', { outcome: 'missing_name' });
|
|
92
|
+
return;
|
|
63
93
|
}
|
|
64
94
|
const workspace = await createWorkspace({ name: name.trim(), ownerId: userId });
|
|
95
|
+
span.setAttribute('workspace.id', workspace.id);
|
|
65
96
|
res.status(201).json({ workspace });
|
|
66
|
-
|
|
97
|
+
recordTrace('workspace.create', { outcome: 'created' });
|
|
98
|
+
})));
|
|
67
99
|
|
|
68
100
|
// GET /api/workspaces/:id — get workspace details
|
|
69
|
-
router.get('/workspaces/:id', wrap(async (req, res) => {
|
|
70
|
-
const userId =
|
|
101
|
+
router.get('/workspaces/:id', wrap(withSpan('workspace.get', async (span, req, res) => {
|
|
102
|
+
const userId = bindUserScope(span, req);
|
|
103
|
+
const { id } = req.params;
|
|
104
|
+
span.setAttribute('workspace.id', id);
|
|
71
105
|
if (!userId) {
|
|
72
|
-
|
|
106
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
107
|
+
recordTrace('workspace.get', { outcome: 'unauthorized' });
|
|
108
|
+
return;
|
|
73
109
|
}
|
|
74
|
-
const { id } = req.params;
|
|
75
110
|
const workspace = await getWorkspace(id);
|
|
76
111
|
if (!workspace) {
|
|
77
|
-
|
|
112
|
+
res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
|
|
113
|
+
recordTrace('workspace.get', { outcome: 'not_found' });
|
|
114
|
+
return;
|
|
78
115
|
}
|
|
79
116
|
const hasAccess = await checkPermission(id, userId, ROLES.VIEWER);
|
|
80
117
|
if (!hasAccess) {
|
|
81
|
-
|
|
118
|
+
res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
|
|
119
|
+
recordTrace('workspace.get', { outcome: 'forbidden' });
|
|
120
|
+
return;
|
|
82
121
|
}
|
|
83
122
|
const members = await listMembers(id);
|
|
123
|
+
span.setAttribute('workspace.member_count', members.length);
|
|
84
124
|
res.json({ workspace, members });
|
|
85
|
-
|
|
125
|
+
recordTrace('workspace.get', { outcome: 'ok' });
|
|
126
|
+
})));
|
|
86
127
|
|
|
87
128
|
// DELETE /api/workspaces/:id — delete workspace (admin only)
|
|
88
|
-
router.delete('/workspaces/:id', wrap(async (req, res) => {
|
|
89
|
-
const userId =
|
|
129
|
+
router.delete('/workspaces/:id', wrap(withSpan('workspace.delete', async (span, req, res) => {
|
|
130
|
+
const userId = bindUserScope(span, req);
|
|
131
|
+
const { id } = req.params;
|
|
132
|
+
span.setAttribute('workspace.id', id);
|
|
90
133
|
if (!userId) {
|
|
91
|
-
|
|
134
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
135
|
+
recordTrace('workspace.delete', { outcome: 'unauthorized' });
|
|
136
|
+
return;
|
|
92
137
|
}
|
|
93
|
-
const { id } = req.params;
|
|
94
138
|
const canDelete = await checkPermission(id, userId, ROLES.ADMIN);
|
|
95
139
|
if (!canDelete) {
|
|
96
|
-
|
|
140
|
+
res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
141
|
+
recordTrace('workspace.delete', { outcome: 'forbidden' });
|
|
142
|
+
return;
|
|
97
143
|
}
|
|
98
144
|
await deleteWorkspace(id);
|
|
99
145
|
res.json({ ok: true });
|
|
100
|
-
|
|
146
|
+
recordTrace('workspace.delete', { outcome: 'ok' });
|
|
147
|
+
})));
|
|
101
148
|
|
|
102
149
|
// POST /api/workspaces/:id/invites — create invite
|
|
103
|
-
router.post('/workspaces/:id/invites', wrap(async (req, res) => {
|
|
104
|
-
const userId =
|
|
150
|
+
router.post('/workspaces/:id/invites', wrap(withSpan('workspace.invite.create', async (span, req, res) => {
|
|
151
|
+
const userId = bindUserScope(span, req);
|
|
152
|
+
const { id } = req.params;
|
|
153
|
+
span.setAttribute('workspace.id', id);
|
|
105
154
|
if (!userId) {
|
|
106
|
-
|
|
155
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
156
|
+
recordTrace('workspace.invite.create', { outcome: 'unauthorized' });
|
|
157
|
+
return;
|
|
107
158
|
}
|
|
108
|
-
const { id } = req.params;
|
|
109
159
|
const { email, role } = req.body || {};
|
|
110
160
|
if (!email || typeof email !== 'string') {
|
|
111
|
-
|
|
161
|
+
res.status(400).json({ error: 'bad_request', message: 'Email is required' });
|
|
162
|
+
recordTrace('workspace.invite.create', { outcome: 'missing_email' });
|
|
163
|
+
return;
|
|
112
164
|
}
|
|
113
165
|
if (!role || !Object.values(ROLES).includes(role)) {
|
|
114
|
-
|
|
166
|
+
res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
|
|
167
|
+
recordTrace('workspace.invite.create', { outcome: 'invalid_role' });
|
|
168
|
+
return;
|
|
115
169
|
}
|
|
116
170
|
const canInvite = await checkPermission(id, userId, ROLES.ADMIN);
|
|
117
171
|
if (!canInvite) {
|
|
118
|
-
|
|
172
|
+
res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
|
|
173
|
+
recordTrace('workspace.invite.create', { outcome: 'forbidden' });
|
|
174
|
+
return;
|
|
119
175
|
}
|
|
176
|
+
span.setAttribute('workspace.invite.role', role);
|
|
120
177
|
const result = await createInvite(id, email, role, userId);
|
|
121
178
|
res.status(201).json(result);
|
|
122
|
-
|
|
179
|
+
recordTrace('workspace.invite.create', { outcome: 'created', role });
|
|
180
|
+
})));
|
|
123
181
|
|
|
124
182
|
// GET /api/workspaces/:id/invites — list pending invites
|
|
125
|
-
router.get('/workspaces/:id/invites', wrap(async (req, res) => {
|
|
126
|
-
const userId =
|
|
183
|
+
router.get('/workspaces/:id/invites', wrap(withSpan('workspace.invite.list', async (span, req, res) => {
|
|
184
|
+
const userId = bindUserScope(span, req);
|
|
185
|
+
const { id } = req.params;
|
|
186
|
+
span.setAttribute('workspace.id', id);
|
|
127
187
|
if (!userId) {
|
|
128
|
-
|
|
188
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
189
|
+
recordTrace('workspace.invite.list', { outcome: 'unauthorized' });
|
|
190
|
+
return;
|
|
129
191
|
}
|
|
130
|
-
const { id } = req.params;
|
|
131
192
|
const canView = await checkPermission(id, userId, ROLES.ADMIN);
|
|
132
193
|
if (!canView) {
|
|
133
|
-
|
|
194
|
+
res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
195
|
+
recordTrace('workspace.invite.list', { outcome: 'forbidden' });
|
|
196
|
+
return;
|
|
134
197
|
}
|
|
135
198
|
const invites = await listInvites(id);
|
|
199
|
+
span.setAttribute('workspace.invite.count', invites.length);
|
|
136
200
|
res.json({ invites });
|
|
137
|
-
|
|
201
|
+
recordTrace('workspace.invite.list', { outcome: 'ok', count_bucket: invites.length === 0 ? '0' : '1+' });
|
|
202
|
+
})));
|
|
138
203
|
|
|
139
204
|
// DELETE /api/workspaces/:id/invites/:token — revoke invite
|
|
140
|
-
router.delete('/workspaces/:id/invites/:token', wrap(async (req, res) => {
|
|
141
|
-
const userId =
|
|
205
|
+
router.delete('/workspaces/:id/invites/:token', wrap(withSpan('workspace.invite.revoke', async (span, req, res) => {
|
|
206
|
+
const userId = bindUserScope(span, req);
|
|
207
|
+
const { id, token } = req.params;
|
|
208
|
+
span.setAttribute('workspace.id', id);
|
|
142
209
|
if (!userId) {
|
|
143
|
-
|
|
210
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
211
|
+
recordTrace('workspace.invite.revoke', { outcome: 'unauthorized' });
|
|
212
|
+
return;
|
|
144
213
|
}
|
|
145
|
-
const { id, token } = req.params;
|
|
146
214
|
const canRevoke = await checkPermission(id, userId, ROLES.ADMIN);
|
|
147
215
|
if (!canRevoke) {
|
|
148
|
-
|
|
216
|
+
res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
217
|
+
recordTrace('workspace.invite.revoke', { outcome: 'forbidden' });
|
|
218
|
+
return;
|
|
149
219
|
}
|
|
150
220
|
await revokeInvite(id, token);
|
|
151
221
|
res.json({ ok: true });
|
|
152
|
-
|
|
222
|
+
recordTrace('workspace.invite.revoke', { outcome: 'ok' });
|
|
223
|
+
})));
|
|
153
224
|
|
|
154
225
|
// POST /api/workspaces/:id/members/:userId — update member role
|
|
155
|
-
router.post('/workspaces/:id/members/:userId', wrap(async (req, res) => {
|
|
156
|
-
const userId =
|
|
226
|
+
router.post('/workspaces/:id/members/:userId', wrap(withSpan('workspace.member.update', async (span, req, res) => {
|
|
227
|
+
const userId = bindUserScope(span, req);
|
|
228
|
+
const { id, userId: targetUserId } = req.params;
|
|
229
|
+
span.setAttribute('workspace.id', id);
|
|
230
|
+
span.setAttribute('workspace.target_user_id', targetUserId);
|
|
157
231
|
if (!userId) {
|
|
158
|
-
|
|
232
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
233
|
+
recordTrace('workspace.member.update', { outcome: 'unauthorized' });
|
|
234
|
+
return;
|
|
159
235
|
}
|
|
160
|
-
const { id, userId: targetUserId } = req.params;
|
|
161
236
|
const { role } = req.body || {};
|
|
162
237
|
if (!role || !Object.values(ROLES).includes(role)) {
|
|
163
|
-
|
|
238
|
+
res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
|
|
239
|
+
recordTrace('workspace.member.update', { outcome: 'invalid_role' });
|
|
240
|
+
return;
|
|
164
241
|
}
|
|
165
242
|
const canUpdate = await checkPermission(id, userId, ROLES.ADMIN);
|
|
166
243
|
if (!canUpdate) {
|
|
167
|
-
|
|
244
|
+
res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
245
|
+
recordTrace('workspace.member.update', { outcome: 'forbidden' });
|
|
246
|
+
return;
|
|
168
247
|
}
|
|
248
|
+
span.setAttribute('workspace.member.new_role', role);
|
|
169
249
|
await updateMemberRole(id, targetUserId, role);
|
|
170
250
|
res.json({ ok: true, role });
|
|
171
|
-
|
|
251
|
+
recordTrace('workspace.member.update', { outcome: 'ok', role });
|
|
252
|
+
})));
|
|
172
253
|
|
|
173
254
|
// DELETE /api/workspaces/:id/members/:userId — remove member
|
|
174
|
-
router.delete('/workspaces/:id/members/:userId', wrap(async (req, res) => {
|
|
175
|
-
const userId =
|
|
255
|
+
router.delete('/workspaces/:id/members/:userId', wrap(withSpan('workspace.member.remove', async (span, req, res) => {
|
|
256
|
+
const userId = bindUserScope(span, req);
|
|
257
|
+
const { id, userId: targetUserId } = req.params;
|
|
258
|
+
span.setAttribute('workspace.id', id);
|
|
259
|
+
span.setAttribute('workspace.target_user_id', targetUserId);
|
|
176
260
|
if (!userId) {
|
|
177
|
-
|
|
261
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
262
|
+
recordTrace('workspace.member.remove', { outcome: 'unauthorized' });
|
|
263
|
+
return;
|
|
178
264
|
}
|
|
179
|
-
const { id, userId: targetUserId } = req.params;
|
|
180
265
|
const canRemove = await checkPermission(id, userId, ROLES.ADMIN);
|
|
181
266
|
if (!canRemove) {
|
|
182
|
-
|
|
267
|
+
res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
268
|
+
recordTrace('workspace.member.remove', { outcome: 'forbidden' });
|
|
269
|
+
return;
|
|
183
270
|
}
|
|
184
271
|
await removeMember(id, targetUserId);
|
|
185
272
|
res.json({ ok: true });
|
|
186
|
-
|
|
273
|
+
recordTrace('workspace.member.remove', { outcome: 'ok' });
|
|
274
|
+
})));
|
|
187
275
|
|
|
188
276
|
// POST /api/invites/:token/accept — accept invite
|
|
189
|
-
router.post('/invites/:token/accept', wrap(async (req, res) => {
|
|
190
|
-
const userId =
|
|
277
|
+
router.post('/invites/:token/accept', wrap(withSpan('workspace.invite.accept', async (span, req, res) => {
|
|
278
|
+
const userId = bindUserScope(span, req);
|
|
279
|
+
const { token } = req.params;
|
|
191
280
|
if (!userId) {
|
|
192
|
-
|
|
281
|
+
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
282
|
+
recordTrace('workspace.invite.accept', { outcome: 'unauthorized' });
|
|
283
|
+
return;
|
|
193
284
|
}
|
|
194
|
-
const { token } = req.params;
|
|
195
285
|
const { email, name } = req.body || {};
|
|
196
286
|
if (!email || !name) {
|
|
197
|
-
|
|
287
|
+
res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
|
|
288
|
+
recordTrace('workspace.invite.accept', { outcome: 'missing_fields' });
|
|
289
|
+
return;
|
|
198
290
|
}
|
|
199
291
|
const result = await acceptInvite(token, userId, email, name);
|
|
292
|
+
if (result?.workspace?.id) span.setAttribute('workspace.id', result.workspace.id);
|
|
293
|
+
if (result?.role) span.setAttribute('workspace.member.role', result.role);
|
|
200
294
|
res.json({ ok: true, workspace: result.workspace, role: result.role });
|
|
201
|
-
|
|
295
|
+
recordTrace('workspace.invite.accept', { outcome: 'ok' });
|
|
296
|
+
})));
|
|
202
297
|
|
|
203
298
|
return router;
|
|
204
299
|
}
|