Merge MVP startup defaults with memory-db mode + tests

This commit is contained in:
2026-03-01 19:24:59 -05:00
parent 5e27459325
commit e4901f027c
12 changed files with 1131 additions and 89 deletions

View File

@@ -18,14 +18,15 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import { createMemoryPool } from './lib/memory-db.js';
import { applyRulesToEmail } from './lib/email-rules.js';
import {
applyRulesToEmail,
buildPCOBody,
buildRFIBody,
extractDomain,
extractFirstJobNumber,
validatePassword
} from './lib/helpers.js';
} from './lib/utils.js';
const { Pool } = pg;
const PgStore = connectPgSimple(session);
@@ -42,6 +43,9 @@ const PORT = process.env.PORT || 3005;
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
const isProd = process.env.NODE_ENV === 'production';
const trustProxy = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true';
const isMemoryDb = !process.env.DATABASE_URL || process.env.DATABASE_URL.startsWith('memory://');
const appRoot = process.env.APP_ROOT || fileURLToPath(new URL('../../', import.meta.url));
const dataRoot = process.env.DATA_DIR || path.join(appRoot, 'data');
if (trustProxy) app.set('trust proxy', 1);
if (isProd && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 24)) {
@@ -56,8 +60,7 @@ function baseHost() {
}
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const useMemorySessionStore = process.env.SESSION_STORE === 'memory' || (!process.env.DATABASE_URL && process.env.NODE_ENV === 'test');
const pool = isMemoryDb ? createMemoryPool() : new Pool({ connectionString: process.env.DATABASE_URL });
app.use(
helmet({
@@ -103,11 +106,6 @@ app.use((req, res, next) => {
return res.status(403).send('Blocked (CSRF)');
});
function firstExistingPath(...paths) {
return paths.find((candidate) => candidate && fs.existsSync(candidate)) || paths[0];
}
const dataRoot = firstExistingPath('/app/data', path.resolve(__dirname, '../../data'));
const uploadDir = path.join(dataRoot, 'uploads');
const attachmentDir = path.join(dataRoot, 'attachments');
fs.mkdirSync(uploadDir, { recursive: true });
@@ -119,22 +117,25 @@ app.use(rateLimit({ windowMs: 60_000, limit: 120 }));
const loginLimiter = rateLimit({ windowMs: 60_000, limit: 10 });
const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 });
app.use(
session({
name: 'mm.sid',
store: useMemorySessionStore ? undefined : new PgStore({ pool, createTableIfMissing: true }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: (process.env.COOKIE_SECURE === 'true') || BASE_URL.startsWith('https://'),
maxAge: 1000 * 60 * 60 * 12 // 12 hours
}
})
);
const sessionConfig = {
name: 'mm.sid',
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: (process.env.COOKIE_SECURE === 'true') || BASE_URL.startsWith('https://'),
maxAge: 1000 * 60 * 60 * 12
}
};
if (!isMemoryDb) {
sessionConfig.store = new PgStore({ pool, createTableIfMissing: true });
}
app.use(session(sessionConfig));
app.use(passport.initialize());
app.use(passport.session());
@@ -785,6 +786,48 @@ app.post('/setup/base-url', requireAuth, async (req, res) => {
res.redirect('/setup');
});
let initialized = false;
export async function initializeApp() {
if (initialized) return;
await ensureSchema();
// Backfill schema changes (safe alters)
await pool.query(`alter table users add column if not exists disabled boolean not null default false;`);
try {
await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`);
} catch (_) {
// constraint may already exist
}
// Create an initial local owner account if none exists (bootstrap)
// For safety, we do NOT create a default owner/owner in production.
const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'");
if (existing[0].c === 0) {
const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase();
const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim();
if (!email || !password) {
console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.');
} else {
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
"insert into users(email, display_name, role) values ($1,'Owner','owner') returning id",
[email]
);
await pool.query(
"insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)",
[rows[0].id, email, hash]
);
console.log(`Created bootstrap local owner: ${email} (change password ASAP)`);
}
}
initialized = true;
}
export { app };
// Admin: user management
app.get('/admin/users', requireOwner, async (_req, res) => {
const { rows } = await pool.query(`
@@ -878,6 +921,9 @@ async function upsertConnector(provider) {
);
}
await upsertConnector('gmail');
await upsertConnector('microsoft');
app.get('/account/password', requireAuth, (req, res) => {
res.render('account_password');
});
@@ -931,13 +977,12 @@ app.post('/admin/email-connectors/:provider/toggle', requireOwner, async (req, r
// Admin: email rules
// Docs (in-app)
const docRoot = path.resolve(__dirname, '../../');
const DOCS = [
{ slug: 'readme', title: 'README', path: firstExistingPath('/app/README.md', path.join(docRoot, 'README.md')) },
{ slug: 'install', title: 'INSTALL', path: firstExistingPath('/app/INSTALL.md', path.join(docRoot, 'INSTALL.md')) },
{ slug: 'operations', title: 'OPERATIONS', path: firstExistingPath('/app/OPERATIONS.md', path.join(docRoot, 'OPERATIONS.md')) },
{ slug: 'development', title: 'DEVELOPMENT', path: firstExistingPath('/app/DEVELOPMENT.md', path.join(docRoot, 'DEVELOPMENT.md')) },
{ slug: 'changelog', title: 'CHANGELOG', path: firstExistingPath('/app/CHANGELOG.md', path.join(docRoot, 'CHANGELOG.md')) }
{ slug: 'readme', title: 'README', path: path.join(appRoot, 'README.md') },
{ slug: 'install', title: 'INSTALL', path: path.join(appRoot, 'INSTALL.md') },
{ slug: 'operations', title: 'OPERATIONS', path: path.join(appRoot, 'OPERATIONS.md') },
{ slug: 'development', title: 'DEVELOPMENT', path: path.join(appRoot, 'DEVELOPMENT.md') },
{ slug: 'changelog', title: 'CHANGELOG', path: path.join(appRoot, 'CHANGELOG.md') }
];
app.get('/docs', requireAuth, (req, res) => res.redirect('/docs/readme'));
@@ -1157,53 +1202,14 @@ app.get('/admin/audit', requireOwner, async (_req, res) => {
res.render('admin_audit', { logs: rows });
});
let initialized = false;
export async function initializeApp() {
if (initialized) return;
await ensureSchema();
await pool.query(`alter table users add column if not exists disabled boolean not null default false;`);
try {
await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`);
} catch (_) {
// constraint may already exist
}
const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'");
if (existing[0].c === 0) {
const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase();
const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim();
if (!email || !password) {
console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.');
} else {
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
"insert into users(email, display_name, role) values ($1,'Owner','owner') returning id",
[email]
);
await pool.query(
"insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)",
[rows[0].id, email, hash]
);
console.log(`Created bootstrap local owner: ${email} (change password ASAP)`);
}
}
await upsertConnector('gmail');
await upsertConnector('microsoft');
initialized = true;
}
export { app };
if (isMainModule) {
await initializeApp();
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`Mastermind web listening on ${BASE_URL}`);
});
if (process.env.MASTERMIND_DISABLE_LISTEN === '1') {
console.log(`Mastermind web ready (${isMemoryDb ? 'memory' : 'postgres'})`);
} else {
app.listen(PORT, () => {
console.log(`Mastermind web listening on ${BASE_URL}`);
});
}
}