@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.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -8
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- 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
|
+
}
|