Production readiness hardening and ops tooling
This commit is contained in:
143
scripts/create-merchant-user.mjs
Normal file
143
scripts/create-merchant-user.mjs
Normal file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomBytes, randomUUID, scryptSync } from "node:crypto";
|
||||
import { Pool } from "pg";
|
||||
import "dotenv/config";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const validRoles = new Set(["owner", "finance", "ops", "viewer"]);
|
||||
|
||||
function getArg(name) {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
npm run merchant:create-user -- --merchant <merchant-id-or-code> --email <email> --name <name> --role <owner|finance|ops|viewer> --password <password> [--inactive] [--rotate-password]
|
||||
|
||||
Examples:
|
||||
npm run merchant:create-user -- --merchant m_abc123 --email owner@merchant.com --name "Merchant Owner" --role owner --password "change-this"
|
||||
npm run merchant:create-user -- --merchant <merchant-id> --email finance@merchant.com --name "Merchant Finance" --role finance --password "change-this" --rotate-password
|
||||
|
||||
Environment:
|
||||
DATABASE_URL or PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE
|
||||
`);
|
||||
}
|
||||
|
||||
function validatePasswordPolicy(value) {
|
||||
const failures = [];
|
||||
if (value.length < 14) failures.push("at least 14 characters");
|
||||
if (!/[a-z]/.test(value)) failures.push("a lowercase letter");
|
||||
if (!/[A-Z]/.test(value)) failures.push("an uppercase letter");
|
||||
if (!/[0-9]/.test(value)) failures.push("a number");
|
||||
if (!/[^A-Za-z0-9]/.test(value)) failures.push("a symbol");
|
||||
if (/password|merchant|qris|soundbox|change-this/i.test(value)) failures.push("no obvious product/default words");
|
||||
return failures;
|
||||
}
|
||||
|
||||
const merchantRef = (getArg("--merchant") || "").trim();
|
||||
const email = (getArg("--email") || "").trim().toLowerCase();
|
||||
const name = (getArg("--name") || "").trim();
|
||||
const roleName = (getArg("--role") || "owner").trim().toLowerCase();
|
||||
const password = getArg("--password") || "";
|
||||
const status = hasFlag("--inactive") ? "inactive" : "active";
|
||||
const rotatePassword = hasFlag("--rotate-password");
|
||||
|
||||
if (hasFlag("--help") || hasFlag("-h")) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
if (!merchantRef) {
|
||||
errors.push("--merchant is required");
|
||||
}
|
||||
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
errors.push("--email must be a valid email address");
|
||||
}
|
||||
if (!name) {
|
||||
errors.push("--name is required");
|
||||
}
|
||||
if (!validRoles.has(roleName)) {
|
||||
errors.push("--role must be one of owner, finance, ops, viewer");
|
||||
}
|
||||
const passwordFailures = validatePasswordPolicy(password);
|
||||
if (passwordFailures.length) {
|
||||
errors.push(`--password must include ${passwordFailures.join(", ")}`);
|
||||
}
|
||||
if (errors.length) {
|
||||
usage();
|
||||
for (const error of errors) {
|
||||
console.error(`ERROR ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function makePool() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
}
|
||||
return new Pool({
|
||||
host: process.env.PGHOST || "127.0.0.1",
|
||||
port: Number(process.env.PGPORT || 5432),
|
||||
user: process.env.PGUSER || "postgres",
|
||||
password: process.env.PGPASSWORD || "postgres",
|
||||
database: process.env.PGDATABASE || "qris_soundbox_platform"
|
||||
});
|
||||
}
|
||||
|
||||
function hashPassword(value) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const keyLength = 64;
|
||||
const derived = scryptSync(value, salt, keyLength).toString("hex");
|
||||
return `scrypt$${salt}$${keyLength}$${derived}`;
|
||||
}
|
||||
|
||||
const pool = makePool();
|
||||
|
||||
try {
|
||||
const merchant = await pool.query(
|
||||
"SELECT * FROM merchants WHERE id = $1 OR merchant_code = $1 LIMIT 1",
|
||||
[merchantRef]
|
||||
);
|
||||
if (!merchant.rows.length) {
|
||||
throw new Error(`merchant '${merchantRef}' not found`);
|
||||
}
|
||||
|
||||
const merchantId = merchant.rows[0].id;
|
||||
const existing = await pool.query("SELECT id, email FROM merchant_users WHERE LOWER(email) = LOWER($1)", [email]);
|
||||
const passwordHash = hashPassword(password);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing.rows.length) {
|
||||
const updatePasswordSql = rotatePassword ? ", password_hash = $7" : "";
|
||||
const params = rotatePassword
|
||||
? [merchantId, name, roleName, status, now, email, passwordHash]
|
||||
: [merchantId, name, roleName, status, now, email];
|
||||
await pool.query(
|
||||
`UPDATE merchant_users
|
||||
SET merchant_id = $1,
|
||||
name = $2,
|
||||
role_name = $3,
|
||||
status = $4,
|
||||
updated_at = $5
|
||||
${updatePasswordSql}
|
||||
WHERE LOWER(email) = LOWER($6)`,
|
||||
params
|
||||
);
|
||||
console.log(`Updated merchant user ${email} merchant=${merchantId} role=${roleName} status=${status} password_rotated=${rotatePassword}`);
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO merchant_users (id, merchant_id, name, email, password_hash, role_name, status, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[`merchant_user_${randomUUID()}`, merchantId, name, email, passwordHash, roleName, status, now, now]
|
||||
);
|
||||
console.log(`Created merchant user ${email} merchant=${merchantId} role=${roleName} status=${status}`);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
Reference in New Issue
Block a user