import bcryptjs from 'bcryptjs' import { sdk } from '../sdk' /** * change-admin-credentials — StartOS Package Action. * * Lets the user rotate the admin email and password for Proof of Work directly * from the StartOS UI, without dropping to a shell. Replaces the manual CLI * fallback documented in DEPLOY_040.md \u00a75b. * * Design notes: * * - allowedStatuses: 'only-stopped'. StartOS forces a Stop before the action * runs, so there is zero risk of two writers (the running Next.js server + * the action's sqlite3 UPDATE) racing on /data/app.db. As a side effect, * any previously-issued session cookies are implicitly invalidated when * the service restarts and re-reads the User row. * * - Bcrypt salt rounds = 10. This MUST match * proof-of-work/lib/auth.ts::hashPassword, which uses bcryptjs.genSalt(10) * followed by bcryptjs.hash. If the app ever changes its rounds, change them * here too \u2014 otherwise login will fail. * * - We compute the bcrypt hash in the action's own JS runtime (bcryptjs is * bundled via package.json), then push only the finished hash into the * subcontainer. The plaintext password never lands in /proc, the SQL log, * or anywhere persistent. * * - The UPDATE is keyed on the oldest user with `isAdmin = 1`, i.e. the * primary admin identity. Safe to re-run after a previous rotation, and * correct under the multi-user model: non-admin users created via * /auth/signup are not targeted (admins reset other users' passwords * from the in-app user management UI). * * - We assert exactly 1 row was updated (`changes() == 1`). Anything else * means the schema/data is in an unexpected state and we abort without * reporting success, so the user is forced to investigate before assuming * credentials rotated successfully. If you see "expected exactly 1 user * row updated", check that at least one User has isAdmin=1 — the boot- * time compat ALTERs auto-promote the oldest user, but a corrupt or * externally-edited DB might not have a valid admin. */ const EMAIL_PATTERN = '^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$' /** Escape a string for safe inclusion inside SQLite single-quoted literal. */ const sqlQuote = (s: string): string => `'${s.replace(/'/g, "''")}'` export const changeAdminCredentials = sdk.Action.withInput( 'change-admin-credentials', // --------------------------------------------------------------------------- // metadata // --------------------------------------------------------------------------- async () => ({ name: 'Change admin credentials', description: 'Rotate the admin email and password stored in /data/app.db. The service must be stopped first; you will log in with the new credentials after starting it again.', warning: 'This permanently overwrites the existing User row. Any browser sessions issued under the old credentials will stop working as soon as the service restarts. Make sure you can receive the new email at the address you enter.', visibility: 'enabled', allowedStatuses: 'only-stopped', group: null, }), // --------------------------------------------------------------------------- // input form // --------------------------------------------------------------------------- sdk.InputSpec.of({ email: sdk.Value.text({ name: 'New email address', description: 'The email you will use to log in.', required: true, default: null, inputmode: 'email', placeholder: 'you@example.com', patterns: [ { regex: EMAIL_PATTERN, description: 'Must be a valid email address (e.g. you@example.com).', }, ], }), password: sdk.Value.text({ name: 'New password', description: 'Minimum 8 characters. No other complexity rules.', required: true, default: null, masked: true, minLength: 8, placeholder: 'At least 8 characters', }), passwordConfirm: sdk.Value.text({ name: 'Confirm new password', description: 'Retype the new password to guard against typos.', required: true, default: null, masked: true, minLength: 8, placeholder: 'Retype new password', }), }), // --------------------------------------------------------------------------- // getInput (prefill) \u2014 we deliberately don't prefill the email. Prefilling // would require spinning up a subcontainer just to read the current row, // which is overkill for what is a once-in-a-blue-moon action. // --------------------------------------------------------------------------- async () => null, // --------------------------------------------------------------------------- // run // --------------------------------------------------------------------------- async ({ effects, input }) => { if (input.password !== input.passwordConfirm) { throw new Error( 'New password and confirmation do not match. Re-enter both fields.', ) } // Compute the bcrypt hash in the action's runtime. Salt rounds 10 to // match proof-of-work/lib/auth.ts::hashPassword. const passwordHash = await bcryptjs.hash(input.password, 10) // Run the UPDATE inside a temp subcontainer with /data mounted. The // subcontainer uses the same image as the main service, so sqlite3 is // available (the runner image apks it in for the entrypoint's compat // ALTERs). await sdk.SubContainer.withTemp( effects, { imageId: 'main' }, sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/data', readonly: false, }), 'change-admin-credentials', async (sc) => { const sql = [ 'BEGIN IMMEDIATE;', `UPDATE User`, `SET email = ${sqlQuote(input.email)},`, ` passwordHash = ${sqlQuote(passwordHash)},`, ` updatedAt = (strftime('%s','now') * 1000)`, `WHERE id = (SELECT id FROM User WHERE isAdmin = 1 ORDER BY createdAt ASC LIMIT 1);`, 'SELECT changes();', 'COMMIT;', ].join('\n') const res = await sc.execFail( ['sqlite3', '/data/app.db'], { input: sql }, 30_000, ) // sqlite3 prints `changes()` on its own line. Be defensive about // trailing whitespace / multiple lines. const changes = parseInt( res.stdout .toString() .split('\n') .map((s) => s.trim()) .filter(Boolean) .pop() ?? '0', 10, ) if (changes !== 1) { throw new Error( `Aborting: expected exactly 1 user row updated, but sqlite3 reported changes()=${changes}. The User table may be empty or contain unexpected rows. Inspect /data/app.db before retrying.`, ) } }, ) return { version: '1', title: 'Credentials updated', message: 'The admin email and password have been rotated. Start the service and log in with your new credentials.', result: { type: 'group', value: [ { type: 'single', name: 'New login email', description: 'What you will enter on the Proof of Work login page.', value: input.email, copyable: true, qr: false, masked: false, }, { type: 'single', name: 'Password', description: 'Stored as a bcrypt hash (salt rounds 10). Not displayed.', value: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022', copyable: false, qr: false, masked: true, }, ], }, } }, )