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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user