@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.
Files changed (60) hide show
  1. package/bizar-dash/dist/assets/MobileChat-BVojK0n5.js +1 -0
  2. package/bizar-dash/dist/assets/MobileChat-BVojK0n5.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js +1 -0
  4. package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js.map +1 -0
  5. package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-Clz0NR6Y.js} +20 -25
  6. package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-C1cpttnv.js → main-8feQWXiF.js} +10 -10
  8. package/bizar-dash/dist/assets/main-8feQWXiF.js.map +1 -0
  9. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
  10. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
  11. package/bizar-dash/dist/assets/mobile-D2pc-iNh.js +1 -0
  12. package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile-D2pc-iNh.js.map} +1 -1
  13. package/bizar-dash/dist/assets/mobile-IaZ47uKC.js +2 -0
  14. package/bizar-dash/dist/assets/mobile-IaZ47uKC.js.map +1 -0
  15. package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
  16. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
  17. package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js +2 -0
  18. package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js.map +1 -0
  19. package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
  20. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
  21. package/bizar-dash/dist/index.html +7 -5
  22. package/bizar-dash/dist/mobile.html +4 -5
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/eval.mjs +93 -3
  25. package/bizar-dash/src/server/metrics.mjs +75 -0
  26. package/bizar-dash/src/server/otel.mjs +141 -14
  27. package/bizar-dash/src/server/plugins/registry.mjs +145 -43
  28. package/bizar-dash/src/server/routes/chat.mjs +46 -9
  29. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
  30. package/bizar-dash/src/server/routes/plugins.mjs +123 -16
  31. package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
  32. package/bizar-dash/src/server/server.mjs +44 -15
  33. package/bizar-dash/src/web/MobileApp.tsx +18 -10
  34. package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
  35. package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
  36. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
  37. package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
  38. package/bizar-dash/tests/otel-spans.test.mjs +253 -0
  39. package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
  40. package/package.json +1 -1
  41. package/templates/eval-fixtures/citation.json +16 -0
  42. package/templates/eval-fixtures/code-review.json +16 -0
  43. package/templates/eval-fixtures/concise-output.json +16 -0
  44. package/templates/eval-fixtures/context-window.json +16 -0
  45. package/templates/eval-fixtures/error-recovery.json +16 -0
  46. package/templates/eval-fixtures/json-output.json +20 -0
  47. package/templates/eval-fixtures/multi-language.json +16 -0
  48. package/templates/eval-fixtures/safe-paths.json +16 -0
  49. package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
  50. package/templates/eval-fixtures/unicode-handling.json +16 -0
  51. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
  52. package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
  53. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
  54. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
  55. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
  56. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
  57. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
  58. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
  59. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
  60. 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
- res.json({ plugins: store.listInstalled() });
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 = getCurrentUserId(req);
67
+ router.get('/workspaces', wrap(withSpan('workspace.list', async (span, req, res) => {
68
+ const userId = bindUserScope(span, req);
47
69
  if (!userId) {
48
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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 = getCurrentUserId(req);
81
+ router.post('/workspaces', wrap(withSpan('workspace.create', async (span, req, res) => {
82
+ const userId = bindUserScope(span, req);
57
83
  if (!userId) {
58
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(400).json({ error: 'bad_request', message: 'Email is required' });
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
- return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
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 = getCurrentUserId(req);
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
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
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
- return res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
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
  }