@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
|
@@ -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).
|
|
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 {
|
|
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
|
|
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
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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,
|
|
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:
|
|
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: '
|
|
313
|
-
|
|
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
|
},
|
package/backend/lib/mcp/index.ts
CHANGED
|
@@ -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';
|