Prod hardening and K8s drafts: auth fail-close, DB pool, init/schema split, prod init guard

This commit is contained in:
2026-06-27 20:41:41 -05:00
parent 744830c3cd
commit 49bf370d41
9 changed files with 346 additions and 257 deletions
+4 -1
View File
@@ -3,7 +3,10 @@ import { SignJWT, jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
import { db } from './db';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is not configured');
}
const secret = new TextEncoder().encode(JWT_SECRET);
export async function hashPassword(password: string) {
+7 -3
View File
@@ -5,7 +5,6 @@ function createPool() {
if (!connectionString) {
if (typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build')) {
// Next.js collects page data during the build. We don't need a real DB for that.
return {
query: async () => {
throw new Error('DATABASE_URL environment variable is not set');
@@ -21,10 +20,15 @@ function createPool() {
throw new Error('DATABASE_URL environment variable is not set');
}
return new Pool({ connectionString });
return new Pool({
connectionString,
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONNECT_TIMEOUT || '10000', 10),
});
}
export const db = createPool();
const db = createPool();
export async function query(text: string, params?: unknown[]) {
const result = await db.query(text, params);
+66 -90
View File
@@ -8,6 +8,9 @@ export async function initSchema() {
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
comments_disabled BOOLEAN DEFAULT FALSE,
first_name VARCHAR(100),
last_name VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
`);
@@ -49,6 +52,7 @@ export async function initSchema() {
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
@@ -75,6 +79,7 @@ export async function initSchema() {
description TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
@@ -150,28 +155,6 @@ export async function initSchema() {
);
`);
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
UPDATE places SET photos = ARRAY[photo] WHERE photo IS NOT NULL AND (photos IS NULL OR photos = '{}');
ALTER TABLE places DROP COLUMN IF EXISTS photo;
`);
await db.query(`
CREATE TABLE IF NOT EXISTS social_events (
id SERIAL PRIMARY KEY,
@@ -200,6 +183,17 @@ export async function initSchema() {
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20) NOT NULL
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
@@ -208,9 +202,46 @@ export async function initSchema() {
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
`);
}
export async function migrateFromLegacyPlaces() {
await db.query(`
UPDATE places SET photos = ARRAY[photo] WHERE photo IS NOT NULL AND (photos IS NULL OR photos = '{}');
`);
await db.query(`
ALTER TABLE places DROP COLUMN IF EXISTS photo;
`);
await db.query(`
ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
}
export async function seed() {
if (process.env.NODE_ENV === 'production') {
throw new Error('Seeding is disabled in production');
}
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
if (adminRows.length === 0) {
const adminHash = bcrypt.hashSync('admin', 10);
@@ -225,11 +256,12 @@ export async function seed() {
const { rows: offerRows } = await db.query(`SELECT id FROM offers`);
if (offerRows.length === 0) {
await db.query(
`INSERT INTO offers (title, description) VALUES
($1, $2), ($3, $4)`,
`INSERT INTO offers (title, description) VALUES ($1, $2), ($3, $4)`,
[
'Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.',
'Sunset Dinner', 'Three-course menu on the terrace every Friday.',
'Summer Wine Weekend',
'Complimentary wine tasting with every two-night stay.',
'Sunset Dinner',
'Three-course menu on the terrace every Friday.',
]
);
}
@@ -237,8 +269,7 @@ export async function seed() {
const { rows: benefitRows } = await db.query(`SELECT id FROM benefits`);
if (benefitRows.length === 0) {
await db.query(
`INSERT INTO benefits (text) VALUES
($1), ($2), ($3), ($4)`,
`INSERT INTO benefits (text) VALUES ($1), ($2), ($3), ($4)`,
[
'Priority booking for peak weekends',
'Late checkout on availability',
@@ -248,69 +279,13 @@ export async function seed() {
);
}
const { rows: configRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
if (configRows.length === 0) {
await db.query(
`INSERT INTO config (key, value) VALUES ($1, $2)`,
['wifi_password', 'LaHuascaGuest2026']
);
const { rows: configWifiRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
if (configWifiRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', 'LaHuascaGuest2026']);
}
const { rows: userResRows } = await db.query(`SELECT id FROM reservations WHERE user_id = (SELECT id FROM users WHERE username = 'user')`);
if (userResRows.length === 0) {
await db.query(
`INSERT INTO reservations (user_id, date, time, guests, table_name)
SELECT id, $2, $3, $4, $5 FROM users WHERE username = $1`,
['user', '2026-07-12', '19:00', 4, 'Terrace 3']
);
await db.query(
`INSERT INTO reservations (user_id, date, time, guests, table_name)
SELECT id, $2, $3, $4, $5 FROM users WHERE username = $1`,
['user', '2026-07-25', '20:30', 2, 'Barrel 7']
);
}
const { rows: userCouponRows } = await db.query(`SELECT id FROM coupons WHERE user_id = (SELECT id FROM users WHERE username = 'user')`);
if (userCouponRows.length === 0) {
await db.query(
`INSERT INTO coupons (user_id, code, discount)
SELECT id, $2, $3 FROM users WHERE username = $1`,
['user', 'WELCOME20', '20% off your next stay']
);
await db.query(
`INSERT INTO coupons (user_id, code, discount)
SELECT id, $2, $3 FROM users WHERE username = $1`,
['user', 'COFFEE5', '$5 off any coffee tasting']
);
}
const { rows: calendarRows } = await db.query(`SELECT id FROM calendar_events`);
if (calendarRows.length === 0) {
const thisYear = new Date().getFullYear();
await db.query(
`INSERT INTO calendar_events (date, title, type, description, color) VALUES
($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10), ($11, $12, $13, $14, $15),
($16, $17, $18, $19, $20), ($21, $22, $23, $24, $25), ($26, $27, $28, $29, $30),
($31, $32, $33, $34, $35), ($36, $37, $38, $39, $40), ($41, $42, $43, $44, $45),
($46, $47, $48, $49, $50), ($51, $52, $53, $54, $55)`,
[
`${thisYear}-01-01`, 'New Year', 'holiday', 'Start the year in the Andes.', '#E8A849',
`${thisYear}-02-14`, 'Valentine', 'holiday', 'Special dinner menu.', '#D94B73',
`${thisYear}-03-20`, 'Spring Equinox', 'season', 'Blooming highlands.', '#7CB342',
`${thisYear}-06-21`, 'Summer Solstice', 'season', 'Longest days of the year.', '#E8A849',
`${thisYear}-09-22`, 'Fall Equinox', 'season', 'Harvest colors.', '#A6683C',
`${thisYear}-10-31`, 'Halloween', 'holiday', 'Costume dinner party.', '#9C27B0',
`${thisYear}-11-02`, 'Day of the Dead', 'holiday', 'Traditional altar and meal.', '#FF9800',
`${thisYear}-12-21`, 'Winter Solstice', 'season', 'Cozy fires and clear skies.', '#4FC3F7',
`${thisYear}-12-25`, 'Christmas', 'holiday', 'Holiday feast.', '#C62828',
`${thisYear}-12-31`, 'New Year Eve', 'holiday', 'Countdown dinner.', '#E8A849',
`${thisYear}-06-28`, 'Sunny 22°C', 'weather', 'Typical Otavalo dry season day.', '#FFD54F',
]
);
}
const { rows: siteRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
if (siteRows.length === 0) {
const { rows: configTaglineRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
if (configTaglineRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', 'Hotel · Restaurant · Andean Highlands']);
}
}
@@ -318,5 +293,6 @@ export async function seed() {
export async function resetDatabase() {
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events, social_events, social_event_reservations CASCADE');
await initSchema();
await migrateFromLegacyPlaces();
await seed();
}
}