#!/usr/bin/env node /** * Apply scripts/db-permissions.sql to a PostgreSQL database. * * Connects with the OWNER / migration URL (DATABASE_URL from the environment, * .env.local or .env) because creating roles, setting default privileges and * revoking CREATE from PUBLIC require owner/superuser rights. The runtime role * it provisions (`receipt_app`) is the least-privilege role the application * should use at runtime — see scripts/db-permissions.sql for the full grant * list. * * Safe to re-run: the SQL file is idempotent. If APP_DATABASE_PASSWORD is set * (env or .env.local), it replaces the documented default password in the SQL * and appends an ALTER ROLE so an existing role's password is rotated too. * * Usage: * node scripts/apply-db-permissions.mjs * APP_DATABASE_PASSWORD= node scripts/apply-db-permissions.mjs */ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Client } from "pg"; const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const DEFAULT_RUNTIME_PASSWORD = "receipt_app_secure_password"; /** Minimal .env parser: strips quotes, ignores comments; first key wins. */ function parseEnvFile(filePath) { const out = {}; let text; try { text = readFileSync(filePath, "utf8"); } catch { return out; } for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq < 1) continue; const key = line.slice(0, eq).trim(); let value = line.slice(eq + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (!(key in out)) out[key] = value; } return out; } /** SQL single-quoted literal (doubles embedded quotes). */ function quoteLiteral(value) { return "'" + String(value).replace(/'/g, "''") + "'"; } // Env precedence: process.env > .env.local > .env. const fileEnv = { ...parseEnvFile(path.join(ROOT, ".env")), ...parseEnvFile(path.join(ROOT, ".env.local")) }; const ownerUrl = process.env.DATABASE_URL || fileEnv.DATABASE_URL; if (!ownerUrl) { console.error("FATAL: DATABASE_URL not found. Set it in the environment, .env.local or .env."); process.exit(1); } const runtimePassword = process.env.APP_DATABASE_PASSWORD || fileEnv.APP_DATABASE_PASSWORD || null; let sql = readFileSync(path.join(ROOT, "scripts", "db-permissions.sql"), "utf8"); // Optional password override: swap the documented default for the provided one // and ensure the role's password matches APP_DATABASE_PASSWORD (ALTER ROLE is // always appended when the variable is set, so re-runs converge — including // rotating a previously custom password back to the default). let passwordSource = "documented default (receipt_app_secure_password)"; if (runtimePassword) { if (runtimePassword === DEFAULT_RUNTIME_PASSWORD) { console.log("APP_DATABASE_PASSWORD equals the documented default - CREATE ROLE token unchanged, password confirmed via ALTER ROLE."); } else { const token = `PASSWORD '${DEFAULT_RUNTIME_PASSWORD}'`; if (!sql.includes(token)) { console.error("FATAL: could not locate the default password token in db-permissions.sql."); process.exit(1); } sql = sql.split(token).join(`PASSWORD ${quoteLiteral(runtimePassword)}`); passwordSource = "APP_DATABASE_PASSWORD (custom)"; } sql += `\n-- Password convergence appended by scripts/apply-db-permissions.mjs\n` + `ALTER ROLE receipt_app WITH PASSWORD ${quoteLiteral(runtimePassword)};\n`; } else { console.log("APP_DATABASE_PASSWORD not set - role password left at the documented default from db-permissions.sql."); } const client = new Client({ connectionString: ownerUrl }); await client.connect(); try { console.log(`Connected as owner (${new URL(ownerUrl).username || "?"}) to execute db-permissions.sql ...`); await client.query(sql); // pg executes the multi-statement SQL in one round trip console.log("Applied scripts/db-permissions.sql successfully."); console.log(" runtime role : receipt_app (LOGIN, NO SUPERUSER, NO CREATEDB, NO CREATEROLE)"); console.log(` password : ${passwordSource}`); console.log(" grants : CONNECT, schema USAGE (no CREATE), table SELECT/INSERT/UPDATE/DELETE,"); console.log(" sequence USAGE/SELECT, matching DEFAULT PRIVILEGES for future objects,"); console.log(" PUBLIC CREATE on schema public revoked."); } catch (err) { console.error("Failed to apply db-permissions.sql:", err.message); process.exitCode = 1; } finally { await client.end(); }