184382f75c
Add the cardio-effort (Gear vs RPE) convention, and tighten Current state: 1.2.0:5 + 1.2.0:4 ALTERs and non-root boot verified on the box via start-cli (installed-version, package logs, and an app-DB read showing a saved gear=1 Assault Bike set). Only the 1.2.0:2 Safari first-tap check remains open.
126 lines
15 KiB
Markdown
126 lines
15 KiB
Markdown
# 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.md` exists, 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):
|
||
|
||
```bash
|
||
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 `.s9pk` to the StartOS box at `host:` in `~/.startos/config.yaml` (via `start-cli package install`).
|
||
- `make publish` — upload every `.s9pk` to the S3 bucket (`s9pk-s3base:`) and index it on `registry:` from `~/.startos/config.yaml` (via `s3cmd` + `start-cli s9pk publish`). **Distinct from `~/.proof-of-work/publish.sh`** below.
|
||
- `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.
|
||
|
||
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 new `start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts` file, imported into `versions/index.ts` and promoted to `current` (previous `current` moves into `other[]`).
|
||
- **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 by `PRAGMA table_info` checks. Keep `schema.prisma` in 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): mirror `calories` end-to-end — `schema.prisma` column + `prisma generate`; guarded additive `ALTER` in `docker_entrypoint.sh`; zod field + insert in all **5 set-write paths** (`workouts` POST, `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 in `app/main/workouts/[id]/page.tsx` + edit mapping in `app/main/workouts/new/page.tsx`; CSV export/parse + `page-csv` payload; field-option label lists (`lib/exerciseOptions.ts`, `app/main/exercises/[id]/page.tsx`, `ExercisePicker.tsx`). The `inputFields` token == the column name; the human label lives in those option lists (token `watts` → "Avg. watts"). `me/export` rides the 1:1 Prisma dump automatically. Add a round-trip test in `tests/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 `inputFields` token). Cardio exercises log breathing **Gear** (`SetLog.gear`, 1–5); everything else logs **RPE** (`SetLog.rpe`, 6–10). The switch is `isCardioExercise(exercise)` (`lib/exerciseOptions.ts`): `type === "cardio"` OR `muscleGroups` contains "cardio". `SetRow` takes an `isCardio` prop (from `WorkoutForm`) 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 is `master`.
|
||
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) 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 `SetLog`s or `ProgramExercise`s from a **client-supplied `exerciseId`** must validate it via `findUnownedExerciseIds(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-day `start`, 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`) call `verifyPasswordOrDummy` (`lib/auth.ts`) so an unknown email spends the same bcrypt as a real one. Never reintroduce an early `if (!user) return` *before* the compare — that's the timing oracle.
|
||
- **Malformed JSON body must return 400, not 500.** Routes whose catch maps `instanceof z.ZodError → 400` parse via `readJsonBody(request)` (`lib/http.ts` — throws a `ZodError` on bad JSON, so the existing branch handles it with no catch change). `safeParse`-style routes (`me/import`, `admin/signups`) wrap `request.json()` in an explicit `try/catch → 400`. (AI/admin routes using `.catch(() => ({}))` are a third, pre-existing pattern — unify if you touch them.)
|
||
- **Next 15 dynamic APIs are async — `await` them.** Route-handler context `params`, page/layout `params` + `searchParams`, and `cookies()`/`headers()` are all Promises. Established idiom (keeps handler bodies untouched): `[id]` routes take `context: { params: Promise<{…}> }` then `const params = await context.params`; server pages take `props` then `const params = await props.params` / `const searchParams = await props.searchParams`. Route tests pass `params: 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.sh` runs as root only to prep `/data` (seed, ALTERs, library reconcile), then `chown -R nextjs:nodejs "$DATA_DIR"` and `exec 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`/`SignupForm` wrap the action in `retryOnTransportError` (`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 with `vi.hoisted()` + `vi.mock`. Route tests run against a real temp SQLite DB (`tests/helpers/db.ts`) with `getCurrentUser` mocked.
|
||
- **Before editing the AI subsystem (`proof-of-work/lib/ai/**` or the generate/generations routes), read `docs/guides/ai-subsystem.md`** — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
|
||
|
||
## Always
|
||
|
||
- Run `npx prisma generate` after any `schema.prisma` edit, then `npx tsc --noEmit`.
|
||
- Run `npm test` AND `npm run build` before shipping a version.
|
||
- Add the boot-time `ALTER TABLE` (with an existence guard) for any new column, in `docker_entrypoint.sh`.
|
||
- Treat API keys / secrets as plaintext in `/data` BY 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 in `next.config.js`.
|
||
- **Never run app commands from the repo root** — always `cd proof-of-work` first.
|
||
- **Never export non-HTTP-method symbols from a `route.ts`** — Next.js rejects the build (helpers go in `lib/`, e.g. `lib/ai/activateConfig.ts`).
|
||
- **Never commit `app.db`, `*.bak`, or any user data** — they're gitignored; double-check `git status` before `git 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.
|
||
|
||
**Pending on-box check (only one left):** the **1.2.0:2** Safari first-tap proof — log in from Safari on iPhone/iPad and confirm the *first* Sign In tap works (client-side; not visible in server logs). If it still occasionally fails, grab the Web Inspector error: `-1005` confirms the retry path is right; anything else points at a proxy↔container keep-alive mismatch.
|
||
|
||
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):
|
||
1. **Finish the P3 hardening batch** (`ROADMAP.md` → Security & hardening — timing oracle + exerciseId ownership now DONE): CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (`try{json}catch{→{}}`) in `programs/[id]/days/[dayId]/start`.
|
||
2. Tiered AI prompt formatting (`ROADMAP.md` → AI quality).
|
||
3. (Later) **Next 15→16** when ready — `next lint` deprecated 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.)
|