Files
proof-of-work/AGENTS.md
T
Keysat 38503436e1
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Update Current state: 1.2.0:5 built + sideloaded (Gear replaces RPE for cardio)
2026-06-16 14:49:43 -05:00

125 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
- **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" (15, Brian MacKenzie) select instead of RPE (610); 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: **1.2.0:4** avg. watts is a first-class `SetLog.watts` column (assault bike fields verified working). 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:** confirm 1.2.0:5 boots clean in StartOS → Logs — existing-data upgrade logs `adding missing column SetLog.gear` (one-shot; no-op thereafter), no permission errors — and that an Assault Bike set shows a **Gear** (15) select, not RPE, and saves/displays as `Gear N`. Still pending from earlier ships: the 1.2.0:3 non-root boot (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 — also clears the long-standing 1.1.0:9 non-root check), **and** the 1.2.0:2 Safari first-tap proof (first Sign In tap works — `-1005` in Web Inspector confirms the retry path).
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.)