@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.
- package/README.md +23 -1
- package/backend/index.ts +20 -0
- package/backend/lib/auth/auth-service.ts +484 -0
- package/backend/lib/auth/index.ts +4 -0
- package/backend/lib/auth/permissions.ts +63 -0
- package/backend/lib/auth/rate-limiter.ts +145 -0
- package/backend/lib/auth/tokens.ts +53 -0
- package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
- package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
- package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
- package/backend/lib/database/migrations/index.ts +21 -0
- package/backend/lib/database/queries/auth-queries.ts +201 -0
- package/backend/lib/database/queries/index.ts +2 -1
- package/backend/lib/engine/adapters/opencode/server.ts +1 -1
- package/backend/lib/mcp/config.ts +13 -18
- package/backend/lib/mcp/index.ts +9 -0
- package/backend/lib/mcp/remote-server.ts +132 -0
- package/backend/lib/mcp/servers/helper.ts +49 -3
- package/backend/lib/mcp/servers/index.ts +3 -2
- package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
- package/backend/lib/preview/browser/browser-pool.ts +73 -176
- package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
- package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
- package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
- package/backend/lib/utils/ws.ts +87 -1
- package/backend/ws/auth/index.ts +21 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +283 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/engine/claude/accounts.ts +3 -1
- package/backend/ws/engine/utils.ts +38 -6
- package/backend/ws/index.ts +4 -4
- package/backend/ws/preview/browser/interact.ts +27 -5
- package/bin/clopen.ts +39 -0
- package/bun.lock +113 -51
- package/frontend/App.svelte +47 -29
- package/frontend/lib/components/auth/InvitePage.svelte +215 -0
- package/frontend/lib/components/auth/LoginPage.svelte +129 -0
- package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
- package/frontend/lib/components/settings/SettingsView.svelte +21 -7
- package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
- package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
- package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
- package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
- package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
- package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/features/auth.svelte.ts +308 -0
- package/frontend/lib/stores/features/settings.svelte.ts +53 -9
- package/frontend/lib/stores/features/user.svelte.ts +26 -68
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
- package/frontend/lib/stores/ui/update.svelte.ts +2 -2
- package/package.json +8 -6
- package/shared/types/stores/settings.ts +16 -2
- package/shared/utils/logger.ts +1 -0
- package/shared/utils/ws-client.ts +30 -13
- package/shared/utils/ws-server.ts +42 -4
- package/backend/lib/mcp/stdio-server.ts +0 -103
- 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 >
|
|
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,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
|
+
}
|