Files
threadcount-community/scripts/create-operator.cjs
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

47 lines
2.6 KiB
JavaScript

/* Create an operator for the operations console.
*
* OPERATOR_PASSWORD='…' node scripts/create-operator.cjs kyle@threadcount.tech "Kyle" OWNER
*
* The password comes from the environment and never from an argument: argv is visible to every
* process on the box through `ps`, and this is the break-glass credential for the whole platform.
*
* Seeds, never resets. It refuses if the address already has an operator, so re-running it cannot
* quietly change a password. TOTP is enrolled at first sign-in; nothing here sets it. The address
* must be one the Cloudflare Access policy on ops.threadcount.tech allows, or the row is an
* operator who cannot reach the door.
*/
require("dotenv/config");
const bcrypt = require("bcryptjs");
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const [emailArg, nameArg, roleArg] = process.argv.slice(2);
const email = String(emailArg || "").trim().toLowerCase();
const name = String(nameArg || "").trim();
const role = String(roleArg || "SUPPORT").trim().toUpperCase();
const password = process.env.OPERATOR_PASSWORD || "";
function die(msg) { console.error(msg); process.exit(1); }
if (!email || !email.includes("@")) die("usage: OPERATOR_PASSWORD='…' node scripts/create-operator.cjs <email> <name> [OWNER|SUPPORT]");
if (!name) die("a display name is required");
if (role !== "OWNER" && role !== "SUPPORT") die("role must be OWNER or SUPPORT");
// Eight, the product's own minimum for a coordinator account (components/AuthForm.tsx). Not a
// stricter number invented here: the operator's real protection is the second factor enrolled at
// first sign-in and Cloudflare Access in front of the door, not the length of the fire-escape key.
if (password.length < 8) die("OPERATOR_PASSWORD must be set in the environment and be at least 8 characters");
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
(async () => {
const existing = await prisma.operator.findUnique({ where: { email }, select: { id: true } });
if (existing) die(`an operator already exists for ${email} — this script seeds, it does not reset`);
// Cost 12, the same as a coordinator account at signup.
const passwordHash = await bcrypt.hash(password, 12);
const o = await prisma.operator.create({ data: { email, name, role, passwordHash }, select: { id: true, email: true, role: true } });
console.log(`created operator ${o.email} (${o.role}) id=${o.id}`);
console.log("TOTP is enrolled at first sign-in.");
})()
.catch((e) => die(e && e.message ? e.message : String(e)))
.finally(() => prisma.$disconnect());