@myrialabs/clopen 0.1.10 → 0.2.1

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 (71) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +20 -0
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  9. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  10. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  11. package/backend/lib/database/migrations/index.ts +21 -0
  12. package/backend/lib/database/queries/auth-queries.ts +201 -0
  13. package/backend/lib/database/queries/index.ts +2 -1
  14. package/backend/lib/engine/adapters/opencode/server.ts +1 -1
  15. package/backend/lib/mcp/config.ts +13 -18
  16. package/backend/lib/mcp/index.ts +9 -0
  17. package/backend/lib/mcp/remote-server.ts +132 -0
  18. package/backend/lib/mcp/servers/helper.ts +49 -3
  19. package/backend/lib/mcp/servers/index.ts +3 -2
  20. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  21. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  22. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  23. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  24. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  25. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  26. package/backend/lib/utils/ws.ts +87 -1
  27. package/backend/ws/auth/index.ts +21 -0
  28. package/backend/ws/auth/invites.ts +84 -0
  29. package/backend/ws/auth/login.ts +283 -0
  30. package/backend/ws/auth/status.ts +41 -0
  31. package/backend/ws/auth/users.ts +32 -0
  32. package/backend/ws/engine/claude/accounts.ts +3 -1
  33. package/backend/ws/engine/utils.ts +38 -6
  34. package/backend/ws/index.ts +4 -4
  35. package/backend/ws/preview/browser/interact.ts +27 -5
  36. package/bin/clopen.ts +39 -0
  37. package/bun.lock +113 -51
  38. package/frontend/App.svelte +47 -29
  39. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  40. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  41. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  42. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  43. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  44. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  45. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  46. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  47. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  48. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  49. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  50. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  51. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  52. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  53. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  54. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  55. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  56. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  57. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  58. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  59. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  60. package/frontend/lib/stores/features/auth.svelte.ts +308 -0
  61. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  62. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  63. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  64. package/frontend/lib/stores/ui/update.svelte.ts +2 -2
  65. package/package.json +8 -6
  66. package/shared/types/stores/settings.ts +16 -2
  67. package/shared/utils/logger.ts +1 -0
  68. package/shared/utils/ws-client.ts +30 -13
  69. package/shared/utils/ws-server.ts +42 -4
  70. package/backend/lib/mcp/stdio-server.ts +0 -103
  71. package/backend/ws/mcp/index.ts +0 -61
package/README.md CHANGED
@@ -18,6 +18,7 @@
18
18
  - **Integrated Terminal** - Multi-tab terminal with full PTY control
19
19
  - **File Management** - Directory browsing, live editing, and real-time file watching
20
20
  - **Git Management** - Full source control: staging, commits, branches, push/pull, stash, log, conflict resolution
21
+ - **Flexible Authentication** - No Login or With Login mode with admin/member roles, invite links, rate-limited login, CLI token recovery — configurable during setup and in Settings
21
22
  - **Real-time Collaboration** - Multiple users can work on the same project simultaneously
22
23
  - **Built-in Cloudflare Tunnel** - Expose local projects publicly for testing and sharing
23
24
 
@@ -48,7 +49,7 @@ Or manually via Bun:
48
49
  bun add -g @myrialabs/clopen
49
50
  ```
50
51
 
51
- You can also update from the **Settings > General > Updates** section in the web UI.
52
+ You can also update from the **Settings > System > Updates** section in the web UI.
52
53
 
53
54
  ### Usage
54
55
 
@@ -58,6 +59,27 @@ clopen
58
59
 
59
60
  Starts the server on `http://localhost:9141`.
60
61
 
62
+ ### First-Time Setup
63
+
64
+ On first launch, a setup wizard guides you through:
65
+
66
+ 1. **Authentication mode** — Choose between **No Login** (no authentication required, ideal for personal/local use) or **With Login** (login with Personal Access Token, supports team collaboration)
67
+ 2. **Admin account** — If With Login mode is selected, create your admin account and save the generated PAT
68
+ 3. **AI Engines** — Check Claude Code and OpenCode installation status
69
+ 4. **Preferences** — Set theme, font size, and notification preferences
70
+
71
+ You can change the authentication mode anytime in **Settings > Security > Authentication**.
72
+
73
+ To invite team members (With Login mode), go to **Settings > Team > Invite** and generate an invite link (valid for 15 minutes).
74
+
75
+ If you lose your admin token:
76
+
77
+ ```bash
78
+ clopen reset-pat
79
+ ```
80
+
81
+ This regenerates and displays a new admin PAT.
82
+
61
83
  ---
62
84
 
63
85
  ## Development
package/backend/index.ts CHANGED
@@ -28,6 +28,20 @@ import { statSync } from 'node:fs';
28
28
  // Import WebSocket router
29
29
  import { wsRouter } from './ws';
30
30
 
31
+ // MCP remote server for Open Code custom tools
32
+ import { handleMcpRequest, closeMcpServer } from './lib/mcp/remote-server';
33
+
34
+ // Auth middleware
35
+ import { checkRouteAccess } from './lib/auth/permissions';
36
+ import { ws as wsServer } from './lib/utils/ws';
37
+
38
+ // Register auth gate on WebSocket router — blocks unauthenticated/unauthorized access
39
+ wsRouter.setAuthMiddleware(async (conn, action) => {
40
+ const isAuth = wsServer.isAuthenticated(conn);
41
+ const role = wsServer.getRole(conn);
42
+ return checkRouteAccess(action, isAuth, role);
43
+ });
44
+
31
45
  /**
32
46
  * Clopen - Elysia Backend Server
33
47
  *
@@ -63,6 +77,10 @@ const app = new Elysia()
63
77
  environment: SERVER_ENV.NODE_ENV
64
78
  }))
65
79
 
80
+ // MCP remote server endpoint for Open Code custom tools
81
+ // Handles GET (SSE stream), POST (JSON-RPC), DELETE (session close)
82
+ .all('/mcp', async ({ request }) => handleMcpRequest(request))
83
+
66
84
  // Mount WebSocket router (all functionality now via WebSocket)
67
85
  .use(wsRouter.asPlugin('/ws'));
68
86
 
@@ -141,6 +159,8 @@ startServer().catch((error) => {
141
159
  async function gracefulShutdown() {
142
160
  console.log('\n🛑 Shutting down server...');
143
161
  try {
162
+ // Close MCP remote server (before engines, as they may still reference it)
163
+ await closeMcpServer();
144
164
  // Dispose all AI engines
145
165
  await disposeAllEngines();
146
166
  // Stop accepting new connections
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Auth Service
3
+ *
4
+ * Core authentication logic: user creation, session management, invite handling.
5
+ */
6
+
7
+ import { authQueries, settingsQueries } from '$backend/lib/database/queries';
8
+ import { generateSessionToken, generatePAT, generateInviteToken, hashToken, getTokenType } from './tokens';
9
+ import { generateColorFromString, getInitials } from '$backend/lib/user/helpers';
10
+ import { debug } from '$shared/utils/logger';
11
+
12
+ /** Default session lifetime in days */
13
+ const DEFAULT_SESSION_DAYS = 30;
14
+
15
+ export interface AuthUser {
16
+ id: string;
17
+ name: string;
18
+ color: string;
19
+ avatar: string;
20
+ role: 'admin' | 'member';
21
+ createdAt: string;
22
+ }
23
+
24
+ export interface AuthResult {
25
+ user: AuthUser;
26
+ sessionToken: string;
27
+ expiresAt: string;
28
+ }
29
+
30
+ export interface SetupResult extends AuthResult {
31
+ personalAccessToken: string;
32
+ }
33
+
34
+ function toAuthUser(dbUser: { id: string; name: string; color: string; avatar: string; role: 'admin' | 'member'; created_at: string }): AuthUser {
35
+ return {
36
+ id: dbUser.id,
37
+ name: dbUser.name,
38
+ color: dbUser.color,
39
+ avatar: dbUser.avatar,
40
+ role: dbUser.role,
41
+ createdAt: dbUser.created_at
42
+ };
43
+ }
44
+
45
+ function createSessionForUser(userId: string, sessionDays?: number): { sessionToken: string; expiresAt: string; tokenHash: string } {
46
+ const days = sessionDays ?? DEFAULT_SESSION_DAYS;
47
+ const sessionToken = generateSessionToken();
48
+ const tokenHash = hashToken(sessionToken);
49
+ const now = new Date().toISOString();
50
+ const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
51
+
52
+ authQueries.createSession({
53
+ id: `session-${crypto.randomUUID()}`,
54
+ user_id: userId,
55
+ token_hash: tokenHash,
56
+ expires_at: expiresAt,
57
+ created_at: now,
58
+ last_active_at: now
59
+ });
60
+
61
+ return { sessionToken, expiresAt, tokenHash };
62
+ }
63
+
64
+ /**
65
+ * Check if the system needs initial setup (no users exist)
66
+ */
67
+ export function needsSetup(): boolean {
68
+ return authQueries.countUsers() === 0;
69
+ }
70
+
71
+ /**
72
+ * Get parsed system settings from DB (cached per call).
73
+ */
74
+ function getSystemSettingsParsed(): Record<string, unknown> {
75
+ try {
76
+ const setting = settingsQueries.get('system:settings');
77
+ if (setting?.value) {
78
+ return typeof setting.value === 'string' ? JSON.parse(setting.value) : setting.value;
79
+ }
80
+ } catch {
81
+ // Settings may not exist yet
82
+ }
83
+ return {};
84
+ }
85
+
86
+ /**
87
+ * Get the current auth mode from system settings.
88
+ * Returns 'required' by default (before setup is complete).
89
+ */
90
+ export function getAuthMode(): 'none' | 'required' {
91
+ const parsed = getSystemSettingsParsed();
92
+ if (parsed.authMode === 'none' || parsed.authMode === 'required') {
93
+ return parsed.authMode as 'none' | 'required';
94
+ }
95
+ return 'required';
96
+ }
97
+
98
+ /**
99
+ * Check if the initial onboarding wizard has been completed.
100
+ */
101
+ export function isOnboardingComplete(): boolean {
102
+ const parsed = getSystemSettingsParsed();
103
+ return parsed.onboardingComplete === true;
104
+ }
105
+
106
+ /**
107
+ * Create a default admin user for no-auth mode.
108
+ * Does not generate a PAT (not needed for no-auth).
109
+ * If users already exist, returns the first admin.
110
+ */
111
+ export function createOrGetNoAuthAdmin(): AuthResult {
112
+ // If users already exist, return the first admin
113
+ const existingUsers = authQueries.getAllUsers();
114
+ const existingAdmin = existingUsers.find(u => u.role === 'admin');
115
+ if (existingAdmin) {
116
+ const { sessionToken, expiresAt } = createSessionForUser(existingAdmin.id);
117
+ debug.log('auth', `No-auth mode: reusing existing admin: ${existingAdmin.name} (${existingAdmin.id})`);
118
+ return {
119
+ user: toAuthUser(existingAdmin),
120
+ sessionToken,
121
+ expiresAt
122
+ };
123
+ }
124
+
125
+ // Create default admin
126
+ const userId = `user-${crypto.randomUUID()}`;
127
+ const now = new Date().toISOString();
128
+ const defaultName = 'Admin';
129
+
130
+ const dbUser = authQueries.createUser({
131
+ id: userId,
132
+ name: defaultName,
133
+ color: generateColorFromString(defaultName),
134
+ avatar: getInitials(defaultName),
135
+ role: 'admin',
136
+ personal_access_token_hash: '', // No PAT for no-auth mode
137
+ created_at: now
138
+ });
139
+
140
+ const { sessionToken, expiresAt } = createSessionForUser(userId);
141
+
142
+ debug.log('auth', `No-auth mode: created default admin: ${defaultName} (${userId})`);
143
+
144
+ return {
145
+ user: toAuthUser(dbUser),
146
+ sessionToken,
147
+ expiresAt
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Create the first admin user (setup flow)
153
+ * Only works when no users exist.
154
+ */
155
+ export function createAdmin(name: string): SetupResult {
156
+ if (!needsSetup()) {
157
+ throw new Error('Setup already completed. Admin account exists.');
158
+ }
159
+
160
+ const trimmedName = name.trim();
161
+ if (trimmedName.length === 0) {
162
+ throw new Error('Name cannot be empty');
163
+ }
164
+
165
+ const userId = `user-${crypto.randomUUID()}`;
166
+ const now = new Date().toISOString();
167
+ const pat = generatePAT();
168
+ const patHash = hashToken(pat);
169
+
170
+ const dbUser = authQueries.createUser({
171
+ id: userId,
172
+ name: trimmedName,
173
+ color: generateColorFromString(trimmedName),
174
+ avatar: getInitials(trimmedName),
175
+ role: 'admin',
176
+ personal_access_token_hash: patHash,
177
+ created_at: now
178
+ });
179
+
180
+ const { sessionToken, expiresAt } = createSessionForUser(userId);
181
+
182
+ debug.log('auth', `Admin account created: ${trimmedName} (${userId})`);
183
+
184
+ return {
185
+ user: toAuthUser(dbUser),
186
+ sessionToken,
187
+ expiresAt,
188
+ personalAccessToken: pat
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Create a user from an invite token
194
+ */
195
+ export function createUserFromInvite(rawInviteToken: string, name: string): SetupResult {
196
+ const trimmedName = name.trim();
197
+ if (trimmedName.length === 0) {
198
+ throw new Error('Name cannot be empty');
199
+ }
200
+
201
+ const inviteHash = hashToken(rawInviteToken);
202
+ const invite = authQueries.getInviteByTokenHash(inviteHash);
203
+
204
+ if (!invite) {
205
+ throw new Error('Invalid invite token');
206
+ }
207
+
208
+ // Check expiry
209
+ if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
210
+ throw new Error('Invite token has expired');
211
+ }
212
+
213
+ // Check max uses
214
+ if (invite.max_uses > 0 && invite.use_count >= invite.max_uses) {
215
+ throw new Error('Invite token has reached maximum uses');
216
+ }
217
+
218
+ // Only allow creating member role from invite (single admin policy)
219
+ const role: 'admin' | 'member' = 'member';
220
+
221
+ const userId = `user-${crypto.randomUUID()}`;
222
+ const now = new Date().toISOString();
223
+ const pat = generatePAT();
224
+ const patHash = hashToken(pat);
225
+
226
+ const dbUser = authQueries.createUser({
227
+ id: userId,
228
+ name: trimmedName,
229
+ color: generateColorFromString(trimmedName),
230
+ avatar: getInitials(trimmedName),
231
+ role,
232
+ personal_access_token_hash: patHash,
233
+ created_at: now
234
+ });
235
+
236
+ // Increment invite use count
237
+ authQueries.incrementUseCount(invite.id);
238
+
239
+ const { sessionToken, expiresAt } = createSessionForUser(userId);
240
+
241
+ debug.log('auth', `User created from invite: ${trimmedName} (${userId}), role: ${role}`);
242
+
243
+ return {
244
+ user: toAuthUser(dbUser),
245
+ sessionToken,
246
+ expiresAt,
247
+ personalAccessToken: pat
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Login with a token (PAT or session token)
253
+ */
254
+ export function loginWithToken(token: string): AuthResult & { tokenHash: string } {
255
+ const tokenType = getTokenType(token);
256
+ const tokenHash = hashToken(token);
257
+
258
+ if (tokenType === 'pat') {
259
+ // PAT login — find user by PAT hash, create new session
260
+ const user = authQueries.getUserByPatHash(tokenHash);
261
+ if (!user) {
262
+ throw new Error('Invalid access token');
263
+ }
264
+
265
+ const session = createSessionForUser(user.id);
266
+ debug.log('auth', `PAT login: ${user.name} (${user.id})`);
267
+
268
+ return {
269
+ user: toAuthUser(user),
270
+ sessionToken: session.sessionToken,
271
+ expiresAt: session.expiresAt,
272
+ tokenHash: session.tokenHash
273
+ };
274
+ }
275
+
276
+ if (tokenType === 'session') {
277
+ // Session token login — validate existing session
278
+ const session = authQueries.getSessionByTokenHash(tokenHash);
279
+ if (!session) {
280
+ throw new Error('Invalid session token');
281
+ }
282
+
283
+ // Check expiry
284
+ if (new Date(session.expires_at) < new Date()) {
285
+ authQueries.deleteSession(session.id);
286
+ throw new Error('Session expired');
287
+ }
288
+
289
+ // Update last active
290
+ authQueries.updateLastActive(session.id);
291
+
292
+ const user = authQueries.getUserById(session.user_id);
293
+ if (!user) {
294
+ authQueries.deleteSession(session.id);
295
+ throw new Error('User not found');
296
+ }
297
+
298
+ debug.log('auth', `Session login: ${user.name} (${user.id})`);
299
+
300
+ return {
301
+ user: toAuthUser(user),
302
+ sessionToken: token,
303
+ expiresAt: session.expires_at,
304
+ tokenHash
305
+ };
306
+ }
307
+
308
+ throw new Error('Invalid token format');
309
+ }
310
+
311
+ /**
312
+ * Logout — delete session by token hash
313
+ */
314
+ export function logout(tokenHash: string): void {
315
+ authQueries.deleteSessionByTokenHash(tokenHash);
316
+ debug.log('auth', 'Session deleted');
317
+ }
318
+
319
+ /**
320
+ * Get user by ID
321
+ */
322
+ export function getUserById(id: string): AuthUser | null {
323
+ const user = authQueries.getUserById(id);
324
+ return user ? toAuthUser(user) : null;
325
+ }
326
+
327
+ /**
328
+ * List all users
329
+ */
330
+ export function listUsers(): AuthUser[] {
331
+ return authQueries.getAllUsers().map(toAuthUser);
332
+ }
333
+
334
+ /**
335
+ * Remove a user (prevents removing the last admin)
336
+ */
337
+ export function removeUser(userId: string): void {
338
+ const user = authQueries.getUserById(userId);
339
+ if (!user) {
340
+ throw new Error('User not found');
341
+ }
342
+
343
+ if (user.role === 'admin' && authQueries.countAdmins() <= 1) {
344
+ throw new Error('Cannot remove the last admin');
345
+ }
346
+
347
+ // Delete all sessions for this user
348
+ authQueries.deleteSessionsByUserId(userId);
349
+ // Delete the user (cascade will handle invite_tokens.created_by)
350
+ authQueries.deleteUser(userId);
351
+
352
+ debug.log('auth', `User removed: ${user.name} (${userId})`);
353
+ }
354
+
355
+ /**
356
+ * Create an invite token
357
+ */
358
+ export function createInvite(
359
+ createdBy: string,
360
+ options: { label?: string; maxUses?: number; expiresInMinutes?: number }
361
+ ): { inviteToken: string; invite: ReturnType<typeof authQueries.getInviteByTokenHash> } {
362
+ const rawToken = generateInviteToken();
363
+ const tokenHash = hashToken(rawToken);
364
+ const now = new Date().toISOString();
365
+
366
+ const expiresAt = options.expiresInMinutes
367
+ ? new Date(Date.now() + options.expiresInMinutes * 60 * 1000).toISOString()
368
+ : null;
369
+
370
+ const invite = authQueries.createInvite({
371
+ id: `invite-${crypto.randomUUID()}`,
372
+ token_hash: tokenHash,
373
+ role: 'member',
374
+ label: options.label ?? null,
375
+ created_by: createdBy,
376
+ max_uses: options.maxUses ?? 1,
377
+ use_count: 0,
378
+ expires_at: expiresAt,
379
+ created_at: now
380
+ });
381
+
382
+ debug.log('auth', `Invite created by ${createdBy}: ${invite.id}`);
383
+
384
+ return { inviteToken: rawToken, invite };
385
+ }
386
+
387
+ /**
388
+ * Validate an invite token (without using it)
389
+ */
390
+ export function validateInviteToken(rawToken: string): { valid: boolean; role?: string; error?: string } {
391
+ const tokenHash = hashToken(rawToken);
392
+ const invite = authQueries.getInviteByTokenHash(tokenHash);
393
+
394
+ if (!invite) {
395
+ return { valid: false, error: 'Invalid invite token' };
396
+ }
397
+
398
+ if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
399
+ return { valid: false, error: 'Invite has expired' };
400
+ }
401
+
402
+ if (invite.max_uses > 0 && invite.use_count >= invite.max_uses) {
403
+ return { valid: false, error: 'Invite has reached maximum uses' };
404
+ }
405
+
406
+ return { valid: true, role: invite.role };
407
+ }
408
+
409
+ /**
410
+ * List all invites
411
+ */
412
+ export function listInvites() {
413
+ return authQueries.getAllInvites();
414
+ }
415
+
416
+ /**
417
+ * Revoke an invite
418
+ */
419
+ export function revokeInvite(id: string): void {
420
+ authQueries.revokeInvite(id);
421
+ debug.log('auth', `Invite revoked: ${id}`);
422
+ }
423
+
424
+ /**
425
+ * Regenerate Personal Access Token for a user
426
+ */
427
+ export function regeneratePAT(userId: string): string {
428
+ const user = authQueries.getUserById(userId);
429
+ if (!user) {
430
+ throw new Error('User not found');
431
+ }
432
+
433
+ const pat = generatePAT();
434
+ const patHash = hashToken(pat);
435
+
436
+ authQueries.updateUser(userId, { personal_access_token_hash: patHash });
437
+
438
+ debug.log('auth', `PAT regenerated for user: ${userId}`);
439
+
440
+ return pat;
441
+ }
442
+
443
+ /**
444
+ * Update user display name
445
+ */
446
+ export function updateUserName(userId: string, newName: string): AuthUser {
447
+ const trimmedName = newName.trim();
448
+ if (trimmedName.length === 0) {
449
+ throw new Error('Name cannot be empty');
450
+ }
451
+
452
+ authQueries.updateUser(userId, {
453
+ name: trimmedName,
454
+ color: generateColorFromString(trimmedName),
455
+ avatar: getInitials(trimmedName)
456
+ });
457
+
458
+ const updated = authQueries.getUserById(userId);
459
+ if (!updated) {
460
+ throw new Error('User not found after update');
461
+ }
462
+
463
+ return toAuthUser(updated);
464
+ }
465
+
466
+ /**
467
+ * Logout all sessions (all users)
468
+ */
469
+ export function logoutAllSessions(): number {
470
+ const count = authQueries.deleteAllSessions();
471
+ debug.log('auth', `All sessions deleted: ${count}`);
472
+ return count;
473
+ }
474
+
475
+ /**
476
+ * Cleanup expired sessions
477
+ */
478
+ export function cleanupExpiredSessions(): number {
479
+ const count = authQueries.deleteExpiredSessions();
480
+ if (count > 0) {
481
+ debug.log('auth', `Cleaned up ${count} expired sessions`);
482
+ }
483
+ return count;
484
+ }
@@ -0,0 +1,4 @@
1
+ export * from './tokens';
2
+ export * from './auth-service';
3
+ export * from './permissions';
4
+ export { authRateLimiter } from './rate-limiter';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Route Permission Configuration
3
+ *
4
+ * Defines which WebSocket routes require authentication and which require admin role.
5
+ * Used by the auth gate in WSRouter.handleMessage().
6
+ */
7
+
8
+ import { getAuthMode } from './auth-service';
9
+
10
+ /** Routes that can be accessed WITHOUT authentication */
11
+ export const PUBLIC_ROUTES = new Set([
12
+ 'auth:status',
13
+ 'auth:login',
14
+ 'auth:setup',
15
+ 'auth:setup-no-auth',
16
+ 'auth:auto-login-no-auth',
17
+ 'auth:accept-invite',
18
+ 'auth:validate-invite',
19
+ 'ws:set-context'
20
+ ]);
21
+
22
+ /** Routes that require admin role */
23
+ export const ADMIN_ONLY_ROUTES = new Set([
24
+ 'auth:create-invite',
25
+ 'auth:list-invites',
26
+ 'auth:revoke-invite',
27
+ 'auth:list-users',
28
+ 'auth:remove-user',
29
+ 'settings:update-system'
30
+ ]);
31
+
32
+ /**
33
+ * Check if a route action is allowed for the given auth state.
34
+ * In no-auth mode, all routes are allowed (bypasses authentication check).
35
+ */
36
+ export function checkRouteAccess(
37
+ action: string,
38
+ authenticated: boolean,
39
+ role: string | null
40
+ ): { allowed: boolean; error?: string } {
41
+ // Public routes — always allowed
42
+ if (PUBLIC_ROUTES.has(action)) {
43
+ return { allowed: true };
44
+ }
45
+
46
+ // No-auth mode — bypass authentication for all routes
47
+ if (getAuthMode() === 'none') {
48
+ return { allowed: true };
49
+ }
50
+
51
+ // Must be authenticated for everything else
52
+ if (!authenticated) {
53
+ return { allowed: false, error: 'Authentication required' };
54
+ }
55
+
56
+ // Admin-only routes
57
+ if (ADMIN_ONLY_ROUTES.has(action) && role !== 'admin') {
58
+ return { allowed: false, error: 'Admin access required' };
59
+ }
60
+
61
+ // All other routes — any authenticated user
62
+ return { allowed: true };
63
+ }