@myrialabs/clopen 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/backend/index.ts +25 -1
- 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/chat/stream-manager.ts +4 -1
- 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/database/queries/session-queries.ts +13 -0
- package/backend/lib/database/queries/snapshot-queries.ts +1 -1
- package/backend/lib/engine/adapters/opencode/server.ts +9 -1
- package/backend/lib/engine/adapters/opencode/stream.ts +175 -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/snapshot/helpers.ts +22 -49
- package/backend/lib/snapshot/snapshot-service.ts +148 -83
- package/backend/lib/utils/ws.ts +65 -1
- package/backend/ws/auth/index.ts +17 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +269 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/chat/stream.ts +13 -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/backend/ws/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- package/bin/clopen.ts +56 -1
- 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/chat/input/ChatInput.svelte +1 -2
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
- package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/git/CommitForm.svelte +6 -4
- package/frontend/lib/components/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- 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/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
- package/frontend/lib/stores/features/auth.svelte.ts +296 -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 -14
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
- 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
|
+
}
|
|
@@ -891,7 +891,10 @@ class StreamManager extends EventEmitter {
|
|
|
891
891
|
const { projectPath, projectId, chatSessionId } = requestData;
|
|
892
892
|
if (projectPath && projectId && chatSessionId && userMessageId) {
|
|
893
893
|
snapshotService.captureSnapshot(projectPath, projectId, chatSessionId, userMessageId)
|
|
894
|
-
.then(() =>
|
|
894
|
+
.then(() => {
|
|
895
|
+
debug.log('chat', `Stream-end snapshot captured for message: ${userMessageId}`);
|
|
896
|
+
this.emit('snapshot:captured', { projectId, chatSessionId });
|
|
897
|
+
})
|
|
895
898
|
.catch(err => debug.error('chat', 'Failed to capture stream-end snapshot:', err));
|
|
896
899
|
}
|
|
897
900
|
}
|
|
@@ -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';
|
|
@@ -188,6 +188,19 @@ export const sessionQueries = {
|
|
|
188
188
|
`).run(messageId, sessionId);
|
|
189
189
|
},
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Clear the HEAD pointer (set to NULL).
|
|
193
|
+
* Used when restoring to the initial state (before any messages).
|
|
194
|
+
*/
|
|
195
|
+
clearHead(sessionId: string): void {
|
|
196
|
+
const db = getDatabase();
|
|
197
|
+
db.prepare(`
|
|
198
|
+
UPDATE chat_sessions
|
|
199
|
+
SET current_head_message_id = NULL
|
|
200
|
+
WHERE id = ?
|
|
201
|
+
`).run(sessionId);
|
|
202
|
+
},
|
|
203
|
+
|
|
191
204
|
/**
|
|
192
205
|
* Get current HEAD message ID for a session
|
|
193
206
|
*/
|
|
@@ -114,7 +114,7 @@ export const snapshotQueries = {
|
|
|
114
114
|
const db = getDatabase();
|
|
115
115
|
const snapshots = db.prepare(`
|
|
116
116
|
SELECT * FROM message_snapshots
|
|
117
|
-
WHERE session_id = ?
|
|
117
|
+
WHERE session_id = ? AND (is_deleted IS NULL OR is_deleted = 0)
|
|
118
118
|
ORDER BY created_at ASC
|
|
119
119
|
`).all(sessionId) as MessageSnapshot[];
|
|
120
120
|
|
|
@@ -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
|
|
|
@@ -81,6 +81,14 @@ export function getClient(): OpencodeClient | null {
|
|
|
81
81
|
return ready ? client : null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Get the OpenCode server base URL (e.g. "http://127.0.0.1:4096").
|
|
86
|
+
* Used for direct HTTP calls to v2 endpoints not available on the v1 client.
|
|
87
|
+
*/
|
|
88
|
+
export function getServerUrl(): string | null {
|
|
89
|
+
return serverHandle?.url ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
/**
|
|
85
93
|
* Dispose the OpenCode client and stop the server.
|
|
86
94
|
* Called during full server shutdown (disposeAllEngines).
|