feat: add better auth sqlite runtime and server-side session flow

This commit is contained in:
Poyraz
2026-07-10 22:07:37 +03:00
parent 3504a02229
commit 57aab2932e
41 changed files with 4907 additions and 143 deletions
+83
View File
@@ -0,0 +1,83 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
const config = ensureDataLayout();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir = path.join(config.backupsDir, `neta-${timestamp}`);
const uploadsBackupDir = path.join(backupDir, "uploads");
const databaseBackupPath = path.join(backupDir, "neta.db");
fs.mkdirSync(backupDir, { recursive: true });
const sqlite = new Database(config.databasePath);
try {
applySqlitePragmas(sqlite);
await sqlite.backup(databaseBackupPath);
} finally {
sqlite.close();
}
copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir);
const manifest = {
createdAt: new Date().toISOString(),
source: {
dataDir: config.dataDir,
databasePath: config.databasePath,
uploadsDir: config.uploadsDir,
},
files: collectFiles(backupDir).map((filePath) => ({
path: path.relative(backupDir, filePath).replace(/\\/g, "/"),
bytes: fs.statSync(filePath).size,
sha256: hashFile(filePath),
})),
};
fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`Backup created at ${backupDir}`);
function copyDirectoryIfExists(sourceDir, targetDir) {
if (!fs.existsSync(sourceDir)) {
return;
}
fs.mkdirSync(targetDir, { recursive: true });
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
copyDirectoryIfExists(sourcePath, targetPath);
} else if (entry.isFile()) {
fs.copyFileSync(sourcePath, targetPath);
}
}
}
function collectFiles(rootDir) {
const files = [];
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
const entryPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...collectFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}
function hashFile(filePath) {
const hash = crypto.createHash("sha256");
hash.update(fs.readFileSync(filePath));
return hash.digest("hex");
}
+43
View File
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
export function getDataConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || "development";
const dataDir = path.resolve(
env.DATA_DIR && env.DATA_DIR.trim().length > 0
? env.DATA_DIR
: nodeEnv === "production"
? "/app/data"
: path.join(process.cwd(), ".data"),
);
const databasePath = path.resolve(
env.DATABASE_PATH && env.DATABASE_PATH.trim().length > 0
? env.DATABASE_PATH
: path.join(dataDir, "neta.db"),
);
return {
dataDir,
databasePath,
uploadsDir: path.join(dataDir, "uploads"),
backupsDir: path.join(dataDir, "backups"),
tmpDir: path.join(dataDir, "tmp"),
migrationsDir: path.join(process.cwd(), "server", "db", "migrations"),
};
}
export function ensureDataLayout(config = getDataConfig()) {
for (const dir of [config.dataDir, config.uploadsDir, config.backupsDir, config.tmpDir]) {
fs.mkdirSync(dir, { recursive: true });
}
return config;
}
export function applySqlitePragmas(sqlite) {
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = NORMAL");
sqlite.pragma("busy_timeout = 5000");
}
+39
View File
@@ -0,0 +1,39 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { pathToFileURL } from "node:url";
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
export function runMigrations(databasePath) {
const config = ensureDataLayout();
const sqlite = new Database(databasePath ?? config.databasePath);
try {
applySqlitePragmas(sqlite);
const db = drizzle({ client: sqlite });
migrate(db, { migrationsFolder: config.migrationsDir });
const now = Date.now();
sqlite
.prepare(
`insert into runtime_checks (key, value, created_at, updated_at)
values (@key, @value, @createdAt, @updatedAt)
on conflict(key) do update set value = excluded.value, updated_at = excluded.updated_at`,
)
.run({
key: "last_migration",
value: new Date(now).toISOString(),
createdAt: now,
updatedAt: now,
});
console.log(`Migrations applied to ${databasePath ?? config.databasePath}`);
} finally {
sqlite.close();
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
runMigrations();
}
+86
View File
@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import Database from "better-sqlite3";
const smokeRoot = path.join(process.cwd(), ".data", `phase1-smoke-${Date.now()}`);
const restoreRoot = `${smokeRoot}-restore`;
const env = { ...process.env, DATA_DIR: smokeRoot, DATABASE_PATH: "" };
fs.mkdirSync(smokeRoot, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
const dbPath = path.join(smokeRoot, "neta.db");
const sqlite = new Database(dbPath);
try {
const before = sqlite.prepare("select value from runtime_checks where key = ?").get("last_migration");
if (!before) {
throw new Error("Migration smoke check failed: runtime_checks row missing.");
}
const now = Date.now();
sqlite
.prepare(
`insert into runtime_checks (key, value, created_at, updated_at)
values (?, ?, ?, ?)
on conflict(key) do update set value = excluded.value, updated_at = excluded.updated_at`,
)
.run("restart_probe", "persisted", now, now);
} finally {
sqlite.close();
}
const reopened = new Database(dbPath, { readonly: true });
try {
const row = reopened.prepare("select value from runtime_checks where key = ?").get("restart_probe");
if (!row || row.value !== "persisted") {
throw new Error("Restart persistence smoke check failed.");
}
} finally {
reopened.close();
}
execFileSync(process.execPath, ["scripts/backup.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
const backupDir = fs
.readdirSync(path.join(smokeRoot, "backups"))
.map((name) => path.join(smokeRoot, "backups", name))
.sort()
.at(-1);
if (!backupDir) {
throw new Error("Backup smoke check failed: no backup directory produced.");
}
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", restoreRoot, "--force"], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
const restored = new Database(path.join(restoreRoot, "neta.db"), { readonly: true });
try {
const row = restored.prepare("select value from runtime_checks where key = ?").get("restart_probe");
if (!row || row.value !== "persisted") {
throw new Error("Restore smoke check failed.");
}
} finally {
restored.close();
}
console.log("Phase 1 smoke checks passed.");
+66
View File
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import { ensureDataLayout } from "./lib/data-dir.mjs";
import { runMigrations } from "./migrate.mjs";
const requiredTables = [
"user",
"session",
"account",
"verification",
"app_profiles",
"app_setup_state",
"portal_invitations",
"auth_audit_events",
];
const requiredIndexes = [
"app_profiles_auth_user_id_unique",
"portal_invitations_token_hash_unique",
"session_user_id_idx",
"account_user_id_idx",
];
async function main() {
const paths = ensureDataLayout();
runMigrations(paths.databasePath);
const sqlite = new Database(paths.databasePath, { readonly: true });
try {
const tables = sqlite
.prepare("select name from sqlite_master where type = 'table'")
.all()
.map((row) => row.name);
const indexes = sqlite
.prepare("select name from sqlite_master where type = 'index'")
.all()
.map((row) => row.name);
for (const table of requiredTables) {
assert.ok(tables.includes(table), `Missing auth table: ${table}`);
}
for (const index of requiredIndexes) {
assert.ok(indexes.includes(index), `Missing auth index: ${index}`);
}
const profileColumns = sqlite.prepare("pragma table_info(app_profiles)").all();
const roleColumn = profileColumns.find((column) => column.name === "role");
const disabledColumn = profileColumns.find((column) => column.name === "disabled");
assert.equal(roleColumn?.notnull, 1, "app_profiles.role must be required");
assert.equal(disabledColumn?.notnull, 1, "app_profiles.disabled must be required");
console.log("Phase 2 auth smoke passed");
} finally {
sqlite.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import { ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs";
const args = parseArgs(process.argv.slice(2));
if (!args.from) {
throw new Error("Usage: node scripts/restore.mjs --from <backup-dir> [--target <data-dir>] [--force]");
}
const targetEnv = {
...process.env,
DATA_DIR: args.target || process.env.DATA_DIR,
DATABASE_PATH: undefined,
};
const config = ensureDataLayout(getDataConfig(targetEnv));
const backupDir = path.resolve(args.from);
const backupDbPath = path.join(backupDir, "neta.db");
const backupUploadsDir = path.join(backupDir, "uploads");
if (!fs.existsSync(backupDbPath)) {
throw new Error(`Backup database not found: ${backupDbPath}`);
}
if (fs.existsSync(config.databasePath) && !args.force) {
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
}
fs.copyFileSync(backupDbPath, config.databasePath);
if (fs.existsSync(backupUploadsDir)) {
fs.rmSync(config.uploadsDir, { recursive: true, force: true });
copyDirectory(backupUploadsDir, config.uploadsDir);
}
console.log(`Backup restored from ${backupDir} to ${config.dataDir}`);
function parseArgs(values) {
const parsed = { force: false };
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (value === "--force") {
parsed.force = true;
} else if (value === "--from") {
parsed.from = values[index + 1];
index += 1;
} else if (value === "--target") {
parsed.target = values[index + 1];
index += 1;
}
}
return parsed;
}
function copyDirectory(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
copyDirectory(sourcePath, targetPath);
} else if (entry.isFile()) {
fs.copyFileSync(sourcePath, targetPath);
}
}
}