From 5f7b3b6b7ac62c27f846b5a09b05088929293fb9 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 19:13:49 -0500 Subject: [PATCH] =?UTF-8?q?v1.0.0:4=20=E2=80=94=20remove=20default=20admin?= =?UTF-8?q?@local=20credentials;=20require=20StartOS=20action=20to=20boots?= =?UTF-8?q?trap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: shipping admin@local / workout123 as a default that the operator was supposed-to-rotate-but-might-not is the kind of footgun that turns into "default-credential exposure" headlines. Eliminated. prisma/seed.ts now ONLY seeds the InstanceSettings singleton — no admin user, no UserPreferences, no exercises in the build-time fallback DB. The image still ships with prisma/exercises.seed.json (curated 164-exercise library) but those rows aren't inserted until an admin is created via the StartOS Action. The change-admin-credentials Action now does INSERT-or-UPDATE in one shot. CREATE mode (no admin exists) inserts the User row, inserts UserPreferences with sensible defaults, and runs ensureExerciseLibrary.cjs for the new admin so they don't have to wait for the next service start to see the curated library. UPDATE mode (admin exists) keeps the v1.0.0:1-3 rotation behavior. The mode is auto-detected by counting `WHERE isAdmin = 1`. The login page is now a server component that reads the admin count upfront. Zero admins -> renders a "needs setup" panel pointing at the StartOS Action ("Services -> Proof of Work -> Actions -> Set admin credentials"). Otherwise renders the existing LoginForm (extracted to LoginForm.tsx). Eliminates the "I tried admin@local/workout123 and it failed, what's wrong" fresh-installer confusion. Backward compatible for upgrades from v1.0.0:1-3: - /data already has an admin user; the no-admin detection never triggers; login behaves identically to before. - The Action's UPDATE mode still works for rotation. Version graph: v1.0.0:4 promoted to current; v1.0.0:1, :2, :3 all listed as `other` for in-place upgrade paths. README updated to call out the explicit no-default-account design and how to bootstrap an admin in local dev (Prisma Studio, since the StartOS action isn't available off-StartOS). --- README.md | 16 +- proof-of-work/app/auth/login/LoginForm.tsx | 92 +++++++ proof-of-work/app/auth/login/page.tsx | 148 +++++------ proof-of-work/prisma/seed.ts | 119 ++------- start9/0.4/docker_entrypoint.sh | 5 + .../startos/actions/changeAdminCredentials.ts | 237 ++++++++++++------ start9/0.4/startos/versions/index.ts | 12 +- start9/0.4/startos/versions/v1.0.0.4.ts | 42 ++++ 8 files changed, 405 insertions(+), 266 deletions(-) create mode 100644 proof-of-work/app/auth/login/LoginForm.tsx create mode 100644 start9/0.4/startos/versions/v1.0.0.4.ts diff --git a/README.md b/README.md index 89fdc43..72dcd61 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,24 @@ cd proof-of-work npm install npx prisma generate # important after schema changes npx prisma db push # create the dev DB at prisma/data/app.db -npm run db:seed # admin@local / workout123 + curated library + admin flag +npm run db:seed # ONLY seeds the InstanceSettings singleton — no admin npm run dev # http://localhost:3000 ``` +For local dev you'll need to create an admin manually since the +StartOS action isn't available — easiest is `npx tsx` a one-off +script, or just open Prisma Studio (`npm run db:studio`) and add a +User row with `isAdmin: true` + a bcrypt hash you generate with +`node -e 'require("bcrypt").hash("yourpassword", 10).then(console.log)'`. + ## Multi-user -Every install starts with one admin user (`admin@local`) and **sign-ups -closed**. To open the instance to additional users: +**Fresh installs ship with no admin user on purpose** — the operator +must run the StartOS Action `Set admin credentials` (Services → Proof +of Work → Actions) before anyone can log in. This eliminates the +default-credentials footgun. + +Once the admin exists, they can open sign-ups for additional users: - In-app: log in as admin -> **Settings -> Instance Settings -> Allow new sign-ups**. diff --git a/proof-of-work/app/auth/login/LoginForm.tsx b/proof-of-work/app/auth/login/LoginForm.tsx new file mode 100644 index 0000000..96ff8b7 --- /dev/null +++ b/proof-of-work/app/auth/login/LoginForm.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { loginAction } from './actions'; + +export default function LoginForm() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const result = await loginAction(email, password); + + if (result.error) { + setError(result.error); + setLoading(false); + return; + } + + if (result.success) { + router.push('/main/dashboard'); + } + } catch (err) { + setError('An unexpected error occurred'); + setLoading(false); + } + }; + + return ( +
+
+ + setEmail(e.target.value)} + required + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ ); +} diff --git a/proof-of-work/app/auth/login/page.tsx b/proof-of-work/app/auth/login/page.tsx index 6912a82..014dd1f 100644 --- a/proof-of-work/app/auth/login/page.tsx +++ b/proof-of-work/app/auth/login/page.tsx @@ -1,38 +1,21 @@ -'use client'; +import { prisma } from '@/lib/prisma'; +import LoginForm from './LoginForm'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { loginAction } from './actions'; +/** + * Server component wrapper. Reads admin count BEFORE rendering so that + * a fresh install (no admin yet) shows a clear "needs setup" panel + * instead of a login form that will reject every attempt with no + * explanation. + * + * The "no admin" state is the explicit shipped default as of v1.0.0:4 + * — a fresh install ships with zero users, the operator must run the + * StartOS Action "Set admin credentials" before anyone can sign in. + */ +export const dynamic = 'force-dynamic'; -export default function LoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - const result = await loginAction(email, password); - - if (result.error) { - setError(result.error); - setLoading(false); - return; - } - - if (result.success) { - router.push('/main/dashboard'); - } - } catch (err) { - setError('An unexpected error occurred'); - setLoading(false); - } - }; +export default async function LoginPage() { + const adminCount = await prisma.user.count({ where: { isAdmin: true } }); + const needsSetup = adminCount === 0; return (
@@ -43,68 +26,59 @@ export default function LoginPage() { Proof of Work

- Track. Lift. Dominate. + {needsSetup ? 'Initial setup required' : 'Track. Lift. Dominate.'}

-
-
- - setEmail(e.target.value)} - required - className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" - disabled={loading} - /> -
+ {needsSetup ? : } -
- - setPassword(e.target.value)} - required - className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" - disabled={loading} - /> -
- - {error && ( -
- {error} -
- )} - - - - -

- Don't have an account?{' '} - - Sign up - -

+ {!needsSetup && ( +

+ Don't have an account?{' '} + + Sign up + +

+ )}
); } + +function NeedsSetupPanel() { + return ( +
+

+ This Proof of Work instance has no admin yet. Logging in or + signing up isn't possible until the operator creates one. +

+
    +
  1. Open the StartOS UI for this service.
  2. +
  3. + Make sure the service is{' '} + stopped. +
  4. +
  5. + Go to{' '} + + Actions → Set admin credentials + + . +
  6. +
  7. Enter the admin email + password and submit.
  8. +
  9. Start the service and reload this page.
  10. +
+

+ This is intentional — the package ships with no default account + so a forgotten password rotation can't leave you with + public-default credentials. +

+
+ ); +} diff --git a/proof-of-work/prisma/seed.ts b/proof-of-work/prisma/seed.ts index 7352f34..43290db 100644 --- a/proof-of-work/prisma/seed.ts +++ b/proof-of-work/prisma/seed.ts @@ -1,112 +1,43 @@ import { PrismaClient } from "@prisma/client"; -import * as bcrypt from "bcrypt"; -import * as fs from "fs"; -import * as path from "path"; /** - * Seeds a fresh database with: - * 1. The default `admin@local` user (password: `workout123`). - * 2. Default UserPreferences for that user. - * 3. The full curated exercise library, loaded from - * `prisma/exercises.seed.json`. + * Seeds a fresh database with ONLY the InstanceSettings singleton. * - * Idempotent — re-running upserts the user and exercises without - * duplicates. Used at Docker build time to populate the empty-schema - * fallback DB and at first boot for any host that didn't get a baked seed. + * Deliberately does NOT seed an admin user, default credentials, or + * the curated exercise library. As of v1.0.0:4, fresh installs ship + * with zero users — the operator must create the first admin via the + * StartOS Action "Set admin credentials" before anyone can log in. * - * The curated library is the same JSON read at runtime by - * `ensureExerciseLibrary.cjs` from docker_entrypoint.sh, so updates - * shipped in a new package version reach existing installs too. + * Why: shipping a default `admin@local` / `workout123` is the kind of + * footgun that turns into "default credential exposure" headlines for + * any operator who forgets to rotate. Forcing the StartOS action up + * front means there's literally no usable default to forget about. + * + * The curated exercise library still ships in + * prisma/exercises.seed.json. It gets inserted for the admin at the + * moment they're created (the StartOS action triggers + * ensureExerciseLibrary right after the User INSERT) and then for + * every subsequent user via the boot-time ensure step. + * + * Used at Docker build time to populate the empty-schema fallback DB + * (the one that gets copied into /data on a brand-new sideload). */ const prisma = new PrismaClient(); -interface LibraryExercise { - name: string; - description: string | null; - type: string; - muscleGroups: string[]; - inputFields: string[]; - defaultWeightUnit: string | null; -} - -function loadLibrary(): LibraryExercise[] { - const libPath = path.resolve(__dirname, "exercises.seed.json"); - if (!fs.existsSync(libPath)) { - console.warn(`[seed] library file not found at ${libPath}; seeding 0 exercises`); - return []; - } - const raw = JSON.parse(fs.readFileSync(libPath, "utf8")); - if (!Array.isArray(raw)) { - throw new Error(`[seed] library file at ${libPath} is not an array`); - } - return raw as LibraryExercise[]; -} - async function main() { - const hashedPassword = await bcrypt.hash("workout123", 10); - - const user = await prisma.user.upsert({ - where: { email: "admin@local" }, - update: { isAdmin: true }, - create: { - email: "admin@local", - passwordHash: hashedPassword, - name: "Admin User", - isAdmin: true, - }, - }); - - console.log("Created/verified admin user:", user.id); - await prisma.instanceSettings.upsert({ where: { id: 1 }, update: {}, create: { id: 1, signupsOpen: false }, }); - - console.log("Created/verified InstanceSettings (signupsOpen: false)"); - - await prisma.userPreferences.upsert({ - where: { userId: user.id }, - update: {}, - create: { - userId: user.id, - theme: "system", - defaultWeightUnit: "lbs", - defaultRestSeconds: 90, - enableClaudeAI: false, - }, - }); - - console.log("Created/verified user preferences"); - - const exercises = loadLibrary(); - console.log(`[seed] loading ${exercises.length} exercises from exercises.seed.json`); - - for (const exercise of exercises) { - await prisma.exercise.upsert({ - where: { - userId_name: { - userId: user.id, - name: exercise.name, - }, - }, - update: {}, - create: { - userId: user.id, - name: exercise.name, - description: exercise.description, - muscleGroups: JSON.stringify(exercise.muscleGroups), - type: exercise.type, - inputFields: JSON.stringify(exercise.inputFields), - defaultWeightUnit: exercise.defaultWeightUnit, - isCustom: false, - }, - }); - } - - console.log(`Created/verified ${exercises.length} exercises`); + console.log( + "[seed] created InstanceSettings singleton (signupsOpen: false)", + ); + console.log( + "[seed] no users seeded — operator must run the StartOS Action " + + "'Set admin credentials' to bootstrap the first admin", + ); } main() diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh index 90496eb..f93101f 100755 --- a/start9/0.4/docker_entrypoint.sh +++ b/start9/0.4/docker_entrypoint.sh @@ -13,6 +13,11 @@ # from /app/seed/data/app.db. v1.0.0:3 stripped both the COPY of # that seed in the Dockerfile and the branch that copied it here, # now that the cutover from `workout-log` is verified done.) +# (v1.0.0:4 made another change to this fallback DB: it now +# contains ONLY the InstanceSettings singleton, NOT a default +# admin user or seeded exercises. The operator is required to +# run the StartOS Action 'Set admin credentials' to bootstrap +# the first admin — eliminates the default-credentials footgun.) # 3. Run idempotent compat ALTERs for columns added after older snapshots. # No-ops on hosts whose schema is already current. # 4. Ensure the curated exercise library is present for every user. New diff --git a/start9/0.4/startos/actions/changeAdminCredentials.ts b/start9/0.4/startos/actions/changeAdminCredentials.ts index 8f9cd90..b7b5eb5 100644 --- a/start9/0.4/startos/actions/changeAdminCredentials.ts +++ b/start9/0.4/startos/actions/changeAdminCredentials.ts @@ -1,62 +1,71 @@ import bcryptjs from 'bcryptjs' +import { randomBytes } from 'node:crypto' 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. + * Two modes (one action): * - * Design notes: + * - 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. * - * - 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. + * - UPDATE mode (an admin already exists): rotates email + password + * on the oldest existing admin, same as the v1.0.0:1-3 behavior. * - * - 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. + * 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. * - * - 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. + * Design notes * - * - 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). + * - 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. * - * - 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. + * - 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,}$' -/** Escape a string for safe inclusion inside SQLite single-quoted literal. */ 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: 'Change admin credentials', + name: 'Set 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.', + '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: - '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.', + '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, @@ -66,7 +75,7 @@ export const changeAdminCredentials = sdk.Action.withInput( // --------------------------------------------------------------------------- sdk.InputSpec.of({ email: sdk.Value.text({ - name: 'New email address', + name: 'Admin email address', description: 'The email you will use to log in.', required: true, default: null, @@ -80,7 +89,7 @@ export const changeAdminCredentials = sdk.Action.withInput( ], }), password: sdk.Value.text({ - name: 'New password', + name: 'Admin password', description: 'Minimum 8 characters. No other complexity rules.', required: true, default: null, @@ -89,19 +98,17 @@ export const changeAdminCredentials = sdk.Action.withInput( placeholder: 'At least 8 characters', }), passwordConfirm: sdk.Value.text({ - name: 'Confirm new password', - description: 'Retype the new password to guard against typos.', + name: 'Confirm password', + description: 'Retype the password to guard against typos.', required: true, default: null, masked: true, minLength: 8, - placeholder: 'Retype new password', + placeholder: 'Retype 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. + // getInput (prefill) — deliberately left empty. // --------------------------------------------------------------------------- async () => null, // --------------------------------------------------------------------------- @@ -110,18 +117,17 @@ export const changeAdminCredentials = sdk.Action.withInput( async ({ effects, input }) => { if (input.password !== input.passwordConfirm) { throw new Error( - 'New password and confirmation do not match. Re-enter both fields.', + '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) + const newUserId = newId() + const newPrefsId = newId() + + type Mode = 'create' | 'update' + let mode: Mode = 'update' as Mode - // 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' }, @@ -133,53 +139,116 @@ export const changeAdminCredentials = sdk.Action.withInput( }), '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( + // Decide create vs update by counting admins. + const countRes = await sc.execFail( ['sqlite3', '/data/app.db'], - { input: sql }, + { input: 'SELECT COUNT(*) FROM User WHERE isAdmin = 1;' }, 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', + const adminCount = parseInt( + countRes.stdout.toString().trim() || '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.`, + + 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: 'Credentials updated', + title: mode === 'create' ? 'Admin created' : 'Credentials updated', message: - 'The admin email and password have been rotated. Start the service and log in with your new credentials.', + 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: 'New login email', + name: 'Login email', description: 'What you will enter on the Proof of Work login page.', value: input.email, copyable: true, @@ -191,11 +260,23 @@ export const changeAdminCredentials = sdk.Action.withInput( name: 'Password', description: 'Stored as a bcrypt hash (salt rounds 10). Not displayed.', - value: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022', + 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, + }, ], }, } diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 963f3ba..d1dd639 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -2,6 +2,7 @@ import { VersionGraph } from '@start9labs/start-sdk' import { v_1_0_0_1 } from './v1.0.0.1' import { v_1_0_0_2 } from './v1.0.0.2' import { v_1_0_0_3 } from './v1.0.0.3' +import { v_1_0_0_4 } from './v1.0.0.4' /** * Version graph for the `proof-of-work` package. @@ -12,12 +13,15 @@ import { v_1_0_0_3 } from './v1.0.0.3' * broke first paint in v1.0.0:1). * v1.0.0:3 — post-cutover seed strip (baked /data snapshot removed * from the image now that the cutover is verified done). + * v1.0.0:4 — removes the default admin@local / workout123 credentials + * from fresh installs. Operator must run the StartOS Action + * "Set admin credentials" before login is possible. * * StartOS picks `current` as the install target; `other` lists every - * node that can upgrade into `current`. Hosts on v1.0.0:1 or :2 - * upgrade to :3 via no-op up migrations; fresh installs land on :3. + * node that can upgrade into `current`. Hosts on v1.0.0:1, :2, or :3 + * upgrade to :4 via no-op up migrations; fresh installs land on :4. */ export const versionGraph = VersionGraph.of({ - current: v_1_0_0_3, - other: [v_1_0_0_1, v_1_0_0_2], + current: v_1_0_0_4, + other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3], }) diff --git a/start9/0.4/startos/versions/v1.0.0.4.ts b/start9/0.4/startos/versions/v1.0.0.4.ts new file mode 100644 index 0000000..a58fe98 --- /dev/null +++ b/start9/0.4/startos/versions/v1.0.0.4.ts @@ -0,0 +1,42 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +/** + * v1.0.0:4 — eliminates the default-admin footgun. + * + * v1.0.0:1-3 shipped with `admin@local` / `workout123` baked into the + * empty-schema fallback DB. Operators were SUPPOSED to rotate via + * Settings or the StartOS Action immediately after install, but + * "supposed to" is the kind of language that puts default credentials + * into HaveIBeenPwned headlines. + * + * From v1.0.0:4 forward: + * - `prisma/seed.ts` only seeds the InstanceSettings singleton. + * No default admin, no UserPreferences, no curated exercises in + * the build-time fallback DB. + * - The StartOS Action `change-admin-credentials` (label: "Set + * admin credentials") now runs in CREATE mode when no admin + * exists — inserts the User row, inserts UserPreferences, and + * triggers ensureExerciseLibrary for the brand-new admin all in + * one shot. Operators run it once on install, then again only + * if they want to rotate. + * - The login page detects zero-admin state and shows a "needs + * setup" panel pointing at the StartOS Action. No more + * "I tried admin@local/workout123 and it failed, what's wrong" + * confusion for fresh installers. + * + * Backward compatible for upgrades from v1.0.0:1-3: + * - Your /data already has an admin user; the no-admin detection + * never triggers; login behaves identically to before. + * - The StartOS Action still works for rotation (UPDATE mode). + */ +export const v_1_0_0_4 = VersionInfo.of({ + version: '1.0.0:4', + releaseNotes: { + en_US: + 'Security: removes the default admin@local / workout123 credentials from fresh installs. The image now ships with no users; the operator must run the StartOS Action "Set admin credentials" to bootstrap the first admin before anyone can log in. Existing installs are unaffected — your admin row stays as-is and the action keeps working for rotation.', + }, + migrations: { + up: async () => {}, + down: IMPOSSIBLE, + }, +})