Add auth bootstrap and database safeguards

This commit is contained in:
JB
2026-07-24 22:12:52 -04:00
parent 8a0bfd2187
commit b430a291fa
22 changed files with 380 additions and 62 deletions
+50
View File
@@ -0,0 +1,50 @@
import { connect } from '@tursodatabase/database';
import { mkdir, writeFile } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import process from 'node:process';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const requestedOutput = process.argv[2];
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
const outputPath = resolve(
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`
);
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
await mkdir(dirname(outputPath), { recursive: true });
const database = await connect(databasePath);
const tableRows = await (
await database.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY rowid
`)
).all();
const tables = [];
let totalRows = 0;
for (const tableRow of tableRows) {
const name = String(tableRow.name);
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
}
const backup = {
format: 'shd-build-database-backup',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables
};
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx'
});
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
console.log(`Backup: ${outputPath}`);
+13 -1
View File
@@ -1,10 +1,17 @@
import { connect } from '@tursodatabase/database';
import { mkdir, readFile } from 'node:fs/promises';
import { access, mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const schemaPath = resolve('./database/schema.sql');
let databaseAlreadyExisted = true;
try {
await access(databasePath);
} catch {
databaseAlreadyExisted = false;
}
await mkdir(dirname(databasePath), { recursive: true });
@@ -22,3 +29,8 @@ for (const statement of statements) {
}
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) {
console.log('New database detected; creating default users.');
await initializeDefaultUsers(database);
}
+58
View File
@@ -0,0 +1,58 @@
import { connect } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import process from 'node:process';
const backupArgument = process.argv[2];
if (!backupArgument) {
console.error('Usage: npm run db:restore -- <backup-file>');
process.exit(2);
}
const backupPath = resolve(backupArgument);
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) {
throw new Error('The selected file is not a supported SHD.build database backup.');
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
const database = await connect(databasePath);
// Restores are deliberately limited to empty databases. This prevents an
// accidental merge from overwriting contributor-entered records.
for (const table of backup.tables) {
const existing = await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
).get();
if (Number(existing?.count) > 0) {
throw new Error(
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
);
}
}
let restoredRows = 0;
await database.exec('BEGIN');
try {
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const columnSql = columns.map(quoteIdentifier).join(', ');
const placeholders = columns.map(() => '?').join(', ');
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
console.log(`Source backup: ${backupPath}`);
+92
View File
@@ -0,0 +1,92 @@
import { argon2id, hash } from 'argon2';
import { randomBytes, randomUUID } from 'node:crypto';
export const defaultUsers = [
{
username: 'Aewyn',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
},
{
username: 'Quartermaster',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
}
];
function temporaryPassword() {
// 120 bits of entropy encoded without punctuation that is awkward to paste.
return randomBytes(15).toString('base64url');
}
async function secureHash(password) {
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
}
/**
* Creates missing default users. With resetPasswords enabled, existing default
* users keep their IDs and related records but receive new password hashes.
*/
export async function initializeDefaultUsers(database, options = {}) {
const resetPasswords = options.resetPasswords === true;
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
const updateCredential = await database.prepare(
'UPDATE users SET password_hash = ?, role = ? WHERE id = ?'
);
const credentials = [];
for (const definition of defaultUsers) {
const existing = await findUser.get(definition.username);
if (existing && !resetPasswords) {
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
continue;
}
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
const password = configuredPassword || temporaryPassword();
if (password.length < 12 || password.length > 128) {
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
}
const passwordHash = await secureHash(password);
if (existing) {
await updateCredential.run(passwordHash, String(existing.role), existing.id);
console.log(`${definition.username}: credentials reset; role and related data preserved`);
} else {
await insertUser.run(
randomUUID(),
definition.username,
passwordHash,
definition.role,
new Date().toISOString()
);
console.log(`${definition.username}: default account created`);
}
credentials.push({
username: definition.username,
role: existing ? String(existing.role) : definition.role,
password,
generated: !configuredPassword
});
}
if (credentials.length) {
console.log('\nDefault user credentials (shown once):');
for (const credential of credentials) {
console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`);
}
console.log('Change generated passwords after the first sign-in.\n');
}
return credentials;
}
+4 -42
View File
@@ -1,48 +1,10 @@
import { connect } from '@tursodatabase/database';
import { argon2id, hash } from 'argon2';
import { randomBytes, randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const database = await connect(databasePath);
const resetPasswords = process.argv.includes('--reset-passwords');
const users = [
{ username: 'Aewyn', role: 'admin' },
{ username: 'Quartermaster', role: 'user' }
];
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
for (const user of users) {
const existing = await findUser.get(user.username);
if (existing) {
console.log(`${user.username}: already exists (${existing.role}); left unchanged`);
continue;
}
// 120 bits of entropy encoded without punctuation that is awkward to paste.
const temporaryPassword = randomBytes(15).toString('base64url');
const passwordHash = await hash(temporaryPassword, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
await insertUser.run(
randomUUID(),
user.username,
passwordHash,
user.role,
new Date().toISOString()
);
console.log(`${user.username} (${user.role}) temporary password: ${temporaryPassword}`);
}
await initializeDefaultUsers(database, { resetPasswords });