Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
114
scripts/apply-db-permissions.mjs
Normal file
114
scripts/apply-db-permissions.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/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=<secret> 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();
|
||||
}
|
||||
Reference in New Issue
Block a user