/** * Direct database access, through Hasura. * * A scratchpad for reading and fixing rows that no screen exposes — setting a * password on an account that was spawned without one, flipping a status, * checking what the API is actually reading. It talks to the Hasura instance * the old console proxies to (`api.workolik.com`), using the admin secret from * `daily_merchant_web/.env`, which is gitignored and stays there. * * node scripts/db.mjs tables * node scripts/db.mjs user care@nearle.in * node scripts/db.mjs setpw care@nearle.in * node scripts/db.mjs sql "select userid, authname from app_users limit 5" * * The secret is read from disk or the environment and never printed, never * written anywhere, and never passed on the command line. * * ── Read this before using `setpw` ──────────────────────────────────────── * This points at PRODUCTION. Every write here is immediate and unversioned. * `setpw` refuses to run unless the account's password column is already * empty, so it can only ever complete a setup that was never finished — it * cannot overwrite a working login. Lift that guard only deliberately. * * Passwords in `app_users` are stored in clear. That is a property of this * backend, not of this script; anything written here is readable by anyone * with database access. */ import { readFileSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ENDPOINT = process.env.HASURA_URL ?? 'https://api.workolik.com/v1/graphql'; /** Where the old console keeps its gitignored secret, relative to this repo. */ const ENV_CANDIDATES = [ resolve(HERE, '../../../nearle-daily/daily_merchant_web/.env'), resolve(HERE, '../../daily_merchant_web/.env'), 'D:/nearle-daily/daily_merchant_web/.env', ]; function readSecret() { if (process.env.HASURA_ADMIN_SECRET) return process.env.HASURA_ADMIN_SECRET; for (const path of ENV_CANDIDATES) { if (!existsSync(path)) continue; const line = readFileSync(path, 'utf8') .split(/\r?\n/) .find((row) => row.startsWith('HASURA_ADMIN_SECRET=')); if (!line) continue; const value = line.slice('HASURA_ADMIN_SECRET='.length).trim().replace(/^["']|["']$/g, ''); if (value) return value; } console.error( 'No admin secret found.\n' + 'Expected HASURA_ADMIN_SECRET in one of:\n' + ENV_CANDIDATES.map((p) => ` ${p}`).join('\n') + '\nor set it in the environment for this command.', ); process.exit(1); } const SECRET = readSecret(); async function gql(query, variables = {}) { let response; try { response = await fetch(ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET }, body: JSON.stringify({ query, variables }), }); } catch (cause) { console.error(`Could not reach ${ENDPOINT} — ${cause.message}`); process.exit(1); } const payload = await response.json().catch(() => null); if (!payload) { console.error(`Malformed response (HTTP ${response.status})`); process.exit(1); } if (payload.errors) { for (const error of payload.errors) console.error(`✗ ${error.message}`); process.exit(1); } return payload.data; } /* ── Commands ─────────────────────────────────────────────────────────────── */ /** Every table Hasura has tracked. Start here if a query says "field not found". */ async function tables() { const data = await gql(`{ __schema { queryType { fields { name } } } }`); const names = data.__schema.queryType.fields .map((field) => field.name) .filter((name) => !name.endsWith('_aggregate') && !name.endsWith('_by_pk')) .sort(); console.log(names.join('\n')); console.log(`\n${names.length} tables`); } const USER_FIELDS = `userid authname firstname lastname contactno roleid status tenantid locationid configid`; async function findUser(email) { const data = await gql( `query ($email: String!) { app_users(where: { authname: { _eq: $email } }) { ${USER_FIELDS} password } }`, { email }, ); return data.app_users ?? []; } async function user(email) { const rows = await findUser(email); if (rows.length === 0) { console.log(`No account with authname "${email}".`); return; } for (const row of rows) { // The password itself is never printed — only whether one exists, which is // the only thing anyone needs to know from here. const { password, ...rest } = row; console.log({ ...rest, haspassword: String(password ?? '').trim() !== '' }); } } /** * Completes a password setup that was never finished. * * Refuses if a password is already set. An account that can sign in must not * be changeable from a scratchpad — that is a support action with a person * behind it, not a one-liner. */ async function setpw(email, password) { if (!password || password.length < 6) { console.error('Password must be at least 6 characters (the backend enforces this too).'); process.exit(1); } const rows = await findUser(email); if (rows.length === 0) { console.error(`No account with authname "${email}".`); process.exit(1); } if (rows.length > 1) { console.error( `${rows.length} accounts share that email (configid ${rows.map((r) => r.configid).join(', ')}).\n` + 'Refusing to guess. Use `sql` with an explicit userid.', ); process.exit(1); } const row = rows[0]; if (String(row.password ?? '').trim() !== '') { console.error( `userid ${row.userid} already has a password. This command only completes an unfinished setup.\n` + 'To reset a working login, do it deliberately with `sql`.', ); process.exit(1); } if (row.roleid === 7 || row.roleid === 8) { console.error( `userid ${row.userid} is a till account (roleid ${row.roleid}). Those sign in at the terminal with a PIN, not here.`, ); process.exit(1); } const data = await gql( `mutation ($userid: Int!, $password: String!) { update_app_users(where: { userid: { _eq: $userid } }, _set: { password: $password }) { affected_rows } }`, { userid: row.userid, password }, ); const affected = data.update_app_users?.affected_rows ?? 0; if (affected !== 1) { console.error(`Expected to update 1 row, updated ${affected}. Nothing assumed — check manually.`); process.exit(1); } console.log( `✓ Password set on userid ${row.userid} (${email}), roleid ${row.roleid}, tenantid ${row.tenantid}.`, ); console.log(' Sign in at the console with it now.'); } /** * Arbitrary read-only SQL, via Hasura's `run_sql`. * * Reads only. A statement that writes is refused here — writes go through a * named command above, where they can carry their own guard. */ async function sql(statement) { if (/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(statement)) { console.error('This command runs reads only. Add a named command for a write.'); process.exit(1); } const endpoint = ENDPOINT.replace(/\/v1\/graphql$/, '/v2/query'); const response = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET }, body: JSON.stringify({ type: 'run_sql', args: { source: 'default', sql: statement, read_only: true }, }), }); const payload = await response.json().catch(() => null); if (!response.ok || !payload) { console.error(payload?.error ?? `HTTP ${response.status}`); process.exit(1); } const rows = payload.result ?? []; for (const row of rows) console.log(row.join('\t')); console.log(`\n${Math.max(0, rows.length - 1)} rows`); } /* ── Dispatch ─────────────────────────────────────────────────────────────── */ const [command, ...rest] = process.argv.slice(2); const COMMANDS = { tables: () => tables(), user: () => user(rest[0]), setpw: () => setpw(rest[0], rest[1]), sql: () => sql(rest.join(' ')), }; if (!command || !COMMANDS[command]) { console.log( [ 'node scripts/db.mjs ', '', ' tables every table Hasura has tracked', ' user show an account (never prints the password)', ' setpw set a password on an account that has none', ' sql "