初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+93
View File
@@ -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>
}
+28
View File
@@ -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
}
+26
View File
@@ -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
}
+31
View File
@@ -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
View File
@@ -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)
}
+29
View File
@@ -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
View File
@@ -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
}
+328
View File
@@ -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()
}
}
+295
View File
@@ -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
View File
@@ -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,
}
}