Rebrand to Proof of Work; multi-user 0.4 package with curated library sync

Repo cleanup
- Add top-level .gitignore (was missing; node_modules, .next, *.s9pk,
  image.tar, seed/data/*.db, log files, etc.) and a root README.
- Delete legacy start9/0.3.5/ package (StartOS 0.3.5 wrapper, no longer
  the deploy target).
- Delete start9-example-packaging/ (template from another project).
- Delete planning docs (START9_PACKAGING_LOG.md, VERSIONING.md,
  STARTOS_0.4_UPGRADE_PROMPT.md, ICON_FILES_INDEX.md, etc.) — info now
  lives in the deploy guide and code comments.
- Drop the standalone Dockerfile, docker-compose.yml, ICON_*, and dev
  log/build artifacts from the app dir.
- Drop the v0.1.0:18/19/20 version files (they belonged to the legacy
  workout-log package and don't apply to the new id).

Rename + new package
- Rename app dir workout-planner/ -> proof-of-work/.
- Rename StartOS package id workout-log -> proof-of-work; the new id
  makes this a brand new StartOS service (clean cutover from the old
  one rather than in-place upgrade).
- Reset version graph; v1.0.0:1 is the seeded cutover release. The
  Dockerfile bakes a one-time /data snapshot and docker_entrypoint.sh
  copies it into the new volume on truly-fresh first boot only (both
  /data/app.db missing AND /data/.seeded absent).
- Move start9/0.4-migration/ -> start9/0.4/; the old start9/0.4/ stub
  is gone.

Curated exercise library (multi-user-aware)
- proof-of-work/prisma/exercises.seed.json is the canonical library
  shipped to every install (164 exercises today, dumped from the live
  snapshot).
- proof-of-work/scripts/sync-library.cjs (npm run sync-library) refreshes
  the JSON from start9/0.4/seed/data/app.db after refresh_seed.sh.
- proof-of-work/prisma/seed.ts now reads from the JSON instead of a
  hardcoded 52-exercise array; runs at Docker build time to seed the
  fallback DB and on first boot for fresh installs.
- proof-of-work/prisma/ensureExerciseLibrary.cjs runs on every container
  boot (from docker_entrypoint.sh) and INSERT OR IGNOREs every library
  entry for every user, keyed on (userId, name). Library updates flow
  to existing installs on package upgrade; user-custom exercises
  (isCustom=true) and any colliding names are never overwritten;
  removed exercises stay on existing installs (additive-only).

Deploy guide (start9/0.4/DEPLOY_040.md)
- Rewritten end-to-end for the workout-log -> proof-of-work cutover:
  refresh_seed, sync-library, build, sideload, verify, rotate creds,
  stop the old service, then post-cutover cleanup release v1.0.0:2.
This commit is contained in:
Keysat
2026-05-08 20:12:25 -05:00
parent 1b64c45c52
commit aa407b5f67
184 changed files with 8314 additions and 3286 deletions
@@ -0,0 +1,201 @@
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 `id = (SELECT id FROM User ORDER BY createdAt ASC
* LIMIT 1)` rather than `WHERE email = 'admin@local'` (the original 0.3.5
* default). That makes the action safe to re-run after a previous rotation.
* The app is single-user by design, so this targets the only 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.
*
* Available from package version 0.1.0:20 onward.
*/
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 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,
},
],
},
}
},
)
+11
View File
@@ -0,0 +1,11 @@
import { sdk } from '../sdk'
import { changeAdminCredentials } from './changeAdminCredentials'
/**
* Package actions registered with StartOS.
*
* - change-admin-credentials (added v0.1.0:20): rotate the admin email +
* password from the StartOS UI without dropping to a shell. See
* ./changeAdminCredentials.ts for full design notes.
*/
export const actions = sdk.Actions.of().addAction(changeAdminCredentials)