#!/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 --email --name --role --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 --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(); }