初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The unified async DB interface. Routes depend only on this — never on
|
||||
* `better-sqlite3` or `pg` directly — so the backend can switch between SQLite
|
||||
* (`deployMode=local`) and Postgres (`deployMode=k8s`) without touching callers.
|
||||
* @module dshs/db/adapter
|
||||
*/
|
||||
|
||||
import type {
|
||||
BusinessPlugin,
|
||||
CredentialKey,
|
||||
CreateSessionInput,
|
||||
CreateUserInput,
|
||||
Domain,
|
||||
DshInstance,
|
||||
DshInstanceRole,
|
||||
DshInstanceStatus,
|
||||
PublicUser,
|
||||
SessionRow,
|
||||
SessionUser,
|
||||
UpsertBusinessPluginInput,
|
||||
UpsertDshInstanceInput,
|
||||
User,
|
||||
UserRole,
|
||||
Workspace,
|
||||
} from './types.js'
|
||||
|
||||
export interface DbAdapter {
|
||||
// users
|
||||
createUser(input: CreateUserInput): Promise<User>
|
||||
findUserByUsername(username: string): Promise<User | undefined>
|
||||
findUserBySlug(slug: string): Promise<User | undefined>
|
||||
findUserById(id: string): Promise<User | undefined>
|
||||
listPublicUsers(): Promise<PublicUser[]>
|
||||
countAdmins(): Promise<number>
|
||||
setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean>
|
||||
/** Assign a Linux uid to a user (used by the legacy backfill). */
|
||||
setUserUid(userId: string, uid: number): Promise<void>
|
||||
/** Ids of users whose uid column is still null (legacy rows awaiting backfill). */
|
||||
listUsersWithoutUid(): Promise<string[]>
|
||||
/**
|
||||
* Permanently delete a user and every owned row (credential_vault / domains /
|
||||
* sessions / dsh_instances / audit_log / folder_plugins / workspaces), inside
|
||||
* one transaction. Returns false when no such user exists.
|
||||
*/
|
||||
deleteUser(userId: string): Promise<boolean>
|
||||
// sessions
|
||||
createSession(input: CreateSessionInput): Promise<void>
|
||||
findSession(tokenHash: string): Promise<SessionRow | undefined>
|
||||
deleteSession(tokenHash: string): Promise<void>
|
||||
deleteUserSessions(userId: string): Promise<void>
|
||||
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
|
||||
/** Whether the user has any session that has not yet expired (idle reap). */
|
||||
hasActiveSession(userId: string): Promise<boolean>
|
||||
// audit
|
||||
audit(actor: string | null, action: string, detail?: string | null): Promise<void>
|
||||
// workspaces / plugins
|
||||
findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined>
|
||||
getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace>
|
||||
setFolderPlugins(workspaceId: string, selections: ReadonlyArray<{ id: string; enabled: boolean }>): Promise<void>
|
||||
getEnabledPluginIds(workspaceId: string): Promise<string[]>
|
||||
// business plugins (系统外插件候选池)
|
||||
listBusinessPlugins(): Promise<BusinessPlugin[]>
|
||||
findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined>
|
||||
upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin>
|
||||
deleteBusinessPlugin(id: string): Promise<boolean>
|
||||
// domains
|
||||
findDomainByUser(userId: string): Promise<Domain | undefined>
|
||||
findDomainById(id: string): Promise<Domain | undefined>
|
||||
listDomains(): Promise<Domain[]>
|
||||
upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain>
|
||||
setDomainVerified(id: string, verified: boolean): Promise<boolean>
|
||||
// credential vault
|
||||
listCredentialKeys(userId: string): Promise<CredentialKey[]>
|
||||
getEnabledCredentialKeyRef(userId: string): Promise<string | null>
|
||||
setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey>
|
||||
selectCredentialKey(userId: string, id: string): Promise<boolean>
|
||||
deleteCredentialKey(userId: string, id: string): Promise<boolean>
|
||||
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
|
||||
upsertInstance(input: UpsertDshInstanceInput): Promise<void>
|
||||
findInstance(id: string): Promise<DshInstance | undefined>
|
||||
findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined>
|
||||
listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]>
|
||||
/** Record a state transition; `exitCode`/`lastError` also stamp `last_exit`. */
|
||||
setInstanceStatus(
|
||||
id: string,
|
||||
status: DshInstanceStatus,
|
||||
outcome?: { exitCode?: number; lastError?: string },
|
||||
): Promise<boolean>
|
||||
deleteInstance(id: string): Promise<boolean>
|
||||
deleteUserInstances(userId: string): Promise<void>
|
||||
// lifecycle
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* SQLite connection lifecycle: open, WAL, foreign keys, migration.
|
||||
* @module dshs/db/connection
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { runSqliteMigrations } from './schema.js'
|
||||
|
||||
/** The better-sqlite3 instance type. */
|
||||
export type Database = Database.Database
|
||||
|
||||
/**
|
||||
* Open (creating parent directories as needed), enable WAL + foreign keys,
|
||||
* then run migrations.
|
||||
* @param path - database file path, or `:memory:`.
|
||||
*/
|
||||
export function openDatabase(path: string): Database {
|
||||
if (path !== ':memory:') {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
}
|
||||
const db: Database = new Database(path)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
runSqliteMigrations(db)
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Driver-agnostic DB error hierarchy. Each adapter maps its driver's error
|
||||
* codes onto these classes, so the route layer catches a single exception type
|
||||
* regardless of backend (SQLite or Postgres).
|
||||
* @module dshs/db/errors
|
||||
*/
|
||||
|
||||
export class DbError extends Error {}
|
||||
export class UniqueViolationError extends DbError {}
|
||||
export class ForeignKeyViolationError extends DbError {}
|
||||
|
||||
/** Map a better-sqlite3 constraint error onto the shared hierarchy. */
|
||||
export function mapSqliteError(e: unknown): never {
|
||||
const code = (e as { code?: string }).code
|
||||
if (code === 'SQLITE_CONSTRAINT_UNIQUE') throw new UniqueViolationError()
|
||||
if (code === 'SQLITE_CONSTRAINT_FOREIGNKEY') throw new ForeignKeyViolationError()
|
||||
throw e
|
||||
}
|
||||
|
||||
/** Map a node-postgres error onto the shared hierarchy. */
|
||||
export function mapPgError(e: unknown): never {
|
||||
const code = (e as { code?: string }).code
|
||||
if (code === '23505') throw new UniqueViolationError()
|
||||
if (code === '23503') throw new ForeignKeyViolationError()
|
||||
throw e
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* DB adapter factory. Picks the backend from config: Postgres when a
|
||||
* `DSHS_DB_URL` is set (k8s / shared HA), else local SQLite.
|
||||
* Also backfills legacy users' uids once (see docs/k8s.md §5.8).
|
||||
* @module dshs/db
|
||||
*/
|
||||
|
||||
import type { ServerConfig } from '../config.js'
|
||||
import { hashUid } from '../isolation.js'
|
||||
import type { DbAdapter } from './adapter.js'
|
||||
import { SqliteAdapter } from './sqlite.js'
|
||||
import { openPgAdapter } from './pg.js'
|
||||
|
||||
export type { DbAdapter } from './adapter.js'
|
||||
export * from './types.js'
|
||||
export { DbError, UniqueViolationError, ForeignKeyViolationError } from './errors.js'
|
||||
|
||||
/** Create the configured backend. SQLite is synchronous-open; Postgres is async. */
|
||||
export async function createDbAdapter(config: ServerConfig): Promise<DbAdapter> {
|
||||
const adapter =
|
||||
config.dbUrl !== undefined
|
||||
? await openPgAdapter(config.dbUrl, config.baseUid)
|
||||
: new SqliteAdapter(config.dbPath, config.baseUid)
|
||||
// Backfill rows created before the uid column with their stable hash uid so
|
||||
// existing workspace file ownership is preserved. New users get `baseUid +
|
||||
// row_id` (set inside createUser), which needs no backfill.
|
||||
for (const userId of await adapter.listUsersWithoutUid()) {
|
||||
await adapter.setUserUid(userId, hashUid(userId, config.baseUid))
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Postgres backend for {@link DbAdapter} via node-postgres (`pg`). Used when
|
||||
* `deployMode=k8s` (a `DSHS_DB_URL` is set). All methods are
|
||||
* genuinely async; transactions use {@link withTx}.
|
||||
* @module dshs/db/pg
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Pool, types, type PoolClient } from 'pg'
|
||||
import type { DbAdapter } from './adapter.js'
|
||||
import { mapPgError } from './errors.js'
|
||||
import { runPgMigrations } from './schema.js'
|
||||
import {
|
||||
toBusinessPlugin,
|
||||
toDomain,
|
||||
toDshInstance,
|
||||
toPublicUser,
|
||||
toSession,
|
||||
toUser,
|
||||
toWorkspace,
|
||||
type BusinessPlugin,
|
||||
type CredentialKey,
|
||||
type CreateSessionInput,
|
||||
type CreateUserInput,
|
||||
type Domain,
|
||||
type DshInstance,
|
||||
type DshInstanceRole,
|
||||
type DshInstanceStatus,
|
||||
type PublicUser,
|
||||
type SessionRow,
|
||||
type SessionUser,
|
||||
type UpsertBusinessPluginInput,
|
||||
type UpsertDshInstanceInput,
|
||||
type User,
|
||||
type UserRole,
|
||||
type Workspace,
|
||||
} from './types.js'
|
||||
|
||||
// Postgres returns int8 (BIGINT) as a string to avoid JS 53-bit precision loss.
|
||||
// Our only BIGINT columns are epoch-*milliseconds*, which stay well below 2^53,
|
||||
// so parse them back to numbers — the shared row mappers then read numbers in
|
||||
// both backends. This is process-global and idempotent.
|
||||
types.setTypeParser(20, (value: string) => Number(value))
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
|
||||
const INSTANCE_COLS =
|
||||
'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch'
|
||||
|
||||
/** Run `fn` on a dedicated client inside a BEGIN/COMMIT/ROLLBACK transaction. */
|
||||
export async function withTx<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const result = await fn(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK')
|
||||
throw e
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export class PgAdapter implements DbAdapter {
|
||||
constructor(private readonly pool: Pool, private readonly baseUid: number) {}
|
||||
|
||||
async createUser(input: CreateUserInput): Promise<User> {
|
||||
const createdAt = Date.now()
|
||||
try {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING row_id',
|
||||
[input.id, input.username, input.passHash, input.role, input.homeDir, createdAt],
|
||||
)
|
||||
const uid = this.baseUid + Number((rows[0] as { row_id: number }).row_id)
|
||||
await client.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, input.id])
|
||||
return {
|
||||
id: input.id,
|
||||
username: input.username,
|
||||
pass_hash: input.passHash,
|
||||
role: input.role,
|
||||
home_dir: input.homeDir,
|
||||
api_key_ref: null,
|
||||
created_at: createdAt,
|
||||
approved_by: null,
|
||||
uid,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async findUserBySlug(slug: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [
|
||||
slug.toLowerCase(),
|
||||
])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async findUserById(id: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE id = $1`, [id])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async listPublicUsers(): Promise<PublicUser[]> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`)
|
||||
return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>)))
|
||||
}
|
||||
|
||||
async countAdmins(): Promise<number> {
|
||||
const { rows } = await this.pool.query(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`)
|
||||
return (rows[0] as { n: number }).n
|
||||
}
|
||||
|
||||
async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> {
|
||||
const result =
|
||||
approvedBy === undefined
|
||||
? await this.pool.query('UPDATE users SET role = $1 WHERE id = $2', [role, id])
|
||||
: await this.pool.query('UPDATE users SET role = $1, approved_by = $2 WHERE id = $3', [role, approvedBy, id])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async setUserUid(userId: string, uid: number): Promise<void> {
|
||||
await this.pool.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, userId])
|
||||
}
|
||||
|
||||
async listUsersWithoutUid(): Promise<string[]> {
|
||||
const { rows } = await this.pool.query('SELECT id FROM users WHERE uid IS NULL')
|
||||
return (rows as Array<{ id: string }>).map((row) => row.id)
|
||||
}
|
||||
|
||||
async createSession(input: CreateSessionInput): Promise<void> {
|
||||
try {
|
||||
await this.pool.query(
|
||||
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)',
|
||||
[input.tokenHash, input.userId, Date.now(), input.expiresAt, input.ip ?? null, input.userAgent ?? null],
|
||||
)
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async findSession(tokenHash: string): Promise<SessionRow | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = $1',
|
||||
[tokenHash],
|
||||
)
|
||||
return rows.length > 0 ? toSession(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async deleteSession(tokenHash: string): Promise<void> {
|
||||
await this.pool.query('DELETE FROM sessions WHERE token_hash = $1', [tokenHash])
|
||||
}
|
||||
|
||||
async deleteUserSessions(userId: string): Promise<void> {
|
||||
await this.pool.query('DELETE FROM sessions WHERE user_id = $1', [userId])
|
||||
}
|
||||
|
||||
async hasActiveSession(userId: string): Promise<boolean> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT 1 FROM sessions WHERE user_id = $1 AND expires_at > $2 LIMIT 1',
|
||||
[userId, Date.now()],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
|
||||
s.expires_at
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = $1`,
|
||||
[tokenHash],
|
||||
)
|
||||
if (rows.length === 0) return undefined
|
||||
const row = rows[0] as Record<string, unknown>
|
||||
return { expiresAt: row.expires_at as number, user: toUser(row) }
|
||||
}
|
||||
|
||||
async audit(actor: string | null, action: string, detail?: string | null): Promise<void> {
|
||||
await this.pool.query('INSERT INTO audit_log (ts, actor, action, detail) VALUES ($1, $2, $3, $4)', [
|
||||
Date.now(),
|
||||
actor,
|
||||
action,
|
||||
detail ?? null,
|
||||
])
|
||||
}
|
||||
|
||||
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = $1 AND rel_path = $2',
|
||||
[userId, relPath],
|
||||
)
|
||||
return rows.length > 0 ? toWorkspace(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> {
|
||||
const existing = await this.findWorkspaceByPath(userId, relPath)
|
||||
if (existing !== undefined) return existing
|
||||
const id = randomUUID()
|
||||
const segments = relPath.split('/').filter(Boolean)
|
||||
const name = segments.at(-1) ?? 'root'
|
||||
try {
|
||||
await this.pool.query(
|
||||
'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES ($1, $2, $3, $4, $5)',
|
||||
[id, userId, name, relPath, Date.now()],
|
||||
)
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
return { id, userId, name, relPath, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
async setFolderPlugins(
|
||||
workspaceId: string,
|
||||
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withTx(this.pool, async (client) => {
|
||||
await client.query('DELETE FROM folder_plugins WHERE workspace_id = $1', [workspaceId])
|
||||
for (const selection of selections) {
|
||||
await client.query(
|
||||
'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES ($1, $2, $3, $4)',
|
||||
[workspaceId, selection.id, selection.enabled ? 1 : 0, Date.now()],
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async getEnabledPluginIds(workspaceId: string): Promise<string[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT plugin_id FROM folder_plugins WHERE workspace_id = $1 AND enabled = 1',
|
||||
[workspaceId],
|
||||
)
|
||||
return (rows as Array<{ plugin_id: string }>).map((row) => row.plugin_id)
|
||||
}
|
||||
|
||||
async listBusinessPlugins(): Promise<BusinessPlugin[]> {
|
||||
const { rows } = await this.pool.query(`SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins ORDER BY name ASC`)
|
||||
return rows.map((row) => toBusinessPlugin(row as Record<string, unknown>))
|
||||
}
|
||||
|
||||
async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins WHERE id = $1`, [id])
|
||||
return rows.length > 0 ? toBusinessPlugin(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> {
|
||||
await this.pool.query(
|
||||
`
|
||||
INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
version = excluded.version,
|
||||
tgz_path = excluded.tgz_path,
|
||||
file_size = excluded.file_size,
|
||||
uploaded_by = excluded.uploaded_by,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
[
|
||||
input.id,
|
||||
input.name,
|
||||
input.description ?? null,
|
||||
input.version ?? null,
|
||||
input.tgzPath,
|
||||
input.fileSize,
|
||||
input.uploadedBy,
|
||||
Date.now(),
|
||||
],
|
||||
)
|
||||
return (await this.findBusinessPlugin(input.id))!
|
||||
}
|
||||
|
||||
async deleteBusinessPlugin(id: string): Promise<boolean> {
|
||||
const result = await this.pool.query('DELETE FROM business_plugins WHERE id = $1', [id])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async findDomainByUser(userId: string): Promise<Domain | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains WHERE user_id = $1`, [userId])
|
||||
return rows.length > 0 ? toDomain(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async findDomainById(id: string): Promise<Domain | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains WHERE id = $1`, [id])
|
||||
return rows.length > 0 ? toDomain(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async listDomains(): Promise<Domain[]> {
|
||||
const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains ORDER BY updated_at DESC`)
|
||||
return rows.map((row) => toDomain(row as Record<string, unknown>))
|
||||
}
|
||||
|
||||
async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> {
|
||||
try {
|
||||
await this.pool.query(
|
||||
`
|
||||
INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at)
|
||||
VALUES ($1, $2, $3, 0, $4, $5)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
domain = excluded.domain,
|
||||
verified = 0,
|
||||
nginx_config = excluded.nginx_config,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
[randomUUID(), userId, domain, nginxConfig, Date.now()],
|
||||
)
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
return (await this.findDomainByUser(userId))!
|
||||
}
|
||||
|
||||
async setDomainVerified(id: string, verified: boolean): Promise<boolean> {
|
||||
const result = await this.pool.query('UPDATE domains SET verified = $1, updated_at = $2 WHERE id = $3', [
|
||||
verified ? 1 : 0,
|
||||
Date.now(),
|
||||
id,
|
||||
])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC',
|
||||
[userId],
|
||||
)
|
||||
return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.key_name,
|
||||
enabled: r.enabled === 1,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1',
|
||||
[userId],
|
||||
)
|
||||
const row = rows[0] as { secret_ref: string } | undefined
|
||||
return row?.secret_ref ?? null
|
||||
}
|
||||
|
||||
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
|
||||
try {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
|
||||
const existing = await client.query(
|
||||
'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2',
|
||||
[userId, name],
|
||||
)
|
||||
let id: string
|
||||
if (existing.rows.length > 0) {
|
||||
id = (existing.rows[0] as { id: string }).id
|
||||
await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [
|
||||
encryptedRef,
|
||||
Date.now(),
|
||||
id,
|
||||
])
|
||||
} else {
|
||||
id = randomUUID()
|
||||
await client.query(
|
||||
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)',
|
||||
[id, userId, name, encryptedRef, Date.now()],
|
||||
)
|
||||
}
|
||||
return { id, name, enabled: true, updatedAt: Date.now() }
|
||||
})
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
|
||||
const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [
|
||||
id,
|
||||
userId,
|
||||
])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
})
|
||||
}
|
||||
|
||||
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<boolean> {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
await client.query('DELETE FROM credential_vault WHERE user_id = $1', [userId])
|
||||
await client.query('DELETE FROM domains WHERE user_id = $1', [userId])
|
||||
await client.query('DELETE FROM sessions WHERE user_id = $1', [userId])
|
||||
await client.query('DELETE FROM dsh_instances WHERE user_id = $1', [userId])
|
||||
await client.query('DELETE FROM audit_log WHERE actor = $1', [userId])
|
||||
await client.query(
|
||||
'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = $1)',
|
||||
[userId],
|
||||
)
|
||||
await client.query('DELETE FROM workspaces WHERE user_id = $1', [userId])
|
||||
const result = await client.query('DELETE FROM users WHERE id = $1', [userId])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
})
|
||||
}
|
||||
|
||||
async upsertInstance(input: UpsertDshInstanceInput): Promise<void> {
|
||||
try {
|
||||
await this.pool.query(
|
||||
`INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
pid = excluded.pid,
|
||||
port = excluded.port,
|
||||
status = excluded.status,
|
||||
started_at = excluded.started_at,
|
||||
folder = excluded.folder,
|
||||
patch = excluded.patch`,
|
||||
[
|
||||
input.id,
|
||||
input.userId,
|
||||
input.workspaceId ?? null,
|
||||
input.role,
|
||||
input.pid ?? null,
|
||||
input.port ?? null,
|
||||
input.status,
|
||||
Date.now(),
|
||||
input.folder ?? null,
|
||||
input.patch ?? null,
|
||||
],
|
||||
)
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async findInstance(id: string): Promise<DshInstance | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE id = $1`, [id])
|
||||
return rows.length > 0 ? toDshInstance(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE user_id = $1 AND role = $2`,
|
||||
[userId, role],
|
||||
)
|
||||
return rows.length > 0 ? toDshInstance(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE role = $1 ORDER BY started_at ASC`,
|
||||
[role],
|
||||
)
|
||||
return rows.map((row) => toDshInstance(row as Record<string, unknown>))
|
||||
}
|
||||
|
||||
async setInstanceStatus(
|
||||
id: string,
|
||||
status: DshInstanceStatus,
|
||||
outcome?: { exitCode?: number; lastError?: string },
|
||||
): Promise<boolean> {
|
||||
const result =
|
||||
outcome === undefined
|
||||
? await this.pool.query('UPDATE dsh_instances SET status = $1 WHERE id = $2', [status, id])
|
||||
: await this.pool.query(
|
||||
'UPDATE dsh_instances SET status = $1, last_exit = $2, exit_code = $3, last_error = $4 WHERE id = $5',
|
||||
[status, Date.now(), outcome.exitCode ?? null, outcome.lastError ?? null, id],
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async deleteInstance(id: string): Promise<boolean> {
|
||||
const result = await this.pool.query('DELETE FROM dsh_instances WHERE id = $1', [id])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async deleteUserInstances(userId: string): Promise<void> {
|
||||
await this.pool.query('DELETE FROM dsh_instances WHERE user_id = $1', [userId])
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a Postgres adapter: connect, run migrations, then wrap the pool. */
|
||||
export async function openPgAdapter(connectionString: string, baseUid: number): Promise<PgAdapter> {
|
||||
const pool = new Pool({ connectionString })
|
||||
await runPgMigrations(pool)
|
||||
return new PgAdapter(pool, baseUid)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Prepared-statement cache. better-sqlite3 does not cache `db.prepare`, so
|
||||
* re-preparing the same SQL on every call re-parses it. This memoizes prepared
|
||||
* statements per connection (WeakMap), which matters for hot paths such as
|
||||
* authentication and the reverse proxy.
|
||||
* @module dshs/db/prepared
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3'
|
||||
import type { Database as Db } from './connection.js'
|
||||
|
||||
type Statement = Database.Statement<unknown[], unknown>
|
||||
|
||||
const caches = new WeakMap<Db, Map<string, Statement>>()
|
||||
|
||||
/** Prepare (and cache) a statement for a connection. */
|
||||
export function prepare(db: Db, sql: string): Statement {
|
||||
let cache = caches.get(db)
|
||||
if (cache === undefined) {
|
||||
cache = new Map()
|
||||
caches.set(db, cache)
|
||||
}
|
||||
let statement = cache.get(sql)
|
||||
if (statement === undefined) {
|
||||
statement = db.prepare(sql)
|
||||
cache.set(sql, statement)
|
||||
}
|
||||
return statement
|
||||
}
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Synchronous SQLite data access. This is the raw layer behind
|
||||
* {@link SqliteAdapter}; the route layer must never import these functions
|
||||
* directly — it goes through {@link DbAdapter} so Postgres can be substituted.
|
||||
*
|
||||
* All access is parameterized (prepared statements). Functions take the
|
||||
* connection explicitly so they stay free of Fastify/app state and testable.
|
||||
* @module dshs/db/repo
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Database } from './connection.js'
|
||||
import { prepare } from './prepared.js'
|
||||
import {
|
||||
toBusinessPlugin,
|
||||
toDomain,
|
||||
toDshInstance,
|
||||
toPublicUser,
|
||||
toSession,
|
||||
toUser,
|
||||
toWorkspace,
|
||||
type BusinessPlugin,
|
||||
type CredentialKey,
|
||||
type CreateSessionInput,
|
||||
type CreateUserInput,
|
||||
type Domain,
|
||||
type DshInstance,
|
||||
type DshInstanceRole,
|
||||
type DshInstanceStatus,
|
||||
type PublicUser,
|
||||
type SessionRow,
|
||||
type SessionUser,
|
||||
type UpsertBusinessPluginInput,
|
||||
type UpsertDshInstanceInput,
|
||||
type User,
|
||||
type UserRole,
|
||||
type Workspace,
|
||||
} from './types.js'
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
|
||||
const INSTANCE_COLS =
|
||||
'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch'
|
||||
|
||||
export function createUser(db: Database, input: CreateUserInput, baseUid: number): User {
|
||||
const createdAt = Date.now()
|
||||
return db.transaction((): User => {
|
||||
const info = prepare(db,
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(input.id, input.username, input.passHash, input.role, input.homeDir, createdAt)
|
||||
// SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
|
||||
const uid = baseUid + Number(info.lastInsertRowid)
|
||||
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id)
|
||||
return {
|
||||
id: input.id,
|
||||
username: input.username,
|
||||
pass_hash: input.passHash,
|
||||
role: input.role,
|
||||
home_dir: input.homeDir,
|
||||
api_key_ref: null,
|
||||
created_at: createdAt,
|
||||
approved_by: null,
|
||||
uid,
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
export function findUserByUsername(db: Database, username: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE username = ?`).get(username)
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/** Case-insensitive username lookup (for subdomain routing). */
|
||||
export function findUserBySlug(db: Database, slug: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE LOWER(username) = ?`).get(slug.toLowerCase())
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function findUserById(db: Database, id: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id)
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function listPublicUsers(db: Database): PublicUser[] {
|
||||
const rows = prepare(db, `SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`).all() as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
return rows.map((row) => toPublicUser(toUser(row)))
|
||||
}
|
||||
|
||||
export function countAdmins(db: Database): number {
|
||||
const row = prepare(db, `SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get() as { n: number }
|
||||
return row.n
|
||||
}
|
||||
|
||||
export function setUserRole(db: Database, id: string, role: UserRole, approvedBy?: string): boolean {
|
||||
const info =
|
||||
approvedBy === undefined
|
||||
? prepare(db, 'UPDATE users SET role = ? WHERE id = ?').run(role, id)
|
||||
: prepare(db, 'UPDATE users SET role = ?, approved_by = ? WHERE id = ?').run(role, approvedBy, id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function createSession(db: Database, input: CreateSessionInput): void {
|
||||
prepare(db,
|
||||
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(input.tokenHash, input.userId, Date.now(), input.expiresAt, input.ip ?? null, input.userAgent ?? null)
|
||||
}
|
||||
|
||||
export function findSession(db: Database, tokenHash: string): SessionRow | undefined {
|
||||
const row = prepare(db, 'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = ?')
|
||||
.get(tokenHash)
|
||||
return row ? toSession(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function deleteSession(db: Database, tokenHash: string): void {
|
||||
prepare(db, 'DELETE FROM sessions WHERE token_hash = ?').run(tokenHash)
|
||||
}
|
||||
|
||||
export function deleteUserSessions(db: Database, userId: string): void {
|
||||
prepare(db, 'DELETE FROM sessions WHERE user_id = ?').run(userId)
|
||||
}
|
||||
|
||||
/** Append an audit entry. `actor` is a user id or `'system'`. */
|
||||
export function audit(db: Database, actor: string | null, action: string, detail?: string | null): void {
|
||||
prepare(db, 'INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
|
||||
Date.now(),
|
||||
actor,
|
||||
action,
|
||||
detail ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export function findWorkspaceByPath(db: Database, userId: string, relPath: string): Workspace | undefined {
|
||||
const row = prepare(db, 'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = ? AND rel_path = ?')
|
||||
.get(userId, relPath)
|
||||
return row ? toWorkspace(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/** Upsert a workspace row by (user, relPath); create with a derived name. */
|
||||
export function getOrCreateWorkspace(db: Database, userId: string, relPath: string): Workspace {
|
||||
const existing = findWorkspaceByPath(db, userId, relPath)
|
||||
if (existing !== undefined) return existing
|
||||
const id = randomUUID()
|
||||
const segments = relPath.split('/').filter(Boolean)
|
||||
const name = segments.at(-1) ?? 'root'
|
||||
prepare(db, 'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES (?, ?, ?, ?, ?)').run(
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
relPath,
|
||||
Date.now(),
|
||||
)
|
||||
return { id, userId, name, relPath, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
/** Replace a workspace's plugin selection (insert/delete in one transaction). */
|
||||
export function setFolderPlugins(
|
||||
db: Database,
|
||||
workspaceId: string,
|
||||
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
|
||||
): void {
|
||||
const tx = db.transaction(() => {
|
||||
prepare(db, 'DELETE FROM folder_plugins WHERE workspace_id = ?').run(workspaceId)
|
||||
const insert = prepare(db,
|
||||
'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
for (const selection of selections) {
|
||||
insert.run(workspaceId, selection.id, selection.enabled ? 1 : 0, Date.now())
|
||||
}
|
||||
})
|
||||
tx()
|
||||
}
|
||||
|
||||
/** Enabled plugin ids for a workspace. */
|
||||
export function getEnabledPluginIds(db: Database, workspaceId: string): string[] {
|
||||
const rows = prepare(db, 'SELECT plugin_id FROM folder_plugins WHERE workspace_id = ? AND enabled = 1')
|
||||
.all(workspaceId) as Array<{ plugin_id: string }>
|
||||
return rows.map((row) => row.plugin_id)
|
||||
}
|
||||
|
||||
export function findDomainByUser(db: Database, userId: string): Domain | undefined {
|
||||
const row = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains WHERE user_id = ?`).get(userId)
|
||||
return row ? toDomain(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function findDomainById(db: Database, id: string): Domain | undefined {
|
||||
const row = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains WHERE id = ?`).get(id)
|
||||
return row ? toDomain(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function listDomains(db: Database): Domain[] {
|
||||
const rows = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains ORDER BY updated_at DESC`).all() as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
return rows.map((row) => toDomain(row))
|
||||
}
|
||||
|
||||
/** Upsert a user's custom domain (resetting `verified` to 0). */
|
||||
export function upsertDomain(db: Database, userId: string, domain: string, nginxConfig: string): Domain {
|
||||
prepare(db, `
|
||||
INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at)
|
||||
VALUES (?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
domain = excluded.domain,
|
||||
verified = 0,
|
||||
nginx_config = excluded.nginx_config,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(randomUUID(), userId, domain, nginxConfig, Date.now())
|
||||
return findDomainByUser(db, userId)!
|
||||
}
|
||||
|
||||
/** List a user's named credential keys (metadata only). */
|
||||
export function listCredentialKeys(db: Database, userId: string): CredentialKey[] {
|
||||
const rows = prepare(
|
||||
db,
|
||||
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC',
|
||||
).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>
|
||||
return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at }))
|
||||
}
|
||||
|
||||
/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */
|
||||
export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null {
|
||||
const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as
|
||||
| { secret_ref: string }
|
||||
| undefined
|
||||
return row?.secret_ref ?? null
|
||||
}
|
||||
|
||||
/** Upsert a named key, disable the others, and enable this one. */
|
||||
export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey {
|
||||
const id = db.transaction(() => {
|
||||
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
|
||||
const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get(
|
||||
userId,
|
||||
name,
|
||||
) as { id: string } | undefined
|
||||
if (existing !== undefined) {
|
||||
prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run(
|
||||
encryptedRef,
|
||||
Date.now(),
|
||||
existing.id,
|
||||
)
|
||||
return existing.id
|
||||
}
|
||||
const id = randomUUID()
|
||||
prepare(
|
||||
db,
|
||||
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)',
|
||||
).run(id, userId, name, encryptedRef, Date.now())
|
||||
return id
|
||||
})()
|
||||
return { id, name, enabled: true, updatedAt: Date.now() }
|
||||
}
|
||||
|
||||
/** Enable one of a user's named keys (disabling the others). */
|
||||
export function selectCredentialKey(db: Database, userId: string, id: string): boolean {
|
||||
const ok = db.transaction(() => {
|
||||
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
|
||||
const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return info.changes > 0
|
||||
})()
|
||||
return ok as boolean
|
||||
}
|
||||
|
||||
/** Delete a named key (by id, scoped to the user). */
|
||||
export function deleteCredentialKey(db: Database, userId: string, id: string): boolean {
|
||||
const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a user and all owned rows. Child tables are removed
|
||||
* explicitly (in dependency order) rather than relying on FK cascade, so the
|
||||
* cleanup works even when `PRAGMA foreign_keys` is off. Returns false when the
|
||||
* user does not exist.
|
||||
*/
|
||||
export function deleteUser(db: Database, userId: string): boolean {
|
||||
const deleted = db.transaction(() => {
|
||||
prepare(db, 'DELETE FROM credential_vault WHERE user_id = ?').run(userId)
|
||||
prepare(db, 'DELETE FROM domains WHERE user_id = ?').run(userId)
|
||||
prepare(db, 'DELETE FROM sessions WHERE user_id = ?').run(userId)
|
||||
prepare(db, 'DELETE FROM dsh_instances WHERE user_id = ?').run(userId)
|
||||
prepare(db, 'DELETE FROM audit_log WHERE actor = ?').run(userId)
|
||||
prepare(
|
||||
db,
|
||||
'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = ?)',
|
||||
).run(userId)
|
||||
prepare(db, 'DELETE FROM workspaces WHERE user_id = ?').run(userId)
|
||||
const info = prepare(db, 'DELETE FROM users WHERE id = ?').run(userId)
|
||||
return info.changes > 0
|
||||
})()
|
||||
return deleted as boolean
|
||||
}
|
||||
|
||||
/** Set the verified flag on a domain. */
|
||||
export function setDomainVerified(db: Database, id: string, verified: boolean): boolean {
|
||||
const info = prepare(db, 'UPDATE domains SET verified = ?, updated_at = ? WHERE id = ?')
|
||||
.run(verified ? 1 : 0, Date.now(), id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
/** Whether any of a user's sessions is still unexpired. */
|
||||
export function hasActiveSession(db: Database, userId: string): boolean {
|
||||
const row = prepare(db, 'SELECT 1 FROM sessions WHERE user_id = ? AND expires_at > ? LIMIT 1')
|
||||
.get(userId, Date.now()) as { 1: number } | undefined
|
||||
return row !== undefined
|
||||
}
|
||||
|
||||
/** Look up a session and its user in a single join. */
|
||||
export function findSessionWithUser(db: Database, tokenHash: string): SessionUser | undefined {
|
||||
const row = prepare(
|
||||
db,
|
||||
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
|
||||
s.expires_at
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = ?`,
|
||||
).get(tokenHash) as Record<string, unknown> | undefined
|
||||
if (row === undefined) return undefined
|
||||
return { expiresAt: row.expires_at as number, user: toUser(row) }
|
||||
}
|
||||
|
||||
/** Assign a Linux uid to a user (legacy backfill). */
|
||||
export function setUserUid(db: Database, userId: string, uid: number): void {
|
||||
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, userId)
|
||||
}
|
||||
|
||||
/** Ids of users whose uid is still null (legacy rows awaiting backfill). */
|
||||
export function listUsersWithoutUid(db: Database): string[] {
|
||||
const rows = prepare(db, 'SELECT id FROM users WHERE uid IS NULL').all() as Array<{ id: string }>
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
/** Record (or re-record) an instance's desired state. Keyed on the caller's
|
||||
* deterministic id, so a relaunch overwrites rather than duplicating. */
|
||||
export function upsertInstance(db: Database, input: UpsertDshInstanceInput): void {
|
||||
prepare(db, `
|
||||
INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
pid = excluded.pid,
|
||||
port = excluded.port,
|
||||
status = excluded.status,
|
||||
started_at = excluded.started_at,
|
||||
folder = excluded.folder,
|
||||
patch = excluded.patch
|
||||
`).run(
|
||||
input.id,
|
||||
input.userId,
|
||||
input.workspaceId ?? null,
|
||||
input.role,
|
||||
input.pid ?? null,
|
||||
input.port ?? null,
|
||||
input.status,
|
||||
Date.now(),
|
||||
input.folder ?? null,
|
||||
input.patch ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export function findInstance(db: Database, id: string): DshInstance | undefined {
|
||||
const row = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE id = ?`).get(id)
|
||||
return row ? toDshInstance(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function findUserInstance(db: Database, userId: string, role: DshInstanceRole): DshInstance | undefined {
|
||||
const row = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE user_id = ? AND role = ?`).get(userId, role)
|
||||
return row ? toDshInstance(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function listInstancesByRole(db: Database, role: DshInstanceRole): DshInstance[] {
|
||||
const rows = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE role = ? ORDER BY started_at ASC`)
|
||||
.all(role) as Array<Record<string, unknown>>
|
||||
return rows.map((row) => toDshInstance(row))
|
||||
}
|
||||
|
||||
/** Record a state transition; an `outcome` also stamps `last_exit`. */
|
||||
export function setInstanceStatus(
|
||||
db: Database,
|
||||
id: string,
|
||||
status: DshInstanceStatus,
|
||||
outcome?: { exitCode?: number; lastError?: string },
|
||||
): boolean {
|
||||
const info =
|
||||
outcome === undefined
|
||||
? prepare(db, 'UPDATE dsh_instances SET status = ? WHERE id = ?').run(status, id)
|
||||
: prepare(db, 'UPDATE dsh_instances SET status = ?, last_exit = ?, exit_code = ?, last_error = ? WHERE id = ?')
|
||||
.run(status, Date.now(), outcome.exitCode ?? null, outcome.lastError ?? null, id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function deleteInstance(db: Database, id: string): boolean {
|
||||
const info = prepare(db, 'DELETE FROM dsh_instances WHERE id = ?').run(id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function deleteUserInstances(db: Database, userId: string): void {
|
||||
prepare(db, 'DELETE FROM dsh_instances WHERE user_id = ?').run(userId)
|
||||
}
|
||||
|
||||
// ── business plugins (系统外插件候选池) ────────────────────────────────
|
||||
|
||||
export function listBusinessPlugins(db: Database): BusinessPlugin[] {
|
||||
const rows = prepare(db, `SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins ORDER BY name ASC`).all() as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
return rows.map((row) => toBusinessPlugin(row))
|
||||
}
|
||||
|
||||
export function findBusinessPlugin(db: Database, id: string): BusinessPlugin | undefined {
|
||||
const row = prepare(db, `SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins WHERE id = ?`).get(id)
|
||||
return row ? toBusinessPlugin(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a candidate-pool row keyed on the bundle package name (`id`). Same-name
|
||||
* upload REPLACES the row — the caller removes the previous tgz file first so no
|
||||
* stale artifact survives.
|
||||
*/
|
||||
export function upsertBusinessPlugin(db: Database, input: UpsertBusinessPluginInput): BusinessPlugin {
|
||||
prepare(db, `
|
||||
INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
version = excluded.version,
|
||||
tgz_path = excluded.tgz_path,
|
||||
file_size = excluded.file_size,
|
||||
uploaded_by = excluded.uploaded_by,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
input.id,
|
||||
input.name,
|
||||
input.description ?? null,
|
||||
input.version ?? null,
|
||||
input.tgzPath,
|
||||
input.fileSize,
|
||||
input.uploadedBy,
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
)
|
||||
return findBusinessPlugin(db, input.id)!
|
||||
}
|
||||
|
||||
export function deleteBusinessPlugin(db: Database, id: string): boolean {
|
||||
const info = prepare(db, 'DELETE FROM business_plugins WHERE id = ?').run(id)
|
||||
return info.changes > 0
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Schema migrations, dual-dialect. Each migration carries SQLite and Postgres
|
||||
* DDL; the active adapter runs only its own dialect. `schema_migrations` is
|
||||
* shared (same table shape), so a SQLite↔Postgres dump/restore round-trips the
|
||||
* applied-version marker too.
|
||||
*
|
||||
* Dialect notes (kept out of the route layer):
|
||||
* - timestamps are epoch **milliseconds** (Date.now()), which exceeds 32-bit
|
||||
* `INTEGER`; SQLite `INTEGER` is 64-bit, Postgres uses `BIGINT`.
|
||||
* - `enabled`/`verified` are `INTEGER 0/1` in *both* dialects so the row mappers
|
||||
* stay byte-identical across backends (no boolean/0/1 branch).
|
||||
* - `audit_log.id` uses SQLite `AUTOINCREMENT` vs Postgres `IDENTITY`.
|
||||
* @module dshs/db/schema
|
||||
*/
|
||||
|
||||
import type { Database } from './connection.js'
|
||||
import type { Pool } from 'pg'
|
||||
|
||||
const SQLITE_V1 = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
pass_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (role IN ('admin','pending','active','disabled')),
|
||||
home_dir TEXT NOT NULL,
|
||||
api_key_ref TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
approved_by TEXT REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
|
||||
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
|
||||
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (user_id, rel_path)
|
||||
);
|
||||
|
||||
-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
|
||||
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
|
||||
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
|
||||
CREATE TABLE IF NOT EXISTS folder_plugins (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
plugin_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (workspace_id, plugin_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dsh_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id),
|
||||
role TEXT NOT NULL CHECK (role IN ('main','watchdog')),
|
||||
pid INTEGER,
|
||||
port INTEGER,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('starting','running','crashed','repairing','stopped')),
|
||||
started_at INTEGER,
|
||||
last_exit INTEGER,
|
||||
exit_code INTEGER,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
actor TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT UNIQUE REFERENCES users(id),
|
||||
domain TEXT NOT NULL,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
nginx_config TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credential_vault (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
key_name TEXT NOT NULL,
|
||||
secret_ref TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (user_id, key_name)
|
||||
);
|
||||
`
|
||||
|
||||
const SQLITE_V2 = `
|
||||
ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
|
||||
const PG_V1 = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
pass_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (role IN ('admin','pending','active','disabled')),
|
||||
home_dir TEXT NOT NULL,
|
||||
api_key_ref TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
approved_by TEXT REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
|
||||
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
|
||||
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
UNIQUE (user_id, rel_path)
|
||||
);
|
||||
|
||||
-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
|
||||
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
|
||||
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
|
||||
CREATE TABLE IF NOT EXISTS folder_plugins (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
plugin_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, plugin_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dsh_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id),
|
||||
role TEXT NOT NULL CHECK (role IN ('main','watchdog')),
|
||||
pid INTEGER,
|
||||
port INTEGER,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('starting','running','crashed','repairing','stopped')),
|
||||
started_at BIGINT,
|
||||
last_exit BIGINT,
|
||||
exit_code INTEGER,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
ts BIGINT NOT NULL,
|
||||
actor TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT UNIQUE REFERENCES users(id),
|
||||
domain TEXT NOT NULL,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
nginx_config TEXT,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credential_vault (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
key_name TEXT NOT NULL,
|
||||
secret_ref TEXT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE (user_id, key_name)
|
||||
);
|
||||
`
|
||||
|
||||
const PG_V2 = `
|
||||
ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
|
||||
// v3: per-user Linux uid (non-colliding for new users via an identity column).
|
||||
// SQLite reuses its implicit `rowid` for the incrementing integer, so only the
|
||||
// `uid` column is added here; Postgres adds an explicit identity `row_id`.
|
||||
const SQLITE_V3 = `
|
||||
ALTER TABLE users ADD COLUMN uid INTEGER;
|
||||
`
|
||||
|
||||
const PG_V3 = `
|
||||
ALTER TABLE users ADD COLUMN row_id BIGINT GENERATED ALWAYS AS IDENTITY;
|
||||
ALTER TABLE users ADD COLUMN uid BIGINT;
|
||||
`
|
||||
|
||||
// v4: desired-state columns on `dsh_instances`. The table has existed since v1
|
||||
// but was never read or written; the k8s controller uses it as the reconcile
|
||||
// target (docs/k8s.md §5.7), which needs the launch folder and the rendered
|
||||
// Cordis patch to rebuild a Pod that went missing.
|
||||
const SQLITE_V4 = `
|
||||
ALTER TABLE dsh_instances ADD COLUMN folder TEXT;
|
||||
ALTER TABLE dsh_instances ADD COLUMN patch TEXT;
|
||||
`
|
||||
|
||||
const PG_V4 = `
|
||||
ALTER TABLE dsh_instances ADD COLUMN folder TEXT;
|
||||
ALTER TABLE dsh_instances ADD COLUMN patch TEXT;
|
||||
`
|
||||
|
||||
// v5: business-plugin candidate pool (系统外插件 = 功能插件). Admin uploads a
|
||||
// tgz bundle into the pool; users enable it per-instance from the dsh settings
|
||||
// section (档案 16). Same-name upload REPLACES the row (not overwrite — the old
|
||||
// tgz file is removed first, so deleted files cannot survive).
|
||||
const SQLITE_V5 = `
|
||||
CREATE TABLE IF NOT EXISTS business_plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
version TEXT,
|
||||
tgz_path TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
uploaded_by TEXT REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
const PG_V5 = `
|
||||
CREATE TABLE IF NOT EXISTS business_plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
version TEXT,
|
||||
tgz_path TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
uploaded_by TEXT REFERENCES users(id),
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
sqlite: string
|
||||
pg: string
|
||||
}
|
||||
|
||||
const MIGRATIONS: readonly Migration[] = [
|
||||
{ version: 1, name: 'initial schema', sqlite: SQLITE_V1, pg: PG_V1 },
|
||||
{ version: 2, name: 'credential vault enabled flag', sqlite: SQLITE_V2, pg: PG_V2 },
|
||||
{ version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 },
|
||||
{ version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 },
|
||||
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
|
||||
]
|
||||
|
||||
/** Apply unapplied SQLite migrations inside a single transaction. */
|
||||
export function runSqliteMigrations(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
const rows = db.prepare('SELECT version FROM schema_migrations').all() as Array<{ version: number }>
|
||||
const applied = new Set(rows.map((row) => row.version))
|
||||
|
||||
const apply = db.transaction(() => {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (applied.has(migration.version)) continue
|
||||
db.exec(migration.sqlite)
|
||||
db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(
|
||||
migration.version,
|
||||
Date.now(),
|
||||
)
|
||||
}
|
||||
})
|
||||
apply()
|
||||
}
|
||||
|
||||
/** Apply unapplied Postgres migrations inside a single transaction. */
|
||||
export async function runPgMigrations(pool: Pool): Promise<void> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL
|
||||
);
|
||||
`)
|
||||
const { rows } = await client.query('SELECT version FROM schema_migrations')
|
||||
const applied = new Set(rows.map((row) => row.version as number))
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (applied.has(migration.version)) continue
|
||||
await client.query(migration.pg)
|
||||
await client.query('INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)', [
|
||||
migration.version,
|
||||
Date.now(),
|
||||
])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK')
|
||||
throw e
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* SQLite backend for {@link DbAdapter}. Wraps the synchronous better-sqlite3
|
||||
* repo in async methods and maps constraint errors onto the shared hierarchy.
|
||||
* Used when `deployMode=local` (no `DSHS_DB_URL`).
|
||||
*
|
||||
* NOTE: better-sqlite3 is synchronous; the `async` here only matches the
|
||||
* interface — the underlying calls still block the event loop. Fine for the
|
||||
* small single-machine local mode this backend targets (see docs/k8s.md §5.1).
|
||||
* @module dshs/db/sqlite
|
||||
*/
|
||||
|
||||
import type { DbAdapter } from './adapter.js'
|
||||
import { openDatabase, type Database } from './connection.js'
|
||||
import { mapSqliteError } from './errors.js'
|
||||
import {
|
||||
audit as auditSync,
|
||||
countAdmins as countAdminsSync,
|
||||
createSession as createSessionSync,
|
||||
createUser as createUserSync,
|
||||
deleteBusinessPlugin as deleteBusinessPluginSync,
|
||||
deleteCredentialKey as deleteCredentialKeySync,
|
||||
deleteInstance as deleteInstanceSync,
|
||||
deleteSession as deleteSessionSync,
|
||||
deleteUser as deleteUserSync,
|
||||
deleteUserInstances as deleteUserInstancesSync,
|
||||
deleteUserSessions as deleteUserSessionsSync,
|
||||
findBusinessPlugin as findBusinessPluginSync,
|
||||
findDomainById as findDomainByIdSync,
|
||||
findDomainByUser as findDomainByUserSync,
|
||||
findInstance as findInstanceSync,
|
||||
findSession as findSessionSync,
|
||||
findSessionWithUser as findSessionWithUserSync,
|
||||
hasActiveSession as hasActiveSessionSync,
|
||||
findUserById as findUserByIdSync,
|
||||
findUserBySlug as findUserBySlugSync,
|
||||
findUserByUsername as findUserByUsernameSync,
|
||||
findUserInstance as findUserInstanceSync,
|
||||
findWorkspaceByPath as findWorkspaceByPathSync,
|
||||
getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync,
|
||||
getEnabledPluginIds as getEnabledPluginIdsSync,
|
||||
getOrCreateWorkspace as getOrCreateWorkspaceSync,
|
||||
listBusinessPlugins as listBusinessPluginsSync,
|
||||
listCredentialKeys as listCredentialKeysSync,
|
||||
listDomains as listDomainsSync,
|
||||
listInstancesByRole as listInstancesByRoleSync,
|
||||
listPublicUsers as listPublicUsersSync,
|
||||
listUsersWithoutUid as listUsersWithoutUidSync,
|
||||
selectCredentialKey as selectCredentialKeySync,
|
||||
setCredentialKey as setCredentialKeySync,
|
||||
setDomainVerified as setDomainVerifiedSync,
|
||||
setFolderPlugins as setFolderPluginsSync,
|
||||
setInstanceStatus as setInstanceStatusSync,
|
||||
setUserRole as setUserRoleSync,
|
||||
setUserUid as setUserUidSync,
|
||||
upsertBusinessPlugin as upsertBusinessPluginSync,
|
||||
upsertDomain as upsertDomainSync,
|
||||
upsertInstance as upsertInstanceSync,
|
||||
} from './repo.js'
|
||||
import type {
|
||||
BusinessPlugin,
|
||||
CredentialKey,
|
||||
CreateSessionInput,
|
||||
CreateUserInput,
|
||||
Domain,
|
||||
DshInstance,
|
||||
DshInstanceRole,
|
||||
DshInstanceStatus,
|
||||
PublicUser,
|
||||
SessionRow,
|
||||
SessionUser,
|
||||
UpsertBusinessPluginInput,
|
||||
UpsertDshInstanceInput,
|
||||
User,
|
||||
UserRole,
|
||||
Workspace,
|
||||
} from './types.js'
|
||||
|
||||
export class SqliteAdapter implements DbAdapter {
|
||||
private readonly db: Database
|
||||
|
||||
constructor(path: string, private readonly baseUid: number) {
|
||||
this.db = openDatabase(path)
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserInput): Promise<User> {
|
||||
try {
|
||||
return createUserSync(this.db, input, this.baseUid)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<User | undefined> {
|
||||
return findUserByUsernameSync(this.db, username)
|
||||
}
|
||||
|
||||
async findUserBySlug(slug: string): Promise<User | undefined> {
|
||||
return findUserBySlugSync(this.db, slug)
|
||||
}
|
||||
|
||||
async findUserById(id: string): Promise<User | undefined> {
|
||||
return findUserByIdSync(this.db, id)
|
||||
}
|
||||
|
||||
async listPublicUsers(): Promise<PublicUser[]> {
|
||||
return listPublicUsersSync(this.db)
|
||||
}
|
||||
|
||||
async countAdmins(): Promise<number> {
|
||||
return countAdminsSync(this.db)
|
||||
}
|
||||
|
||||
async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> {
|
||||
return setUserRoleSync(this.db, id, role, approvedBy)
|
||||
}
|
||||
|
||||
async setUserUid(userId: string, uid: number): Promise<void> {
|
||||
setUserUidSync(this.db, userId, uid)
|
||||
}
|
||||
|
||||
async listUsersWithoutUid(): Promise<string[]> {
|
||||
return listUsersWithoutUidSync(this.db)
|
||||
}
|
||||
|
||||
async createSession(input: CreateSessionInput): Promise<void> {
|
||||
try {
|
||||
createSessionSync(this.db, input)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async findSession(tokenHash: string): Promise<SessionRow | undefined> {
|
||||
return findSessionSync(this.db, tokenHash)
|
||||
}
|
||||
|
||||
async deleteSession(tokenHash: string): Promise<void> {
|
||||
deleteSessionSync(this.db, tokenHash)
|
||||
}
|
||||
|
||||
async deleteUserSessions(userId: string): Promise<void> {
|
||||
deleteUserSessionsSync(this.db, userId)
|
||||
}
|
||||
|
||||
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
|
||||
return findSessionWithUserSync(this.db, tokenHash)
|
||||
}
|
||||
|
||||
async hasActiveSession(userId: string): Promise<boolean> {
|
||||
return hasActiveSessionSync(this.db, userId)
|
||||
}
|
||||
|
||||
async audit(actor: string | null, action: string, detail?: string | null): Promise<void> {
|
||||
auditSync(this.db, actor, action, detail)
|
||||
}
|
||||
|
||||
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
|
||||
return findWorkspaceByPathSync(this.db, userId, relPath)
|
||||
}
|
||||
|
||||
async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> {
|
||||
try {
|
||||
return getOrCreateWorkspaceSync(this.db, userId, relPath)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async setFolderPlugins(
|
||||
workspaceId: string,
|
||||
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
setFolderPluginsSync(this.db, workspaceId, selections)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async getEnabledPluginIds(workspaceId: string): Promise<string[]> {
|
||||
return getEnabledPluginIdsSync(this.db, workspaceId)
|
||||
}
|
||||
|
||||
async listBusinessPlugins(): Promise<BusinessPlugin[]> {
|
||||
return listBusinessPluginsSync(this.db)
|
||||
}
|
||||
|
||||
async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> {
|
||||
return findBusinessPluginSync(this.db, id)
|
||||
}
|
||||
|
||||
async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> {
|
||||
try {
|
||||
return upsertBusinessPluginSync(this.db, input)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBusinessPlugin(id: string): Promise<boolean> {
|
||||
return deleteBusinessPluginSync(this.db, id)
|
||||
}
|
||||
|
||||
async findDomainByUser(userId: string): Promise<Domain | undefined> {
|
||||
return findDomainByUserSync(this.db, userId)
|
||||
}
|
||||
|
||||
async findDomainById(id: string): Promise<Domain | undefined> {
|
||||
return findDomainByIdSync(this.db, id)
|
||||
}
|
||||
|
||||
async listDomains(): Promise<Domain[]> {
|
||||
return listDomainsSync(this.db)
|
||||
}
|
||||
|
||||
async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> {
|
||||
try {
|
||||
return upsertDomainSync(this.db, userId, domain, nginxConfig)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async setDomainVerified(id: string, verified: boolean): Promise<boolean> {
|
||||
return setDomainVerifiedSync(this.db, id, verified)
|
||||
}
|
||||
|
||||
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
|
||||
return listCredentialKeysSync(this.db, userId)
|
||||
}
|
||||
|
||||
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
|
||||
return getEnabledCredentialKeyRefSync(this.db, userId)
|
||||
}
|
||||
|
||||
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
|
||||
try {
|
||||
return setCredentialKeySync(this.db, userId, name, encryptedRef)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
return selectCredentialKeySync(this.db, userId, id)
|
||||
}
|
||||
|
||||
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
return deleteCredentialKeySync(this.db, userId, id)
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<boolean> {
|
||||
return deleteUserSync(this.db, userId)
|
||||
}
|
||||
|
||||
async upsertInstance(input: UpsertDshInstanceInput): Promise<void> {
|
||||
try {
|
||||
upsertInstanceSync(this.db, input)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async findInstance(id: string): Promise<DshInstance | undefined> {
|
||||
return findInstanceSync(this.db, id)
|
||||
}
|
||||
|
||||
async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> {
|
||||
return findUserInstanceSync(this.db, userId, role)
|
||||
}
|
||||
|
||||
async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> {
|
||||
return listInstancesByRoleSync(this.db, role)
|
||||
}
|
||||
|
||||
async setInstanceStatus(
|
||||
id: string,
|
||||
status: DshInstanceStatus,
|
||||
outcome?: { exitCode?: number; lastError?: string },
|
||||
): Promise<boolean> {
|
||||
return setInstanceStatusSync(this.db, id, status, outcome)
|
||||
}
|
||||
|
||||
async deleteInstance(id: string): Promise<boolean> {
|
||||
return deleteInstanceSync(this.db, id)
|
||||
}
|
||||
|
||||
async deleteUserInstances(userId: string): Promise<void> {
|
||||
deleteUserInstancesSync(this.db, userId)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Domain types shared by both DB backends (SQLite and Postgres). Kept free of
|
||||
* any driver so the `DbAdapter` implementations and the route layer depend only
|
||||
* on these shapes, never on `better-sqlite3` or `pg`.
|
||||
* @module dshs/db/types
|
||||
*/
|
||||
|
||||
export type UserRole = 'admin' | 'pending' | 'active' | 'disabled'
|
||||
|
||||
/** A full user row, including secrets (never serialized to clients). */
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
pass_hash: string
|
||||
role: UserRole
|
||||
home_dir: string
|
||||
api_key_ref: string | null
|
||||
created_at: number
|
||||
approved_by: string | null
|
||||
/** Assigned Linux uid; null until backfilled/assigned (legacy rows). */
|
||||
uid: number | null
|
||||
}
|
||||
|
||||
/** The user shape safe to return over the wire. */
|
||||
export interface PublicUser {
|
||||
id: string
|
||||
username: string
|
||||
role: UserRole
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** A persisted login session (token stored only as its hash). */
|
||||
export interface SessionRow {
|
||||
token_hash: string
|
||||
user_id: string
|
||||
created_at: number
|
||||
expires_at: number
|
||||
ip: string | null
|
||||
user_agent: string | null
|
||||
}
|
||||
|
||||
/** A session joined with its user, for the authn hot path (one query). */
|
||||
export interface SessionUser {
|
||||
expiresAt: number
|
||||
user: User
|
||||
}
|
||||
|
||||
/** A per-user project folder (workspace) row. */
|
||||
export interface Workspace {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
relPath: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** A custom-domain row. */
|
||||
export interface Domain {
|
||||
id: string
|
||||
userId: string
|
||||
domain: string
|
||||
verified: number
|
||||
nginxConfig: string | null
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Which half of a user's DSH pair a `dsh_instances` row describes. */
|
||||
export type DshInstanceRole = 'main' | 'watchdog'
|
||||
|
||||
/** Lifecycle state of a `dsh_instances` row (mirrors the column's CHECK). */
|
||||
export type DshInstanceStatus = 'starting' | 'running' | 'crashed' | 'repairing' | 'stopped'
|
||||
|
||||
/**
|
||||
* A persisted DSH instance — the **desired** state the k8s controller reconciles
|
||||
* the cluster against (docs/k8s.md §5.7). `folder` and `patch` are what a
|
||||
* relaunch needs; `pid`/`port` are local-mode only and stay null under k8s.
|
||||
*/
|
||||
export interface DshInstance {
|
||||
id: string
|
||||
userId: string
|
||||
workspaceId: string | null
|
||||
role: DshInstanceRole
|
||||
pid: number | null
|
||||
port: number | null
|
||||
status: DshInstanceStatus
|
||||
startedAt: number | null
|
||||
lastExit: number | null
|
||||
exitCode: number | null
|
||||
lastError: string | null
|
||||
folder: string | null
|
||||
/** Rendered Cordis patch content (not a path — the control plane holds no user volume). */
|
||||
patch: string | null
|
||||
}
|
||||
|
||||
/** A named per-user credential key (secret never exposed). */
|
||||
export interface CredentialKey {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */
|
||||
export interface BusinessPlugin {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
version: string | null
|
||||
tgzPath: string
|
||||
fileSize: number
|
||||
uploadedBy: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface UpsertBusinessPluginInput {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
version?: string | null
|
||||
tgzPath: string
|
||||
fileSize: number
|
||||
uploadedBy: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
id: string
|
||||
username: string
|
||||
passHash: string
|
||||
role: UserRole
|
||||
homeDir: string
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
tokenHash: string
|
||||
userId: string
|
||||
expiresAt: number
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
/** Upsert payload for `dsh_instances`; `id` is the deterministic resource name. */
|
||||
export interface UpsertDshInstanceInput {
|
||||
id: string
|
||||
userId: string
|
||||
role: DshInstanceRole
|
||||
status: DshInstanceStatus
|
||||
folder?: string
|
||||
patch?: string
|
||||
workspaceId?: string
|
||||
pid?: number
|
||||
port?: number
|
||||
}
|
||||
|
||||
export function toPublicUser(user: User): PublicUser {
|
||||
return { id: user.id, username: user.username, role: user.role, createdAt: user.created_at }
|
||||
}
|
||||
|
||||
// Row mappers. Shared by both adapters — they read the same column names, so the
|
||||
// only dialect difference (SQLite 64-bit INTEGER vs Postgres BIGINT→number) is
|
||||
// resolved by the Postgres int8 parser before these run.
|
||||
|
||||
export function toUser(row: Record<string, unknown>): User {
|
||||
return {
|
||||
id: row.id as string,
|
||||
username: row.username as string,
|
||||
pass_hash: row.pass_hash as string,
|
||||
role: row.role as UserRole,
|
||||
home_dir: row.home_dir as string,
|
||||
api_key_ref: (row.api_key_ref as string | null) ?? null,
|
||||
created_at: row.created_at as number,
|
||||
approved_by: (row.approved_by as string | null) ?? null,
|
||||
uid: (row.uid as number | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toSession(row: Record<string, unknown>): SessionRow {
|
||||
return {
|
||||
token_hash: row.token_hash as string,
|
||||
user_id: row.user_id as string,
|
||||
created_at: row.created_at as number,
|
||||
expires_at: row.expires_at as number,
|
||||
ip: (row.ip as string | null) ?? null,
|
||||
user_agent: (row.user_agent as string | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toWorkspace(row: Record<string, unknown>): Workspace {
|
||||
return {
|
||||
id: row.id as string,
|
||||
userId: row.user_id as string,
|
||||
name: row.name as string,
|
||||
relPath: row.rel_path as string,
|
||||
createdAt: row.created_at as number,
|
||||
}
|
||||
}
|
||||
|
||||
export function toDshInstance(row: Record<string, unknown>): DshInstance {
|
||||
return {
|
||||
id: row.id as string,
|
||||
userId: row.user_id as string,
|
||||
workspaceId: (row.workspace_id as string | null) ?? null,
|
||||
role: row.role as DshInstanceRole,
|
||||
pid: (row.pid as number | null) ?? null,
|
||||
port: (row.port as number | null) ?? null,
|
||||
status: row.status as DshInstanceStatus,
|
||||
startedAt: (row.started_at as number | null) ?? null,
|
||||
lastExit: (row.last_exit as number | null) ?? null,
|
||||
exitCode: (row.exit_code as number | null) ?? null,
|
||||
lastError: (row.last_error as string | null) ?? null,
|
||||
folder: (row.folder as string | null) ?? null,
|
||||
patch: (row.patch as string | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toDomain(row: Record<string, unknown>): Domain {
|
||||
return {
|
||||
id: row.id as string,
|
||||
userId: row.user_id as string,
|
||||
domain: row.domain as string,
|
||||
verified: row.verified as number,
|
||||
nginxConfig: (row.nginx_config as string | null) ?? null,
|
||||
updatedAt: row.updated_at as number,
|
||||
}
|
||||
}
|
||||
|
||||
export function toBusinessPlugin(row: Record<string, unknown>): BusinessPlugin {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
description: (row.description as string | null) ?? null,
|
||||
version: (row.version as string | null) ?? null,
|
||||
tgzPath: row.tgz_path as string,
|
||||
fileSize: row.file_size as number,
|
||||
uploadedBy: (row.uploaded_by as string | null) ?? null,
|
||||
createdAt: row.created_at as number,
|
||||
updatedAt: row.updated_at as number,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user