Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to Workout Lens are documented here.

## [1.5.7] — 2026-05-15

### Security
- **Sporty-sync HTTP trigger now requires Supabase JWT (issue #228)** — `POST /api/sporty-sync` previously relied on a shared `SPORTY_SYNC_API_KEY` secret that was also used by the health endpoint; any authenticated app user can now trigger a manual sync, and the health endpoint (`GET /api/sporty-health`) is the only remaining consumer of the API key. `Home.jsx` updated to send `X-Supabase-Token` instead.
- **Recommendation cache owner-scoped writes (issue #235)** — `recommendation_cache` gains a `written_by uuid NOT NULL DEFAULT auth.uid()` column. The open UPDATE policy (`auth.role() = 'authenticated'`) is replaced by an owner-only policy (`written_by = auth.uid()`), preventing one user from overwriting another user's cached recommendation. INSERT policy tightened to the same constraint.
- **Prompt injection protection in `buildMuscleInferencePrompt` (issue #236)** — exercise names containing `<>` characters could inject XML tags into the Claude prompt. Names are now sanitised (angle brackets stripped) and wrapped in `<exercise>…</exercise>` XML tags before insertion, following Anthropic's recommended boundary pattern. Three regression tests added.
- **`handle_new_user()` RPC access revoked (issue #242)** — the auth trigger function was callable directly via `/rest/v1/rpc/handle_new_user` by any `anon` or `authenticated` role. Privilege revoked; the function now only fires internally via the `on_auth_user_created` trigger.
- **`SET search_path = public` on all stored functions (issue #242)** — `replace_template_exercises`, `save_session`, and `update_session` lacked a pinned `search_path`. A privileged attacker could shadow public-schema tables with malicious objects in a different schema. Fixed with `ALTER FUNCTION … SET search_path = public`.
- **`SPORTY_SYNC_API_KEY` removed from gitignore allowlist (issue #242)** — the key was previously explicitly tracked in `app/api/local.settings.json.example`; file updated to reflect health-only scope.
- **Content-Security-Policy tightened (issue #242)** — `script-src` and `connect-src` directives in `staticwebapp.config.json` reduced to the minimum required origins.

### Developer / Infrastructure
- **Supabase preview branches now work (issue #247)** — `supabase/migrations/` previously contained only delta migrations (ALTER TABLE / REVOKE). Supabase preview creates a fresh database and runs every file in order; the earliest delta (`20260514_add_template_type_to_session_templates.sql`) failed immediately because `session_templates` didn't exist. Fixed by adding `20260101000000_baseline_schema.sql` — a comprehensive idempotent snapshot of all 15 tables, functions, the `on_auth_user_created` trigger, grants, and RLS policies. All `CREATE` statements use `IF NOT EXISTS` / `CREATE OR REPLACE` so the file is also safe to apply to the live database as a no-op. The two existing delta migrations were updated to use `ADD COLUMN IF NOT EXISTS` / `DROP POLICY IF EXISTS` for the same reason.

## [1.5.6] — 2026-05-15

### Developer / Infrastructure
Expand Down
16 changes: 13 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ Shared lookup table — no `user_id` column. Any authenticated user whose filter
recommendation_cache
cache_key text PRIMARY KEY, -- v{RECS_PROMPT_VERSION}_{periodDays}_{sessionCount}_{trainedIds}_{untrainedIds}
recs jsonb NOT NULL, -- array of { name, primary[], secondary[], tip }
fetched_at timestamptz NOT NULL DEFAULT now()
fetched_at timestamptz NOT NULL DEFAULT now(),
written_by uuid NOT NULL DEFAULT auth.uid() REFERENCES profiles(id) -- owner (issue #235)
```

`cache_key` encodes everything Claude sees: prompt version, period, session count, and sorted trained/untrained muscle ID lists. Changing any of these produces a different key → natural cache miss → fresh fetch.
Expand All @@ -262,7 +263,7 @@ recommendation_cache
- **Shared muscle/SVG module:** `app/src/lib/bodymap.jsx` exports `MUSCLES`, `SHAPES`, `EX_DB`, color constants (`PRIMARY_FILL`, `PRIMARY_HOVER`, `PRIMARY_STROKE`, heat vars), `calcMuscles`, `BodySVG`, `HeatmapBodySVG` (accepts `onHover(id|null)` and `hovered` props — when `onHover` is set the internal tooltip is suppressed), and `useIsMobile`. Do not duplicate these in component files.
- **Shared hooks:** `app/src/lib/hooks.js` — exports `useDebouncedSearch(delayMs?)` (returns `{ search, setSearch, debouncedSearch }`; debounced value is raw — trim/lowercase at use-site) and `useFetch(fn, deps?)` (returns `{ data, loading, error, setData }`; `setData` allows optimistic updates; use only for data fetched once per mount without mutation-driven refetches). Import from here; do not copy the timer pattern locally.
- **Shared utilities:** `app/src/lib/utils.js` — exports `toBase64`, `getMediaType`, `buildMuscleMapFromExercises` (with EX_DB fallback, for confirm/edit steps), `buildMuscleMapFromSession` (reads saved DB session for History read mode), `buildRecMuscleMap` (for recommendation body maps), `isInvalidNum` (validates sets/reps as integers 1–99), `callClaude(body)` (authenticated fetch to `/api/claude` — returns raw `Response`; always call `await res.json()` to read the body), `inferMusclesFromName(name)` (calls Claude Sonnet text API to infer muscle IDs for a single exercise name — returns `{ primary, secondary }` or `null`; handles markdown code fences defensively), `extractMuscles(session)` (splits `muscle_activations` into primary/secondary Sets, removes primary from secondary), `toWeekIso(date)` (Date → `"2026-W19"` ISO week string), `weekIsoToMonday(weekIso)` (`"2026-W19"` → Monday `Date`), `isoWeekMonday(date)` (Date → Monday `Date` of that ISO week, local time), `toIsoDate(date)` (Date → `"yyyy-MM-dd"` string using local time getters — replaces `date-fns` `format`), `getIntlLocale()` (maps `i18n.language` to the IETF tag `Intl` expects, e.g. `"nb" → "no"`). Do not redefine these locally in component files.
- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (sonnet-4-6, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet-4-6, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; also keep `RECS_PROMPT_VERSION` in `app/api/recsCacheCleanup.js` in sync), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference). All model IDs and prompt text live here; update in one place.
- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (sonnet-4-6, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet-4-6, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; also keep `RECS_PROMPT_VERSION` in `app/api/recsCacheCleanup.js` in sync), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference — strips `<>` from the name and wraps it in `<exercise>…</exercise>` XML tags per Anthropic's prompt injection boundary pattern; 3 regression tests in `prompts.test.js`). All model IDs and prompt text live here; update in one place.
- Claude returns muscle IDs directly in JSON — local keyword matching (EX_DB) was abandoned because Norwegian abbreviations and whiteboard variants didn't match reliably. EX_DB is kept only as fallback for manually added exercises.
- SVG body uses `BODY_PATH` (bezier curves, viewBox `0 0 160 360`) — improved silhouette with curved shoulders, arms, waist and hips. Still simplified, not anatomically precise. `SHAPES` entries are either ellipses (`{ cx, cy, rx, ry }`) or SVG paths (`{ d }`); the render loop handles both. Key muscles with path shapes: `traps` (trapezoid with neck notch), `lats` (wing paths). `BodySVG` renders primary muscles as solid green glow, secondary as diagonal blue stripes (`<pattern id="sec-stripe-{view}">`).
- `useIsMobile(breakpoint=500)` — exported hook from `bodymap.jsx`. Below breakpoint: single body view with Front/Bak toggle. Above: side-by-side. Consumed via `BodyPanel` — do not use directly in page components.
Expand All @@ -281,7 +282,7 @@ recommendation_cache
- Anthropic API calls go through `app/api/claude.js` — Azure Function v4 model, browser hits `/api/claude`
- **Azure Functions entry point:** `app/api/index.js` imports all function files (`claude.js`, `sportySync.js`, `recsCacheCleanup.js`). `package.json#main` points to `index.js`. Azure Functions v4 only loads the single file referenced in `main` — add new function files here or they will never be registered. **The API `package.json` only has `@azure/functions` as a dependency — use raw `fetch` to the Supabase REST API (as `sportySync.js` does), never `import { createClient } from '@supabase/supabase-js'` in API files.**
- **Sporty.no sync:** `app/api/sportySync.js` — timer trigger at 04:00, 11:00, and 14:00 UTC upserts today's sessions from `https://sporty.no/api/v1/businessunits/8/groupactivities` into `gym_calendar` by `sporty_id`. Class names are passed through `normalizeName()` (imported from `sportyUtils.js`) before storage, which strips any quoted annotation (e.g. `"SVART TRØYE"`) — handles both straight and curly double quotes generically.
- **`app/api/sportyUtils.js`** — pure utility with no Azure SDK dependency. Exports `normalizeName(name)`. Kept separate from `sportySync.js` so it can be unit-tested without mocking the Azure Functions runtime. Business unit `8` is hardcoded — intentional for now (single-gym product); if extended to multiple gyms, this must become an env var or DB config. HTTP trigger `POST /api/sporty-sync` available for manual testing; accepts optional JSON body `{ "shiftDays": -7 }` to offset all timestamps by N days (useful for backfilling historical gym calendar data without re-running the live API). `GET /api/sporty-health` returns DB row counts (total rows, earliest/latest row timestamps, today's session count — no session list) — requires `x-api-key: <SPORTY_SYNC_API_KEY>` header (same key as the sync endpoint). Requires `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `SPORTY_SYNC_API_KEY` as Azure app settings (service role needed because the timer has no auth user). **The timer registration is guarded by `process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Production'`** — it is skipped entirely in local dev because the SWA CLI emulator only supports HTTP triggers.
- **`app/api/sportyUtils.js`** — pure utility with no Azure SDK dependency. Exports `normalizeName(name)`. Kept separate from `sportySync.js` so it can be unit-tested without mocking the Azure Functions runtime. Business unit `8` is hardcoded — intentional for now (single-gym product); if extended to multiple gyms, this must become an env var or DB config. HTTP trigger `POST /api/sporty-sync` requires a valid Supabase JWT in `X-Supabase-Token` header (verified via `verifySupabaseJwt`); accepts optional JSON body `{ "shiftDays": -7 }` to offset all timestamps by N days (useful for backfilling historical gym calendar data). `GET /api/sporty-health` returns DB row counts (total rows, earliest/latest row timestamps, today's session count — no session list) — still requires `x-api-key: <SPORTY_SYNC_API_KEY>` header (external monitoring tool, not a frontend call). Requires `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ANON_KEY`, and `SPORTY_SYNC_API_KEY` as Azure app settings (service role needed because the timer has no auth user; anon key used by `verifySupabaseJwt`). **The timer registration is guarded by `process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Production'`** — it is skipped entirely in local dev because the SWA CLI emulator only supports HTTP triggers.
- **Rec cache cleanup:** `app/api/recsCacheCleanup.js` — timer trigger every Sunday at 03:00 UTC. Two DELETE passes: (1) entries older than 7 days (TTL — forces fresh recommendations; also catches version-orphaned entries that were never refreshed); (2) entries whose `cache_key` does not start with `v${RECS_PROMPT_VERSION}_` (sweeps orphans immediately after a prompt/model version bump). Uses raw `fetch` to Supabase REST API (no SDK). Requires `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`.
- **Claude API proxy:** `app/api/claude.js` verifies incoming Supabase JWTs via `GET /auth/v1/user`. Requires `ANTHROPIC_API_KEY`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY` as Azure app settings. Use `SUPABASE_ANON_KEY` (no `VITE_` prefix) — the `VITE_` prefix is Vite build-time only and is invisible to the Azure Functions runtime.
- **CI/CD build split:** the frontend is pre-built in the GitHub Actions runner (`npm ci && npm run build` with `VITE_*` in `env:`), then the Azure SWA action uploads `app/dist/` directly (`app_location: "app/dist"`). This bypasses Oryx for the frontend — Oryx strips `VITE_*` env vars before spawning Vite and they never reach the bundle. Oryx still handles the API (`app/api`). `vite.config.js` has a build-time assertion that fails immediately if the required vars are missing.
Expand Down Expand Up @@ -443,6 +444,15 @@ Symptom: uploading an iPhone 17 Pro photo failed with `Serverfeil (400): image e

**Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height. Do not use `b64.length * 0.75` to compare against Anthropic's limit — compare `b64.length` directly.

### Issue #247 — Supabase preview branches fail with "relation does not exist" (resolved 2026-05-15)
Symptom: every PR showed a failing "Supabase Preview" check with `ERROR: relation 'session_templates' does not exist (SQLSTATE 42P01)` at the first delta migration (`20260514_add_template_type_to_session_templates.sql`).

**Root cause — no baseline migration:** Supabase preview branches create a fresh, empty database and then apply every file in `supabase/migrations/` in filename order. The repo only had delta migrations (ALTER TABLE, REVOKE, etc.) — no initial CREATE TABLE statements. On a fresh DB, `ALTER TABLE session_templates ADD COLUMN template_type` fails immediately because the table doesn't exist.

**Fix:** Added `supabase/migrations/20260101000000_baseline_schema.sql` — a comprehensive snapshot of all 15 tables, 3 stored functions, the `on_auth_user_created` trigger, all grants, and all RLS policies. Every statement uses `IF NOT EXISTS` / `CREATE OR REPLACE` so the file is safe to apply to the live production database as a no-op. Updated the two existing delta migrations to use `ADD COLUMN IF NOT EXISTS` and `DROP POLICY IF EXISTS` for the same idempotency guarantee.

**Pattern:** Every future migration that adds a table or policy must have a corresponding entry in the baseline (or be added via a new delta migration with `IF NOT EXISTS`). Delta migrations that run after the baseline must not assume a specific prior state — they must be idempotent. When adding a new table in a migration, also add `CREATE TABLE IF NOT EXISTS` for it to the baseline, and reference the delta in the baseline's comment block at the top.

### Issue #173 — fetchLastSession returning null (resolved 2026-05-14)
Symptom: Home → "Siste økt" showed "Ingen økter logget ennå" even though sessions existed in the DB and the weekly strip showed the correct session count.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Live URL: `https://workout.umulig.org`
| `SUPABASE_URL` | Used by the Claude proxy (JWT verification) and sporty.no sync function |
| `VITE_SUPABASE_ANON_KEY` | Used by the Claude proxy to verify Supabase JWTs — same value as the GitHub Actions secret |
| `SUPABASE_SERVICE_ROLE_KEY` | Used by the sporty.no sync function (bypasses RLS — timer has no auth user) |
| `SPORTY_SYNC_API_KEY` | Required `x-api-key` header value for `POST /api/sporty-sync` — any secret string; endpoint returns 401 without it |
| `SPORTY_SYNC_API_KEY` | Required `x-api-key` header for `GET /api/sporty-health` (external monitoring check) — `POST /api/sporty-sync` now uses Supabase JWT auth |

> **Note:** The frontend is built in the GitHub Actions runner (not by Oryx inside Azure SWA's Docker container). Oryx strips `VITE_*` env vars before spawning Vite, so they would never reach the bundle if built there. The workflow pre-builds `app/dist/` and the Azure SWA action uploads it directly via `app_location: "app/dist"`. Do not revert this.

Expand Down
Loading