@polderlabs/bizar 5.0.2 → 5.2.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-TCputYzr.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
- 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--17fkfrl.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.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-BG-DhEck.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.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 +8 -6
- 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/api.mjs +4 -0
- 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 +131 -16
- package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
- package/bizar-dash/src/server/routes/voice.mjs +42 -6
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +57 -15
- package/bizar-dash/src/server/voice-store.mjs +27 -0
- package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
- package/bizar-dash/src/web/App.tsx +9 -0
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
- package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
- package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
- package/bizar-dash/src/web/components/Toggle.tsx +31 -0
- package/bizar-dash/src/web/components/Topbar.tsx +6 -0
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/src/web/styles/main.css +545 -0
- package/bizar-dash/src/web/views/Eval.tsx +172 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
- package/bizar-dash/src/web/views/Plugins.tsx +128 -0
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
- package/cli/bin.mjs +14 -0
- package/cli/commands/dash.mjs +19 -1
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/tailscale.mjs +251 -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 +0 -19
- 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
|
@@ -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
|
}
|
|
@@ -41,7 +41,7 @@ import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
|
|
|
41
41
|
import { createV2Router } from './routes-v2/index.mjs';
|
|
42
42
|
import { counter, gauge, render as renderMetrics } from './metrics.mjs';
|
|
43
43
|
import { warn } from './logger.mjs';
|
|
44
|
-
import { initOtel, shutdownOtel } from './otel.mjs';
|
|
44
|
+
import { initOtel, shutdownOtel, tracer } from './otel.mjs';
|
|
45
45
|
|
|
46
46
|
// v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
|
|
47
47
|
// registry; render() emits the text exposition format consumed by
|
|
@@ -238,25 +238,54 @@ export async function createServer({
|
|
|
238
238
|
// limiter middleware (chat / event); if no scope header is
|
|
239
239
|
// present, log the 429 with scope=unknown (it may be an upstream
|
|
240
240
|
// 429 proxied from the opencode plugin).
|
|
241
|
+
//
|
|
242
|
+
// v5.1.0 — Also opens an HTTP-server root span on every request
|
|
243
|
+
// via the OpenTelemetry tracer. The span is created inside
|
|
244
|
+
// `startActiveSpan` so the per-route spans below automatically
|
|
245
|
+
// parent under it (the OTel SDK's AsyncHooksContextManager is
|
|
246
|
+
// installed by NodeSDK.start()). HTTP semantic convention
|
|
247
|
+
// attributes (`http.request.method`, `url.path`, `http.route`,
|
|
248
|
+
// `http.response.status_code`, `http.user_agent`) are added up
|
|
249
|
+
// front / on finish. Cost when OTEL is off: zero — the no-op
|
|
250
|
+
// tracer does nothing.
|
|
241
251
|
app.use((req, res, next) => {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
scope,
|
|
252
|
+
tracer.startActiveSpan('http.request', { attributes: {
|
|
253
|
+
'http.request.method': req.method,
|
|
254
|
+
'url.path': req.originalUrl || req.url || '',
|
|
255
|
+
'url.scheme': req.protocol || 'http',
|
|
256
|
+
'http.user_agent': (req.headers && req.headers['user-agent']) || '',
|
|
257
|
+
'http.client_ip': req.ip || (req.socket && req.socket.remoteAddress) || '',
|
|
258
|
+
'http.host': req.headers?.host || '',
|
|
259
|
+
} }, (rootSpan) => {
|
|
260
|
+
res.on('finish', () => {
|
|
261
|
+
const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
|
|
262
|
+
httpRequestsTotal.inc({
|
|
254
263
|
method: req.method,
|
|
255
264
|
route,
|
|
265
|
+
status: String(res.statusCode),
|
|
256
266
|
});
|
|
257
|
-
|
|
267
|
+
try {
|
|
268
|
+
rootSpan.setAttribute('http.route', route);
|
|
269
|
+
rootSpan.setAttribute('http.response.status_code', res.statusCode);
|
|
270
|
+
if (res.statusCode >= 500) {
|
|
271
|
+
rootSpan.setStatus({ code: 2 /* ERROR */, message: `HTTP ${res.statusCode}` });
|
|
272
|
+
} else {
|
|
273
|
+
rootSpan.setStatus({ code: 1 /* OK */ });
|
|
274
|
+
}
|
|
275
|
+
} catch { /* never throw from OTel helpers */ }
|
|
276
|
+
try { rootSpan.end(); } catch { /* ignore */ }
|
|
277
|
+
if (res.statusCode === 429) {
|
|
278
|
+
const scope = res.getHeader('X-RateLimit-Scope') || 'unknown';
|
|
279
|
+
warn('rate_limit_exceeded', {
|
|
280
|
+
ip: req.ip || (req.socket && req.socket.remoteAddress) || 'unknown',
|
|
281
|
+
scope,
|
|
282
|
+
method: req.method,
|
|
283
|
+
route,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
next();
|
|
258
288
|
});
|
|
259
|
-
next();
|
|
260
289
|
});
|
|
261
290
|
|
|
262
291
|
// v4.7.0 — Cache-Control headers for live JSON endpoints. The
|
|
@@ -444,6 +473,19 @@ export async function createServer({
|
|
|
444
473
|
console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
|
|
445
474
|
}
|
|
446
475
|
|
|
476
|
+
// v5.2 — Background transcription worker for voice notes. Uploads
|
|
477
|
+
// save audio immediately and enqueue the noteId here; the worker
|
|
478
|
+
// drains the queue, calls Whisper (or BIZAR_WHISPER_ENDPOINT), and
|
|
479
|
+
// broadcasts a `voice:updated` event so the dashboard updates in
|
|
480
|
+
// place. Errors here MUST NOT block boot — the worker is best-effort
|
|
481
|
+
// and a missing transcribe just leaves the note with no transcript.
|
|
482
|
+
try {
|
|
483
|
+
const { startTranscriptionWorker } = await import('./workers/transcription-worker.mjs');
|
|
484
|
+
startTranscriptionWorker({ broadcast: localBroadcast });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
console.warn('[bizar-dash] transcription worker failed to start:', err?.message || err);
|
|
487
|
+
}
|
|
488
|
+
|
|
447
489
|
// All /api/* routes go through apiRouter (after mod routes are checked).
|
|
448
490
|
// IMPORTANT: mount v2 router FIRST so `/api/v2/*` matches before
|
|
449
491
|
// apiRouter's internal 404 catch-all (api.mjs line ~109) can swallow it.
|
|
@@ -168,6 +168,33 @@ export function getVoiceNote(id) {
|
|
|
168
168
|
return loadIndex().find((n) => n.id === id);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* v5.2 — Patch an existing note in place. Used by the background
|
|
173
|
+
* transcription worker to attach the transcript after Whisper returns.
|
|
174
|
+
*
|
|
175
|
+
* Atomic write via the same `atomicWriteJson` helper as `saveVoiceNote`.
|
|
176
|
+
* `updatedAtMs` is refreshed on every call so callers / the UI can see
|
|
177
|
+
* when the note last changed. `createdAtMs` is preserved.
|
|
178
|
+
*
|
|
179
|
+
* If the id is unknown, returns null without touching the index. Callers
|
|
180
|
+
* (e.g. the worker) treat null as "skip and log".
|
|
181
|
+
*
|
|
182
|
+
* @param {string} id
|
|
183
|
+
* @param {Partial<VoiceNote>} patch
|
|
184
|
+
* @returns {VoiceNote|null}
|
|
185
|
+
*/
|
|
186
|
+
export function updateVoiceNote(id, patch) {
|
|
187
|
+
if (!id || !patch || typeof patch !== 'object') return null;
|
|
188
|
+
const notes = loadIndex();
|
|
189
|
+
const note = notes.find((n) => n.id === id);
|
|
190
|
+
if (!note) return null;
|
|
191
|
+
|
|
192
|
+
Object.assign(note, patch, { updatedAtMs: Date.now() });
|
|
193
|
+
saveIndex(notes);
|
|
194
|
+
log.info('voice note updated', { id, fields: Object.keys(patch) });
|
|
195
|
+
return note;
|
|
196
|
+
}
|
|
197
|
+
|
|
171
198
|
/**
|
|
172
199
|
* @param {string} id
|
|
173
200
|
* @returns {boolean}
|