Record the installed-version / package logs / attach+sqlite3 commands used to verify ALTERs and persisted data on the box, so future sessions verify directly instead of deferring to the StartOS web UI.
16 KiB
AGENTS.md — Proof of Work
Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 s9pk, published to a private Start9 registry.
Inbox check: At session start, if
~/Projects/standards/INBOX.mdexists, scan it for items tagged(proof-of-work)and surface them before proposing next steps; triage with/triage.
Stack (versions that matter)
- Next.js 15 (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
- React 19, TypeScript 5, TailwindCSS 3
- Prisma 5 ORM over SQLite (WAL mode; tuned at boot)
- bcrypt (native — NOT bcryptjs), zod 3 for validation
- Vitest 4 for tests
- @start9labs/start-sdk for the 0.4 packaging layer
- Node >= 20 to build
Layout (two projects in one repo)
proof-of-work/ ← the Next.js app (THIS is where you run npm)
app/ ← App Router routes; app/api/** = route handlers
app/main/ ← authed UI; navigation.tsx = sidebar
components/ ← React components (workouts/, ai/, settings/)
lib/ai/ ← AI subsystem (see below)
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 5 registered providers)
prisma/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
start9/0.4/ ← StartOS packaging wrapper
docker_entrypoint.sh ← boot: first-boot seed, additive ALTERs, library reconcile
Makefile / s9pk.mk ← s9pk build (ARCHES := x86)
startos/versions/ ← one file per ExVer version + index.ts (the version graph)
~/.proof-of-work/ ← publish.sh + unpublish.sh (NOT in repo; self-hosted registry)
workout-planner/ is scratch (only logs/) — ignore. start9/0.4/*.s9pk are build artifacts.
Commands
Run app commands from proof-of-work/ (running tsc/vitest/next from repo root fails — wrong cwd):
cd proof-of-work
npm run dev # local dev server
npm run build # next build (run this to catch route/type errors before shipping)
npm run lint # next lint
npm test # vitest run (full suite)
npx vitest run tests/ai-pricing.test.ts # single file
npx vitest run -t "findPrice" # single test by name
npx tsc --noEmit # typecheck only
npx prisma generate # REQUIRED after editing schema.prisma (else TS can't see new fields)
npm run db:seed # seed InstanceSettings singleton (NO users/library — see below)
npm run create-admin -- you@example.com pw "Name" # create first admin locally (--force resets existing)
Build/sideload the s9pk (from start9/0.4/): make x86 then make install. Targets come from s9pk.mk (the wrapper Makefile just sets ARCHES := x86):
make x86— build the x86 s9pk.make install— sideload the newest local.s9pkto the StartOS box athost:in~/.startos/config.yaml(viastart-cli package install).make publish— upload every.s9pkto the S3 bucket (s9pk-s3base:) and index it onregistry:from~/.startos/config.yaml(vias3cmd+start-cli s9pk publish). Distinct from~/.proof-of-work/publish.shbelow.make clean— remove build artifacts.
Both install and publish read host/registry config from ~/.startos/config.yaml, which is not in the repo — verify against the live setup, not from a checkout.
Verify on-box state read-only via start-cli (the same host config) instead of punting to the StartOS web UI — used this way to confirm the 1.2.0:4/:5 ALTERs and a persisted set:
start-cli package installed-version proof-of-work— what version the box actually runs.start-cli package logs proof-of-work --limit N | grep -iE "adding missing column|as nextjs|error"— confirms boot ALTERs ran (each logs once) and the non-root launch.start-cli package attach proof-of-work -- sqlite3 /data/app.db "<SELECT …>"— read-only query of the live app DB (e.g. confirm a new column exists / a value persisted). SELECT only; never mutate prod data this way.
Canonical publish path for this project: ~/.proof-of-work/publish.sh (builds, uploads to FileBrowser, registers) — separate from the generic make publish. Unpublish: ~/.proof-of-work/unpublish.sh.
npm run db:seed (= tsx prisma/seed.ts) seeds only the InstanceSettings singleton — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is live, not dead — invoked at Docker image-build time (start9/0.4/Dockerfile) and the local-dev first-run path. npm run create-admin (= tsx scripts/create-admin.ts) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; --force to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by docker_entrypoint.sh.
Conventions
- Versioning is ExVer:
1.1.0:4(note the colon). Every release = a newstart9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.tsfile, imported intoversions/index.tsand promoted tocurrent(previouscurrentmoves intoother[]). - Bump the version BEFORE building the s9pk — Start9 0.4 won't recognize a rebuild as an update otherwise.
- Schema changes are additive ALTERs in
docker_entrypoint.sh, guarded byPRAGMA table_infochecks. Keepschema.prismain sync as the mirror, but the entrypoint is what migrates live/data. Never write a destructive migration. - Adding a first-class numeric set metric (precedent:
watts, 1.2.0:4): mirrorcaloriesend-to-end —schema.prismacolumn +prisma generate; guarded additiveALTERindocker_entrypoint.sh; zod field + insert in all 5 set-write paths (workoutsPOST,workouts/[id]PATCH,workouts/[id]/sets,workouts/import/save,me/import);SetRow.tsx(show*/state/emitUpdate/buildSummary/firstField/input) +WorkoutForm.tsx(set interfaces,buildPayload,handleUpdateSet,initial*prop, has-data checks); read summary inapp/main/workouts/[id]/page.tsx+ edit mapping inapp/main/workouts/new/page.tsx; CSV export/parse +page-csvpayload; field-option label lists (lib/exerciseOptions.ts,app/main/exercises/[id]/page.tsx,ExercisePicker.tsx). TheinputFieldstoken == the column name; the human label lives in those option lists (tokenwatts→ "Avg. watts").me/exportrides the 1:1 Prisma dump automatically. Add a round-trip test intests/routes-crud.test.ts. - Logged-set effort is Gear or RPE, by cardio-ness (1.2.0:5): the effort select is always shown (not an
inputFieldstoken). Cardio exercises log breathing Gear (SetLog.gear, 1–5); everything else logs RPE (SetLog.rpe, 6–10). The switch isisCardioExercise(exercise)(lib/exerciseOptions.ts):type === "cardio"ORmuscleGroupscontains "cardio".SetRowtakes anisCardioprop (fromWorkoutForm) and renders one; both are always emitted (the hidden one stays empty). Distinct from program/AI target-RPE (ProgramExercise.rpe), which is unrelated and unaffected. - Commit subject =
vX.Y.Z:N — short summary, imperative, body explains the why. - Git remote is self-hosted (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in
~/.proof-of-work/{publish,unpublish}.sh; FileBrowser creds live in~/.keysat/filebrowser.env(outside the repo, gitignored). Default branch ismaster. - Authorization tiers: whole-instance routes (
settings/{export,import}-db) are admin-only (!user.isAdmin → 403); per-user data routes scope byuser.id. Custom-URL AI providers (Ollama, OpenAI-compatible — anything withrequiresBaseUrl) are admin-only (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI. - Cross-user id ownership: any route writing
SetLogs orProgramExercises from a client-suppliedexerciseIdmust validate it viafindUnownedExerciseIds(userId, ids)(lib/exerciseOwnership.ts) →400 { error, details: bad }. Unknown vs. foreign ids are deliberately indistinguishable (no existence oracle). Applied to all workout-write routes (create/PATCH/add-sets/import-save) + both program routes. (Server-derived ids, e.g. program-daystart, are already owned — no check needed.) - Login must not leak account existence: both login paths (
app/auth/login/actions.ts,app/api/auth/route.ts) callverifyPasswordOrDummy(lib/auth.ts) so an unknown email spends the same bcrypt as a real one. Never reintroduce an earlyif (!user) returnbefore the compare — that's the timing oracle. - Malformed JSON body must return 400, not 500. Routes whose catch maps
instanceof z.ZodError → 400parse viareadJsonBody(request)(lib/http.ts— throws aZodErroron bad JSON, so the existing branch handles it with no catch change).safeParse-style routes (me/import,admin/signups) wraprequest.json()in an explicittry/catch → 400. (AI/admin routes using.catch(() => ({}))are a third, pre-existing pattern — unify if you touch them.) - Next 15 dynamic APIs are async —
awaitthem. Route-handler contextparams, page/layoutparams+searchParams, andcookies()/headers()are all Promises. Established idiom (keeps handler bodies untouched):[id]routes takecontext: { params: Promise<{…}> }thenconst params = await context.params; server pages takepropsthenconst params = await props.params/const searchParams = await props.searchParams. Route tests passparams: Promise.resolve({…}). All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here. - The container runs the Node server as non-root.
docker_entrypoint.shruns as root only to prep/data(seed, ALTERs, library reconcile), thenchown -R nextjs:nodejs "$DATA_DIR"andexec su-exec nextjs:nodejs node /app/server.js(su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run before that final line. - Auth forms retry their server action once on a transport failure:
LoginForm/SignupFormwrap the action inretryOnTransportError(lib/retryAction.ts). iOS Safari drops the first POST on a stale keep-alive socket (NSURLErrorNetworkConnectionLost, "The network connection was lost") → the client catch showed "An unexpected error occurred". Retry only on a thrown error; a returned{ error }is a real result and passes through. - Tests live in
proof-of-work/tests/; mock server-action deps withvi.hoisted()+vi.mock. Route tests run against a real temp SQLite DB (tests/helpers/db.ts) withgetCurrentUsermocked. - Before editing the AI subsystem (
proof-of-work/lib/ai/**or the generate/generations routes), readdocs/guides/ai-subsystem.md— provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
Always
- Run
npx prisma generateafter anyschema.prismaedit, thennpx tsc --noEmit. - Run
npm testANDnpm run buildbefore shipping a version. - Add the boot-time
ALTER TABLE(with an existence guard) for any new column, indocker_entrypoint.sh. - Treat API keys / secrets as plaintext in
/dataBY DESIGN (threat model: the operator owns/data). Reference env-var names (DATABASE_URL, etc.); never hardcode values. - Keep migrations idempotent and additive; data already on a user's server must survive upgrades.
- Verify the published file actually changed (size / 404 / Last-Modified) after publish.sh.
Never
- Never add
Co-Authored-By/ "Generated with" trailers to commits — the user authors commits solo. (This was done wrong in earlier commits; do not repeat.) - Never reintroduce nonce-based CSP — it broke first paint. Use the static
'unsafe-inline'CSP innext.config.js. - Never run app commands from the repo root — always
cd proof-of-workfirst. - Never export non-HTTP-method symbols from a
route.ts— Next.js rejects the build (helpers go inlib/, e.g.lib/ai/activateConfig.ts). - Never commit
app.db,*.bak, or any user data — they're gitignored; double-checkgit statusbeforegit add. - Never click Uninstall on a StartOS package during a data cutover — it destroys the volume; use Stop.
- Never assume GitHub — don't add a GitHub remote or push there.
Current state
Latest version is 1.2.0:5 — Gear replaces RPE as the cardio effort field: cardio exercises now log a breathing "Gear" (1–5, Brian MacKenzie) select instead of RPE (6–10); strength keeps RPE. An exercise is cardio when its equipment type is "cardio" or its muscleGroups contains "cardio" (isCardioExercise in lib/exerciseOptions.ts) — so Assault Bike (type "assault bike") qualifies, as do Box jump & Soccer (both tagged cardio). New nullable SetLog.gear column via boot-time guarded ALTER; plumbed through all 5 set-write paths, summary/edit views, CSV/JSON import-export. Program/AI target-RPE is a separate concept and untouched. Built + sideloaded (immense-voyage.local, 2026-06-16, master) as proof-of-work_x86_64.s9pk (80M, git 4be489d). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), 231 tests pass (incl. gear + isCardioExercise), next build succeeds. Registry empty, publishing parked (sideload-only via make install).
Confirmed on-box (2026-06-16, via start-cli): box runs 1.2.0:5; entrypoint logged adding missing column SetLog.gear and (earlier boot) SetLog.watts, each once; app launches as nextjs with no permission errors (clears the 1.2.0:3 / long-standing 1.1.0:9 non-root check). App DB shows an Assault Bike set saved with gear=1 and no rpe — Gear select renders + persists for cardio, RPE for strength. Recent prior ships (1.2.0 line): 1.2.0:3 P3 hardening (login timing oracle + exerciseId ownership); 1.2.0:2 iOS Safari login first-tap retry; 1.2.0:1 Next 15 / React 19 upgrade.
No on-box checks pending. Known bug (tracked in ROADMAP.md → Known bugs): the 1.2.0:2 Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced on 1.2.0:5, first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (-1005 → client delayed-retry; 502/503 → Node keep-alive tuning).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
Next steps (priority order):
- Finish the P3 hardening batch (
ROADMAP.md→ Security & hardening — timing oracle + exerciseId ownership now DONE): CSPunsafe-eval,/api/healthinfo disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (try{json}catch{→{}}) inprograms/[id]/days/[dayId]/start. - Tiered AI prompt formatting (
ROADMAP.md→ AI quality). - (Later) Next 15→16 when ready —
next lintdeprecated in 15.5 (removed in 16) + Next 16 breaking changes; its own tested bump.
Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). publish.sh Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (ROADMAP.md → Packaging).
Git remote: origin → self-hosted Gitea at ssh://git@immense-voyage.local:59916/grant/proof-of-work.git; master tracks it. (~/.proof-of-work/{publish,unpublish}.sh registry/FileBrowser hosts are separate from this code remote.)