@polderlabs/bizar 4.8.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-DmpSFPJY.js +9 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
- 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-DX_Jh8Wc.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-Chvf9u_B.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/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/otel.mjs +133 -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/chat.mjs +246 -170
- 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/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- 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/server.mjs +40 -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/SettingsSearch.tsx +204 -89
- 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/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +166 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +22 -2
- package/bizar-dash/src/web/views/Settings.tsx +99 -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/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -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 +73 -0
- 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/settings-search.test.tsx +180 -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/docker-build.test.mjs +96 -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/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/otel.test.mjs +188 -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/dash.mjs +6 -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 -2
- 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-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +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
|
+
}
|