Compare commits

...

12 Commits

Author SHA1 Message Date
Keysat 0401a831b7 Document read-only on-box verification via start-cli
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
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.
2026-06-16 16:37:54 -05:00
Keysat d1bc895e5e Log Safari first-tap login bug as a known bug with diagnosis
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
1.2.0:2's retryOnTransportError does not fix the mobile-Safari first-login
failure (reproduced on 1.2.0:5: first tap errors, second works). Record the
diagnosis and the gating next step (capture the first request's error code:
-1005 -> client delayed retry; 502/503 -> Node keep-alive tuning) so a future
session resumes from here. Correct the now-stale Current state line.
2026-06-16 16:04:12 -05:00
Keysat 184382f75c Record Gear/RPE effort convention; mark 1.2.0:5 confirmed on-box
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
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.
2026-06-16 15:44:47 -05:00
Keysat 38503436e1 Update Current state: 1.2.0:5 built + sideloaded (Gear replaces RPE for cardio)
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
2026-06-16 14:49:43 -05:00
Keysat 4be489d6d3 v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the effort field for cardio
Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
instead of RPE (6-10) as their effort field; strength keeps RPE. An exercise
counts as cardio when its equipment type is "cardio" or it carries the
"cardio" muscle group (isCardioExercise in lib/exerciseOptions), so the
Assault Bike (type "assault bike") qualifies.

New nullable SetLog.gear column added by the boot-time guarded ALTER in
docker_entrypoint.sh (additive, idempotent); plumbed through all 5 set-write
paths, the summary/edit views, and CSV/JSON import-export. Existing rpe data
is untouched and still displays. Program/AI target-RPE is unaffected.
2026-06-16 14:49:15 -05:00
Keysat ef3d079ca2 Record first-class-set-metric convention + CSV round-trip backlog
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Capture the ~17-touchpoint recipe for promoting a set metric to a column
(the watts precedent) so the next one doesn't need a repo-wide grep, and log
the pre-existing CSV export/import header-name asymmetry as backlog.
2026-06-16 14:07:03 -05:00
Keysat 486dcb3773 Update Current state: 1.2.0:4 built + sideloaded (avg. watts first-class field)
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
2026-06-16 12:58:48 -05:00
Keysat 390aaf556e v1.2.0:4 — make avg. watts a first-class SetLog field
Average watts (assault bike, rower, ski erg) was a free-text entry stuffed
into the per-set customMetrics JSON blob. Promote it to a real nullable
column, SetLog.watts, written through every set path (create / PATCH /
add-sets / import-save / account-import) and shown everywhere as
"Avg. watts" with a proper numeric input.

The column is added by the boot-time guarded ALTER in docker_entrypoint.sh
(additive, idempotent), so the version migration stays empty. Existing data
is untouched: legacy watts values remain readable from customMetrics and
migrate to the column the next time a set is saved.
2026-06-16 12:52:59 -05:00
Keysat 4d1f9126b0 Update Current state: 1.2.0:3 built + sideloaded; record session patterns
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Current state now reflects 1.2.0:3 (P3 hardening) built + sideloaded
(git f540a47, 221 tests). Add durable conventions for the three patterns
established this session: cross-user exerciseId ownership
(lib/exerciseOwnership), login timing-oracle avoidance
(verifyPasswordOrDummy), and the iOS-Safari auth-form retry
(lib/retryAction). ROADMAP: move the shipped P3 items (timing oracle,
exerciseId ownership) and the Next 15 bump into the Done lines.
2026-06-15 18:33:16 -05:00
Keysat f540a473ef v1.2.0:3 — close login timing oracle, enforce exerciseId ownership on workout writes
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Two P3 multi-user hardening fixes from the 2026-06-13 full-eval.

Login timing oracle: both login paths (the UI server action and
POST /api/auth) returned immediately on an unknown email but ran
bcrypt.compare when the email matched a user, so response latency
revealed which emails have accounts. New verifyPasswordOrDummy() in
lib/auth runs bcrypt against a fixed dummy hash when there is no user,
so every attempt spends exactly one bcrypt; the two error branches in
each route collapse into one.

exerciseId ownership: exercises are per-user, but the workout
create / PATCH (set-replace) / add-sets and CSV import-save routes wrote
SetLogs from a client-supplied exerciseId with no ownership check —
letting a user attach another user's exercise to their own workout,
which leaks that exercise's name/notes on fetch and wires up a
cross-user onDelete: Cascade link. All four now reject unowned ids with
400 via the shared lib/exerciseOwnership helper; the pre-existing inline
checks in both programs routes are refactored onto the same helper.

App-code only — no schema, no API contract change, no data migration.
2026-06-15 18:30:08 -05:00
Keysat 00a4b704e8 Update Current state: 1.2.0:2 built + sideloaded
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Record the Safari first-tap retry release as built + sideloaded, refresh
the pending on-box check (now also the first-tap proof from Safari), and
bump the local-verification numbers (213 tests).

Also folds in the pending doc-name alignment left in the working tree:
the AGENTS.md inbox tag and the EVALUATION.md title now read
"proof-of-work" instead of the old "Workout-log".
2026-06-15 16:44:58 -05:00
Keysat 0178f8f5cc v1.2.0:2 — retry login/signup server action once on transport failure
iOS Safari reuses a keep-alive socket the server closed while the login
form sat idle during typing, so the first Sign In / Create account POST
dies instantly with NSURLErrorNetworkConnectionLost ("The network
connection was lost"). That rejects the server-action call, hitting the
client-side catch in LoginForm/SignupForm and showing "An unexpected
error occurred"; the second tap lands on a fresh connection and works.

Add lib/retryAction.ts: retryOnTransportError() retries the action once
only when the call throws. A returned { error } (bad password, rate
limit) is a real result and passes straight through. A lost-on-a-stale-
socket POST never reached the server, so retrying it once is safe.
2026-06-15 16:44:33 -05:00
39 changed files with 908 additions and 105 deletions
+18 -8
View File
@@ -3,7 +3,7 @@
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 `(Workout-log)` and surface them before proposing next steps; triage with `/triage`.
> items tagged `(proof-of-work)` and surface them before proposing next steps; triage with `/triage`.
## Stack (versions that matter)
@@ -63,6 +63,11 @@ Build/sideload the s9pk (from `start9/0.4/`): `make x86` then `make install`. Ta
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`.
@@ -72,13 +77,18 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
- **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`, 15); everything else logs **RPE** (`SetLog.rpe`, 610). 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.
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`.
- **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
@@ -102,18 +112,18 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state
Latest version is **1.2.0:1**the **Next.js 14→15 / React 18→19** upgrade (the remaining P1; closes the Next framework RSC + middleware-bypass CVEs). **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-13, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `f487204`). Verified locally before build: tsc + lint clean, **209 tests pass**, `next build` succeeds, standalone bundle traces the Prisma engine. Registry empty, **publishing parked** (sideload-only via `make install`).
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`).
**Pending on-box check:** confirm 1.2.0:1 boots clean in StartOS → Logs (this supersedes the still-unconfirmed 1.1.0:9 non-root clean-boot check — same Logs verification: entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 with no permission errors).
**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).
Done this session: **Next 15 / React 19 upgrade.** Async-`params`/`searchParams` migration across 10 `[id]` route files + 4 server pages (uniform `await` re-derive idiom — see Conventions). Deps: `next` 15.5.x, `react`/`react-dom` 19.x, `eslint-config-next` 15.5.x, `lucide-react` → 1.x, `next-themes` → 0.4.x (the latter two bumped for React-19 peers). `next.config.js`/middleware unchanged; no schema/data change. Residual `npm audit` items are dev/build-only tooling (esbuild/tsx, picomatch, bundled postcss) — **not in the runtime image; do NOT `audit fix --force`** (npm wrongly suggests downgrading to `next@9`).
Next steps (priority order):
1. **P3 hardening batch** (`ROADMAP.md` → Security & hardening): login timing oracle, CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership on workout PATCH/sets POST, 30-day sessions, text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start`.
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` is deprecated in 15.5 (removed in 16), plus Next 16's own breaking changes; do it as its own tested bump.
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).
+1 -1
View File
@@ -1,4 +1,4 @@
# Evaluation — proof-of-work (Workout-log) — 2026-06-13
# Evaluation — proof-of-work — 2026-06-13
Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk.
+11 -2
View File
@@ -2,6 +2,14 @@
Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Current state.
## Known bugs
- **Mobile-Safari first-login-tap fails ("An unexpected error occurred"); second tap works.** Reproduced on iPhone/iPad Safari against 1.2.0:5 (desktop Safari untested — user declined). The first Sign In tap fails, a second manual tap succeeds. **1.2.0:2's `retryOnTransportError` does NOT fix it.** Diagnosis so far: `LoginForm` only surfaces that error when *both* the initial action call and its in-tap retry throw, so the immediate retry isn't escaping the bad connection — only a fresh user-initiated tap does. Box app logs show no server-side error/500/reset around the attempt, so it's a transport-layer failure, not an app bug.
- **Gating data (do this first):** capture the first failed request's error in Safari Web Inspector (iOS→Mac, Network/Console tab). The code picks the fix:
- `-1005` "The network connection was lost" → client-side stale keep-alive socket. Fix = a *delayed* retry (let Safari tear down the dead socket before retrying), not the current instant one.
- `502`/`503` → StartOS-proxy↔Node keep-alive mismatch (Node closing idle conns the proxy reuses). Fix = raise Node `keepAliveTimeout`/`headersTimeout` server-side; a client retry only masks it.
- Files: `lib/retryAction.ts`, `app/auth/login/LoginForm.tsx`, `app/auth/signup/SignupForm.tsx`.
## AI quality
- Tiered prompt formatting (also the immediate next step): JSON-Schema output enforcement via Ollama `format` and OpenAI `response_format`; pipe-separated library rows; XML-tagged prompt sections; Ollama-only few-shot example; stable prefix first for prompt-cache hits.
@@ -9,10 +17,10 @@ Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Curren
## Security & hardening (from 2026-06-13 full-eval; full detail + file:line in `EVALUATION.md`)
- **Next.js 14→15 major bump** (CVEs: RSC DoS, WS-upgrade SSRF, App Router XSS). Own tested change — breaking App Router/caching semantics, needs its own build + sideload verification.
- **Still open — verify on the box:** whether the StartOS proxy forwards real client IPs to the app. The rate limiter now keys on the rightmost (trusted-proxy) `X-Forwarded-For` entry; if the proxy instead makes every client look like one IP, the per-IP cap collapses to a single global bucket. Confirm with live headers.
- P3 hardening batch: login timing oracle (dummy bcrypt on unknown email), CSP `unsafe-eval` vs comment, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership unchecked on workout PATCH/sets POST, 30-day sessions, no text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start` (`try{json}catch{→{}}`).
- P3 hardening batch (remaining): CSP `unsafe-eval` vs comment, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), no text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start` (`try{json}catch{→{}}`).
Done in 1.2.0:1:3: Next 14→15 / React 18→19 bump (1.2.0:1, closed RSC DoS / WS-upgrade SSRF / App Router XSS + middleware-bypass CVEs); iOS-Safari login first-tap retry (1.2.0:2); login timing oracle closed + `exerciseId` ownership enforced on all workout-write & program routes (1.2.0:3).
Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJsonBody` + explicit guards); `POST /api/auth` rate-limited; XFF anti-spoof; container drops root via su-exec.
## Packaging / distribution
@@ -25,6 +33,7 @@ Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJson
- Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists).
- Per-user export/import polish and scheduled backups.
- CSV export↔import round-trip: export writes `setX`-prefixed headers (`setCalories`/`setWatts`/`setNotes`) the importer doesn't read (it expects `calories`/`watts`/`notes`), so the app's own CSV export silently drops those on re-import (calories long-standing; watts since 1.2.0:4). Fix by aligning export header names with the parser, or adding the prefixed names as `knownColumns` aliases. (JSON account export/import round-trips fine.)
- Charts/progress views over history (the data and 1RM estimates already exist).
## Hygiene
+8 -11
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyPassword, createSession } from '@/lib/auth';
import { verifyPasswordOrDummy, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { readJsonBody } from '@/lib/http';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
@@ -33,17 +33,14 @@ export async function POST(request: NextRequest) {
where: { email },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Always run a bcrypt compare (against a dummy hash when the email is
// unknown) so response time doesn't reveal whether an account exists.
const isValid = await verifyPasswordOrDummy(
password,
user?.passwordHash ?? null,
);
// Verify the password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
if (!user || !isValid) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
@@ -19,7 +19,9 @@ interface ParsedSet {
distance?: number;
distanceUnit?: string;
calories?: number;
watts?: number;
rpe?: number;
gear?: number;
customMetrics?: Record<string, string>;
notes?: string;
}
@@ -136,7 +138,9 @@ export async function POST(request: NextRequest) {
"distance_unit",
"distanceunit",
"calories",
"watts",
"rpe",
"gear",
"notes",
"custom_metrics_json",
"custommetricsjson",
@@ -199,7 +203,9 @@ export async function POST(request: NextRequest) {
const distance = parseFloatMaybe(row.distance);
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
const calories = parseIntMaybe(row.calories);
const watts = parseIntMaybe(row.watts);
const rpe = parseIntMaybe(row.rpe);
const gear = parseIntMaybe(row.gear);
const customMetrics: Record<string, string> = {};
const customJson = row.custom_metrics_json || row.custommetricsjson;
@@ -253,7 +259,9 @@ export async function POST(request: NextRequest) {
distance,
distanceUnit,
calories,
watts,
rpe,
gear,
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
notes: notes || undefined,
});
+4
View File
@@ -51,10 +51,12 @@ const setLogImport = z.object({
weight: z.number().nullable().optional(),
weightUnit: z.string().optional(),
rpe: z.number().int().nullable().optional(),
gear: z.number().int().nullable().optional(),
durationSeconds: z.number().int().nullable().optional(),
distance: z.number().nullable().optional(),
distanceUnit: z.string().nullable().optional(),
calories: z.number().int().nullable().optional(),
watts: z.number().int().nullable().optional(),
customMetrics: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// The exported set carries an exerciseId pointing into the export's
@@ -201,10 +203,12 @@ export async function POST(request: NextRequest) {
weight: s.weight ?? null,
weightUnit: s.weightUnit ?? 'lbs',
rpe: s.rpe ?? null,
gear: s.gear ?? null,
durationSeconds: s.durationSeconds ?? null,
distance: s.distance ?? null,
distanceUnit: s.distanceUnit ?? null,
calories: s.calories ?? null,
watts: s.watts ?? null,
customMetrics: s.customMetrics ?? null,
notes: s.notes ?? null,
});
+10 -14
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { getProgramById } from "@/lib/db/programs";
/**
@@ -86,25 +87,20 @@ export async function PATCH(
const body = await readJsonBody(request);
const validated = patchSchema.parse(body);
// If replacing the tree, verify exercise ownership.
// If replacing the tree, verify exercise ownership
// (see lib/exerciseOwnership).
if (validated.weeks) {
const allExerciseIds = new Set<string>();
for (const w of validated.weeks)
for (const d of w.days)
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
if (allExerciseIds.size > 0) {
const owned = await prisma.exercise.findMany({
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } },
select: { id: true },
});
const ownedIds = new Set(owned.map((e) => e.id));
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id));
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
const bad = await findUnownedExerciseIds(user.id, allExerciseIds);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
}
+9 -14
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { getPrograms } from "@/lib/db/programs";
/**
@@ -65,25 +66,19 @@ export async function POST(request: NextRequest) {
const body = await readJsonBody(request);
const validated = createProgramSchema.parse(body);
// Verify any referenced exerciseIds belong to this user.
// Verify any referenced exerciseIds belong to this user
// (see lib/exerciseOwnership).
const allExerciseIds = new Set<string>();
for (const w of validated.weeks)
for (const d of w.days)
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
if (allExerciseIds.size > 0) {
const owned = await prisma.exercise.findMany({
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } },
select: { id: true },
});
const ownedIds = new Set(owned.map((e) => e.id));
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id));
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
const bad = await findUnownedExerciseIds(user.id, allExerciseIds);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
const program = await prisma.$transaction(async (tx) => {
@@ -70,7 +70,9 @@ export async function GET() {
"distance",
"distanceUnit",
"setCalories",
"setWatts",
"rpe",
"setGear",
"setNotes",
"customMetricsJson",
];
@@ -101,7 +103,9 @@ export async function GET() {
set.distance ?? "",
set.distanceUnit ?? "",
set.calories ?? "",
set.watts ?? "",
set.rpe ?? "",
set.gear ?? "",
set.notes ?? "",
set.customMetrics ?? "",
];
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { z } from "zod";
// GET: Get workout by ID
@@ -56,10 +57,12 @@ const setSchema = z.object({
weight: z.number().optional().nullable(),
weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional().nullable(),
gear: z.number().int().min(1).max(5).optional().nullable(),
durationSeconds: z.number().int().positive().optional().nullable(),
distance: z.number().positive().optional().nullable(),
distanceUnit: z.string().optional().nullable(),
calories: z.number().int().positive().optional().nullable(),
watts: z.number().int().positive().optional().nullable(),
customMetrics: z.record(z.string()).optional().nullable(),
notes: z.string().optional().nullable(),
});
@@ -101,6 +104,21 @@ export async function PATCH(
const body = await readJsonBody(request);
const validated = updateWorkoutSchema.parse(body);
// When replacing sets, every referenced exercise must belong to this
// user (see lib/exerciseOwnership).
if (validated.sets) {
const bad = await findUnownedExerciseIds(
user.id,
validated.sets.map((s) => s.exerciseId),
);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
}
const workoutData: Record<string, unknown> = {};
if (validated.name !== undefined) workoutData.name = validated.name;
if (validated.notes !== undefined) workoutData.notes = validated.notes || null;
@@ -136,10 +154,12 @@ export async function PATCH(
weight: set.weight ?? undefined,
weightUnit: set.weightUnit,
rpe: set.rpe ?? undefined,
gear: set.gear ?? undefined,
durationSeconds: set.durationSeconds ?? undefined,
distance: set.distance ?? undefined,
distanceUnit: set.distanceUnit ?? undefined,
calories: set.calories ?? undefined,
watts: set.watts ?? undefined,
customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics)
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { z } from "zod";
const addSetsSchema = z.object({
@@ -13,10 +14,12 @@ const addSetsSchema = z.object({
weight: z.number().optional(),
weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional(),
gear: z.number().int().min(1).max(5).optional(),
durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(),
distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
customMetrics: z.record(z.string()).optional(),
notes: z.string().optional(),
})
@@ -51,6 +54,15 @@ export async function POST(
const body = await readJsonBody(request);
const validated = addSetsSchema.parse(body);
// The exercise must belong to this user (see lib/exerciseOwnership).
const bad = await findUnownedExerciseIds(user.id, [validated.exerciseId]);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
// Delete existing sets for this exercise in this workout (replace mode)
await prisma.setLog.deleteMany({
where: {
@@ -69,10 +81,12 @@ export async function POST(
weight: set.weight,
weightUnit: set.weightUnit,
rpe: set.rpe,
gear: set.gear,
durationSeconds: set.durationSeconds,
distance: set.distance,
distanceUnit: set.distanceUnit,
calories: set.calories,
watts: set.watts,
customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics)
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
const setSchema = z.object({
reps: z.number().int().positive().optional(),
@@ -12,7 +13,9 @@ const setSchema = z.object({
distance: z.number().positive().optional(),
distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
rpe: z.number().int().min(1).max(10).optional(),
gear: z.number().int().min(1).max(5).optional(),
notes: z.string().optional(),
});
@@ -44,6 +47,22 @@ export async function POST(request: Request) {
const body = await readJsonBody(request);
const validated = saveImportSchema.parse(body);
// An explicitly-matched `existingExerciseId` must belong to this user;
// name-matched and newly-created exercises are owned by construction
// (see lib/exerciseOwnership).
const claimedIds = validated.workouts.flatMap((w) =>
w.exercises
.map((e) => e.existingExerciseId)
.filter((id): id is string => !!id)
);
const bad = await findUnownedExerciseIds(user.id, claimedIds);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
// Load all user exercises for matching
const existingExercises = await prisma.exercise.findMany({
where: { userId: user.id },
@@ -106,10 +125,12 @@ export async function POST(request: Request) {
weight: set.weight || null,
weightUnit: set.weightUnit || "lbs",
rpe: set.rpe || null,
gear: set.gear || null,
durationSeconds: set.durationSeconds || null,
distance: set.distance || null,
distanceUnit: set.distanceUnit || null,
calories: set.calories || null,
watts: set.watts || null,
notes: set.notes || null,
});
});
+18
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
// Schema now supports creating empty workouts (just date) or with sets
const createWorkoutSchema = z.object({
@@ -25,10 +26,12 @@ const createWorkoutSchema = z.object({
weight: z.number().positive().optional(),
weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional(),
gear: z.number().int().min(1).max(5).optional(),
durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(),
distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
customMetrics: z.record(z.string()).optional(),
notes: z.string().optional(),
})
@@ -140,6 +143,19 @@ export async function POST(request: NextRequest) {
const body = await readJsonBody(request);
const validated = createWorkoutSchema.parse(body);
// Every referenced exercise must belong to this user (see
// lib/exerciseOwnership).
const bad = await findUnownedExerciseIds(
user.id,
validated.sets.map((s) => s.exerciseId),
);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
const workoutDate = validated.date ? new Date(validated.date) : new Date();
const createData: Prisma.WorkoutCreateInput = {
@@ -160,10 +176,12 @@ export async function POST(request: NextRequest) {
weight: set.weight,
weightUnit: set.weightUnit,
rpe: set.rpe,
gear: set.gear,
durationSeconds: set.durationSeconds,
distance: set.distance,
distanceUnit: set.distanceUnit,
calories: set.calories,
watts: set.watts,
customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics)
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { loginAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function LoginForm() {
const router = useRouter();
@@ -17,7 +18,9 @@ export default function LoginForm() {
setLoading(true);
try {
const result = await loginAction(email, password);
const result = await retryOnTransportError(() =>
loginAction(email, password)
);
if (result.error) {
setError(result.error);
+8 -8
View File
@@ -2,7 +2,7 @@
import { redirect } from 'next/navigation';
import { cookies, headers } from 'next/headers';
import { verifyPassword, createSession } from '@/lib/auth';
import { verifyPasswordOrDummy, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
@@ -24,14 +24,14 @@ export async function loginAction(email: string, password: string) {
where: { email },
});
if (!user) {
return { error: 'Invalid email or password' };
}
// Always run a bcrypt compare (against a dummy hash when the email is
// unknown) so response time doesn't reveal whether an account exists.
const isValid = await verifyPasswordOrDummy(
password,
user?.passwordHash ?? null,
);
// Verify the password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
if (!user || !isValid) {
return { error: 'Invalid email or password' };
}
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signupAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function SignupForm() {
const router = useRouter();
@@ -19,7 +20,9 @@ export default function SignupForm() {
setLoading(true);
try {
const result = await signupAction(email, password, passwordConfirm, name);
const result = await retryOnTransportError(() =>
signupAction(email, password, passwordConfirm, name)
);
if (result.error) {
setError(result.error);
setLoading(false);
@@ -57,6 +57,7 @@ const INPUT_FIELD_OPTIONS = [
{ value: "duration", label: "Duration" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" },
];
+15 -7
View File
@@ -22,7 +22,9 @@ interface ParsedSet {
distance?: number;
distanceUnit?: string;
calories?: number;
watts?: number;
rpe?: number;
gear?: number;
customMetrics?: Record<string, string>;
notes?: string;
}
@@ -392,9 +394,15 @@ export default function ImportCSVPage() {
if (typeof set.calories === "number" && !Number.isNaN(set.calories)) {
payloadSet.calories = set.calories;
}
if (typeof set.watts === "number" && !Number.isNaN(set.watts)) {
payloadSet.watts = set.watts;
}
if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) {
payloadSet.rpe = set.rpe;
}
if (typeof set.gear === "number" && !Number.isNaN(set.gear)) {
payloadSet.gear = set.gear;
}
if (
set.customMetrics &&
typeof set.customMetrics === "object" &&
@@ -763,7 +771,7 @@ export default function ImportCSVPage() {
</p>
<p className="text-sm text-zinc-500">
CSV columns: date, exercise, set, weight, reps, duration_seconds,
distance, distance_unit, calories, rpe, notes, custom_*
distance, distance_unit, calories, watts, rpe, gear, notes, custom_*
</p>
</div>
{loading && (
@@ -778,12 +786,12 @@ export default function ImportCSVPage() {
CSV Format Example
</h3>
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_temperature,custom_watts,custom_metrics_json
2025-02-15,Bench,1,225,lbs,5,,,,,8,good form,,,
2025-02-15,Bench,2,225,lbs,5,,,,,8,,,,
2025-02-16,Squat,1,315,lbs,8,,,,,9,30kg per leg,,,
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,7,,,"{\"resistance\":\"8\"}"
2025-02-18,Cold Plunge,1,,, ,180,,,,,felt great,50,,`}
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,watts,rpe,gear,notes,custom_temperature,custom_metrics_json
2025-02-15,Bench,1,225,lbs,5,,,,,,8,,good form,,
2025-02-15,Bench,2,225,lbs,5,,,,,,8,,,,
2025-02-16,Squat,1,315,lbs,8,,,,,,9,,30kg per leg,,
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,,4,,,"{\"resistance\":\"8\"}"
2025-02-18,Cold Plunge,1,,, ,180,,,,,,,felt great,50,`}
</pre>
</div>
</div>
+14 -2
View File
@@ -19,10 +19,12 @@ function buildSetSummary(set: {
weightUnit?: string | null;
reps?: number | null;
rpe?: number | null;
gear?: number | null;
notes?: string | null;
durationSeconds?: number | null;
distance?: number | null;
calories?: number | null;
watts?: number | null;
customMetrics?: string | null;
}) {
const parts: string[] = [];
@@ -35,15 +37,25 @@ function buildSetSummary(set: {
}
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
if ((set as any).watts) parts.push(`${(set as any).watts} W`);
if ((set as any).customMetrics) {
try {
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
for (const [k, v] of Object.entries(custom)) {
if (v) parts.push(`${k}: ${v}`);
if (!v) continue;
// Watts is now a first-class column. Legacy sets still carry it under
// customMetrics — render it the same way (and skip if the column
// already supplied it) so old and new sets read identically.
if (k === "watts") {
if (!(set as any).watts) parts.push(`${v} W`);
continue;
}
parts.push(`${k}: ${v}`);
}
} catch {}
}
if (set.rpe) parts.push(`RPE ${set.rpe}`);
if (set.gear) parts.push(`Gear ${set.gear}`);
else if (set.rpe) parts.push(`RPE ${set.rpe}`);
if (set.notes) parts.push(set.notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
}
@@ -50,9 +50,11 @@ export default async function NewWorkoutPage(props: {
reps: set.reps ?? undefined,
weight: set.weight ?? undefined,
rpe: set.rpe ?? undefined,
gear: set.gear ?? undefined,
durationSeconds: set.durationSeconds ?? undefined,
distance: set.distance ?? undefined,
calories: set.calories ?? undefined,
watts: set.watts ?? undefined,
customMetrics,
notes: set.notes ?? undefined,
});
@@ -61,7 +61,7 @@ export default function ExercisePicker({
// Derive custom types/muscles/fields from existing exercises
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
const derivedCustomTypes = useMemo(() => {
const set = new Set<string>();
@@ -506,6 +506,7 @@ export default function ExercisePicker({
{ value: "duration", label: "Time" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" },
...customFieldOptions,
].map((field) => (
@@ -527,7 +528,7 @@ export default function ExercisePicker({
onSubmit={(e) => {
e.preventDefault();
const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
}
@@ -545,7 +546,7 @@ export default function ExercisePicker({
onChange={(e) => setNewFieldText(e.target.value)}
onBlur={() => {
const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
}
+106 -28
View File
@@ -10,6 +10,7 @@ export type InputField =
| "duration"
| "distance"
| "calories"
| "watts"
| "notes"
| string;
@@ -17,13 +18,17 @@ export interface SetRowProps {
setNumber: number;
inputFields?: InputField[];
weightUnit?: string;
/** Cardio sets log breathing "Gear" (1-5) instead of RPE (6-10). */
isCardio?: boolean;
initialReps?: number;
initialWeight?: number;
initialRpe?: number;
initialGear?: number;
initialNotes?: string;
initialDuration?: number;
initialDistance?: number;
initialCalories?: number;
initialWatts?: number;
initialCustomMetrics?: Record<string, string>;
initialLocked?: boolean;
autoFocus?: boolean;
@@ -31,10 +36,12 @@ export interface SetRowProps {
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
notes?: string;
durationSeconds?: number;
distance?: number;
calories?: number;
watts?: number;
customMetrics?: Record<string, string>;
}) => void;
onConfirm?: () => void;
@@ -42,10 +49,12 @@ export interface SetRowProps {
weight?: string;
reps?: string;
rpe?: string;
gear?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
watts?: string;
}) => void;
onDelete: () => void;
}
@@ -54,13 +63,16 @@ export default function SetRow({
setNumber,
inputFields = ["sets", "reps", "weight"],
weightUnit = "lbs",
isCardio = false,
initialReps,
initialWeight,
initialRpe,
initialGear,
initialNotes,
initialDuration,
initialDistance,
initialCalories,
initialWatts,
initialCustomMetrics,
initialLocked = false,
autoFocus = false,
@@ -86,13 +98,21 @@ export default function SetRow({
const [reps, setReps] = useState(initialReps?.toString() || "");
const [weight, setWeight] = useState(initialWeight?.toString() || "");
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
const [gear, setGear] = useState(initialGear?.toString() || "");
const [notes, setNotes] = useState(initialNotes || "");
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
const [distance, setDistance] = useState(initialDistance?.toString() || "");
const [calories, setCalories] = useState(initialCalories?.toString() || "");
const [customValues, setCustomValues] = useState<Record<string, string>>(
initialCustomMetrics || {}
// Watts is now a first-class field. Legacy sets stored it under the
// customMetrics "watts" key — seed from there so old data shows up, and
// strip it from customValues so it isn't also rendered in the custom grid.
const [watts, setWatts] = useState(
initialWatts?.toString() || initialCustomMetrics?.watts || ""
);
const [customValues, setCustomValues] = useState<Record<string, string>>(() => {
const { watts: _legacyWatts, ...rest } = initialCustomMetrics || {};
return rest;
});
const [showNotes, setShowNotes] = useState(!!initialNotes);
const [locked, setLocked] = useState(initialLocked);
@@ -101,6 +121,7 @@ export default function SetRow({
const showDuration = inputFields.includes("duration");
const showDistance = inputFields.includes("distance");
const showCalories = inputFields.includes("calories");
const showWatts = inputFields.includes("watts");
const showNotesField = inputFields.includes("notes");
const customFields = inputFields.filter(
(f) =>
@@ -111,6 +132,7 @@ export default function SetRow({
"duration",
"distance",
"calories",
"watts",
"notes",
].includes(f)
);
@@ -120,19 +142,23 @@ export default function SetRow({
reps?: string;
weight?: string;
rpe?: string;
gear?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
watts?: string;
customMetrics?: Record<string, string>;
}) => {
const r = overrides.reps ?? reps;
const w = overrides.weight ?? weight;
const p = overrides.rpe ?? rpe;
const gr = overrides.gear ?? gear;
const n = overrides.notes ?? notes;
const dur = overrides.duration ?? duration;
const dist = overrides.distance ?? distance;
const cal = overrides.calories ?? calories;
const wt = overrides.watts ?? watts;
const cm = overrides.customMetrics ?? customValues;
const cleanedCustomMetrics = Object.fromEntries(
Object.entries(cm).filter(([, value]) => value !== "")
@@ -142,17 +168,19 @@ export default function SetRow({
reps: r ? parseInt(r) : undefined,
weight: w ? parseFloat(w) : undefined,
rpe: p ? parseInt(p) : undefined,
gear: gr ? parseInt(gr) : undefined,
notes: n || undefined,
durationSeconds: minuteStringToSeconds(dur),
distance: dist ? parseFloat(dist) : undefined,
calories: cal ? parseInt(cal) : undefined,
watts: wt ? parseInt(wt) : undefined,
customMetrics:
Object.keys(cleanedCustomMetrics).length > 0
? cleanedCustomMetrics
: undefined,
});
},
[reps, weight, rpe, notes, duration, distance, calories, customValues, onUpdate]
[reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, onUpdate]
);
const handleConfirm = () => {
@@ -175,7 +203,7 @@ export default function SetRow({
const handleNextSet = () => {
emitUpdate({});
setLocked(true);
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories });
onNextSet?.({ weight, reps, rpe, gear, notes, duration, distance, calories, watts });
};
// Build a summary string for the locked view
@@ -186,11 +214,16 @@ export default function SetRow({
if (showDuration && duration) parts.push(`${duration} min`);
if (showDistance && distance) parts.push(`${distance} mi`);
if (showCalories && calories) parts.push(`${calories} cal`);
if (showWatts && watts) parts.push(`${watts} W`);
for (const field of customFields) {
const value = customValues[field];
if (value) parts.push(`${field}: ${value}`);
}
if (rpe) parts.push(`RPE ${rpe}`);
if (isCardio) {
if (gear) parts.push(`Gear ${gear}`);
} else if (rpe) {
parts.push(`RPE ${rpe}`);
}
if (showNotesField && notes) parts.push(notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
};
@@ -238,7 +271,7 @@ export default function SetRow({
}
// Determine which field gets autoFocus
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null;
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : showWatts ? "watts" : null;
// ---------- EDIT VIEW ----------
return (
@@ -357,28 +390,73 @@ export default function SetRow({
</div>
)}
{/* RPE select — always shown */}
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
RPE
</label>
<select
value={rpe}
onChange={(e) => {
const val = e.target.value;
setRpe(val);
emitUpdate({ rpe: val });
}}
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="">-</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
</select>
</div>
{/* Avg. watts input */}
{showWatts && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Avg. watts
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "watts"}
value={watts}
onChange={(e) => {
const val = e.target.value;
setWatts(val);
emitUpdate({ watts: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */}
{isCardio ? (
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Gear
</label>
<select
value={gear}
onChange={(e) => {
const val = e.target.value;
setGear(val);
emitUpdate({ gear: val });
}}
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="">-</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
) : (
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
RPE
</label>
<select
value={rpe}
onChange={(e) => {
const val = e.target.value;
setRpe(val);
emitUpdate({ rpe: val });
}}
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="">-</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
</select>
</div>
)}
{/* Next set button — confirm + add new pre-filled set */}
{onNextSet && (
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Loader, Trash2, Plus, Save, Pencil, Check, Arro
import ExercisePicker from "./ExercisePicker";
import SetRow, { InputField } from "./SetRow";
import { formatSetsSummary } from "@/lib/formatSets";
import { isCardioExercise } from "@/lib/exerciseOptions";
// --------------- Exercise History Popup ---------------
type HistoryEntry = {
@@ -232,9 +233,11 @@ interface ExerciseWithSets {
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
durationSeconds?: number;
distance?: number;
calories?: number;
watts?: number;
customMetrics?: Record<string, string>;
notes?: string;
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
@@ -256,9 +259,11 @@ export interface EditWorkoutData {
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
durationSeconds?: number;
distance?: number;
calories?: number;
watts?: number;
customMetrics?: Record<string, string>;
notes?: string;
}>;
@@ -342,10 +347,12 @@ export default function WorkoutForm({
weight: s.weight,
weightUnit: (e.exercise as any).defaultWeightUnit || "lbs",
rpe: s.rpe,
gear: s.gear,
durationSeconds: s.durationSeconds,
distance: s.distance,
distanceUnit: s.distance !== undefined ? "mi" : undefined,
calories: s.calories,
watts: s.watts,
customMetrics: s.customMetrics,
notes: s.notes,
}))
@@ -504,10 +511,12 @@ export default function WorkoutForm({
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
notes?: string;
durationSeconds?: number;
distance?: number;
calories?: number;
watts?: number;
customMetrics?: Record<string, string>;
}
) => {
@@ -555,6 +564,7 @@ export default function WorkoutForm({
weight?: string;
reps?: string;
rpe?: string;
gear?: string;
notes?: string;
duration?: string;
distance?: string;
@@ -576,6 +586,7 @@ export default function WorkoutForm({
weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined,
reps: undefined, // User typically changes reps per set
rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined,
gear: currentValues.gear ? parseInt(currentValues.gear) : undefined,
notes: currentValues.notes || undefined,
forceEdit: true, // Start in edit mode even though weight is pre-filled
},
@@ -852,12 +863,15 @@ export default function WorkoutForm({
setNumber={set.setNumber}
inputFields={parseInputFields(item.exercise)}
weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"}
isCardio={isCardioExercise(item.exercise)}
initialReps={set.reps}
initialWeight={set.weight}
initialRpe={set.rpe}
initialGear={set.gear}
initialDuration={set.durationSeconds}
initialDistance={set.distance}
initialCalories={set.calories}
initialWatts={set.watts}
initialCustomMetrics={set.customMetrics}
initialNotes={set.notes}
initialLocked={
@@ -869,6 +883,7 @@ export default function WorkoutForm({
set.durationSeconds ||
set.distance ||
set.calories ||
set.watts ||
(set.customMetrics &&
Object.values(set.customMetrics).some((v) => v))
)
@@ -880,7 +895,8 @@ export default function WorkoutForm({
!set.weight &&
!set.durationSeconds &&
!set.distance &&
!set.calories)
!set.calories &&
!set.watts)
}
onUpdate={(data) =>
handleUpdateSet(
+27
View File
@@ -24,6 +24,33 @@ export async function verifyPassword(
return bcrypt.compare(password, hash);
}
/**
* A valid bcrypt hash (cost 10, matching real password hashes) of a
* throwaway string. Not a secret — it verifies no real password. Its only
* job is to give the no-such-user login path something to bcrypt against.
*/
const DUMMY_PASSWORD_HASH =
"$2b$10$4Q3ukhdLWRqxvYHp4JezhuSPskBFVXvewuUhhfUML64nh4xBuYyPC";
/**
* Verify a password against a user's hash, or against a fixed dummy hash
* when `hash` is null (no user matched the email). Either way exactly one
* bcrypt.compare runs, so an unknown-email attempt costs the same wall
* time as a real one — closing the timing oracle that would otherwise let
* an attacker enumerate which emails have accounts. The null-hash path
* always returns false.
*/
export async function verifyPasswordOrDummy(
password: string,
hash: string | null,
): Promise<boolean> {
if (hash === null) {
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
return false;
}
return bcrypt.compare(password, hash);
}
/**
* Create a session token for a user (30-day expiration).
*
+17
View File
@@ -46,6 +46,7 @@ export const BASE_TRACKING_FIELDS: Option[] = [
{ value: "duration", label: "Time" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" },
];
@@ -130,3 +131,19 @@ export function deriveTrackingFieldOptions(exercises: Exercise[]): Option[] {
export function displayLabel(value: string): string {
return titleCaseToken(value);
}
/**
* Cardio exercises log breathing "Gear" (1-5) instead of RPE (6-10) as their
* effort field. An exercise counts as cardio if its equipment type is "cardio"
* or it carries the "cardio" muscle group (e.g. Assault Bike, type
* "assault bike", is tagged cardio).
*/
export function isCardioExercise(exercise: {
type?: string | null;
muscleGroups?: string | null;
}): boolean {
if (normalizeValue(exercise.type || "") === "cardio") return true;
return parseJsonArray(exercise.muscleGroups ?? null).some(
(group) => normalizeValue(group) === "cardio"
);
}
+32
View File
@@ -0,0 +1,32 @@
import { prisma } from "./prisma";
/**
* Return the subset of `exerciseIds` that do NOT belong to `userId`.
*
* Exercises are per-user (`Exercise.userId`, `@@unique([userId, name])`) —
* even curated-library entries are copied per account. Routes that write
* SetLogs from a client-supplied exerciseId must check ownership first;
* otherwise a user could attach another user's exercise to their own
* workout, which leaks that exercise's name/notes back on fetch and wires
* up a cross-user `onDelete: Cascade` dependency.
*
* Unknown ids and ids owned by someone else are deliberately
* indistinguishable in the result, so a caller's 400 can't be used to
* probe which exerciseIds exist. An empty input returns an empty array
* (no query).
*/
export async function findUnownedExerciseIds(
userId: string,
exerciseIds: Iterable<string>,
): Promise<string[]> {
const ids = Array.from(new Set(exerciseIds));
if (ids.length === 0) return [];
const owned = await prisma.exercise.findMany({
where: { userId, id: { in: ids } },
select: { id: true },
});
const ownedIds = new Set(owned.map((e) => e.id));
return ids.filter((id) => !ownedIds.has(id));
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Run a server action, retrying it ONCE if the call rejects at the
* transport layer.
*
* iOS Safari (and Safari generally) frequently drops the first POST sent
* on a keep-alive socket that the server closed while the connection sat
* idle — e.g. while the user typed their credentials. The request fails
* instantly with `NSURLErrorNetworkConnectionLost` ("The network
* connection was lost", -1005); a retry lands on a fresh connection and
* succeeds. This is why a first login/signup tap shows "An unexpected
* error occurred" and the second tap works.
*
* Only a *thrown* rejection is retried. A server action that returns a
* value — including an application-level `{ error }` ("Invalid email or
* password", a rate-limit message) — is a real result and passes
* straight through untouched. A lost-on-a-stale-socket POST never
* reached the server, so retrying it once is safe.
*/
export async function retryOnTransportError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch {
return await fn();
}
}
+4 -2
View File
@@ -116,12 +116,14 @@ model SetLog {
reps Int?
weight Float?
weightUnit String @default("lbs")
rpe Int? // Rate of Perceived Exertion (1-10)
rpe Int? // Rate of Perceived Exertion (1-10) — non-cardio effort
gear Int? // breathing "gear" (1-5, Brian MacKenzie) — cardio effort
durationSeconds Int? // for timed exercises (assault bike, jump rope, planks)
distance Float? // for distance-based exercises
distanceUnit String? // "mi", "km", "m"
calories Int? // for cardio machines that report calories
customMetrics String? // JSON map for dynamic custom metrics (e.g. {"watts":"157"})
watts Int? // average watts for cardio machines (assault bike, rower, ski erg)
customMetrics String? // JSON map for dynamic custom metrics (legacy watts lived here as {"watts":"157"})
notes String?
createdAt DateTime @default(now())
+30 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '@/lib/auth';
import {
hashPassword,
verifyPassword,
verifyPasswordOrDummy,
} from '@/lib/auth';
// Pure-function bits of lib/auth.ts (no Prisma, no cookies).
@@ -27,3 +31,28 @@ describe('hashPassword / verifyPassword', () => {
expect(hash.startsWith('$2')).toBe(true);
});
});
describe('verifyPasswordOrDummy', () => {
it('verifies a correct password against a real hash', async () => {
const hash = await hashPassword('hunter2');
expect(await verifyPasswordOrDummy('hunter2', hash)).toBe(true);
});
it('rejects a wrong password against a real hash', async () => {
const hash = await hashPassword('hunter2');
expect(await verifyPasswordOrDummy('wrong', hash)).toBe(false);
});
it('returns false without throwing when there is no user (null hash)', async () => {
expect(await verifyPasswordOrDummy('anything', null)).toBe(false);
});
it('still spends bcrypt time on the null-hash path (timing-oracle guard)', async () => {
// A real cost-10 bcrypt.compare is tens of ms; a path that skipped
// bcrypt would return in well under 1ms. 5ms is a safe lower bound,
// so this fails if someone removes the dummy compare.
const start = Date.now();
await verifyPasswordOrDummy('anything', null);
expect(Date.now() - start).toBeGreaterThan(5);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { isCardioExercise } from '@/lib/exerciseOptions';
describe('isCardioExercise', () => {
it('treats type "cardio" as cardio', () => {
expect(isCardioExercise({ type: 'cardio', muscleGroups: '["cardio"]' })).toBe(true);
});
it('treats the cardio muscle group as cardio even when type differs (Assault Bike)', () => {
expect(
isCardioExercise({ type: 'assault bike', muscleGroups: '["cardio","legs","back","shoulders"]' })
).toBe(true);
});
it('is case/whitespace-insensitive on the muscle group', () => {
expect(isCardioExercise({ type: 'other', muscleGroups: '[" Cardio "]' })).toBe(true);
});
it('treats strength work (no cardio signal) as non-cardio', () => {
expect(isCardioExercise({ type: 'barbell', muscleGroups: '["back","biceps"]' })).toBe(false);
});
it('handles missing/empty fields without throwing', () => {
expect(isCardioExercise({})).toBe(false);
expect(isCardioExercise({ type: null, muscleGroups: null })).toBe(false);
expect(isCardioExercise({ type: '', muscleGroups: 'not json' })).toBe(false);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { retryOnTransportError } from '@/lib/retryAction';
describe('retryOnTransportError', () => {
it('returns the result without retrying when the call succeeds', async () => {
const fn = vi.fn().mockResolvedValue({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes through an application-level { error } without retrying', async () => {
// A returned error (bad password, rate limit) is a real result, not a
// transport failure — it must not trigger a retry.
const fn = vi.fn().mockResolvedValue({ error: 'Invalid email or password' });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ error: 'Invalid email or password' });
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries once on a thrown transport error and returns the second result', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new TypeError('The network connection was lost'))
.mockResolvedValueOnce({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(2);
});
it('rejects after a single retry when both attempts throw', async () => {
const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(retryOnTransportError(fn)).rejects.toThrow('Failed to fetch');
expect(fn).toHaveBeenCalledTimes(2);
});
});
+233
View File
@@ -13,6 +13,9 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { GET as getExercises, POST as postExercise } from '@/app/api/exercises/route';
import { POST as postWorkout, GET as getWorkouts } from '@/app/api/workouts/route';
import { POST as postSets } from '@/app/api/workouts/[id]/sets/route';
import { PATCH as patchWorkout } from '@/app/api/workouts/[id]/route';
import { POST as postImportSave } from '@/app/api/workouts/import/save/route';
// `NextRequest` accepts a slightly stricter RequestInit (no `signal:
// null`), so cast the standard RequestInit to the constructor's
@@ -299,6 +302,82 @@ describe('POST /api/workouts', () => {
expect(body.setLogs[1].rpe).toBe(8);
});
it('persists avg. watts as a first-class set field (assault bike)', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: {
userId: alice.id,
name: 'Assault Bike',
type: 'assault bike',
muscleGroups: '[]',
inputFields: '["sets","duration","distance","calories","watts","notes"]',
},
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', {
name: 'Conditioning',
sets: [
{
exerciseId: bike.id,
setNumber: 1,
durationSeconds: 600,
distance: 2.5,
distanceUnit: 'mi',
calories: 120,
watts: 157,
},
],
}),
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.setLogs).toHaveLength(1);
expect(body.setLogs[0].watts).toBe(157);
// And it round-trips out of the DB, not just the response.
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
expect(stored?.watts).toBe(157);
});
it('persists gear (cardio breathing effort) on a set', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: {
userId: alice.id,
name: 'Assault Bike',
type: 'assault bike',
muscleGroups: '["cardio"]',
inputFields: '["sets","duration","distance","calories","watts","notes"]',
},
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', {
name: 'Conditioning',
sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 3 }],
}),
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.setLogs[0].gear).toBe(3);
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
expect(stored?.gear).toBe(3);
});
it('rejects gear outside 1-5 via Zod with 400', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '["cardio"]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', {
sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 7 }],
}),
);
expect(res.status).toBe(400);
});
it('rejects negative reps via Zod with 400', async () => {
const alice = await makeUser({ email: 'a@x' });
const bench = await prisma.exercise.create({
@@ -321,4 +400,158 @@ describe('POST /api/workouts', () => {
const body = await res.json();
expect(body.error).toMatch(/invalid/i);
});
it("rejects a set referencing another user's exerciseId with 400", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', {
name: 'Steal attempt',
sets: [
{ exerciseId: bobExercise.id, setNumber: 1, reps: 5, weightUnit: 'lbs' },
],
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
// Nothing was written — the guard runs before any create.
expect(await prisma.workout.count()).toBe(0);
expect(await prisma.setLog.count()).toBe(0);
});
});
describe('POST /api/workouts/[id]/sets', () => {
it("rejects adding sets for another user's exerciseId with 400", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const aliceWorkout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Leg Day' },
});
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postSets(
jsonReq('http://x/api/workouts/' + aliceWorkout.id + '/sets', {
exerciseId: bobExercise.id,
sets: [{ setNumber: 1, reps: 5, weightUnit: 'lbs' }],
}),
{ params: Promise.resolve({ id: aliceWorkout.id }) },
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
expect(await prisma.setLog.count()).toBe(0);
});
});
describe('PATCH /api/workouts/[id]', () => {
it("rejects replacing sets with another user's exerciseId (400, nothing written)", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const aliceWorkout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Day' },
});
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await patchWorkout(
jsonReq(
'http://x/api/workouts/' + aliceWorkout.id,
{
sets: [
{ exerciseId: bobExercise.id, setNumber: 1, reps: 5, weightUnit: 'lbs' },
],
},
{ method: 'PATCH' },
),
{ params: Promise.resolve({ id: aliceWorkout.id }) },
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
// The guard runs before the set-replace transaction.
expect(await prisma.setLog.count()).toBe(0);
});
it('persists avg. watts when replacing sets via PATCH', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '[]' },
});
const workout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Cond' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await patchWorkout(
jsonReq(
'http://x/api/workouts/' + workout.id,
{
sets: [
{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, watts: 180 },
],
},
{ method: 'PATCH' },
),
{ params: Promise.resolve({ id: workout.id }) },
);
expect(res.status).toBe(200);
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
expect(stored?.watts).toBe(180);
});
it('persists gear when replacing sets via PATCH', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '["cardio"]' },
});
const workout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Cond' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await patchWorkout(
jsonReq(
'http://x/api/workouts/' + workout.id,
{ sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 4 }] },
{ method: 'PATCH' },
),
{ params: Promise.resolve({ id: workout.id }) },
);
expect(res.status).toBe(200);
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
expect(stored?.gear).toBe(4);
});
});
describe('POST /api/workouts/import/save', () => {
it("rejects an existingExerciseId owned by another user (400)", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postImportSave(
jsonReq('http://x/api/workouts/import/save', {
workouts: [
{
date: new Date().toISOString(),
exercises: [
{ name: 'Squat', existingExerciseId: bobExercise.id, sets: [{ reps: 5 }] },
],
},
],
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
expect(await prisma.workout.count()).toBe(0);
});
});
+2
View File
@@ -89,7 +89,9 @@ export type ParsedSet = {
distance?: number | null;
distanceUnit?: string | null;
calories?: number | null;
watts?: number | null;
rpe?: number | null;
gear?: number | null;
notes?: string | null;
};
+10
View File
@@ -72,6 +72,16 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|watts|"; then
log "adding missing column SetLog.watts"
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|gear|"; then
log "adding missing column SetLog.gear"
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN gear INTEGER;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
log "adding missing column Workout.deletedAt"
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
+24 -1
View File
@@ -16,6 +16,10 @@ import { v_1_1_0_7 } from './v1.1.0.7'
import { v_1_1_0_8 } from './v1.1.0.8'
import { v_1_1_0_9 } from './v1.1.0.9'
import { v_1_2_0_1 } from './v1.2.0.1'
import { v_1_2_0_2 } from './v1.2.0.2'
import { v_1_2_0_3 } from './v1.2.0.3'
import { v_1_2_0_4 } from './v1.2.0.4'
import { v_1_2_0_5 } from './v1.2.0.5'
/**
* Version graph for the `proof-of-work` package.
@@ -61,9 +65,24 @@ import { v_1_2_0_1 } from './v1.2.0.1'
* v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next
* framework RSC + middleware-bypass CVEs; async-params migration
* across all [id] routes + server pages. No schema/data change.
* v1.2.0:2 — Login/signup first-tap retry: iOS Safari drops the first
* server-action POST on a stale keep-alive socket
* (NSURLErrorNetworkConnectionLost); retry once on transport
* failure. Client-only, no schema/data change.
* v1.2.0:3 — P3 hardening: close the login timing oracle (dummy-hash
* bcrypt on unknown email) and enforce exerciseId ownership on
* workout create/PATCH/add-sets + CSV-import-save (shared
* lib/exerciseOwnership). No schema/data change.
* v1.2.0:4 — Avg. watts promoted to a first-class set field (SetLog.watts
* column, added by the boot-time additive ALTER). Written through
* every set path; legacy watts in customMetrics stays readable and
* migrates on next save.
* v1.2.0:5 — Gear (breathing, 1-5, Brian MacKenzie) replaces RPE as the effort
* field for cardio exercises (type "cardio" or "cardio" muscle
* group); strength keeps RPE. New SetLog.gear column via boot ALTER.
*/
export const versionGraph = VersionGraph.of({
current: v_1_2_0_1,
current: v_1_2_0_5,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -81,5 +100,9 @@ export const versionGraph = VersionGraph.of({
v_1_1_0_7,
v_1_1_0_8,
v_1_1_0_9,
v_1_2_0_1,
v_1_2_0_2,
v_1_2_0_3,
v_1_2_0_4,
],
})
+32
View File
@@ -0,0 +1,32 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:2 — Login/signup first-tap retry on Safari (2026-06-15).
*
* Fixes a long-standing "first Sign In fails with 'An unexpected error
* occurred', second works" report from iOS Safari. The error string is
* the client-side catch in LoginForm/SignupForm — i.e. the server-action
* POST itself rejected at the transport layer, not any login-logic path
* (those return a clean { error }). Cause: iOS Safari reuses a keep-alive
* socket the server closed while the form sat idle during typing, so the
* first POST dies instantly with NSURLErrorNetworkConnectionLost ("The
* network connection was lost"); a retry lands on a fresh connection.
*
* Fix is client-only: a shared retryOnTransportError() helper retries the
* action ONCE when the call throws (a returned { error } is a real result
* and passes straight through). A stale-socket POST never reached the
* server, so the retry is safe.
*
* App-code only — no schema, no API contract change, no data migration.
*/
export const v_1_2_0_2 = VersionInfo.of({
version: '1.2.0:2',
releaseNotes: {
en_US:
'Fixes the occasional "An unexpected error occurred" on the first Sign In / Create account tap (most common in Safari on iPhone/iPad) — the form now retries automatically, so logging in works on the first try. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+35
View File
@@ -0,0 +1,35 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:3 — P3 hardening: login timing oracle + exerciseId ownership (2026-06-15).
*
* Two multi-user hardening fixes from the 2026-06-13 full-eval P3 batch:
*
* 1. Login timing oracle. Both login paths (the UI server action and
* POST /api/auth) returned immediately when no user matched the email,
* but ran bcrypt.compare when one did — so response latency revealed
* which emails have accounts. Now an unknown email is compared against
* a fixed dummy hash (lib/auth verifyPasswordOrDummy), so every attempt
* spends one bcrypt regardless.
*
* 2. exerciseId ownership. Exercises are per-user, but the workout
* create/PATCH/add-sets and CSV-import-save routes wrote SetLogs from a
* client-supplied exerciseId without checking ownership — letting a user
* attach another user's exercise to their own workout (leaking its
* name/notes on fetch + a cross-user cascade-delete link). All four now
* reject unowned ids with 400 via the shared lib/exerciseOwnership
* helper (the same check programs-create already did, now centralized).
*
* App-code only — no schema, no API contract change, no data migration.
*/
export const v_1_2_0_3 = VersionInfo.of({
version: '1.2.0:3',
releaseNotes: {
en_US:
'Security hardening: login no longer leaks (via response timing) whether an email has an account, and workouts can only reference exercises from your own library. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+27
View File
@@ -0,0 +1,27 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:4 — Avg. watts as a first-class set field (2026-06-16).
*
* Average watts (assault bike, rower, ski erg) used to be a free-text entry
* stuffed into the per-set customMetrics JSON blob. It's now a real nullable
* column, SetLog.watts, written through every set path (create / PATCH /
* add-sets / import-save / account-import) and shown everywhere as
* "Avg. watts" with a proper numeric input.
*
* Additive schema change: the SetLog.watts column is added by the boot-time
* guarded ALTER in docker_entrypoint.sh (so this migration stays empty, like
* every other column add). Existing data is untouched — legacy watts values
* remain readable from customMetrics and migrate to the column on next save.
*/
export const v_1_2_0_4 = VersionInfo.of({
version: '1.2.0:4',
releaseNotes: {
en_US:
'Average watts is now a first-class field for cardio machines (assault bike, rower, ski erg) — a proper numeric "Avg. watts" input instead of a free-text custom metric. Existing data is preserved.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+25
View File
@@ -0,0 +1,25 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the cardio effort field (2026-06-16).
*
* Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
* instead of RPE (6-10) as their effort field; non-cardio keeps RPE. An
* exercise counts as cardio if its equipment type is "cardio" or it carries
* the "cardio" muscle group (so Assault Bike, type "assault bike", qualifies).
*
* Additive schema change: the new nullable SetLog.gear column is added by the
* boot-time guarded ALTER in docker_entrypoint.sh (migration stays empty, like
* every other column add). Existing rpe data is untouched and still displays.
*/
export const v_1_2_0_5 = VersionInfo.of({
version: '1.2.0:5',
releaseNotes: {
en_US:
'Cardio exercises (assault bike, rower, ski erg, running, etc.) now log a breathing "Gear" (1-5) instead of RPE as their effort field. Strength exercises still use RPE. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})