@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
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Auth Rate Limiter
3
+ *
4
+ * Protects auth endpoints against brute-force and credential stuffing attacks.
5
+ * Tracks failed attempts per IP with progressive lockout.
6
+ *
7
+ * Thresholds:
8
+ * 5 failures → 30 second lockout
9
+ * 10 failures → 2 minute lockout
10
+ * 20 failures → 10 minute lockout
11
+ *
12
+ * Attempts decay after the lockout window expires.
13
+ */
14
+
15
+ import { debug } from '$shared/utils/logger';
16
+
17
+ /** Routes that should be rate-limited */
18
+ const RATE_LIMITED_ROUTES = new Set([
19
+ 'auth:login',
20
+ 'auth:accept-invite',
21
+ 'auth:validate-invite',
22
+ 'auth:setup'
23
+ ]);
24
+
25
+ interface AttemptRecord {
26
+ failures: number;
27
+ lastFailure: number;
28
+ lockedUntil: number;
29
+ }
30
+
31
+ /** Lockout tiers: [maxFailures, lockoutMs] */
32
+ const LOCKOUT_TIERS: [number, number][] = [
33
+ [5, 30_000], // 5 failures → 30 seconds
34
+ [10, 2 * 60_000], // 10 failures → 2 minutes
35
+ [20, 10 * 60_000], // 20 failures → 10 minutes
36
+ ];
37
+
38
+ /** After this duration of no failures, the record is considered stale and cleaned up */
39
+ const STALE_AFTER_MS = 15 * 60_000; // 15 minutes
40
+
41
+ /** How often to run cleanup (ms) */
42
+ const CLEANUP_INTERVAL_MS = 5 * 60_000; // 5 minutes
43
+
44
+ class AuthRateLimiter {
45
+ private attempts = new Map<string, AttemptRecord>();
46
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
47
+
48
+ constructor() {
49
+ // Periodic cleanup of stale entries
50
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
51
+ }
52
+
53
+ /**
54
+ * Check if an action is rate-limited for the given identifier.
55
+ * Returns null if allowed, or an error message if blocked.
56
+ */
57
+ check(identifier: string, action: string): string | null {
58
+ if (!RATE_LIMITED_ROUTES.has(action)) {
59
+ return null; // Not a rate-limited route
60
+ }
61
+
62
+ const record = this.attempts.get(identifier);
63
+ if (!record) {
64
+ return null; // No previous failures
65
+ }
66
+
67
+ const now = Date.now();
68
+
69
+ // Check if currently locked out
70
+ if (record.lockedUntil > now) {
71
+ const remainingSec = Math.ceil((record.lockedUntil - now) / 1000);
72
+ debug.warn('auth', `Rate limited: ${identifier} (${remainingSec}s remaining, ${record.failures} failures)`);
73
+ return `Too many failed attempts. Try again in ${remainingSec} seconds.`;
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Record a failed auth attempt for the given identifier.
81
+ */
82
+ recordFailure(identifier: string, action: string): void {
83
+ if (!RATE_LIMITED_ROUTES.has(action)) return;
84
+
85
+ const now = Date.now();
86
+ const record = this.attempts.get(identifier) ?? { failures: 0, lastFailure: 0, lockedUntil: 0 };
87
+
88
+ record.failures += 1;
89
+ record.lastFailure = now;
90
+
91
+ // Determine lockout duration based on failure count
92
+ let lockoutMs = 0;
93
+ for (const [threshold, duration] of LOCKOUT_TIERS) {
94
+ if (record.failures >= threshold) {
95
+ lockoutMs = duration;
96
+ }
97
+ }
98
+
99
+ if (lockoutMs > 0) {
100
+ record.lockedUntil = now + lockoutMs;
101
+ debug.warn('auth', `Lockout triggered: ${identifier} — ${record.failures} failures, locked for ${lockoutMs / 1000}s`);
102
+ }
103
+
104
+ this.attempts.set(identifier, record);
105
+ }
106
+
107
+ /**
108
+ * Clear failure record on successful auth (e.g., successful login).
109
+ */
110
+ recordSuccess(identifier: string): void {
111
+ this.attempts.delete(identifier);
112
+ }
113
+
114
+ /**
115
+ * Remove stale entries to prevent memory leaks.
116
+ */
117
+ private cleanup(): void {
118
+ const now = Date.now();
119
+ let removed = 0;
120
+
121
+ for (const [key, record] of this.attempts) {
122
+ if (now - record.lastFailure > STALE_AFTER_MS && record.lockedUntil < now) {
123
+ this.attempts.delete(key);
124
+ removed++;
125
+ }
126
+ }
127
+
128
+ if (removed > 0) {
129
+ debug.log('auth', `Rate limiter cleanup: removed ${removed} stale entries`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Dispose — stop cleanup timer.
135
+ */
136
+ dispose(): void {
137
+ if (this.cleanupTimer) {
138
+ clearInterval(this.cleanupTimer);
139
+ this.cleanupTimer = null;
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Singleton rate limiter instance */
145
+ export const authRateLimiter = new AuthRateLimiter();
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Token Generation & Hashing Utilities
3
+ *
4
+ * Token types:
5
+ * - clp_ses_* — Session tokens (login sessions, 30 day expiry)
6
+ * - clp_pat_* — Personal Access Tokens (cross-device login, permanent)
7
+ * - clp_inv_* — Invite tokens (for inviting new users)
8
+ */
9
+
10
+ const SESSION_PREFIX = 'clp_ses_';
11
+ const PAT_PREFIX = 'clp_pat_';
12
+ const INVITE_PREFIX = 'clp_inv_';
13
+
14
+ function randomHex(bytes: number): string {
15
+ const arr = new Uint8Array(bytes);
16
+ crypto.getRandomValues(arr);
17
+ return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
18
+ }
19
+
20
+ /** All token types use the same random length for consistency */
21
+ const TOKEN_BYTES = 24; // 48 hex chars
22
+
23
+ export function generateSessionToken(): string {
24
+ return SESSION_PREFIX + randomHex(TOKEN_BYTES);
25
+ }
26
+
27
+ export function generatePAT(): string {
28
+ return PAT_PREFIX + randomHex(TOKEN_BYTES);
29
+ }
30
+
31
+ export function generateInviteToken(): string {
32
+ return INVITE_PREFIX + randomHex(TOKEN_BYTES);
33
+ }
34
+
35
+ /**
36
+ * SHA-256 hash a token string.
37
+ * Uses Bun native CryptoHasher for performance.
38
+ */
39
+ export function hashToken(token: string): string {
40
+ const hasher = new Bun.CryptoHasher('sha256');
41
+ hasher.update(token);
42
+ return hasher.digest('hex');
43
+ }
44
+
45
+ /**
46
+ * Determine token type from prefix
47
+ */
48
+ export function getTokenType(token: string): 'session' | 'pat' | 'invite' | 'unknown' {
49
+ if (token.startsWith(SESSION_PREFIX)) return 'session';
50
+ if (token.startsWith(PAT_PREFIX)) return 'pat';
51
+ if (token.startsWith(INVITE_PREFIX)) return 'invite';
52
+ return 'unknown';
53
+ }
@@ -0,0 +1,29 @@
1
+ import type { DatabaseConnection } from '$shared/types/database/connection';
2
+ import { debug } from '$shared/utils/logger';
3
+
4
+ export const description = 'Create users table for authentication';
5
+
6
+ export const up = (db: DatabaseConnection): void => {
7
+ debug.log('migration', 'Creating users table...');
8
+
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS users (
11
+ id TEXT PRIMARY KEY,
12
+ name TEXT NOT NULL,
13
+ color TEXT NOT NULL,
14
+ avatar TEXT NOT NULL,
15
+ role TEXT NOT NULL CHECK(role IN ('admin', 'member')),
16
+ personal_access_token_hash TEXT UNIQUE,
17
+ created_at TEXT NOT NULL,
18
+ updated_at TEXT NOT NULL
19
+ )
20
+ `);
21
+
22
+ debug.log('migration', 'users table created');
23
+ };
24
+
25
+ export const down = (db: DatabaseConnection): void => {
26
+ debug.log('migration', 'Dropping users table...');
27
+ db.exec('DROP TABLE IF EXISTS users');
28
+ debug.log('migration', 'users table dropped');
29
+ };
@@ -0,0 +1,38 @@
1
+ import type { DatabaseConnection } from '$shared/types/database/connection';
2
+ import { debug } from '$shared/utils/logger';
3
+
4
+ export const description = 'Create auth_sessions table for login session management';
5
+
6
+ export const up = (db: DatabaseConnection): void => {
7
+ debug.log('migration', 'Creating auth_sessions table...');
8
+
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS auth_sessions (
11
+ id TEXT PRIMARY KEY,
12
+ user_id TEXT NOT NULL,
13
+ token_hash TEXT NOT NULL UNIQUE,
14
+ expires_at TEXT NOT NULL,
15
+ created_at TEXT NOT NULL,
16
+ last_active_at TEXT NOT NULL,
17
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
18
+ )
19
+ `);
20
+
21
+ db.exec(`
22
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_token_hash
23
+ ON auth_sessions(token_hash)
24
+ `);
25
+
26
+ db.exec(`
27
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id
28
+ ON auth_sessions(user_id)
29
+ `);
30
+
31
+ debug.log('migration', 'auth_sessions table created');
32
+ };
33
+
34
+ export const down = (db: DatabaseConnection): void => {
35
+ debug.log('migration', 'Dropping auth_sessions table...');
36
+ db.exec('DROP TABLE IF EXISTS auth_sessions');
37
+ debug.log('migration', 'auth_sessions table dropped');
38
+ };
@@ -0,0 +1,31 @@
1
+ import type { DatabaseConnection } from '$shared/types/database/connection';
2
+ import { debug } from '$shared/utils/logger';
3
+
4
+ export const description = 'Create invite_tokens table for invite link management';
5
+
6
+ export const up = (db: DatabaseConnection): void => {
7
+ debug.log('migration', 'Creating invite_tokens table...');
8
+
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS invite_tokens (
11
+ id TEXT PRIMARY KEY,
12
+ token_hash TEXT NOT NULL UNIQUE,
13
+ role TEXT NOT NULL CHECK(role IN ('admin', 'member')),
14
+ label TEXT,
15
+ created_by TEXT NOT NULL,
16
+ max_uses INTEGER NOT NULL DEFAULT 1,
17
+ use_count INTEGER NOT NULL DEFAULT 0,
18
+ expires_at TEXT,
19
+ created_at TEXT NOT NULL,
20
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
21
+ )
22
+ `);
23
+
24
+ debug.log('migration', 'invite_tokens table created');
25
+ };
26
+
27
+ export const down = (db: DatabaseConnection): void => {
28
+ debug.log('migration', 'Dropping invite_tokens table...');
29
+ db.exec('DROP TABLE IF EXISTS invite_tokens');
30
+ debug.log('migration', 'invite_tokens table dropped');
31
+ };
@@ -22,6 +22,9 @@ import * as migration020 from './020_add_snapshot_tree_hash';
22
22
  import * as migration021 from './021_drop_prompt_templates_table';
23
23
  import * as migration022 from './022_add_snapshot_changes_column';
24
24
  import * as migration023 from './023_create_user_unread_sessions_table';
25
+ import * as migration024 from './024_create_users_table';
26
+ import * as migration025 from './025_create_auth_sessions_table';
27
+ import * as migration026 from './026_create_invite_tokens_table';
25
28
 
26
29
  // Export all migrations in order
27
30
  export const migrations = [
@@ -162,6 +165,24 @@ export const migrations = [
162
165
  description: migration023.description,
163
166
  up: migration023.up,
164
167
  down: migration023.down
168
+ },
169
+ {
170
+ id: '024',
171
+ description: migration024.description,
172
+ up: migration024.up,
173
+ down: migration024.down
174
+ },
175
+ {
176
+ id: '025',
177
+ description: migration025.description,
178
+ up: migration025.up,
179
+ down: migration025.down
180
+ },
181
+ {
182
+ id: '026',
183
+ description: migration026.description,
184
+ up: migration026.up,
185
+ down: migration026.down
165
186
  }
166
187
  ];
167
188
 
@@ -0,0 +1,201 @@
1
+ import { getDatabase } from '../index';
2
+
3
+ export interface DBUser {
4
+ id: string;
5
+ name: string;
6
+ color: string;
7
+ avatar: string;
8
+ role: 'admin' | 'member';
9
+ personal_access_token_hash: string | null;
10
+ created_at: string;
11
+ updated_at: string;
12
+ }
13
+
14
+ export interface DBAuthSession {
15
+ id: string;
16
+ user_id: string;
17
+ token_hash: string;
18
+ expires_at: string;
19
+ created_at: string;
20
+ last_active_at: string;
21
+ }
22
+
23
+ export interface DBInviteToken {
24
+ id: string;
25
+ token_hash: string;
26
+ role: 'admin' | 'member';
27
+ label: string | null;
28
+ created_by: string;
29
+ max_uses: number;
30
+ use_count: number;
31
+ expires_at: string | null;
32
+ created_at: string;
33
+ }
34
+
35
+ export const authQueries = {
36
+ // ===================== Users =====================
37
+
38
+ createUser(user: Omit<DBUser, 'updated_at'> & { updated_at?: string }): DBUser {
39
+ const db = getDatabase();
40
+ const now = new Date().toISOString();
41
+ db.prepare(`
42
+ INSERT INTO users (id, name, color, avatar, role, personal_access_token_hash, created_at, updated_at)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
44
+ `).run(
45
+ user.id,
46
+ user.name,
47
+ user.color,
48
+ user.avatar,
49
+ user.role,
50
+ user.personal_access_token_hash,
51
+ user.created_at,
52
+ user.updated_at ?? now
53
+ );
54
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as DBUser;
55
+ },
56
+
57
+ getUserById(id: string): DBUser | null {
58
+ const db = getDatabase();
59
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser | null;
60
+ },
61
+
62
+ getUserByPatHash(hash: string): DBUser | null {
63
+ const db = getDatabase();
64
+ return db.prepare('SELECT * FROM users WHERE personal_access_token_hash = ?').get(hash) as DBUser | null;
65
+ },
66
+
67
+ getAllUsers(): DBUser[] {
68
+ const db = getDatabase();
69
+ return db.prepare('SELECT * FROM users ORDER BY created_at ASC').all() as DBUser[];
70
+ },
71
+
72
+ countUsers(): number {
73
+ const db = getDatabase();
74
+ const result = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
75
+ return result.count;
76
+ },
77
+
78
+ countAdmins(): number {
79
+ const db = getDatabase();
80
+ const result = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number };
81
+ return result.count;
82
+ },
83
+
84
+ updateUser(id: string, fields: Partial<Pick<DBUser, 'name' | 'color' | 'avatar' | 'personal_access_token_hash'>>): void {
85
+ const db = getDatabase();
86
+ const now = new Date().toISOString();
87
+ const sets: string[] = ['updated_at = ?'];
88
+ const values: any[] = [now];
89
+
90
+ if (fields.name !== undefined) { sets.push('name = ?'); values.push(fields.name); }
91
+ if (fields.color !== undefined) { sets.push('color = ?'); values.push(fields.color); }
92
+ if (fields.avatar !== undefined) { sets.push('avatar = ?'); values.push(fields.avatar); }
93
+ if (fields.personal_access_token_hash !== undefined) { sets.push('personal_access_token_hash = ?'); values.push(fields.personal_access_token_hash); }
94
+
95
+ values.push(id);
96
+ db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values);
97
+ },
98
+
99
+ deleteUser(id: string): void {
100
+ const db = getDatabase();
101
+ db.prepare('DELETE FROM users WHERE id = ?').run(id);
102
+ },
103
+
104
+ // ===================== Auth Sessions =====================
105
+
106
+ createSession(session: DBAuthSession): DBAuthSession {
107
+ const db = getDatabase();
108
+ db.prepare(`
109
+ INSERT INTO auth_sessions (id, user_id, token_hash, expires_at, created_at, last_active_at)
110
+ VALUES (?, ?, ?, ?, ?, ?)
111
+ `).run(
112
+ session.id,
113
+ session.user_id,
114
+ session.token_hash,
115
+ session.expires_at,
116
+ session.created_at,
117
+ session.last_active_at
118
+ );
119
+ return db.prepare('SELECT * FROM auth_sessions WHERE id = ?').get(session.id) as DBAuthSession;
120
+ },
121
+
122
+ getSessionByTokenHash(hash: string): DBAuthSession | null {
123
+ const db = getDatabase();
124
+ return db.prepare('SELECT * FROM auth_sessions WHERE token_hash = ?').get(hash) as DBAuthSession | null;
125
+ },
126
+
127
+ updateLastActive(id: string): void {
128
+ const db = getDatabase();
129
+ const now = new Date().toISOString();
130
+ db.prepare('UPDATE auth_sessions SET last_active_at = ? WHERE id = ?').run(now, id);
131
+ },
132
+
133
+ deleteSession(id: string): void {
134
+ const db = getDatabase();
135
+ db.prepare('DELETE FROM auth_sessions WHERE id = ?').run(id);
136
+ },
137
+
138
+ deleteSessionByTokenHash(hash: string): void {
139
+ const db = getDatabase();
140
+ db.prepare('DELETE FROM auth_sessions WHERE token_hash = ?').run(hash);
141
+ },
142
+
143
+ deleteSessionsByUserId(userId: string): void {
144
+ const db = getDatabase();
145
+ db.prepare('DELETE FROM auth_sessions WHERE user_id = ?').run(userId);
146
+ },
147
+
148
+ deleteExpiredSessions(): number {
149
+ const db = getDatabase();
150
+ const now = new Date().toISOString();
151
+ const result = db.prepare('DELETE FROM auth_sessions WHERE expires_at < ?').run(now) as { changes: number };
152
+ return result.changes;
153
+ },
154
+
155
+ // ===================== Invite Tokens =====================
156
+
157
+ createInvite(invite: DBInviteToken): DBInviteToken {
158
+ const db = getDatabase();
159
+ db.prepare(`
160
+ INSERT INTO invite_tokens (id, token_hash, role, label, created_by, max_uses, use_count, expires_at, created_at)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
162
+ `).run(
163
+ invite.id,
164
+ invite.token_hash,
165
+ invite.role,
166
+ invite.label,
167
+ invite.created_by,
168
+ invite.max_uses,
169
+ invite.use_count,
170
+ invite.expires_at,
171
+ invite.created_at
172
+ );
173
+ return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(invite.id) as DBInviteToken;
174
+ },
175
+
176
+ getInviteByTokenHash(hash: string): DBInviteToken | null {
177
+ const db = getDatabase();
178
+ return db.prepare('SELECT * FROM invite_tokens WHERE token_hash = ?').get(hash) as DBInviteToken | null;
179
+ },
180
+
181
+ incrementUseCount(id: string): void {
182
+ const db = getDatabase();
183
+ db.prepare('UPDATE invite_tokens SET use_count = use_count + 1 WHERE id = ?').run(id);
184
+ },
185
+
186
+ getAllInvites(): DBInviteToken[] {
187
+ const db = getDatabase();
188
+ return db.prepare('SELECT * FROM invite_tokens ORDER BY created_at DESC').all() as DBInviteToken[];
189
+ },
190
+
191
+ revokeInvite(id: string): void {
192
+ const db = getDatabase();
193
+ db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(id);
194
+ },
195
+
196
+ deleteAllSessions(): number {
197
+ const db = getDatabase();
198
+ const result = db.prepare('DELETE FROM auth_sessions').run() as { changes: number };
199
+ return result.changes;
200
+ }
201
+ };
@@ -6,4 +6,5 @@ export { settingsQueries } from './settings-queries';
6
6
  export { dbUtils } from './utils-queries';
7
7
  export { snapshotQueries } from './snapshot-queries';
8
8
  export { checkpointQueries } from './checkpoint-queries';
9
- export { engineQueries } from './engine-queries';
9
+ export { engineQueries } from './engine-queries';
10
+ export { authQueries } from './auth-queries';
@@ -54,7 +54,7 @@ async function init(): Promise<void> {
54
54
  if (Object.keys(mcpConfig).length > 0) {
55
55
  debug.log('engine', `Open Code server: injecting ${Object.keys(mcpConfig).length} MCP server(s)`);
56
56
  for (const [name, config] of Object.entries(mcpConfig)) {
57
- debug.log('engine', ` → ${name}: ${config.type} (${(config as any).command?.join(' ') || (config as any).url})`);
57
+ debug.log('engine', ` → ${name}: ${config.type} (${(config as any).url || (config as any).command?.join(' ')})`);
58
58
  }
59
59
  }
60
60
 
@@ -6,11 +6,10 @@
6
6
  */
7
7
 
8
8
  import type { McpSdkServerConfigWithInstance, McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
9
- import type { McpLocalConfig } from '@opencode-ai/sdk';
9
+ import type { McpRemoteConfig } from '@opencode-ai/sdk';
10
10
  import type { ServerConfig, ParsedMcpToolName, ServerName } from './types';
11
11
  import { serverRegistry, serverFactories } from './servers';
12
12
  import { debug } from '$shared/utils/logger';
13
- import { resolve } from 'path';
14
13
  import { SERVER_ENV } from '../shared/env';
15
14
 
16
15
  /**
@@ -21,7 +20,7 @@ import { SERVER_ENV } from '../shared/env';
21
20
  *
22
21
  * Type-safe: Server names and tool names are validated at compile time!
23
22
  */
24
- const mcpServersConfig: Record<ServerName, ServerConfig> = {
23
+ export const mcpServersConfig: Record<ServerName, ServerConfig> = {
25
24
  "weather-service": {
26
25
  enabled: true,
27
26
  tools: [
@@ -256,6 +255,8 @@ export function getMcpStats() {
256
255
  * This function strips the prefix and maps back using the mcpServers
257
256
  * registry — the SAME source that defines which tools exist.
258
257
  *
258
+ * Works for both remote HTTP MCP and legacy stdio MCP (same naming convention).
259
+ *
259
260
  * Returns null if the tool name is not one of our custom MCP tools.
260
261
  */
261
262
  export function resolveOpenCodeToolName(toolName: string): string | null {
@@ -263,7 +264,7 @@ export function resolveOpenCodeToolName(toolName: string): string | null {
263
264
  if (toolName.startsWith('mcp__')) return toolName;
264
265
 
265
266
  // Strip Open Code MCP server prefix if present
266
- // Open Code prefixes with the stdio server name: "clopen-mcp_<tool>"
267
+ // Open Code prefixes with the server config key: "clopen-mcp_<tool>"
267
268
  let rawName = toolName;
268
269
  const ocPrefix = 'clopen-mcp_';
269
270
  if (rawName.startsWith(ocPrefix)) {
@@ -288,32 +289,26 @@ export function resolveOpenCodeToolName(toolName: string): string | null {
288
289
  /**
289
290
  * Get MCP configuration for Open Code engine.
290
291
  *
291
- * Open Code expects MCP servers as local (stdio subprocess) or remote (HTTP URL).
292
- * We provide a single local MCP server that wraps all our custom tools.
293
- * The server communicates with the main Clopen process via an HTTP bridge
294
- * for tools that need in-process access (browser-automation).
292
+ * Open Code connects to a remote MCP HTTP server running in the main Clopen
293
+ * process. Tool handlers execute in-process no subprocess, no bridge.
294
+ *
295
+ * This is the Open Code equivalent of Claude Code's in-process MCP servers.
295
296
  */
296
- export function getOpenCodeMcpConfig(): Record<string, McpLocalConfig> {
297
+ export function getOpenCodeMcpConfig(): Record<string, McpRemoteConfig> {
297
298
  // Check if any servers are enabled
298
299
  const enabledServers = getEnabledServerNames();
299
300
  if (enabledServers.length === 0) {
300
301
  return {};
301
302
  }
302
303
 
303
- // Resolve path to the stdio server script
304
- const stdioServerPath = resolve(import.meta.dir, 'stdio-server.ts');
305
304
  const port = SERVER_ENV.PORT;
306
305
 
307
- debug.log('mcp', `📦 Open Code MCP: stdio server at ${stdioServerPath}`);
308
- debug.log('mcp', `📦 Open Code MCP: bridge port ${port}`);
306
+ debug.log('mcp', `📦 Open Code MCP: remote server at http://localhost:${port}/mcp`);
309
307
 
310
308
  return {
311
309
  'clopen-mcp': {
312
- type: 'local',
313
- command: ['bun', 'run', stdioServerPath],
314
- environment: {
315
- CLOPEN_PORT: String(port),
316
- },
310
+ type: 'remote',
311
+ url: `http://localhost:${port}/mcp`,
317
312
  enabled: true,
318
313
  timeout: 10000,
319
314
  },
@@ -2,6 +2,11 @@
2
2
  * MCP (Model Context Protocol) Custom Tools
3
3
  *
4
4
  * Main export point for the custom MCP tools system.
5
+ *
6
+ * Claude Code: in-process MCP servers via createSdkMcpServer()
7
+ * Open Code: remote HTTP MCP server via createRemoteMcpServer()
8
+ *
9
+ * Both use the same tool definitions from defineServer() in servers/helper.ts.
5
10
  */
6
11
 
7
12
  // Type definitions
@@ -13,6 +18,7 @@ export type {
13
18
  // Main configuration and all utilities
14
19
  export {
15
20
  mcpServers,
21
+ mcpServersConfig,
16
22
  getEnabledMcpServers,
17
23
  getAllowedMcpTools,
18
24
  getServerConfig,
@@ -31,5 +37,8 @@ export {
31
37
  // Server implementations
32
38
  export * from './servers';
33
39
 
40
+ // Remote MCP HTTP server for Open Code
41
+ export { handleMcpRequest, closeMcpServer } from './remote-server';
42
+
34
43
  // Project context service for MCP tool handlers
35
44
  export { projectContextService } from './project-context';