feat: add better auth sqlite runtime and server-side session flow
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user