@polderlabs/bizar 4.9.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
@@ -0,0 +1,220 @@
1
+ /**
2
+ * src/server/routes/plugins.mjs
3
+ *
4
+ * v5.0.0 — Plugin marketplace REST surface.
5
+ *
6
+ * GET /api/plugins/registry?q=&category=&tag=
7
+ * — search the public registry
8
+ * GET /api/plugins/registry/:id — get one plugin's details
9
+ * GET /api/plugins/installed — list installed plugins
10
+ * POST /api/plugins/install — install from registry
11
+ * body { pluginId, force? }
12
+ * DELETE /api/plugins/:id — uninstall
13
+ * PUT /api/plugins/:id — update OR set config
14
+ * body { action: 'update' | 'config', config? }
15
+ * POST /api/plugins/:id/invoke — invoke a method in the sandbox
16
+ * body { method, args? }
17
+ *
18
+ * Express-ordering note: /registry and /installed must come BEFORE the
19
+ * `:id` wildcard routes so Express doesn't match "registry" as an id.
20
+ */
21
+ import { Router } from 'express';
22
+ import { wrap } from './_shared.mjs';
23
+ import * as registry from '../plugins/registry.mjs';
24
+ import * as store from '../plugins/store.mjs';
25
+
26
+ /**
27
+ * @returns {import('express').Router}
28
+ */
29
+ export function createPluginsRouter() {
30
+ const router = Router();
31
+
32
+ // ── GET /api/plugins/registry?q=&category=&tag= ──────────────────────
33
+ router.get('/plugins/registry', wrap(async (req, res) => {
34
+ const q = typeof req.query.q === 'string' ? req.query.q : '';
35
+ const category = typeof req.query.category === 'string' ? req.query.category : undefined;
36
+ const tag = typeof req.query.tag === 'string' ? req.query.tag : undefined;
37
+ try {
38
+ const plugins = await registry.searchPlugins(q, { category, tag });
39
+ // Annotate each entry with installed flag for the UI.
40
+ const installed = new Map(store.listInstalled().map((p) => [p.id, p]));
41
+ res.json({
42
+ registry: {
43
+ source: registry.getRegistryUrl(),
44
+ updatedAt: null, // populated only on a full fetchRegistry; ok to be null here
45
+ },
46
+ plugins: plugins.map((p) => {
47
+ const local = installed.get(p.id);
48
+ return {
49
+ ...p,
50
+ installed: !!local,
51
+ installedVersion: local ? local.version : null,
52
+ upgradeAvailable:
53
+ local && local.version !== p.version ? p.version : null,
54
+ };
55
+ }),
56
+ });
57
+ } catch (err) {
58
+ const status = err.code === 'registry_unreachable' ? 502 : 500;
59
+ res.status(status).json({
60
+ error: err.code || 'registry_error',
61
+ message: err.message,
62
+ registryUrl: registry.getRegistryUrl(),
63
+ });
64
+ }
65
+ }));
66
+
67
+ // ── GET /api/plugins/registry/:id ────────────────────────────────────
68
+ router.get('/plugins/registry/:id', wrap(async (req, res) => {
69
+ try {
70
+ const plugin = await registry.getPlugin(req.params.id);
71
+ if (!plugin) {
72
+ res.status(404).json({ error: 'not_found' });
73
+ return;
74
+ }
75
+ const local = store.getInstalled(plugin.id);
76
+ res.json({
77
+ ...plugin,
78
+ installed: !!local,
79
+ installedVersion: local ? local.version : null,
80
+ upgradeAvailable:
81
+ local && local.version !== plugin.version ? plugin.version : null,
82
+ });
83
+ } catch (err) {
84
+ const status = err.code === 'registry_unreachable' ? 502 : 500;
85
+ res.status(status).json({
86
+ error: err.code || 'registry_error',
87
+ message: err.message,
88
+ });
89
+ }
90
+ }));
91
+
92
+ // ── GET /api/plugins/installed ───────────────────────────────────────
93
+ router.get('/plugins/installed', wrap(async (_req, res) => {
94
+ res.json({ plugins: store.listInstalled() });
95
+ }));
96
+
97
+ // ── POST /api/plugins/install ────────────────────────────────────────
98
+ router.post('/plugins/install', wrap(async (req, res) => {
99
+ const pluginId = req.body?.pluginId || req.body?.id;
100
+ if (!pluginId || typeof pluginId !== 'string') {
101
+ res.status(400).json({
102
+ error: 'bad_request',
103
+ message: 'pluginId is required',
104
+ });
105
+ return;
106
+ }
107
+ const force = !!req.body?.force;
108
+ try {
109
+ const installed = await store.installPlugin(pluginId, { force });
110
+ res.status(201).json(installed);
111
+ } catch (err) {
112
+ const map = {
113
+ not_found: 404,
114
+ already_installed: 409,
115
+ checksum_mismatch: 422,
116
+ download_failed: 502,
117
+ bad_manifest: 422,
118
+ };
119
+ const status = map[err.code] || 500;
120
+ res.status(status).json({
121
+ error: err.code || 'install_failed',
122
+ message: err.message,
123
+ });
124
+ }
125
+ }));
126
+
127
+ // ── PUT /api/plugins/:id (update OR set config) ──────────────────────
128
+ router.put('/plugins/:id', wrap(async (req, res) => {
129
+ const action = req.body?.action;
130
+ if (action === 'update') {
131
+ try {
132
+ const result = await store.updatePlugin(req.params.id);
133
+ res.json({
134
+ ok: true,
135
+ from: result.from,
136
+ to: result.to,
137
+ plugin: result.plugin,
138
+ });
139
+ } catch (err) {
140
+ const status = err.code === 'not_installed' ? 404 : 500;
141
+ res.status(status).json({
142
+ error: err.code || 'update_failed',
143
+ message: err.message,
144
+ });
145
+ }
146
+ return;
147
+ }
148
+ if (action === 'config') {
149
+ const config = req.body?.config;
150
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
151
+ res.status(400).json({
152
+ error: 'bad_request',
153
+ message: 'config must be an object',
154
+ });
155
+ return;
156
+ }
157
+ try {
158
+ const updated = store.replaceConfig(req.params.id, config);
159
+ res.json(updated);
160
+ } catch (err) {
161
+ const status = err.code === 'not_installed' ? 404 : 400;
162
+ res.status(status).json({
163
+ error: err.code || 'config_failed',
164
+ message: err.message,
165
+ });
166
+ }
167
+ return;
168
+ }
169
+ res.status(400).json({
170
+ error: 'bad_request',
171
+ message: 'action must be "update" or "config"',
172
+ });
173
+ }));
174
+
175
+ // ── DELETE /api/plugins/:id ──────────────────────────────────────────
176
+ router.delete('/plugins/:id', wrap(async (req, res) => {
177
+ const ok = store.uninstallPlugin(req.params.id);
178
+ if (!ok) {
179
+ res.status(404).json({ error: 'not_found' });
180
+ return;
181
+ }
182
+ res.status(204).end();
183
+ }));
184
+
185
+ // ── POST /api/plugins/:id/invoke ─────────────────────────────────────
186
+ router.post('/plugins/:id/invoke', wrap(async (req, res) => {
187
+ const method = req.body?.method;
188
+ const args = Array.isArray(req.body?.args) ? req.body.args : [];
189
+ if (!method || typeof method !== 'string') {
190
+ res.status(400).json({
191
+ error: 'bad_request',
192
+ message: 'method (string) is required',
193
+ });
194
+ return;
195
+ }
196
+ const timeoutMs = Number.isFinite(req.body?.timeoutMs)
197
+ ? req.body.timeoutMs
198
+ : undefined;
199
+ const result = await store.invokePlugin(req.params.id, method, args, {
200
+ timeoutMs,
201
+ });
202
+ if (result.ok) {
203
+ res.json(result);
204
+ return;
205
+ }
206
+ // Distinguish "this is a plugin bug" (500) from "this is a usage
207
+ // error" (400/404) so the client can decide whether to retry.
208
+ const usageCodes = new Set([
209
+ 'not_installed',
210
+ 'no_such_method',
211
+ 'bad_manifest',
212
+ 'permission_denied',
213
+ 'corrupt_install',
214
+ ]);
215
+ const status = usageCodes.has(result.code) ? 400 : 500;
216
+ res.status(status).json(result);
217
+ }));
218
+
219
+ return router;
220
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * src/server/routes/users.mjs
3
+ *
4
+ * v5.0.0 — User management REST API.
5
+ *
6
+ * Endpoints:
7
+ * GET /api/users/me — current user info
8
+ * PATCH /api/users/me — update profile (name, email)
9
+ * GET /api/users/:id — get user (workspace member only)
10
+ */
11
+ import { Router } from 'express';
12
+ import { getUser, updateUser, listWorkspacesWithRoles } from '../workspaces.mjs';
13
+ import { getCurrentUserId } from '../auth.mjs';
14
+ import { wrap } from './_shared.mjs';
15
+
16
+ /**
17
+ * @returns {import('express').Router}
18
+ */
19
+ export function createUsersRouter() {
20
+ const router = Router();
21
+
22
+ // GET /api/users/me — current user info
23
+ router.get('/users/me', wrap(async (req, res) => {
24
+ const userId = getCurrentUserId(req);
25
+ if (!userId) {
26
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
27
+ }
28
+ const user = await getUser(userId);
29
+ if (!user) {
30
+ return res.status(404).json({ error: 'not_found', message: 'User not found' });
31
+ }
32
+ const workspaces = await listWorkspacesWithRoles(userId);
33
+ res.json({
34
+ user: {
35
+ id: user.id,
36
+ email: user.email,
37
+ name: user.name,
38
+ },
39
+ workspaces,
40
+ });
41
+ }));
42
+
43
+ // PATCH /api/users/me — update profile
44
+ router.patch('/users/me', wrap(async (req, res) => {
45
+ const userId = getCurrentUserId(req);
46
+ if (!userId) {
47
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
48
+ }
49
+ const { name, email } = req.body || {};
50
+ if (!name && !email) {
51
+ return res.status(400).json({ error: 'bad_request', message: 'name or email required' });
52
+ }
53
+ const updates = {};
54
+ if (name !== undefined) updates.name = name;
55
+ if (email !== undefined) updates.email = email;
56
+ const user = await updateUser(userId, updates);
57
+ res.json({ user });
58
+ }));
59
+
60
+ // GET /api/users/:id — get user (workspace member only)
61
+ router.get('/users/:id', wrap(async (req, res) => {
62
+ const userId = getCurrentUserId(req);
63
+ if (!userId) {
64
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
65
+ }
66
+ const { id } = req.params;
67
+ const user = await getUser(id);
68
+ if (!user) {
69
+ return res.status(404).json({ error: 'not_found', message: 'User not found' });
70
+ }
71
+ // Only return user if both are members of at least one common workspace
72
+ // For simplicity, we check if the requesting user exists (they're authenticated)
73
+ // In a real implementation, we'd check shared workspace membership
74
+ res.json({
75
+ user: {
76
+ id: user.id,
77
+ email: user.email,
78
+ name: user.name,
79
+ },
80
+ });
81
+ }));
82
+
83
+ return router;
84
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * src/server/routes/voice.mjs
3
+ *
4
+ * v5.0.0 — Voice notes REST API.
5
+ *
6
+ * Endpoints:
7
+ * POST /api/voice/upload — multipart upload, transcribe, save
8
+ * GET /api/voice/list?vaultPath= — list notes (optionally filtered)
9
+ * GET /api/voice/:id — get note details
10
+ * DELETE /api/voice/:id — delete note + audio + transcript
11
+ * GET /api/voice/:id/audio — stream audio file
12
+ */
13
+
14
+ import { Router } from 'express';
15
+ import { createReadStream, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { info, warn, child } from '../logger.mjs';
18
+ import { saveVoiceNote, listVoiceNotes, getVoiceNote, deleteVoiceNote } from '../voice-store.mjs';
19
+ import { transcribe } from '../voice-transcribe.mjs';
20
+ import { wrap } from './_shared.mjs';
21
+ import formidable from 'formidable';
22
+
23
+ const log = child({ module: 'voice-routes' });
24
+
25
+ /**
26
+ * @param {object} _deps
27
+ * @returns {import('express').Router}
28
+ */
29
+ export function createVoiceRouter(_deps) {
30
+ const router = Router();
31
+
32
+ // POST /api/voice/upload — multipart: audio file + vaultPath field
33
+ router.post('/voice/upload', wrap(async (req, res) => {
34
+ const form = formidable({ maxFileSize: 50 * 1024 * 1024 }); // 50 MB limit
35
+ form.parse(req, async (err, fields, files) => {
36
+ if (err) {
37
+ warn('voice upload: parse error', { err: err.message });
38
+ res.status(400).json({ error: 'upload_error', message: err.message });
39
+ return;
40
+ }
41
+
42
+ const vaultPath = Array.isArray(fields.vaultPath)
43
+ ? fields.vaultPath[0]
44
+ : fields.vaultPath || null;
45
+
46
+ const audioFile = files.audio?.[0];
47
+ if (!audioFile) {
48
+ res.status(400).json({ error: 'missing_audio', message: 'No audio file in upload' });
49
+ return;
50
+ }
51
+
52
+ // Read audio bytes
53
+ let audioBuffer;
54
+ try {
55
+ const { readFileSync } = await import('node:fs');
56
+ audioBuffer = readFileSync(audioFile.filepath);
57
+ } catch (readErr) {
58
+ warn('voice upload: failed to read audio file', { err: readErr.message });
59
+ res.status(500).json({ error: 'read_error', message: 'Could not read audio file' });
60
+ return;
61
+ }
62
+
63
+ // Transcribe
64
+ const transcript = await transcribe(audioBuffer, { mimeType: 'audio/webm' });
65
+
66
+ // Duration from audioFile (formidable provides size but not duration — we
67
+ // store null and the frontmatter will show '?')
68
+ const durationSec = null;
69
+
70
+ // Save
71
+ let saved;
72
+ try {
73
+ saved = await saveVoiceNote({ audioBuffer, transcript, durationSec, vaultPath });
74
+ } catch (saveErr) {
75
+ warn('voice upload: save failed', { err: saveErr.message });
76
+ res.status(500).json({ error: 'save_error', message: saveErr.message });
77
+ return;
78
+ }
79
+
80
+ log.info('voice note saved via upload', { id: saved.id, transcriptLength: transcript?.length ?? 0 });
81
+ res.json({ notePath: saved.notePath, audioPath: saved.audioPath, id: saved.id, transcription: transcript });
82
+ });
83
+ }));
84
+
85
+ // GET /api/voice/list?vaultPath=
86
+ router.get('/voice/list', wrap(async (req, res) => {
87
+ const vaultPath = req.query.vaultPath || null;
88
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
89
+ const notes = listVoiceNotes({ vaultPath, limit });
90
+ res.json({ notes });
91
+ }));
92
+
93
+ // GET /api/voice/:id
94
+ router.get('/voice/:id', wrap(async (req, res) => {
95
+ const note = getVoiceNote(req.params.id);
96
+ if (!note) {
97
+ res.status(404).json({ error: 'not_found' });
98
+ return;
99
+ }
100
+ res.json({ note });
101
+ }));
102
+
103
+ // DELETE /api/voice/:id
104
+ router.delete('/voice/:id', wrap(async (req, res) => {
105
+ const ok = deleteVoiceNote(req.params.id);
106
+ if (!ok) {
107
+ res.status(404).json({ error: 'not_found' });
108
+ return;
109
+ }
110
+ res.status(204).end();
111
+ }));
112
+
113
+ // GET /api/voice/:id/audio — stream audio file
114
+ router.get('/voice/:id/audio', wrap(async (req, res) => {
115
+ const note = getVoiceNote(req.params.id);
116
+ if (!note) {
117
+ res.status(404).json({ error: 'not_found' });
118
+ return;
119
+ }
120
+ if (!existsSync(note.audioPath)) {
121
+ warn('voice audio file missing', { id: req.params.id, path: note.audioPath });
122
+ res.status(404).json({ error: 'audio_missing' });
123
+ return;
124
+ }
125
+ res.setHeader('Content-Type', 'audio/webm');
126
+ res.setHeader('Cache-Control', 'no-cache');
127
+ createReadStream(note.audioPath).pipe(res);
128
+ }));
129
+
130
+ return router;
131
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * src/server/routes/workspaces.mjs
3
+ *
4
+ * v5.0.0 — Workspace management REST API.
5
+ *
6
+ * Endpoints:
7
+ * GET /api/workspaces — list user's workspaces
8
+ * POST /api/workspaces — create workspace
9
+ * GET /api/workspaces/:id — get workspace details
10
+ * DELETE /api/workspaces/:id — delete workspace (admin only)
11
+ * POST /api/workspaces/:id/invites — create invite
12
+ * GET /api/workspaces/:id/invites — list pending invites
13
+ * DELETE /api/workspaces/:id/invites/:token — revoke invite
14
+ * POST /api/workspaces/:id/members/:userId — update member role
15
+ * DELETE /api/workspaces/:id/members/:userId — remove member
16
+ * POST /api/invites/:token/accept — accept invite
17
+ */
18
+ import { Router } from 'express';
19
+ import {
20
+ createWorkspace,
21
+ getWorkspace,
22
+ listWorkspacesWithRoles,
23
+ deleteWorkspace,
24
+ createInvite,
25
+ listInvites,
26
+ revokeInvite,
27
+ addMember,
28
+ removeMember,
29
+ updateMemberRole,
30
+ acceptInvite,
31
+ checkPermission,
32
+ listMembers,
33
+ ROLES,
34
+ } from '../workspaces.mjs';
35
+ import { getCurrentUserId, getCurrentWorkspaceId } from '../auth.mjs';
36
+ import { wrap } from './_shared.mjs';
37
+
38
+ /**
39
+ * @returns {import('express').Router}
40
+ */
41
+ export function createWorkspacesRouter() {
42
+ const router = Router();
43
+
44
+ // GET /api/workspaces — list user's workspaces
45
+ router.get('/workspaces', wrap(async (req, res) => {
46
+ const userId = getCurrentUserId(req);
47
+ if (!userId) {
48
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
49
+ }
50
+ const workspaces = await listWorkspacesWithRoles(userId);
51
+ res.json({ workspaces });
52
+ }));
53
+
54
+ // POST /api/workspaces — create workspace
55
+ router.post('/workspaces', wrap(async (req, res) => {
56
+ const userId = getCurrentUserId(req);
57
+ if (!userId) {
58
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
59
+ }
60
+ const { name } = req.body || {};
61
+ if (!name || typeof name !== 'string' || !name.trim()) {
62
+ return res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
63
+ }
64
+ const workspace = await createWorkspace({ name: name.trim(), ownerId: userId });
65
+ res.status(201).json({ workspace });
66
+ }));
67
+
68
+ // GET /api/workspaces/:id — get workspace details
69
+ router.get('/workspaces/:id', wrap(async (req, res) => {
70
+ const userId = getCurrentUserId(req);
71
+ if (!userId) {
72
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
73
+ }
74
+ const { id } = req.params;
75
+ const workspace = await getWorkspace(id);
76
+ if (!workspace) {
77
+ return res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
78
+ }
79
+ const hasAccess = await checkPermission(id, userId, ROLES.VIEWER);
80
+ if (!hasAccess) {
81
+ return res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
82
+ }
83
+ const members = await listMembers(id);
84
+ res.json({ workspace, members });
85
+ }));
86
+
87
+ // DELETE /api/workspaces/:id — delete workspace (admin only)
88
+ router.delete('/workspaces/:id', wrap(async (req, res) => {
89
+ const userId = getCurrentUserId(req);
90
+ if (!userId) {
91
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
92
+ }
93
+ const { id } = req.params;
94
+ const canDelete = await checkPermission(id, userId, ROLES.ADMIN);
95
+ if (!canDelete) {
96
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
97
+ }
98
+ await deleteWorkspace(id);
99
+ res.json({ ok: true });
100
+ }));
101
+
102
+ // POST /api/workspaces/:id/invites — create invite
103
+ router.post('/workspaces/:id/invites', wrap(async (req, res) => {
104
+ const userId = getCurrentUserId(req);
105
+ if (!userId) {
106
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
107
+ }
108
+ const { id } = req.params;
109
+ const { email, role } = req.body || {};
110
+ if (!email || typeof email !== 'string') {
111
+ return res.status(400).json({ error: 'bad_request', message: 'Email is required' });
112
+ }
113
+ if (!role || !Object.values(ROLES).includes(role)) {
114
+ return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
115
+ }
116
+ const canInvite = await checkPermission(id, userId, ROLES.ADMIN);
117
+ if (!canInvite) {
118
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
119
+ }
120
+ const result = await createInvite(id, email, role, userId);
121
+ res.status(201).json(result);
122
+ }));
123
+
124
+ // GET /api/workspaces/:id/invites — list pending invites
125
+ router.get('/workspaces/:id/invites', wrap(async (req, res) => {
126
+ const userId = getCurrentUserId(req);
127
+ if (!userId) {
128
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
129
+ }
130
+ const { id } = req.params;
131
+ const canView = await checkPermission(id, userId, ROLES.ADMIN);
132
+ if (!canView) {
133
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
134
+ }
135
+ const invites = await listInvites(id);
136
+ res.json({ invites });
137
+ }));
138
+
139
+ // DELETE /api/workspaces/:id/invites/:token — revoke invite
140
+ router.delete('/workspaces/:id/invites/:token', wrap(async (req, res) => {
141
+ const userId = getCurrentUserId(req);
142
+ if (!userId) {
143
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
144
+ }
145
+ const { id, token } = req.params;
146
+ const canRevoke = await checkPermission(id, userId, ROLES.ADMIN);
147
+ if (!canRevoke) {
148
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
149
+ }
150
+ await revokeInvite(id, token);
151
+ res.json({ ok: true });
152
+ }));
153
+
154
+ // 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);
157
+ if (!userId) {
158
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
159
+ }
160
+ const { id, userId: targetUserId } = req.params;
161
+ const { role } = req.body || {};
162
+ if (!role || !Object.values(ROLES).includes(role)) {
163
+ return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
164
+ }
165
+ const canUpdate = await checkPermission(id, userId, ROLES.ADMIN);
166
+ if (!canUpdate) {
167
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
168
+ }
169
+ await updateMemberRole(id, targetUserId, role);
170
+ res.json({ ok: true, role });
171
+ }));
172
+
173
+ // DELETE /api/workspaces/:id/members/:userId — remove member
174
+ router.delete('/workspaces/:id/members/:userId', wrap(async (req, res) => {
175
+ const userId = getCurrentUserId(req);
176
+ if (!userId) {
177
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
178
+ }
179
+ const { id, userId: targetUserId } = req.params;
180
+ const canRemove = await checkPermission(id, userId, ROLES.ADMIN);
181
+ if (!canRemove) {
182
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
183
+ }
184
+ await removeMember(id, targetUserId);
185
+ res.json({ ok: true });
186
+ }));
187
+
188
+ // POST /api/invites/:token/accept — accept invite
189
+ router.post('/invites/:token/accept', wrap(async (req, res) => {
190
+ const userId = getCurrentUserId(req);
191
+ if (!userId) {
192
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
193
+ }
194
+ const { token } = req.params;
195
+ const { email, name } = req.body || {};
196
+ if (!email || !name) {
197
+ return res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
198
+ }
199
+ const result = await acceptInvite(token, userId, email, name);
200
+ res.json({ ok: true, workspace: result.workspace, role: result.role });
201
+ }));
202
+
203
+ return router;
204
+ }