Files
Keysat 5f7b3b6b7a v1.0.0:4 — remove default admin@local credentials; require StartOS action to bootstrap
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).
2026-05-09 19:13:49 -05:00

285 lines
10 KiB
TypeScript

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,
},
],
},
}
},
)