| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import Database from 'better-sqlite3'; |
| import path from 'path'; |
| import fs from 'fs'; |
| import { randomUUID } from 'crypto'; |
| import { enqueueEvent } from '../webhooks/outbox'; |
| import { startDeliveryLoop } from '../webhooks/delivery'; |
|
|
| let systemDb: Database.Database | null = null; |
|
|
| function getDataDir(): string { |
| return process.env.DATA_DIR || path.join(process.cwd(), 'data'); |
| } |
|
|
| function ensureDir(dirPath: string): void { |
| if (!fs.existsSync(dirPath)) { |
| fs.mkdirSync(dirPath, { recursive: true }); |
| } |
| } |
|
|
| |
| |
| |
|
|
| export interface SystemUser { |
| id: string; |
| email: string; |
| password_hash: string; |
| display_name: string | null; |
| is_admin: number; |
| active: number; |
| default_workspace_id: string | null; |
| created_at: string; |
| updated_at: string; |
| } |
|
|
| export interface SystemWorkspace { |
| id: string; |
| name: string; |
| owner_id: string; |
| max_projects: number; |
| max_deployments: number; |
| max_storage_mb: number; |
| created_at: string; |
| updated_at: string; |
| } |
|
|
| export interface WorkspaceAccess { |
| user_id: string; |
| workspace_id: string; |
| role: 'owner' | 'editor' | 'viewer'; |
| created_at: string; |
| } |
|
|
| |
| |
| |
|
|
| const ROLE_LEVELS: Record<string, number> = { viewer: 1, editor: 2, owner: 3 }; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function getSystemDatabase(): Database.Database { |
| if (systemDb) return systemDb; |
|
|
| const dataDir = getDataDir(); |
| ensureDir(dataDir); |
|
|
| const dbPath = path.join(dataDir, 'system.sqlite'); |
| systemDb = new Database(dbPath); |
| const encryptionKey = process.env.DB_ENCRYPTION_KEY; |
| if (encryptionKey) { |
| systemDb.pragma(`key='${encryptionKey}'`); |
| } |
| systemDb.pragma('journal_mode = WAL'); |
| systemDb.pragma('foreign_keys = ON'); |
| systemDb.pragma('synchronous = NORMAL'); |
|
|
| initSystemSchema(systemDb); |
|
|
| startDeliveryLoop(); |
|
|
| return systemDb; |
| } |
|
|
| function initSystemSchema(db: Database.Database): void { |
| db.exec(` |
| CREATE TABLE IF NOT EXISTS users ( |
| id TEXT PRIMARY KEY, |
| email TEXT UNIQUE NOT NULL, |
| password_hash TEXT NOT NULL, |
| display_name TEXT, |
| is_admin INTEGER NOT NULL DEFAULT 0, |
| active INTEGER NOT NULL DEFAULT 1, |
| default_workspace_id TEXT, |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS workspaces ( |
| id TEXT PRIMARY KEY, |
| name TEXT NOT NULL, |
| owner_id TEXT NOT NULL, |
| max_projects INTEGER NOT NULL DEFAULT 3, |
| max_deployments INTEGER NOT NULL DEFAULT 1, |
| max_storage_mb INTEGER NOT NULL DEFAULT 100, |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| updated_at TEXT NOT NULL DEFAULT (datetime('now')), |
| FOREIGN KEY (owner_id) REFERENCES users(id) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS workspace_access ( |
| user_id TEXT NOT NULL, |
| workspace_id TEXT NOT NULL, |
| role TEXT NOT NULL DEFAULT 'editor', |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| PRIMARY KEY (user_id, workspace_id), |
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
| FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE |
| ); |
| |
| CREATE TABLE IF NOT EXISTS deployment_routing ( |
| deployment_id TEXT PRIMARY KEY, |
| workspace_id TEXT NOT NULL, |
| slug TEXT UNIQUE, |
| custom_domain TEXT UNIQUE, |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); |
| CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id); |
| CREATE INDEX IF NOT EXISTS idx_workspace_access_user ON workspace_access(user_id); |
| CREATE INDEX IF NOT EXISTS idx_workspace_access_workspace ON workspace_access(workspace_id); |
| CREATE INDEX IF NOT EXISTS idx_deployment_routing_workspace ON deployment_routing(workspace_id); |
| CREATE INDEX IF NOT EXISTS idx_deployment_routing_slug ON deployment_routing(slug); |
| |
| CREATE TABLE IF NOT EXISTS webhook_outbox ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| event_type TEXT NOT NULL, |
| payload TEXT NOT NULL, |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| delivered INTEGER NOT NULL DEFAULT 0, |
| delivered_at TEXT, |
| attempts INTEGER NOT NULL DEFAULT 0, |
| last_attempted_at TEXT |
| ); |
| `); |
|
|
| |
| try { |
| db.prepare('SELECT custom_domain FROM deployment_routing LIMIT 0').get(); |
| } catch { |
| db.prepare('ALTER TABLE deployment_routing ADD COLUMN custom_domain TEXT').run(); |
| db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_routing_domain ON deployment_routing(custom_domain)').run(); |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function createUser(email: string, passwordHash: string, displayName?: string): string { |
| const db = getSystemDatabase(); |
| const id = randomUUID(); |
| db.prepare(` |
| INSERT INTO users (id, email, password_hash, display_name) |
| VALUES (?, ?, ?, ?) |
| `).run(id, email.toLowerCase().trim(), passwordHash, displayName || null); |
|
|
| enqueueEvent('user.created', { userId: id, email: email.toLowerCase().trim(), displayName: displayName || null }); |
|
|
| return id; |
| } |
|
|
| |
| |
| |
| export function getUserByEmail(email: string): SystemUser | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT * FROM users WHERE email = ? AND active = 1') |
| .get(email.toLowerCase().trim()) as SystemUser | undefined; |
| } |
|
|
| |
| |
| |
| export function getUserById(id: string): SystemUser | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT * FROM users WHERE id = ? AND active = 1') |
| .get(id) as SystemUser | undefined; |
| } |
|
|
| |
| |
| |
| export function getUserCount(): number { |
| const db = getSystemDatabase(); |
| const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }; |
| return row.count; |
| } |
|
|
| |
| |
| |
| export function deactivateUser(id: string): void { |
| const db = getSystemDatabase(); |
| db.prepare("UPDATE users SET active = 0, updated_at = datetime('now') WHERE id = ?").run(id); |
| enqueueEvent('user.deactivated', { userId: id }); |
| } |
|
|
| |
| |
| |
| export function listUsers(): Omit<SystemUser, 'password_hash'>[] { |
| const db = getSystemDatabase(); |
| return db.prepare(` |
| SELECT id, email, display_name, is_admin, active, |
| default_workspace_id, created_at, updated_at |
| FROM users ORDER BY created_at DESC |
| `).all() as Omit<SystemUser, 'password_hash'>[]; |
| } |
|
|
| |
| |
| |
| export function updateUser(id: string, updates: { display_name?: string; active?: number }): void { |
| const db = getSystemDatabase(); |
| const setClauses: string[] = ["updated_at = datetime('now')"]; |
| const values: (string | number)[] = []; |
|
|
| if (updates.display_name !== undefined) { setClauses.push('display_name = ?'); values.push(updates.display_name); } |
| if (updates.active !== undefined) { setClauses.push('active = ?'); values.push(updates.active); } |
|
|
| values.push(id); |
| db.prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`).run(...values); |
|
|
| const updated = getUserById(id); |
| if (updated) { |
| enqueueEvent('user.updated', { userId: id, email: updated.email, displayName: updated.display_name }); |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function createWorkspace(name: string, ownerId: string): string { |
| const db = getSystemDatabase(); |
| const id = randomUUID(); |
|
|
| db.prepare(`INSERT INTO workspaces (id, name, owner_id) VALUES (?, ?, ?)`).run(id, name, ownerId); |
|
|
| |
| db.prepare(` |
| INSERT INTO workspace_access (user_id, workspace_id, role) |
| VALUES (?, ?, 'owner') |
| `).run(ownerId, id); |
|
|
| |
| const workspaceDir = path.join(getDataDir(), 'workspaces', id); |
| ensureDir(workspaceDir); |
|
|
| enqueueEvent('workspace.created', { workspaceId: id, name, ownerId }); |
|
|
| return id; |
| } |
|
|
| |
| |
| |
| export function getWorkspaceById(id: string): SystemWorkspace | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT * FROM workspaces WHERE id = ?') |
| .get(id) as SystemWorkspace | undefined; |
| } |
|
|
| |
| |
| |
| export function listWorkspaces(): SystemWorkspace[] { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT * FROM workspaces ORDER BY created_at DESC') |
| .all() as SystemWorkspace[]; |
| } |
|
|
| |
| |
| |
| export function listUserWorkspaces(userId: string): (SystemWorkspace & { role: string })[] { |
| const db = getSystemDatabase(); |
| return db.prepare(` |
| SELECT w.*, wa.role |
| FROM workspaces w |
| JOIN workspace_access wa ON wa.workspace_id = w.id |
| WHERE wa.user_id = ? |
| ORDER BY w.created_at DESC |
| `).all(userId) as (SystemWorkspace & { role: string })[]; |
| } |
|
|
| |
| |
| |
| export function updateWorkspace(id: string, updates: { |
| name?: string; |
| max_projects?: number; |
| max_deployments?: number; |
| max_storage_mb?: number; |
| }): void { |
| const db = getSystemDatabase(); |
| const setClauses: string[] = ["updated_at = datetime('now')"]; |
| const values: (string | number)[] = []; |
|
|
| if (updates.name !== undefined) { setClauses.push('name = ?'); values.push(updates.name); } |
| if (updates.max_projects !== undefined) { setClauses.push('max_projects = ?'); values.push(updates.max_projects); } |
| if (updates.max_deployments !== undefined) { setClauses.push('max_deployments = ?'); values.push(updates.max_deployments); } |
| if (updates.max_storage_mb !== undefined) { setClauses.push('max_storage_mb = ?'); values.push(updates.max_storage_mb); } |
|
|
| values.push(id); |
| db.prepare(`UPDATE workspaces SET ${setClauses.join(', ')} WHERE id = ?`).run(...values); |
|
|
| if (updates.name !== undefined) { |
| enqueueEvent('workspace.updated', { workspaceId: id, name: updates.name }); |
| } |
| } |
|
|
| |
| |
| |
| export function deleteWorkspace(id: string): void { |
| const db = getSystemDatabase(); |
| db.prepare('DELETE FROM workspaces WHERE id = ?').run(id); |
| enqueueEvent('workspace.deleted', { workspaceId: id }); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function grantWorkspaceAccess(userId: string, workspaceId: string, role: 'owner' | 'editor' | 'viewer'): void { |
| const db = getSystemDatabase(); |
| db.prepare(` |
| INSERT OR REPLACE INTO workspace_access (user_id, workspace_id, role) |
| VALUES (?, ?, ?) |
| `).run(userId, workspaceId, role); |
|
|
| const grantedUser = getUserById(userId); |
| if (grantedUser) { |
| enqueueEvent('workspace.access_granted', { workspaceId, email: grantedUser.email, role }); |
| } |
| } |
|
|
| |
| |
| |
| export function revokeWorkspaceAccess(userId: string, workspaceId: string): void { |
| const db = getSystemDatabase(); |
| const revokedUser = getUserById(userId); |
| db.prepare('DELETE FROM workspace_access WHERE user_id = ? AND workspace_id = ?') |
| .run(userId, workspaceId); |
|
|
| if (revokedUser) { |
| enqueueEvent('workspace.access_revoked', { workspaceId, email: revokedUser.email }); |
| } |
| } |
|
|
| |
| |
| |
| export function getWorkspaceAccess(userId: string, workspaceId: string): WorkspaceAccess | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT * FROM workspace_access WHERE user_id = ? AND workspace_id = ?') |
| .get(userId, workspaceId) as WorkspaceAccess | undefined; |
| } |
|
|
| |
| |
| |
| |
| |
| export function verifyWorkspaceAccess( |
| userId: string, |
| workspaceId: string, |
| requiredRole: 'owner' | 'editor' | 'viewer' = 'viewer' |
| ): void { |
| |
| const user = getUserById(userId); |
| if (user?.is_admin) return; |
|
|
| |
| if (userId === 'admin' || userId === 'desktop' || userId === 'instance-api') return; |
|
|
| const access = getWorkspaceAccess(userId, workspaceId); |
| if (!access) throw new Error('Workspace access denied'); |
|
|
| const userLevel = ROLE_LEVELS[access.role] || 0; |
| const requiredLevel = ROLE_LEVELS[requiredRole] || 0; |
| if (userLevel < requiredLevel) throw new Error('Insufficient workspace permissions'); |
| } |
|
|
| |
| |
| |
| export function setDefaultWorkspace(userId: string, workspaceId: string): void { |
| const db = getSystemDatabase(); |
| db.prepare("UPDATE users SET default_workspace_id = ?, updated_at = datetime('now') WHERE id = ?") |
| .run(workspaceId, userId); |
| } |
|
|
| |
| |
| |
| export function getUserDefaultWorkspace(userId: string): string | undefined { |
| const db = getSystemDatabase(); |
| const row = db.prepare('SELECT default_workspace_id FROM users WHERE id = ?') |
| .get(userId) as { default_workspace_id: string | null } | undefined; |
| return row?.default_workspace_id ?? undefined; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function registerDeploymentRoute(deploymentId: string, workspaceId: string, slug?: string, customDomain?: string): void { |
| const db = getSystemDatabase(); |
|
|
| |
| const existing = db.prepare('SELECT workspace_id FROM deployment_routing WHERE deployment_id = ?') |
| .get(deploymentId) as { workspace_id: string } | undefined; |
| if (existing && existing.workspace_id !== workspaceId) { |
| throw new Error('Deployment is owned by another workspace'); |
| } |
|
|
| if (customDomain) { |
| const domainOwner = db.prepare( |
| 'SELECT deployment_id FROM deployment_routing WHERE custom_domain = ? AND deployment_id != ?' |
| ).get(customDomain, deploymentId) as { deployment_id: string } | undefined; |
| if (domainOwner) { |
| throw new Error('Domain is already registered to another deployment'); |
| } |
| } |
|
|
| db.prepare(` |
| INSERT OR REPLACE INTO deployment_routing (deployment_id, workspace_id, slug, custom_domain) |
| VALUES (?, ?, ?, ?) |
| `).run(deploymentId, workspaceId, slug || null, customDomain || null); |
| } |
|
|
| |
| |
| |
| export function removeDeploymentRoute(deploymentId: string): void { |
| const db = getSystemDatabase(); |
| db.prepare('DELETE FROM deployment_routing WHERE deployment_id = ?').run(deploymentId); |
| } |
|
|
| |
| |
| |
| export function getDeploymentRoute(deploymentId: string): { deployment_id: string; workspace_id: string; slug: string | null; custom_domain: string | null } | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT deployment_id, workspace_id, slug, custom_domain FROM deployment_routing WHERE deployment_id = ?') |
| .get(deploymentId) as { deployment_id: string; workspace_id: string; slug: string | null; custom_domain: string | null } | undefined; |
| } |
|
|
| |
| |
| |
| export function getDeploymentWorkspace(deploymentId: string): string | undefined { |
| const db = getSystemDatabase(); |
| const row = db.prepare('SELECT workspace_id FROM deployment_routing WHERE deployment_id = ?') |
| .get(deploymentId) as { workspace_id: string } | undefined; |
| return row?.workspace_id; |
| } |
|
|
| |
| |
| |
| export function getDeploymentBySlug(slug: string): { deployment_id: string; workspace_id: string } | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare('SELECT deployment_id, workspace_id FROM deployment_routing WHERE slug = ?') |
| .get(slug) as { deployment_id: string; workspace_id: string } | undefined; |
| } |
|
|
| |
| |
| |
| export function getDeploymentByDomain(domain: string): { deployment_id: string; workspace_id: string } | undefined { |
| const db = getSystemDatabase(); |
| return db.prepare( |
| 'SELECT deployment_id, workspace_id FROM deployment_routing WHERE custom_domain = ?' |
| ).get(domain) as { deployment_id: string; workspace_id: string } | undefined; |
| } |
|
|
| |
| |
| |
| export function getAllDomainRoutes(): { deployment_id: string; workspace_id: string; custom_domain: string }[] { |
| const db = getSystemDatabase(); |
| return db.prepare( |
| 'SELECT deployment_id, workspace_id, custom_domain FROM deployment_routing WHERE custom_domain IS NOT NULL ORDER BY custom_domain' |
| ).all() as { deployment_id: string; workspace_id: string; custom_domain: string }[]; |
| } |
|
|
| |
| |
| |
| export function getAllSlugRoutes(): { deployment_id: string; slug: string }[] { |
| const db = getSystemDatabase(); |
| return db.prepare( |
| 'SELECT deployment_id, slug FROM deployment_routing WHERE slug IS NOT NULL ORDER BY slug' |
| ).all() as { deployment_id: string; slug: string }[]; |
| } |
|
|
| |
| |
| |
| export function getWorkspaceDeploymentCount(workspaceId: string): number { |
| const db = getSystemDatabase(); |
| const row = db.prepare('SELECT COUNT(*) as count FROM deployment_routing WHERE workspace_id = ?') |
| .get(workspaceId) as { count: number }; |
| return row.count; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export function getWorkspaceProjectCount(workspaceId: string): number { |
| const dbPath = path.join(getDataDir(), 'workspaces', workspaceId, 'osws.sqlite'); |
| if (!fs.existsSync(dbPath)) return 0; |
| try { |
| const db = new Database(dbPath, { readonly: true }); |
| const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get(); |
| if (!tableExists) { db.close(); return 0; } |
| const count = (db.prepare('SELECT COUNT(*) as count FROM projects').get() as { count: number }).count; |
| db.close(); |
| return count; |
| } catch { return 0; } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function closeSystemDatabase(): void { |
| if (systemDb) { |
| try { systemDb.close(); } catch {} |
| systemDb = null; |
| } |
| } |
|
|