0ed41765daea1ecc84e9ed4659c67b9f41d3a68b
28 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0ed41765da |
Mine AGENTS.md brain onto disk: resolve TODOs, extract AI subsystem guide
Retrofit per the playbook. Resolve both AGENTS.md TODOs with verified facts (make-target set; db:seed is live at image-build + local dev), reconcile the AI-provider count (4 files -> 5 registered providers), and extract the AI subsystem cheat-sheet into docs/guides/ai-subsystem.md, lazy-loaded via a .claude/rules symlink with an index line in AGENTS.md. All AGENTS.md commands verified green (tests 177/177, build, tsc, lint). |
||
|
|
29b9d2437c |
Add AGENTS.md, ROADMAP.md, and CLAUDE.md symlink
Onboarding doc for fresh agent sessions: stack, commands, layout, conventions, and an Always/Never list of gotchas hit during the AI overhaul. Current state section tracks the 1.1.0:7 checkpoint. ROADMAP.md holds the longer-term backlog. CLAUDE.md symlinks AGENTS.md so Claude Code loads it. Secrets kept out — private registry/file-host URLs and creds referenced by file location, not value. |
||
|
|
1a77a0bfc2 |
v1.1.0:7 — exercise-history popup auto-loads on scroll
The popup HAD an IntersectionObserver-based infinite scroll (since v1.0.0:6 alongside the main workout-history page), but the observer was unreliable inside an `absolute`-positioned scroll container with a small 60px rootMargin. It often didn't fire at all — leaving the user with a popup that scrolled internally but never fetched more data even when hundreds of history entries existed server-side. Fix: replace IntersectionObserver with a plain `scroll` event listener on the popup. Fires whenever the user scrolls within 300px of the bottom (matching WorkoutsList's lookahead on the main page). Also runs once on mount in case the first page doesn't fill the popup. Bottom status row now shows "Loading more..." / "Scroll to load more" / "End of history" so the user has feedback on state. No schema, no API, no data. |
||
|
|
01529204cb |
v1.1.0:6 — exercise history popup scrolls further
The clock-icon popup in the workout editor was capped at max-h-80 (~320px = ~5 history rows). Users with multi-year history saw older sessions hidden behind a tiny inner scrollbar. Bumped to 70vh so it scales with the viewport — ~15+ rows on a normal display, more on a large monitor. The IntersectionObserver pagination already loaded more rows on demand; the old cap just kept them off-screen. Pure CSS-class change. No schema, no API, no data. |
||
|
|
35539a9341 |
v1.1.0:5 — Gemini model menu correctness
User pointed out their Google AI Studio dropdown shows gemini-3-pro, gemini-3.1-pro, gemini-3-flash and gemini-2.5-flash — not the longer preview names I shipped in v1.1.0:4. The menu was missing all the Flash variants entirely. Fix: - Add gemini-3.1-pro (short form, what AI Studio shows) - Add gemini-3.1-flash + gemini-3.1-flash-lite (the cheapest 3.x) - Add gemini-3-pro + gemini-3-flash (older tier, still available) - Pricing entries for all of the above (~$0.50/$3 per M for Flash) Pure data fix; no schema or behavior changes. |
||
|
|
7a62690a4a |
v1.1.0:4 — multi-config AI, background generation, ollama auto-detect, system prompt overhaul
User-feedback-driven release after testing v1.1.0:3. Nine themes:
1. Multi-config persistence
- New AIConfigProfile table (per-user). Save N configs, toggle one
active. Switching providers no longer wipes the previous setup.
- UserPreferences gains activeAIConfigId; legacy single-config
columns are mirrored from the active profile so existing reads
keep working without conditional logic.
- Idempotent boot migration lifts any existing single-config row
into a default profile.
2. Ollama auto-detect
- The "Add config" form probes /api/tags on the StartOS internal
addresses (ollama.startos / ollama.embassy on :11434). If
reachable: URL pre-fills, model field becomes a dropdown of
installed models. Fixes the copy-paste UX.
3. Curated model dropdowns for major providers
- Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
- OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
- Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
- "Other (type your own)" stays for niche models.
- Fixes "I tried gemini-3.0-pro and got 404."
4. Background generation
- lib/ai/generationRunner.ts: detached runner with in-memory
pub/sub bus. POST /api/ai/generate kicks it off and returns
immediately. SSE stream attaches by id. The runner survives
request cancellation; navigating away no longer kills it.
- New AIGeneration columns: progressText (in-flight stream),
durationMs (final wall-clock).
- Generate UI shows a banner explaining background-safety.
- History detail page polls progress + renders partial JSON
live for cross-process resume (page refresh, new tab).
5. System prompt overhaul
- lib/ai/systemPromptBase.ts: structural contract prepended to
every template. Forces JSON-only output, library-exerciseId
usage (kills "exerciseId doesn't belong to this user" errors),
and per-resistance-exercise suggestedWeight (with-history vs
without-history variants).
- aiExerciseSchema + ProgramExercise gain suggestedWeight +
suggestedWeightUnit. Starting a workout from a ProgramDay
pre-populates SetLog.weight from the suggestion.
6. Test connection improvements
- Latency in seconds (was ms — confusing for slow Ollama).
- Stale "✓ Connected" clears on form change.
- Per-config Test (no need to activate first).
- Generous maxOutputTokens for thinking models.
- Gemini surfaces finishReason on empty response (e.g. "blocked
by safety filter") instead of generic "empty response."
- Test endpoint accepts a draft body so you can verify before
saving + before activating.
7. History detail view
- Click row → full program tree + exact prompts sent. Apply from
here without re-generating. Pending rows poll for progress.
8. Sidebar sub-navigation
- AI: Generate / History / Templates
- Settings: General / Password / Sessions / AI integration /
Export / Instance (admin) / Danger zone, with anchor scroll.
9. API key UX
- "Key saved" indicator on saved configs (was confusing to see
an empty input after a successful save).
Schema migrations (additive, idempotent in entrypoint):
- AIConfigProfile table created
- UserPreferences.activeAIConfigId
- AIGeneration.progressText + durationMs
- ProgramExercise.suggestedWeight + suggestedWeightUnit
Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.
|
||
|
|
dba478aa23 |
v1.1.0:3 — AI upgrades: history context, test connection, cost estimator, streaming preview
Four incremental upgrades to the AI program generator. No schema change, no /data migration.
1. History as context (the killer feature)
- lib/ai/historyContext.ts builds a 90-day per-exercise rollup:
frequency, recent weights, estimated 1RM (Epley), avg RPE,
days-since-last, plus a STAGNANT flag when the heaviest weight in
the new half doesn't beat the old half.
- Generate page surfaces an "Include my workout history as context"
checkbox (default on at >=10 logged workouts). When checked, the
~1-3 KB summary is appended to the system prompt so the model can
recommend things like "you've stalled bench at 245 — try paused reps."
- We deliberately don't ship raw set logs (privacy + token cost).
2. Test connection
- POST /api/ai/test sends a tiny "say hi in 3 words" prompt and
reports latency + first sample, or the error inline.
- "Test connection" button next to "Save AI config" in
Settings -> AI integration. Verifies provider/model/key/baseUrl
without going through full program generation.
3. Cost estimator
- lib/ai/pricing.ts ships a price table for major models
(Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5).
Ollama always returns 0; openai-compatible returns null.
- Generation history shows per-row cost + a 30-day rolling total
at the top of the page.
4. Streaming preview render
- lib/ai/lenientJson.ts: stack-aware partial-JSON parser that
auto-closes open strings/brackets/braces in reverse-of-opening
order, drops dangling key:value pairs and partial keywords.
Returns a best-effort snapshot of the program-so-far on each chunk.
- Generate UI now renders a live "Building program..." panel that
updates as weeks/days/exercises arrive instead of just showing
raw text and waiting for stream end.
Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts,
ai-pricing.test.ts). 161 total pass.
|
||
|
|
974c3eb07d |
v1.1.0:2 — model-agnostic AI program generation (5 providers)
Five providers behind one streaming abstraction:
- claude (Anthropic)
- openai (api.openai.com)
- openai-compatible (any base URL — OpenRouter / LiteLLM /
vLLM / Together / your own gateway)
- gemini (Google)
- ollama (self-hosted; no key; LAN URL like
http://ollama.embassy:11434)
The "self-hosted Ollama on Start9" angle is the killer use case —
configure Settings → AI integration with the LAN URL of your Ollama
service and no API keys ever leave your network.
Architecture
- lib/ai/types.ts LLMProvider streaming interface
- lib/ai/sse.ts shared SSE + NDJSON line iterators
- lib/ai/providers/*.ts 5 implementations + factory
- lib/ai/programSchema.ts Zod schema + JSON-schema-for-prompt +
parseAIProgram with markdown-fence
stripping and balanced-brace JSON
extraction
- lib/ai/apply.ts materializes parsed AIProgram into
Program tree (validates exerciseIds,
rejects unresolved nulls, atomic
transaction, sets aiGenerated=true)
Schema
- UserPreferences gets aiProvider/aiModel/aiBaseUrl/aiApiKey
(plaintext — same threat model as the rest of /data). Dead
enableClaudeAI/claudeApiKey columns from v1.0.0:1-7 stay as
no-op fields.
- AIPromptTemplate (userId nullable; userId=NULL = built-in)
- AIGeneration (raw response + parsed program + status +
appliedProgramId + token counts)
- All compat-ALTER'd in docker_entrypoint.sh on first boot.
API
- POST /api/ai/generate SSE streaming: emits
generation/text/usage/complete
events; persists AIGeneration
row up front so failures show
in history too
- POST /api/ai/apply takes user-edited AIProgram,
creates Program, marks
generation as applied
- GET /api/ai/templates built-ins + this user's own
- POST /api/ai/templates create user-owned template
- PATCH /api/ai/templates/[id] edit; built-ins admin-only
- DELETE /api/ai/templates/[id] delete; built-ins admin-only
- GET /api/ai/generations list (paginated)
- GET /api/ai/generations/[id] full row
- DELETE /api/ai/generations/[id] delete one (Program survives)
- GET /api/ai/config returns aiKeyConfigured flag,
never plaintext key
- POST /api/ai/config update provider config
- DELETE /api/admin/ai/generations admin-only "clear all" with
optional userId / olderThanDays
UI
- Settings → AI integration provider/model/URL/key form;
plaintext key warning visible
- /main/ai hub page with cards
- /main/ai/generate template picker + textarea +
live SSE stream + cancel +
ProgramPreview with inline
unknown-exercise resolver +
apply button + redirect to
the new Program
- /main/ai/templates list + create + edit + delete;
per-row "show prompt" expand;
built-in delete warns about
reconcile re-creation
- /main/ai/history list + delete; status badges;
link to applied Program
- Nav: "AI" entry between Programs and Exercises (Sparkles icon)
Built-in templates
- prisma/aiTemplates.seed.json: 5 starter templates (hypertrophy /
strength / endurance / recovery / custom)
- prisma/ensurePromptTemplates.cjs: per-boot reconcile,
INSERT-or-UPDATE keyed on (userId IS NULL AND name=...);
user-created templates never touched
Tests
- tests/ai-programSchema.test.ts: extractJson + parseAIProgram
edge cases (markdown fences, balanced braces, malformed JSON,
Zod shape rejection, unresolved-exerciseId tolerance)
- tests/ai-apply.test.ts: materializes valid AIProgram, rejects
cross-user exerciseIds, rejects unresolved exercises, honors
isActive flag
- tests/routes-ai-templates.test.ts: built-in vs user permissions,
cross-user template isolation, /api/ai/config plaintext-key safety,
provider enum validation
- 123 tests across 14 files, all passing.
No data migration. Existing /data is augmented with the new columns
+ tables only.
|
||
|
|
3a5b929284 |
v1.1.0:1 — Programs UI (manual create / save / follow)
Schema
- Workout.programDayId added (nullable FK to ProgramDay) so a
Workout logged from a program day can be tied back to the planned
session for adherence analytics. Compat ALTER in entrypoint adds
the column + index to existing /data; ON DELETE SET NULL so
deleting a program doesn't remove historical workouts logged
against it.
- Back-relation `workouts: Workout[]` added to ProgramDay.
API (proof-of-work/app/api/programs/...)
- GET /api/programs — list user's programs
- POST /api/programs — create with full nested
weeks/days/exercises
tree in one transaction
- GET /api/programs/[id] — full tree
- PATCH /api/programs/[id] — update metadata AND/OR
replace entire weeks
tree (same shape as
POST). UI editor + AI
apply flow share this.
- DELETE /api/programs/[id] — cascading
- POST /api/programs/[id]/days/[dayId]/start
— creates a Workout
pre-populated with
empty SetLogs (one per
planned set), tagged
with programDayId.
UI (proof-of-work/app/main/programs/...)
- /main/programs — list with cards, today's-session
callout, "active" badge
- /main/programs/new — create form using ProgramEditor
- /main/programs/[id] — detail + edit using same editor;
today's-session card + Start button
if program is active
- ProgramEditor component (components/programs/ProgramEditor.tsx) —
expandable tree editor for weeks -> days -> exercises with
per-row sets/reps/RPE/rest/notes fields + library exercise picker
- ProgramActions: delete button
- StartSessionButton: POSTs to start endpoint, redirects to new
workout
Navigation
- "Programs" link added to bottom nav + sidebar (between Workouts
and Exercises).
- /main/programs page itself shows the today's-session card; the
same component pattern can be lifted into the dashboard later
if we want.
lib/db/programs.ts
- getPrograms, getProgramById, getActivePrograms,
computeTodaysSessionForProgram, getTodaysSession helpers.
- Today's session math: floor((todayUTC - startDateUTC) / 1day),
weekNumber = floor(.../7) + 1, dayOfWeek = today.getUTCDay().
Returns null if not started, past durationWeeks, or no day
matching today's slot (= rest day).
Tests (tests/routes-programs.test.ts)
- 11 new tests covering: 401 unauthenticated, full-tree create
with nested weeks+days+exercises, cross-user exerciseId
rejection, list scoped to actor, GET detail returns 404 for
another user's program, PATCH replace-tree atomicity,
cascading DELETE, start-day Workout creation with the right
number of empty SetLogs + programDayId stamped, start-day
refused for cross-user program day.
- Total: 96 tests across 11 files.
This is the foundation for v1.1.0:2's AI-generated programs —
the AI will produce the same JSON shape POST /api/programs
already accepts, so the apply path is `editor.tsx + POST
/api/programs` with no new API surface.
|
||
|
|
55c17614b8 |
v1.0.0:7 — exercise library cleanup, photo-import removal, AI-section honesty
Library JSON cleanup (proof-of-work/prisma/exercises.seed.json)
19 exercises corrected:
- Cycling/Jump Rope/Rowing/Running: type=cardio with proper
inputFields (duration/distance/calories — no more reps/weight).
- Walking Lunge/Wall Sit/Headstand/Hip Extension: reclassified
out of cardio into bodyweight.
- Plank/Mace warmup/Hollow Body Landmine/Soccer: inputFields
fixed.
- Descriptions added for ~10 cryptic exercises (Core, Resistance
Band, Stir the pot, Slide Board, Neck Circuit, TGU, Captains
of Crush, etc.).
Reconcile-on-boot (ensureExerciseLibrary.cjs)
Changed from INSERT-OR-IGNORE to INSERT-OR-UPDATE keyed on
(userId, name). Existing rows where isCustom = 0 get
description/type/muscleGroups/inputFields/defaultWeightUnit
refreshed from the curated JSON. Rows where isCustom = 1 are
skipped — user customizations always win.
Verified end-to-end: applied patches propagate to a copy of the
user's snapshot DB; manually-tampered isCustom=1 rows survive a
second reconcile pass untouched.
PATCH /api/exercises/[id] flips isCustom -> true on user edits
Once you edit a library exercise via the in-app UI, the row's
isCustom flag becomes 1 and the boot-time reconcile leaves it
alone forever. Closes the only failure mode where a maintainer
curated-library refresh could overwrite user edits.
Photo-import (Claude vision) removed
- app/api/workouts/import/route.ts deleted.
- components/import/WorkoutImportClient.tsx deleted (orphan
component — wasn't referenced anywhere by the live UI).
- CSV import (app/main/import → page-csv.tsx →
/api/workouts/import/save) is unchanged. The save endpoint
stays — it's used by the CSV flow too.
Settings UI: "Claude AI Integration" section removed
The toggle + API key input promised "personalized workout
recommendations" that the codebase never delivered (the only
actually-wired use was the photo-import we just removed).
Schema columns User.enableClaudeAI / User.claudeApiKey stay
as harmless dead fields — they'll get cleaned up or repurposed
when the model-agnostic AI work lands. The preferences API
no longer accepts or returns those fields.
No data migration. /data on existing installs is untouched.
v1.0.0:7 promoted to current; :1-:6 in other.
|
||
|
|
ffa8e0d480 |
v1.0.0:6 — paginate workout history (infinite scroll)
Two surfaces had invisible 50-row caps that this commit removes.
Exercise history popup (clock button in WorkoutForm):
- /api/exercises/[id] now accepts ?offset=N&limit=N (default 25,
max 100) and returns { exercise, history, hasMore }. Pagination
uses take: limit + 1 to detect hasMore without a second COUNT
round-trip.
- Query rewritten to use Prisma's setLogs.some filter — single SQL
that hits the (userId, deletedAt, date) composite index, instead
of fetching all set logs then grouping in JS.
- ExerciseHistoryPopup now uses an IntersectionObserver on a
sentinel div. When sentinel scrolls into view (root: the popup
itself, not the viewport), fetches next page and appends. Status
row at the bottom shows a spinner while loading and "End of
history" when done.
- Container max height bumped from h-64 -> h-80 for a bit more
breathing room on first render.
Workout history page (/main/workouts):
- Page still server-renders the first 50 workouts (instant paint
+ correct date filter forwarding). Now uses take: PAGE_SIZE + 1
to detect hasMore.
- New WorkoutsList client component takes initial workouts +
hasMore + filter values as props. IntersectionObserver on a
sentinel below the cards auto-fetches the next page from
/api/workouts?offset=N&limit=50&q=...&dateFrom=...&dateTo=...
when scrolled to. Filters round-trip through URL params, so a
filter change re-renders the page from scratch with a fresh
first page.
- "End of history · N workouts" line shown once everything is
loaded.
Tests:
- tests/routes-exercise-history.test.ts: 6 new tests covering
auth, cross-user 404, first-page hasMore=true, second-page
hasMore=false + no overlap, set-log filter scoped to the
queried exerciseId, soft-deleted workouts excluded.
- All 87 tests pass.
No schema changes, no migration. /data untouched.
|
||
|
|
dc6a3b1116 |
v1.0.0:5 — remove caloriesBurned raw-SQL workaround
The three exported helpers in lib/prisma.ts (getCaloriesBurned, setCaloriesBurned, getCaloriesBurnedBulk) existed because an early Prisma client generation didn't include the column. Schema and client have been aligned for several releases — the workaround is dead weight. Removed: the helpers from lib/prisma.ts (~30 lines of $queryRawUnsafe / $executeRawUnsafe). Updated callers to use plain caloriesBurned field references: - app/api/workouts/route.ts (GET list + POST create) - app/api/workouts/[id]/route.ts (GET detail + PATCH update) - app/api/settings/export-csv/route.ts (CSV export) All call sites now go through normal type-safe Prisma queries. Net effect for users: zero. Net effect for the codebase: cleaner read paths, stronger TS coverage on caloriesBurned, fewer SQL strings to audit. No schema changes, no migration. Existing /data is untouched. v1.0.0:5 promoted to current; :1, :2, :3, :4 in other. |
||
|
|
5f7b3b6b7a |
v1.0.0:4 — remove default admin@local credentials; require StartOS action to bootstrap
Security: shipping admin@local / workout123 as a default that the
operator was supposed-to-rotate-but-might-not is the kind of footgun
that turns into "default-credential exposure" headlines. Eliminated.
prisma/seed.ts now ONLY seeds the InstanceSettings singleton — no
admin user, no UserPreferences, no exercises in the build-time
fallback DB. The image still ships with prisma/exercises.seed.json
(curated 164-exercise library) but those rows aren't inserted until
an admin is created via the StartOS Action.
The change-admin-credentials Action now does INSERT-or-UPDATE in one
shot. CREATE mode (no admin exists) inserts the User row, inserts
UserPreferences with sensible defaults, and runs
ensureExerciseLibrary.cjs for the new admin so they don't have to
wait for the next service start to see the curated library. UPDATE
mode (admin exists) keeps the v1.0.0:1-3 rotation behavior. The
mode is auto-detected by counting `WHERE isAdmin = 1`.
The login page is now a server component that reads the admin count
upfront. Zero admins -> renders a "needs setup" panel pointing at
the StartOS Action ("Services -> Proof of Work -> Actions -> Set
admin credentials"). Otherwise renders the existing LoginForm
(extracted to LoginForm.tsx). Eliminates the
"I tried admin@local/workout123 and it failed, what's wrong"
fresh-installer confusion.
Backward compatible for upgrades from v1.0.0:1-3:
- /data already has an admin user; the no-admin detection never
triggers; login behaves identically to before.
- The Action's UPDATE mode still works for rotation.
Version graph: v1.0.0:4 promoted to current; v1.0.0:1, :2, :3 all
listed as `other` for in-place upgrade paths.
README updated to call out the explicit no-default-account design
and how to bootstrap an admin in local dev (Prisma Studio, since
the StartOS action isn't available off-StartOS).
|
||
|
|
a64fee4873 |
Replace placeholder manifest URLs with real keysat-xyz/proof-of-work
packageRepo + upstreamRepo now point at the public GitHub repo where this code will live. marketingUrl set to null (not yet a landing page). |
||
|
|
97ed07fd07 |
v1.0.0:3 — post-cutover seed strip
Removes the one-time `/data` snapshot from the deployed Docker image now that the cutover from the legacy `workout-log` package is verified done (v1.0.0:1 + :2 in production). Dockerfile - Drops `COPY start9/0.4/seed/data /app/seed/data`. - Drops the `WORKOUT_BAKED_SEED_DB_PATH` env var. - Comment block explains the rationale + how to re-seed if ever needed. docker_entrypoint.sh - Step 1 collapses to single-branch fallback: if /data is empty AND /app/prisma/data/app.db exists, copy the empty-schema fallback. The baked-seed branch is gone. - Comment cross-references v1.0.0:3 for the rationale. start9/0.4/seed/README.md rewritten to reflect historical-only status + how to re-seed for the rare "spin up another instance with this history" case. Version graph - Adds startos/versions/v1.0.0.3.ts with empty up/down migrations and release notes. - Promotes v1.0.0:3 to `current`; v1.0.0:1 and :2 move to `other` so hosts on either upgrade in place. No schema changes, no data migration. /data on existing installs is left exactly as-is. Image size drops by ~1.7MB (the snapshot size). |
||
|
|
a5df05c3ce |
Broaden gitignore to cover *.bak under seed/data/ (followup to 5f16855)
Commit 5f16855's message claimed it broadened .gitignore alongside the git rm --cached, but the .gitignore edit was left unstaged. This commit actually applies the change so future *.db.bak / *.bak files dropped into start9/*/seed/data/ stay out of version control. |
||
|
|
32b855f25b |
Untrack accidentally-committed seed/data/app.db.bak
The CSP-revert commit ba5a5d9 picked up start9/0.4/seed/data/app.db.bak because the gitignore only matched *.db, not *.db.bak. That file is the prior live snapshot (1.6MB of real workout history + bcrypt'd password hash) and must never be in version control. git rm --cached removes it from the index; the file stays on disk. .gitignore broadened to cover *.db.bak and *.bak under start9/*/seed/data/ so this can't recur. NOTE: the file is still in the previous commit. If this repo is ever pushed to a public remote, run `git filter-repo --path start9/0.4/seed/data/app.db.bak --invert-paths` (or BFG) to scrub history first. |
||
|
|
edeb1eb148 |
v1.0.0:2 — revert CSP nonces; restore inline-friendly CSP
v1.0.0:1 shipped a per-request nonce-based CSP via Next.js middleware. In production it produced a blank first paint: Next 14.2.x's bootstrap inline scripts weren't picking up the nonce reliably from the x-nonce request header, so the browser blocked them. This release reverts to the pre-experiment posture: - middleware.ts back to auth gating only (no nonce, no CSP). - next.config.js restores the static CSP with `'unsafe-inline'` allowed for script-src and style-src. Same headers (HSTS, Referrer-Policy, Permissions-Policy, frame-ancestors 'none', etc.) all stay. - New startos/versions/v1.0.0.2.ts with empty up/down migrations and a release note explaining the bug + revert. Promoted to `current` in the version graph; v1.0.0:1 moves to `other` so existing installs upgrade in place. No schema changes, no data migration. Existing v1.0.0:1 installs keep their /data. Re-attempt path documented in middleware.ts and next.config.js comments: future PR can revisit nonce CSP using Next's documented pattern verbatim (notably setting CSP on BOTH request headers and response headers — we only set it on response). |
||
|
|
990f5582b8 |
Typed Prisma queries, bcrypt native, CSP nonces, /api/me/import, more tests
Typed Prisma queries
- where: any in app/api/workouts/route.ts (GET + POST) and
lib/db/workouts.ts replaced with Prisma.WorkoutWhereInput +
Prisma.WorkoutCreateInput + Prisma.DateTimeFilter. Catches typos
at compile time and surfaces query shape directly in tooltips.
Workout import endpoint tests (tests/routes-import.test.ts)
- 7 tests covering /api/workouts/import/save: 401 unauthenticated,
empty workouts rejected, case-insensitive name matching against
existing exercises, new-exercise creation with isCustom=true and
type='other' default, explicit existingExerciseId honored over
name lookup, multiple workouts per call, sequential setNumber
per exercise per workout.
bcryptjs -> bcrypt (native)
- Roughly 10x faster than the pure-JS implementation under load —
login latency drops from ~250ms to ~25ms. Hash format is fully
cross-compatible with bcryptjs ($2a$ / $2b$ both verify), so
existing user passwords keep working without migration.
- Dockerfile builder stage adds python3 + make + g++ as a safety net
for native node-gyp compilation on alpine when prebuilt binaries
aren't available.
- Runner stage explicitly COPYs node_modules/bcrypt so the .node
binding is unambiguously present even if Next.js standalone
tracing somehow misses it.
- StartOS package's changeAdminCredentials.ts keeps bcryptjs (it's
bundled by ncc into a single JS file and runs only on the rare
admin action; native bcrypt would require shipping the .node
binding through ncc which it doesn't handle gracefully).
CSP nonces (middleware.ts + next.config.js)
- Per-request nonce generated in middleware. Forwarded to Next via
the x-nonce request header, which Next 13.4+ automatically stamps
onto its inline bootstrap scripts. CSP response header includes
`'nonce-${nonce}' 'strict-dynamic'`, dropping the previous
`'unsafe-inline'` from script-src.
- Static CSP removed from next.config.js (middleware-set headers
override static ones, so keeping both was redundant).
- Middleware matcher widened to all paths except static assets so
the CSP applies to every page response. Existing /main + /api
auth gating preserved.
- style-src keeps 'unsafe-inline' — Next/Tailwind still inject
critical inline <style>; tightening that requires hash-based
style-src or per-style nonce stamping (Next doesn't auto-do
either). Worth a follow-up if you want the cleanest possible CSP.
/api/me/import (mirror of /api/me/export)
- Accepts the same JSON shape /api/me/export emits (schema string
validated: only `proof-of-work-export@1` accepted today).
- mode: 'merge' (default) — adds imported rows; existing exercises
with matching names are NOT overwritten (the user's custom version
wins). All workout sets with a known exercise get rebound to the
user's actual exercise id via name lookup.
- mode: 'replace' — wipes the user's exercises/workouts/sets first,
then imports. Requires `confirm: "REPLACE"` in the body.
- Always scoped to the actor — never touches other users' data.
- Profile/admin flag/sessions/InstanceSettings deliberately not
imported (account identity stays put).
- 7 tests cover: 401, schema rejection, merge create+skip, replace
confirmation gate, replace wipes-then-imports, isolation across
users.
- ExportMyData component grew Import (merge) + Import (replace)
buttons with native browser confirm() before the destructive
replace.
Test suite now 81 tests across 9 files in ~2.6s.
|
||
|
|
54fa77f2eb |
Sessions UI, CSV parser tests, route tests, composite indexes, verify-db action
Per-user sessions UI (Settings -> Active sessions) - listMySessions returns the current user's still-valid sessions with last-8-char token suffix (UX hint) and an isCurrent flag (the authoritative "this device" marker). - revokeSession refuses if the target is the actor's current token — use Sign out for that flow. Per-row Revoke button on every other. - revokeAllOtherSessions = the previously-internal `deleteOtherSessions` helper exposed as a single button "Sign out other devices". - All gated to the actor's own userId (never lets a user touch another user's sessions). CSV parser refactor + tests - Extracted parseCSV, NAME_MAP, parseFloatMaybe, parseIntMaybe, getVariationNote, resolveExerciseName, parseDate from app/api/import/parse/route.ts to lib/csvParser.ts. Behavior byte-identical; route is now a thin wrapper that imports from the lib. - 18 tests covering: empty input, simple rows, lowercased headers, quoted-field commas, escaped double quotes, CRLF normalization, empty-line handling; numeric maybe-parsers; getVariationNote known patterns + null pass-through; ALL 27 NAME_MAP entries map to their canonical target; named CSV-shorthand examples; M/D/YYYY + ISO date parsing with noon-UTC anchoring (so US negative-offset zones still see the same calendar day). Workout + exercise CRUD route tests - New tests/routes-crud.test.ts: GET/POST /api/exercises, GET/POST /api/workouts. 401 on unauthenticated, per-user data isolation, query filtering, soft-delete exclusion, isCustom stamping, duplicate detection, type-driven inputFields defaults (cardio gets duration+calories), Zod validation rejection, set creation with weight/reps/rpe persisted, negative-reps rejected. - Helper builds NextRequest objects so the routes' nextUrl.searchParams access works. Composite indexes for hot query paths (schema.prisma + entrypoint) - Session: (userId, expiresAt) for "list my still-valid sessions" and per-user cleanup. - Workout: (userId, deletedAt, date) for the workout list query (filter by user + alive + date order). - SetLog: (workoutId, setNumber) for the always-ordered set fetch under each workout. - Existing single-column indexes kept; composites are additive. - Entrypoint runs CREATE INDEX IF NOT EXISTS so live snapshots pick up the new indexes on first boot after upgrade. verify-database StartOS action (start9/0.4/startos/actions/verifyDatabase.ts) - Read-only. Runs PRAGMA integrity_check + quick_check + row-count queries against /data/app.db, reports as a structured result. - allowedStatuses: only-running. Mounts the volume read-only. - Use after a StartOS Backup, after a host crash, or after a fresh sideload to confirm the data is sound before relying on it. Test suite now 67 tests across 7 files in ~2.4s. |
||
|
|
5de974edaf |
ESLint, server-action tests, export-my-data, enriched healthcheck, CHANGELOG
ESLint
- Pinned eslint@^8 + eslint-config-next@^14 to match Next 14's `next lint`.
ESLint 9's flat-config breaks `next lint` for legacy projects.
- .eslintrc.json extends next/core-web-vitals; ignores tests/, scripts/,
prisma/data/, .next/, node_modules.
- 7 pre-existing warnings surfaced (exhaustive-deps + alt-text + img tag
in user-written components). Left as warnings — pre-existing, not
breaking. CI runs lint; warnings don't fail the job.
Server action tests (tests/actions-admin.test.ts, tests/actions-auth.test.ts)
- Vitest setup file (tests/helpers/setup-actions.ts) sets DATABASE_URL
to a per-process temp SQLite DB and runs `prisma db push` BEFORE
lib/prisma instantiates its global PrismaClient. Tests then call the
real server actions against an isolated DB.
- vi.mock + vi.hoisted to mock @/lib/auth.getCurrentUser, next/headers
cookies+headers, next/navigation redirect, next/cache revalidatePath.
- Coverage:
- admin: setUserAdmin (Forbidden, promote, last-admin demote refused,
demote-with-other-admin allowed), deleteUser (last-admin guard,
self-delete refused, cascading delete to exercises + workouts),
adminResetPassword (hash-and-revoke, short-password rejected).
- auth flows: signupAction (closed by default, opens-and-creates,
mismatched confirm rejected, short pwd rejected, malformed email
rejected, no email-enumeration leak), changePasswordAction
(rotate-and-revoke-others, wrong current pwd rejected, no-op pwd
rejected), deleteMyAccountAction (phrase required, password required,
last-admin refused, success cascades + clears cookie + redirects).
- Total suite: 34 tests, ~2s.
Export my data (/api/me/export + Settings -> Export my data)
- Downloads a JSON dump of every workout/set/exercise/program tied to
the user. Excludes password hash and sessions. Filename includes
email + date. content-disposition: attachment, no-store cache.
- Exported shape matches the underlying tables 1:1 so a future "import
my data" flow can round-trip without ambiguity.
Enriched /api/health
- Now reports: database.connected, database.journalMode (and walEnabled
shortcut), users count, instanceSettings.signupsOpen, library.available
+ sizeBytes. Surfaces a `warnings` array if journal_mode != 'wal' but
doesn't fail the check (app still works without WAL — just unsafe for
online backups). Returns 503 only on hard DB failure.
CHANGELOG.md
- Single Unreleased section documenting everything that will ship as
v1.0.0:1 once the maintainer drops a fresh /data snapshot. Added /
Changed / Removed / Compat-notes sections.
|
||
|
|
65f4b7a7c7 |
Test suite (Vitest) + GitHub Actions CI
Test suite (proof-of-work/tests/)
- vitest 4 + @vitest/coverage-v8 added as devDeps. New scripts: test,
test:watch, test:coverage.
- vitest.config.ts: single-fork pool so DB-backed tests don't trample
each other on temp file paths. `@/` alias mirrors tsconfig.
- tests/helpers/db.ts: setupTestDb() spins up a fresh schema-only
SQLite file per test suite via `prisma db push --skip-generate`,
returns a scoped PrismaClient + cleanup that removes WAL/SHM
sidecars too.
- tests/rateLimit.test.ts: under-limit / over-limit / per-key
isolation / window-slides-and-allows-again. Plus tests for
clientIpFromHeaders header preference order.
- tests/auth-pure.test.ts: hashPassword roundtrips, salt-randomness
(same input, different hash), bcrypt format ($2 prefix).
- tests/library.test.ts: actually runs the runtime
ensureExerciseLibrary.cjs against a temp DB with two users — verifies
the full library lands for every user, idempotent across two runs,
and a user's own custom exercise with a colliding name is NOT
overwritten on subsequent ensure passes. This is the highest-stakes
test in the suite (covers the exact code path that runs on every
container boot).
12 tests, ~1.0s total.
GitHub Actions CI (.github/workflows/ci.yml)
- Two jobs running in parallel on push + PR to master/main:
- `app`: cd proof-of-work && npm ci && prisma validate && prisma
generate && tsc --noEmit && npm test
- `startos`: cd start9/0.4 && npm ci && npm run check (the
StartOS package's existing tsc --noEmit script)
- Both jobs use Node 20 with npm cache keyed off the package-lock.
|
||
|
|
d51400c2a9 |
Robustness: WAL mode, security headers, last-login, delete-my-account
SQLite WAL mode (start9/0.4/docker_entrypoint.sh) - Switches journal_mode to WAL on every boot. WAL persists in the DB header so this is effectively a one-shot but rerunning is harmless. - Crucial for the "background StartOS Backup while users are using the app" case: under the default rollback journal, a long backup can capture an inconsistent snapshot. WAL keeps readers and the writer from blocking each other. - synchronous=NORMAL paired with WAL: still crash-consistent at every checkpoint, ~10x faster than FULL. Security headers (proof-of-work/next.config.js) - Content-Security-Policy with frame-ancestors 'none', base-uri 'self', form-action 'self', object-src 'none'. Keeps 'unsafe-inline' for script/style because Next.js emits inline bootstrap; tightening to nonce-based CSP is a follow-up. - Strict-Transport-Security: max-age=31536000; includeSubDomains. - Referrer-Policy: strict-origin-when-cross-origin (don't leak workout IDs etc. to third-party sites). - Permissions-Policy: deny camera, mic, geolocation, USB, etc. across the board (none of those APIs are used today; explicit deny means vulnerability scanners have one less thing to flag). Last-login tracking - New User.lastLoginAt column. createSession stamps it inside the same transaction as the new Session row. - Compat ALTER in entrypoint adds the column to legacy snapshots. - Admin Users table now shows a relative-age cell (today / Nd ago / Nmo ago / Ny ago / "never" if the user hasn't signed in since the column was added). Hover reveals the exact ISO timestamp. Self-serve delete-my-account (Settings -> Danger Zone) - Requires both the user's current password AND typing the literal phrase "delete my account" (defense against a stolen-session attacker nuking the account in one click). - Refused for the last admin (instance can't be left with no admin — the user is told to promote someone first). - Cascades through Prisma onDelete: Cascade on every relation owned by User, so workouts, exercises, sessions, preferences all go in one shot. Session cookie cleared, redirected to /auth/login. |
||
|
|
a11639cc56 |
Self-serve password change, admin user management, login/signup rate limit
Per-user password change (Settings -> Change password)
- changePasswordAction verifies current password before rotating, blocks
same-as-current, requires 8+ chars and matching confirm.
- Always revokes every other session for the user via
deleteOtherSessions(userId, currentToken). If you're rotating because
you suspect compromise, the worst-case kicks the attacker off
immediately. UI surfaces how many sessions were revoked.
- ChangePasswordForm sits between SettingsForm and AdminInstanceSettings
on the existing settings page. Available to every user, no admin
privileges required.
Admin user management (/main/admin/users — admin only)
- New page lists every account: email, name, joined date, workout count,
role. Linked from the AdminInstanceSettings panel ("Manage users ->").
- Per-row actions: Promote/Demote (toggles isAdmin), Reset password
(inline 8+ char input), Delete (cascading delete via Prisma onDelete:
Cascade — workouts, exercises, sessions, preferences all go).
- Last-admin guard: setUserAdmin and deleteUser refuse if it would
leave 0 admins. Self-delete is blocked from the admin UI (preserves
the actor's session and forces them to use a "danger zone" flow they
set up explicitly elsewhere).
- adminResetPassword force-revokes ALL of the target user's sessions —
admin reset implies the old credential is no longer trusted.
- Server actions all do their own requireAdmin() gate (defense in depth
beyond the page-level redirect).
Rate limit on /auth/login + /auth/signup
- New lib/rateLimit.ts: tiny in-process sliding-window limiter, no deps.
Map<key, timestamps[]> with cutoff filtering on each call. Per Node
process — fine for the single-replica StartOS deploy shape.
- clientIpFromHeaders prefers x-forwarded-for (leftmost), falls back to
x-real-ip, then 'unknown' (acts as a global cap in dev).
- signup: 5 attempts per IP per 15min. Cuts off automated account
spraying without blocking legitimate household-member sign-ups.
- login: 10 attempts per IP per 15min. Slows credential stuffing while
giving typo-prone users headroom.
|
||
|
|
53d2bade5c |
Use crypto.randomBytes for session tokens; add deleteOtherSessions helper
Session tokens were derived from Math.random() + Date.now() — predictable enough that a determined attacker could brute-force or guess valid tokens for other users. Switch to crypto.randomBytes(32) (256 bits of CSPRNG output, hex-encoded), the standard for opaque bearer tokens. Also adds deleteOtherSessions(userId, keepToken) so the upcoming password-change flow can log a user out of every other device when they rotate their password. |
||
|
|
d9c4e6c4a0 |
Multi-user: self-serve sign-up gated by admin-toggleable flag
Schema - User.isAdmin: Boolean default false (Prisma) - New InstanceSettings singleton (id=1) holding signupsOpen flag Boot-time compat ALTERs (docker_entrypoint.sh) - Adds User.isAdmin column to legacy snapshots; auto-promotes the oldest user to admin if no admin exists yet, so workout-log -> proof-of-work cutover preserves admin functionality with no manual SQL. - Creates InstanceSettings table + singleton row (signupsOpen=0) for any snapshot that doesn't have it. App: sign-up flow - /auth/signup page: server component that reads InstanceSettings upfront. If sign-ups are closed it shows a closed-instance message and a back-to-sign-in link rather than a dead form. If open it renders SignupForm (client) which calls signupAction (server). - signupAction: re-checks the flag (defense in depth), validates email format / 8-char password / matching confirm, blocks duplicate-email enumeration with a generic error, creates the user with isAdmin=false, seeds default UserPreferences, ensures the curated exercise library for the new user (lib/library.ts upserts every entry), then issues a session cookie. - Login page now links to /auth/signup; old "Demo: admin@example.com / password" footer (which was wrong anyway) removed. App: admin in-app toggle - Settings page renders new AdminInstanceSettings component for admins only. Optimistic toggle posts to /api/admin/signups; error rollback on failure. - /api/admin/signups: GET returns current flag (any authed user, so the UI knows whether to show the sign-up CTA later); POST flips it (admin only). StartOS package action - toggle-signups: same setter as the in-app toggle, accessible from the StartOS UI without an admin login. Single boolean input. Asserts the read-back value matches what was written before reporting success. - changeAdminCredentials now keys the UPDATE on `WHERE isAdmin = 1 ORDER BY createdAt ASC LIMIT 1` (was: just ORDER BY createdAt) — correct under multi-user. Release notes / docs - v1.0.0:1 release notes expanded to call out multi-user as part of the cutover release (no separate version needed since this is the first proof-of-work release shipping to anyone). - Root README: short Multi-user section explaining both toggle paths and that new users get the curated library automatically. - README dev setup adds `npx prisma generate` step (required after schema changes for local dev). |
||
|
|
aa407b5f67 |
Rebrand to Proof of Work; multi-user 0.4 package with curated library sync
Repo cleanup - Add top-level .gitignore (was missing; node_modules, .next, *.s9pk, image.tar, seed/data/*.db, log files, etc.) and a root README. - Delete legacy start9/0.3.5/ package (StartOS 0.3.5 wrapper, no longer the deploy target). - Delete start9-example-packaging/ (template from another project). - Delete planning docs (START9_PACKAGING_LOG.md, VERSIONING.md, STARTOS_0.4_UPGRADE_PROMPT.md, ICON_FILES_INDEX.md, etc.) — info now lives in the deploy guide and code comments. - Drop the standalone Dockerfile, docker-compose.yml, ICON_*, and dev log/build artifacts from the app dir. - Drop the v0.1.0:18/19/20 version files (they belonged to the legacy workout-log package and don't apply to the new id). Rename + new package - Rename app dir workout-planner/ -> proof-of-work/. - Rename StartOS package id workout-log -> proof-of-work; the new id makes this a brand new StartOS service (clean cutover from the old one rather than in-place upgrade). - Reset version graph; v1.0.0:1 is the seeded cutover release. The Dockerfile bakes a one-time /data snapshot and docker_entrypoint.sh copies it into the new volume on truly-fresh first boot only (both /data/app.db missing AND /data/.seeded absent). - Move start9/0.4-migration/ -> start9/0.4/; the old start9/0.4/ stub is gone. Curated exercise library (multi-user-aware) - proof-of-work/prisma/exercises.seed.json is the canonical library shipped to every install (164 exercises today, dumped from the live snapshot). - proof-of-work/scripts/sync-library.cjs (npm run sync-library) refreshes the JSON from start9/0.4/seed/data/app.db after refresh_seed.sh. - proof-of-work/prisma/seed.ts now reads from the JSON instead of a hardcoded 52-exercise array; runs at Docker build time to seed the fallback DB and on first boot for fresh installs. - proof-of-work/prisma/ensureExerciseLibrary.cjs runs on every container boot (from docker_entrypoint.sh) and INSERT OR IGNOREs every library entry for every user, keyed on (userId, name). Library updates flow to existing installs on package upgrade; user-custom exercises (isCustom=true) and any colliding names are never overwritten; removed exercises stay on existing installs (additive-only). Deploy guide (start9/0.4/DEPLOY_040.md) - Rewritten end-to-end for the workout-log -> proof-of-work cutover: refresh_seed, sync-library, build, sideload, verify, rotate creds, stop the old service, then post-cutover cleanup release v1.0.0:2. |
||
|
|
1b64c45c52 | Initial commit for Start9 packaging |