@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,626 @@
1
+ /**
2
+ * src/server/workspaces.mjs
3
+ *
4
+ * v5.0.0 — Multi-user workspace data layer.
5
+ *
6
+ * File-based store at ~/.local/share/bizar/workspaces/ (mode 0700):
7
+ *
8
+ * workspaces/
9
+ * ├── index.json # { workspaces: [{id, name, ownerId, createdAt}], users: [{id, email, name}] }
10
+ * ├── {workspaceId}/
11
+ * │ ├── members.json # [{userId, role: 'admin'|'editor'|'viewer', joinedAt}]
12
+ * │ ├── invites.json # [{token, email, role, invitedBy, expiresAt}]
13
+ * │ ├── tasks.jsonl # existing tasks, scoped to workspace
14
+ * │ └── settings.json # workspace-scoped settings
15
+ *
16
+ * Backwards compatible: existing single-user setups keep working without migration.
17
+ * A "default" workspace is auto-created on first run.
18
+ */
19
+ import {
20
+ existsSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ renameSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ statSync,
27
+ chmodSync,
28
+ unlinkSync,
29
+ } from 'node:fs';
30
+ import { join, dirname } from 'node:path';
31
+ import { homedir } from 'node:os';
32
+ import { randomBytes } from 'node:crypto';
33
+
34
+ // ── Constants ─────────────────────────────────────────────────────────────────
35
+
36
+ export const ROLES = {
37
+ ADMIN: 'admin',
38
+ EDITOR: 'editor',
39
+ VIEWER: 'viewer',
40
+ };
41
+
42
+ /** Role hierarchy: admin > editor > viewer */
43
+ const ROLE_RANK = {
44
+ [ROLES.ADMIN]: 3,
45
+ [ROLES.EDITOR]: 2,
46
+ [ROLES.VIEWER]: 1,
47
+ };
48
+
49
+ // Use functions instead of constants so tests can override process.env.HOME
50
+ // before calling any workspace functions.
51
+ function getHome() {
52
+ return homedir();
53
+ }
54
+ function getBizarLocal() {
55
+ return join(getHome(), '.local', 'share', 'bizar');
56
+ }
57
+ function getWorkspacesRoot() {
58
+ return join(getBizarLocal(), 'workspaces');
59
+ }
60
+ function getIndexFile() {
61
+ return join(getWorkspacesRoot(), 'index.json');
62
+ }
63
+
64
+ const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
65
+
66
+ // ── ID generation ─────────────────────────────────────────────────────────────
67
+
68
+ function genId(prefix) {
69
+ return `${prefix}_${randomBytes(6).toString('hex').slice(0, 10)}`;
70
+ }
71
+
72
+ function genWorkspaceId() {
73
+ return `ws_${randomBytes(6).toString('hex').slice(0, 10)}`;
74
+ }
75
+
76
+ function genUserId() {
77
+ return `usr_${randomBytes(6).toString('hex').slice(0, 10)}`;
78
+ }
79
+
80
+ function genInviteToken() {
81
+ return randomBytes(24).toString('hex');
82
+ }
83
+
84
+ // ── Safe JSON helpers ──────────────────────────────────────────────────────────
85
+
86
+ function safeReadJSON(file, fallback = null) {
87
+ try {
88
+ if (!existsSync(file)) return fallback;
89
+ const text = readFileSync(file, 'utf8');
90
+ if (!text.trim()) return fallback;
91
+ return JSON.parse(text);
92
+ } catch {
93
+ return fallback;
94
+ }
95
+ }
96
+
97
+ function atomicWriteJson(filePath, data) {
98
+ const tmp = `${filePath}.tmp.${process.pid}`;
99
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
100
+ renameSync(tmp, filePath);
101
+ }
102
+
103
+ // ── Ensure directory structure ─────────────────────────────────────────────────
104
+
105
+ function ensureWorkspacesRoot() {
106
+ mkdirSync(getWorkspacesRoot(), { recursive: true, mode: 0o700 });
107
+ chmodSync(getWorkspacesRoot(), 0o700);
108
+ }
109
+
110
+ function ensureWorkspaceDir(workspaceId) {
111
+ const dir = join(getWorkspacesRoot(), workspaceId);
112
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
113
+ chmodSync(dir, 0o700);
114
+ return dir;
115
+ }
116
+
117
+ // ── Index file helpers ─────────────────────────────────────────────────────────
118
+
119
+ function loadIndex() {
120
+ ensureWorkspacesRoot();
121
+ const raw = safeReadJSON(getIndexFile(), { workspaces: [], users: [] });
122
+ return raw;
123
+ }
124
+
125
+ function saveIndex(index) {
126
+ ensureWorkspacesRoot();
127
+ atomicWriteJson(getIndexFile(), index);
128
+ chmodSync(getIndexFile(), 0o600);
129
+ }
130
+
131
+ // ── Workspace members / invites helpers ───────────────────────────────────────
132
+
133
+ function loadMembers(workspaceId) {
134
+ const file = join(getWorkspacesRoot(), workspaceId, 'members.json');
135
+ return safeReadJSON(file, []);
136
+ }
137
+
138
+ function saveMembers(workspaceId, members) {
139
+ const dir = ensureWorkspaceDir(workspaceId);
140
+ const file = join(dir, 'members.json');
141
+ atomicWriteJson(file, members);
142
+ chmodSync(file, 0o600);
143
+ }
144
+
145
+ function loadInvites(workspaceId) {
146
+ const file = join(getWorkspacesRoot(), workspaceId, 'invites.json');
147
+ return safeReadJSON(file, []);
148
+ }
149
+
150
+ function saveInvites(workspaceId, invites) {
151
+ const dir = ensureWorkspaceDir(workspaceId);
152
+ const file = join(dir, 'invites.json');
153
+ atomicWriteJson(file, invites);
154
+ chmodSync(file, 0o600);
155
+ }
156
+
157
+ // ── User helpers ───────────────────────────────────────────────────────────────
158
+
159
+ function loadUser(userId) {
160
+ const index = loadIndex();
161
+ return index.users.find((u) => u.id === userId) || null;
162
+ }
163
+
164
+ function findUserByEmail(email) {
165
+ const index = loadIndex();
166
+ return index.users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null;
167
+ }
168
+
169
+ // ── Public API ────────────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Creates the "default" workspace and adds the current user as admin.
173
+ * Called on server startup if no workspaces exist.
174
+ *
175
+ * @param {object} opts
176
+ * @param {string} opts.userId — existing user id
177
+ * @param {string} opts.email — user email
178
+ * @param {string} opts.name — user display name
179
+ * @returns {Promise<{workspace: object, user: object}>}
180
+ */
181
+ export async function ensureDefaultWorkspace({ userId, email, name }) {
182
+ const index = loadIndex();
183
+
184
+ // Already initialized
185
+ if (index.workspaces.length > 0) {
186
+ const workspace = index.workspaces[0];
187
+ const user = loadUser(userId) || index.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
188
+ return { workspace, user };
189
+ }
190
+
191
+ // Create default workspace
192
+ const workspaceId = 'ws_default';
193
+ const now = new Date().toISOString();
194
+ const workspace = { id: workspaceId, name: 'Default Workspace', ownerId: userId, createdAt: now };
195
+
196
+ // Ensure user exists in index
197
+ let user = loadUser(userId);
198
+ if (!user) {
199
+ user = { id: userId, email, name };
200
+ index.users.push(user);
201
+ }
202
+
203
+ index.workspaces.push(workspace);
204
+ saveIndex(index);
205
+
206
+ // Add user as admin of the workspace
207
+ ensureWorkspaceDir(workspaceId);
208
+ saveMembers(workspaceId, [{ userId, role: ROLES.ADMIN, joinedAt: now }]);
209
+
210
+ return { workspace, user };
211
+ }
212
+
213
+ /**
214
+ * Create a new workspace.
215
+ *
216
+ * @param {object} opts
217
+ * @param {string} opts.name — workspace name
218
+ * @param {string} opts.ownerId — user id of the owner
219
+ * @returns {Promise<object>} created workspace
220
+ */
221
+ export async function createWorkspace({ name, ownerId }) {
222
+ const index = loadIndex();
223
+ const workspaceId = genWorkspaceId();
224
+ const now = new Date().toISOString();
225
+
226
+ // Ensure owner exists in users index (needed for listWorkspaces to find them)
227
+ if (!index.users.some((u) => u.id === ownerId)) {
228
+ // Owner not in index - create a minimal user entry
229
+ // Note: We don't know email/name here, so we use placeholder
230
+ index.users.push({ id: ownerId, email: `${ownerId}@local`, name: ownerId });
231
+ saveIndex(index);
232
+ }
233
+
234
+ const workspace = { id: workspaceId, name, ownerId, createdAt: now };
235
+ index.workspaces.push(workspace);
236
+ saveIndex(index);
237
+
238
+ ensureWorkspaceDir(workspaceId);
239
+ saveMembers(workspaceId, [{ userId: ownerId, role: ROLES.ADMIN, joinedAt: now }]);
240
+
241
+ return workspace;
242
+ }
243
+
244
+ /**
245
+ * Get a workspace by id.
246
+ *
247
+ * @param {string} id
248
+ * @returns {Promise<object|null>}
249
+ */
250
+ export async function getWorkspace(id) {
251
+ const index = loadIndex();
252
+ return index.workspaces.find((w) => w.id === id) || null;
253
+ }
254
+
255
+ /**
256
+ * List all workspaces a user belongs to.
257
+ *
258
+ * @param {string} userId
259
+ * @returns {Promise<Array>}
260
+ */
261
+ export async function listWorkspaces(userId) {
262
+ const index = loadIndex();
263
+ const result = [];
264
+ for (const workspace of index.workspaces) {
265
+ const members = loadMembers(workspace.id);
266
+ if (members.some((m) => m.userId === userId)) {
267
+ result.push(workspace);
268
+ }
269
+ }
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * List workspaces where user is a member, with their roles.
275
+ *
276
+ * @param {string} userId
277
+ * @returns {Promise<Array<{workspace: object, role: string}>>}
278
+ */
279
+ export async function listWorkspacesWithRoles(userId) {
280
+ const index = loadIndex();
281
+ const user = loadUser(userId);
282
+ if (!user) return [];
283
+
284
+ const result = [];
285
+ for (const workspace of index.workspaces) {
286
+ const members = loadMembers(workspace.id);
287
+ const member = members.find((m) => m.userId === userId);
288
+ if (member) {
289
+ result.push({ workspace, role: member.role });
290
+ }
291
+ }
292
+ return result;
293
+ }
294
+
295
+ /**
296
+ * Add a member to a workspace.
297
+ *
298
+ * @param {string} workspaceId
299
+ * @param {string} userId
300
+ * @param {string} role — admin | editor | viewer
301
+ * @returns {Promise<void>}
302
+ */
303
+ export async function addMember(workspaceId, userId, role) {
304
+ if (!Object.values(ROLES).includes(role)) {
305
+ throw Object.assign(new Error(`Invalid role: ${role}`), { status: 400, code: 'bad_request' });
306
+ }
307
+
308
+ const index = loadIndex();
309
+ const workspace = index.workspaces.find((w) => w.id === workspaceId);
310
+ if (!workspace) {
311
+ throw Object.assign(new Error('Workspace not found'), { status: 404, code: 'not_found' });
312
+ }
313
+
314
+ const members = loadMembers(workspaceId);
315
+ const existing = members.find((m) => m.userId === userId);
316
+ if (existing) {
317
+ throw Object.assign(new Error('User is already a member'), { status: 409, code: 'conflict' });
318
+ }
319
+
320
+ members.push({ userId, role, joinedAt: new Date().toISOString() });
321
+ saveMembers(workspaceId, members);
322
+ }
323
+
324
+ /**
325
+ * Remove a member from a workspace.
326
+ *
327
+ * @param {string} workspaceId
328
+ * @param {string} userId
329
+ * @returns {Promise<void>}
330
+ */
331
+ export async function removeMember(workspaceId, userId) {
332
+ const index = loadIndex();
333
+ const workspace = index.workspaces.find((w) => w.id === workspaceId);
334
+ if (!workspace) {
335
+ throw Object.assign(new Error('Workspace not found'), { status: 404, code: 'not_found' });
336
+ }
337
+
338
+ const members = loadMembers(workspaceId);
339
+ const filtered = members.filter((m) => m.userId !== userId);
340
+ if (filtered.length === members.length) {
341
+ throw Object.assign(new Error('User is not a member'), { status: 404, code: 'not_found' });
342
+ }
343
+
344
+ saveMembers(workspaceId, filtered);
345
+ }
346
+
347
+ /**
348
+ * Update a member's role.
349
+ *
350
+ * @param {string} workspaceId
351
+ * @param {string} userId
352
+ * @param {string} newRole
353
+ * @returns {Promise<void>}
354
+ */
355
+ export async function updateMemberRole(workspaceId, userId, newRole) {
356
+ if (!Object.values(ROLES).includes(newRole)) {
357
+ throw Object.assign(new Error(`Invalid role: ${newRole}`), { status: 400, code: 'bad_request' });
358
+ }
359
+
360
+ const members = loadMembers(workspaceId);
361
+ const member = members.find((m) => m.userId === userId);
362
+ if (!member) {
363
+ throw Object.assign(new Error('Member not found'), { status: 404, code: 'not_found' });
364
+ }
365
+
366
+ member.role = newRole;
367
+ saveMembers(workspaceId, members);
368
+ }
369
+
370
+ /**
371
+ * Create an invite for a workspace.
372
+ *
373
+ * @param {string} workspaceId
374
+ * @param {string} email — invitee email
375
+ * @param {string} role — admin | editor | viewer
376
+ * @param {string} invitedBy — user id of inviter
377
+ * @returns {Promise<{token: string, url: string}>}
378
+ */
379
+ export async function createInvite(workspaceId, email, role, invitedBy) {
380
+ if (!Object.values(ROLES).includes(role)) {
381
+ throw Object.assign(new Error(`Invalid role: ${role}`), { status: 400, code: 'bad_request' });
382
+ }
383
+
384
+ const index = loadIndex();
385
+ const workspace = index.workspaces.find((w) => w.id === workspaceId);
386
+ if (!workspace) {
387
+ throw Object.assign(new Error('Workspace not found'), { status: 404, code: 'not_found' });
388
+ }
389
+
390
+ const token = genInviteToken();
391
+ const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS).toISOString();
392
+
393
+ const invites = loadInvites(workspaceId);
394
+ invites.push({ token, email: email.toLowerCase(), role, invitedBy, expiresAt });
395
+ saveInvites(workspaceId, invites);
396
+
397
+ // Build invite URL — the dashboard port is read from the port file
398
+ const configDir = join(homedir(), '.config', 'bizar');
399
+ let port = '4097';
400
+ try {
401
+ const portFile = join(configDir, 'dashboard.port');
402
+ port = readFileSync(portFile, 'utf8').trim() || '4097';
403
+ } catch {
404
+ /* use default */
405
+ }
406
+
407
+ const url = `http://localhost:${port}/accept-invite?token=${token}`;
408
+ return { token, url };
409
+ }
410
+
411
+ /**
412
+ * List pending invites for a workspace.
413
+ *
414
+ * @param {string} workspaceId
415
+ * @returns {Promise<Array>}
416
+ */
417
+ export async function listInvites(workspaceId) {
418
+ const invites = loadInvites(workspaceId);
419
+ const now = Date.now();
420
+ return invites
421
+ .filter((inv) => new Date(inv.expiresAt).getTime() > now)
422
+ .map((inv) => ({ email: inv.email, role: inv.role, invitedBy: inv.invitedBy, expiresAt: inv.expiresAt }));
423
+ }
424
+
425
+ /**
426
+ * Revoke an invite.
427
+ *
428
+ * @param {string} workspaceId
429
+ * @param {string} inviteToken
430
+ * @returns {Promise<void>}
431
+ */
432
+ export async function revokeInvite(workspaceId, inviteToken) {
433
+ const invites = loadInvites(workspaceId);
434
+ const filtered = invites.filter((inv) => inv.token !== inviteToken);
435
+ if (filtered.length === invites.length) {
436
+ throw Object.assign(new Error('Invite not found'), { status: 404, code: 'not_found' });
437
+ }
438
+ saveInvites(workspaceId, filtered);
439
+ }
440
+
441
+ /**
442
+ * Accept an invite token. Creates the user if they don't exist.
443
+ *
444
+ * @param {string} token
445
+ * @param {string} userId — existing or new user id
446
+ * @param {string} email — user email
447
+ * @param {string} name — user display name
448
+ * @returns {Promise<{workspace: object, role: string}>}
449
+ */
450
+ export async function acceptInvite(token, userId, email, name) {
451
+ const index = loadIndex();
452
+
453
+ // Find the invite across all workspaces
454
+ for (const workspace of index.workspaces) {
455
+ const invites = loadInvites(workspace.id);
456
+ const invite = invites.find((inv) => inv.token === token);
457
+ if (!invite) continue;
458
+
459
+ if (new Date(invite.expiresAt).getTime() < Date.now()) {
460
+ throw Object.assign(new Error('Invite has expired'), { status: 410, code: 'gone' });
461
+ }
462
+
463
+ // Ensure user exists in index
464
+ let user = index.users.find((u) => u.id === userId);
465
+ if (!user) {
466
+ user = { id: userId, email: email.toLowerCase(), name };
467
+ index.users.push(user);
468
+ saveIndex(index);
469
+ }
470
+
471
+ // Add as member
472
+ const members = loadMembers(workspace.id);
473
+ if (!members.some((m) => m.userId === userId)) {
474
+ members.push({ userId, role: invite.role, joinedAt: new Date().toISOString() });
475
+ saveMembers(workspace.id, members);
476
+ }
477
+
478
+ // Remove the invite
479
+ const updatedInvites = invites.filter((inv) => inv.token !== token);
480
+ saveInvites(workspace.id, updatedInvites);
481
+
482
+ return { workspace, role: invite.role };
483
+ }
484
+
485
+ throw Object.assign(new Error('Invite not found'), { status: 404, code: 'not_found' });
486
+ }
487
+
488
+ /**
489
+ * Check if a user has at least the required role in a workspace.
490
+ *
491
+ * @param {string} workspaceId
492
+ * @param {string} userId
493
+ * @param {string} requiredRole — admin | editor | viewer
494
+ * @returns {Promise<boolean>}
495
+ */
496
+ export async function checkPermission(workspaceId, userId, requiredRole) {
497
+ const members = loadMembers(workspaceId);
498
+ const member = members.find((m) => m.userId === userId);
499
+ if (!member) return false;
500
+ const userRank = ROLE_RANK[member.role] || 0;
501
+ const requiredRank = ROLE_RANK[requiredRole] || 0;
502
+ return userRank >= requiredRank;
503
+ }
504
+
505
+ /**
506
+ * Get the current user's role in a workspace.
507
+ *
508
+ * @param {string} workspaceId
509
+ * @param {string} userId
510
+ * @returns {Promise<string|null>}
511
+ */
512
+ export async function getMemberRole(workspaceId, userId) {
513
+ const members = loadMembers(workspaceId);
514
+ const member = members.find((m) => m.userId === userId);
515
+ return member ? member.role : null;
516
+ }
517
+
518
+ /**
519
+ * List all members of a workspace.
520
+ *
521
+ * @param {string} workspaceId
522
+ * @returns {Promise<Array>}
523
+ */
524
+ export async function listMembers(workspaceId) {
525
+ const index = loadIndex();
526
+ const members = loadMembers(workspaceId);
527
+
528
+ return members.map((m) => {
529
+ const user = index.users.find((u) => u.id === m.userId);
530
+ return {
531
+ userId: m.userId,
532
+ role: m.role,
533
+ joinedAt: m.joinedAt,
534
+ email: user?.email || null,
535
+ name: user?.name || null,
536
+ };
537
+ });
538
+ }
539
+
540
+ /**
541
+ * Delete a workspace.
542
+ *
543
+ * @param {string} workspaceId
544
+ * @returns {Promise<void>}
545
+ */
546
+ export async function deleteWorkspace(workspaceId) {
547
+ const index = loadIndex();
548
+ const workspaceIdx = index.workspaces.findIndex((w) => w.id === workspaceId);
549
+ if (workspaceIdx === -1) {
550
+ throw Object.assign(new Error('Workspace not found'), { status: 404, code: 'not_found' });
551
+ }
552
+
553
+ index.workspaces.splice(workspaceIdx, 1);
554
+ saveIndex(index);
555
+
556
+ // Remove workspace directory
557
+ const dir = join(getWorkspacesRoot(), workspaceId);
558
+ try {
559
+ const files = readdirSync(dir);
560
+ for (const file of files) {
561
+ unlinkSync(join(dir, file));
562
+ }
563
+ unlinkSync(dir);
564
+ } catch {
565
+ /* ignore cleanup errors */
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Get or create a user by email. For invite acceptance flow.
571
+ *
572
+ * @param {string} email
573
+ * @param {string} name
574
+ * @returns {Promise<{user: object, created: boolean}>}
575
+ */
576
+ export async function getOrCreateUser(email, name) {
577
+ const index = loadIndex();
578
+ const existing = index.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
579
+ if (existing) return { user: existing, created: false };
580
+
581
+ const user = { id: genUserId(), email: email.toLowerCase(), name };
582
+ index.users.push(user);
583
+ saveIndex(index);
584
+ return { user, created: true };
585
+ }
586
+
587
+ /**
588
+ * Update a user's profile.
589
+ *
590
+ * @param {string} userId
591
+ * @param {object} updates — { name?, email? }
592
+ * @returns {Promise<object>}
593
+ */
594
+ export async function updateUser(userId, updates) {
595
+ const index = loadIndex();
596
+ const user = index.users.find((u) => u.id === userId);
597
+ if (!user) {
598
+ throw Object.assign(new Error('User not found'), { status: 404, code: 'not_found' });
599
+ }
600
+
601
+ if (updates.name !== undefined) user.name = updates.name;
602
+ if (updates.email !== undefined) user.email = updates.email.toLowerCase();
603
+ saveIndex(index);
604
+ return user;
605
+ }
606
+
607
+ /**
608
+ * Get a user by id.
609
+ *
610
+ * @param {string} userId
611
+ * @returns {Promise<object|null>}
612
+ */
613
+ export async function getUser(userId) {
614
+ return loadUser(userId);
615
+ }
616
+
617
+ /**
618
+ * Get the default workspace id for a user (first workspace they're a member of).
619
+ *
620
+ * @param {string} userId
621
+ * @returns {Promise<string|null>}
622
+ */
623
+ export async function getDefaultWorkspaceId(userId) {
624
+ const workspaces = await listWorkspaces(userId);
625
+ return workspaces[0]?.id || null;
626
+ }