import bcryptjs from 'bcryptjs' import { randomBytes } from 'node:crypto' import { sdk } from '../sdk' /** * change-admin-credentials — StartOS Package Action. * * Two modes (one action): * * - CREATE mode (no admin exists yet): inserts the User row + * UserPreferences row + seeds the curated exercise library for * them. Required on every fresh install — as of v1.0.0:4 the * image ships with zero users so this is the only way to * bootstrap login. * * - UPDATE mode (an admin already exists): rotates email + password * on the oldest existing admin, same as the v1.0.0:1-3 behavior. * * The action picks the mode by counting `User WHERE isAdmin = 1` in * the same subcontainer pass. Operators don't have to think about * which one applies — fresh install or rotation, same form, same * action. * * Design notes * * - allowedStatuses: 'only-stopped'. StartOS forces a Stop before the * action runs, so there's zero risk of two writers (the running * Next.js server + the action's sqlite3 INSERT/UPDATE) racing on * /data/app.db. Side effect: any previously-issued session cookies * are invalidated when the service restarts and re-reads the User * row. * * - Bcrypt salt rounds = 10. MUST match * proof-of-work/lib/auth.ts::hashPassword. If the app ever changes * its rounds, change them here too — otherwise login will fail. * * - The plaintext password lives only in the action's JS runtime — * we hash before the SQL ever touches the subcontainer. No * plaintext in /proc, the SQL log, or anywhere persistent. * * - CREATE mode also runs ensureExerciseLibrary.cjs for the new * admin so they don't have to wait for the next service start to * see the curated 164-exercise library. * * - We assert the post-action User-row count matches expectation: * create-mode expects to go from 0 admins to 1; update-mode keeps * the count unchanged. Anything else, abort and surface the * discrepancy. */ const EMAIL_PATTERN = '^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$' const sqlQuote = (s: string): string => `'${s.replace(/'/g, "''")}'` /** cuid-shaped id (25 chars, "c" prefix + 24 random hex). */ const newId = (): string => 'c' + randomBytes(12).toString('hex') export const changeAdminCredentials = sdk.Action.withInput( 'change-admin-credentials', // --------------------------------------------------------------------------- // metadata // --------------------------------------------------------------------------- async () => ({ name: 'Set admin credentials', description: 'Bootstrap the first admin (required after a fresh install before anyone can log in) OR rotate an existing admin\'s email + password. The service must be stopped first; you will log in with the new credentials after starting it again.', warning: 'On a fresh install this is the ONLY way to create the first admin — the image ships with no default account on purpose. On an existing install this overwrites the current admin row; previously-issued browser sessions stop working as soon as the service restarts. Make sure you can receive email at the address you enter.', visibility: 'enabled', allowedStatuses: 'only-stopped', group: null, }), // --------------------------------------------------------------------------- // input form // --------------------------------------------------------------------------- sdk.InputSpec.of({ email: sdk.Value.text({ name: 'Admin 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: 'Admin 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 password', description: 'Retype the password to guard against typos.', required: true, default: null, masked: true, minLength: 8, placeholder: 'Retype password', }), }), // --------------------------------------------------------------------------- // getInput (prefill) — deliberately left empty. // --------------------------------------------------------------------------- async () => null, // --------------------------------------------------------------------------- // run // --------------------------------------------------------------------------- async ({ effects, input }) => { if (input.password !== input.passwordConfirm) { throw new Error( 'Password and confirmation do not match. Re-enter both fields.', ) } const passwordHash = await bcryptjs.hash(input.password, 10) const newUserId = newId() const newPrefsId = newId() type Mode = 'create' | 'update' let mode: Mode = 'update' as Mode await sdk.SubContainer.withTemp( effects, { imageId: 'main' }, sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/data', readonly: false, }), 'change-admin-credentials', async (sc) => { // Decide create vs update by counting admins. const countRes = await sc.execFail( ['sqlite3', '/data/app.db'], { input: 'SELECT COUNT(*) FROM User WHERE isAdmin = 1;' }, 30_000, ) const adminCount = parseInt( countRes.stdout.toString().trim() || '0', 10, ) if (adminCount === 0) { mode = 'create' // Single transaction: insert User, insert UserPreferences, // verify exactly one admin exists at the end. const sql = [ 'BEGIN IMMEDIATE;', `INSERT INTO User (id, email, passwordHash, isAdmin, createdAt, updatedAt) VALUES (${sqlQuote(newUserId)}, ${sqlQuote(input.email)}, ${sqlQuote(passwordHash)}, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);`, `INSERT INTO UserPreferences (id, userId, theme, defaultWeightUnit, defaultRestSeconds, enableClaudeAI, createdAt, updatedAt) VALUES (${sqlQuote(newPrefsId)}, ${sqlQuote(newUserId)}, 'system', 'lbs', 90, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);`, 'COMMIT;', 'SELECT COUNT(*) FROM User WHERE isAdmin = 1;', ].join('\n') const res = await sc.execFail( ['sqlite3', '/data/app.db'], { input: sql }, 30_000, ) const finalCount = parseInt( res.stdout.toString().trim() || '0', 10, ) if (finalCount !== 1) { throw new Error( `Aborting: expected exactly 1 admin after CREATE, got ${finalCount}. /data/app.db may be in an unexpected state.`, ) } // Seed the curated exercise library for this brand-new admin. // Failure here is non-fatal — the next service boot's // ensureExerciseLibrary will catch it. We log loudly and keep // going so the operator can still use the new credentials. try { await sc.execFail( [ 'node', '/app/prisma/ensureExerciseLibrary.cjs', '--db', '/data/app.db', '--json', '/app/prisma/exercises.seed.json', ], undefined, 60_000, ) } catch (e) { console.error( '[change-admin-credentials] WARN: ensureExerciseLibrary failed for the new admin; the next service boot will retry.', e, ) } } else { // UPDATE path: existing rotation behavior. Targets oldest admin. 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, ) 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, got changes()=${changes}. The User table may be empty or contain unexpected rows. Inspect /data/app.db before retrying.`, ) } } }, ) return { version: '1', title: mode === 'create' ? 'Admin created' : 'Credentials updated', message: mode === 'create' ? 'The admin account is set up and the curated exercise library is seeded for it. Start the service and log in with these credentials.' : 'The admin email and password have been rotated. Start the service and log in with the new credentials.', result: { type: 'group', value: [ { type: 'single', name: '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: '••••••••', copyable: false, qr: false, masked: true, }, { type: 'single', name: 'Mode', description: mode === 'create' ? 'Created the first admin (no admin existed before this run).' : 'Updated the existing admin row.', value: mode, copyable: false, qr: false, masked: false, }, ], }, } }, )