From a4811c8de58796e5a84ef2023fc451e83b47e38f Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:38:22 -0400 Subject: [PATCH 01/14] docs: add modernization spec and Step 0 plan Co-Authored-By: Claude Sonnet 5.5 --- .../2026-10-03-step0-per-user-privacy-fix.md | 1223 +++++++++++++++++ .../2026-10-03-somada-modernization-design.md | 100 ++ 2 files changed, 1323 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md create mode 100644 docs/superpowers/specs/2026-10-03-somada-modernization-design.md diff --git a/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md b/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md new file mode 100644 index 0000000..e9277ac --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md @@ -0,0 +1,1223 @@ +# Step 0: Per-User Data and Authenticated API — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop all users sharing one publicly readable `health_data` row by giving each user their own RLS-protected row, a separate read-only demo row, and an API that only serves data to a verified caller. + +**Architecture:** A new SQL migration adds `user_id` and `is_demo` to `health_data` with RLS scoped to `auth.uid()`. The Python API verifies the caller's Supabase JWT (`GET /auth/v1/user`) and then queries PostgREST **with the caller's JWT** so RLS, not application code, decides visibility. The legacy `index.html` is patched minimally to send the token and upsert by `user_id`. + +**Tech Stack:** Supabase (Postgres, RLS, PostgREST, Auth), Python 3.12 stdlib `http.server`/`urllib` on Vercel, pytest, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-10-03-somada-modernization-design.md` (section "Step 0 — Privacy fix"). This is plan 1 of 6, one per rollout step. Steps 1-5 (Vite/React rebuild) and the cutover get their own plans after this ships. + +## Global Constraints + +- Missing or invalid token returns **401**; no `limit=1` fallback and no anon-key reads of user data. +- The legacy `index.html` keeps working for existing users throughout (patched in this plan, not rewritten). +- BYOK keys remain browser-only; the server never stores or logs them. +- `api/config.py` and `api/supabase_config.py` remain public and unchanged. +- No new runtime dependencies: stdlib only in `api/`. Dev-only deps go in `requirements-dev.txt` (never `requirements.txt`, which Vercel would install). +- When `SUPABASE_URL`/`SUPABASE_ANON_KEY` are unset (local dev via `server.py`), behavior is unchanged: no auth, local `health_data.json`. +- Demo data is a dedicated read-only row (`is_demo = true`) holding **synthetic** data, never a real person's export. +- Rate limiting, model-ID changes and grounded-chat work are **out of scope** here (later plans). +- Default git branch is `master`. Do all work on `feat/per-user-data`. + +## File Structure + +```text +supabase/ + migrations/0001_initial_health_data.sql # NEW: original schema, for fresh installs only + migrations/0002_per_user_health_data.sql # NEW: the privacy fix (run this on the live DB) + rollback/0002_down.sql # NEW: emergency revert of 0002 + tests/rls_check.sql # NEW: RLS assertions, run on a scratch project + seed_demo.sql # NEW (generated): synthetic demo row +scripts/make_demo_seed.py # NEW: deterministic synthetic demo generator +api/supabase_http.py # NEW: env detection + tiny HTTP GET helper +api/auth.py # NEW: bearer extraction, JWT verification +api/chat_core.py # MODIFY: per-user fetch, auth-aware load_health_data +api/chat.py, api/health.py # MODIFY: authenticate first, 401 on failure +index.html # MODIFY: send token, upsert by user_id +tests/api/{conftest,test_auth,test_chat_core_data,test_handlers}.py # NEW +tests/scripts/test_make_demo_seed.py # NEW +pytest.ini, requirements-dev.txt # NEW +.github/workflows/tests.yml # NEW +README.md, CLAUDE.md # MODIFY: schema + auth docs +``` + +--- + +### Task 1: Branch, verify live schema, back up the legacy row + +**Files:** +- No repo files changed except committing the spec and this plan. + +**Interfaces:** +- Produces: a confirmed answer to "does the live `health_data` table match the README schema?" Later tasks assume columns `id integer PK`, `data jsonb`, `updated_at timestamptz` and policies `allow_public_read` / `allow_auth_write`. If the answer is no, **stop and revise Task 2** before continuing. + +- [ ] **Step 1: Create the branch and commit the spec and plan** + +```bash +cd /c/Users/meera/Github-Projects/Somada +git checkout -b feat/per-user-data +git add docs/superpowers/specs docs/superpowers/plans +git commit -m "docs: add modernization spec and Step 0 plan" +``` + +- [ ] **Step 2: Inspect the live schema** (Supabase dashboard → SQL editor, production project) + +```sql +select column_name, data_type, column_default +from information_schema.columns +where table_schema = 'public' and table_name = 'health_data' +order by ordinal_position; + +select id, updated_at, length(data::text) as bytes from public.health_data; + +select policyname, cmd, roles, qual, with_check +from pg_policies where tablename = 'health_data'; +``` + +Expected: columns `id` (integer, default `1`), `data` (jsonb), `updated_at` (timestamptz); one row with `id = 1`; policies `allow_public_read` (SELECT, `true`) and `allow_auth_write` (ALL). If anything differs, note it and revise Task 2's SQL before proceeding. + +- [ ] **Step 3: Back up the legacy row outside the repo** + +In the SQL editor run `select * from public.health_data;`, export the result as CSV, and save it to `C:\Users\meera\somada-backup\health_data-2026-10-03.csv`. This folder is **outside** the repo on purpose (personal health data must never be committed). + +- [ ] **Step 4: Create a scratch Supabase project for testing** (free tier, name `somada-scratch`). Note its URL and anon key in your password manager. All RLS testing in Task 2 runs here, not on production. + +No commit (nothing changed beyond Step 1). + +--- + +### Task 2: Migrations, rollback, RLS check, and schema docs + +**Files:** +- Create: `supabase/migrations/0001_initial_health_data.sql` +- Create: `supabase/migrations/0002_per_user_health_data.sql` +- Create: `supabase/rollback/0002_down.sql` +- Create: `supabase/tests/rls_check.sql` +- Modify: `README.md` (self-host step 1 and the "Data note"), `CLAUDE.md` (Supabase → Database section) + +**Interfaces:** +- Produces: table `public.health_data(id integer PK, data jsonb, updated_at timestamptz, user_id uuid UNIQUE NULL, is_demo boolean)`. Invariants: a row is either a user's row (`user_id` set, `is_demo = false`) or the single demo row (`user_id` null, `is_demo = true`). The legacy row keeps `user_id` null / `is_demo` false, so it is invisible to everyone under RLS but still present for rollback. +- Produces: upsert contract `upsert({ user_id, data }, { onConflict: 'user_id' })` (needs a plain unique constraint, not a partial index, or PostgREST rejects `on_conflict`). + +- [ ] **Step 1: Write the failing RLS check** — `supabase/tests/rls_check.sql` + +```sql +-- Run in the SQL editor of the SCRATCH project after applying 0001 and 0002. +-- Everything happens in one transaction that is rolled back. A failed assertion +-- raises an exception; success prints ALL RLS CHECKS PASSED at the end. +begin; + +insert into auth.users (id, email) values + ('00000000-0000-0000-0000-0000000000a1', 'a@test.local'), + ('00000000-0000-0000-0000-0000000000b2', 'b@test.local'), + ('00000000-0000-0000-0000-0000000000c3', 'c@test.local'); + +insert into public.health_data (user_id, data) values + ('00000000-0000-0000-0000-0000000000a1', '{"who":"a"}'), + ('00000000-0000-0000-0000-0000000000b2', '{"who":"b"}'); +insert into public.health_data (is_demo, data) values (true, '{"who":"demo"}'); + +set local role authenticated; +select set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-0000000000a1', true); +select set_config('request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-0000000000a1","role":"authenticated"}', true); + +do $$ +declare n int; +begin + select count(*) into n from public.health_data where data->>'who' = 'b'; + if n <> 0 then raise exception 'FAIL: user A can read user B row'; end if; + + select count(*) into n from public.health_data where data->>'who' = 'a'; + if n <> 1 then raise exception 'FAIL: user A cannot read own row'; end if; + + select count(*) into n from public.health_data where is_demo; + if n <> 1 then raise exception 'FAIL: user A cannot read demo row'; end if; + + update public.health_data set data = '{"who":"hacked"}' where data->>'who' = 'b'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A updated user B row'; end if; + + update public.health_data set data = '{"who":"hacked"}' where is_demo; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A updated the demo row'; end if; + + begin + insert into public.health_data (user_id, data) + values ('00000000-0000-0000-0000-0000000000c3', '{}'); + raise exception 'FAIL: user A inserted a row for user C'; + exception when insufficient_privilege then null; + end; + + begin + insert into public.health_data (is_demo, data) values (true, '{}'); + raise exception 'FAIL: user A inserted a demo row'; + exception when insufficient_privilege or unique_violation then null; + end; +end $$; + +reset role; +set local role anon; +do $$ +declare n int; +begin + begin + select count(*) into n from public.health_data; + if n <> 0 then raise exception 'FAIL: anon can read % rows', n; end if; + exception when insufficient_privilege then null; + end; +end $$; + +rollback; +select 'ALL RLS CHECKS PASSED' as result; +``` + +- [ ] **Step 2: Write the original schema** — `supabase/migrations/0001_initial_health_data.sql` + +```sql +-- FRESH INSTALLS ONLY. Existing deployments already have this table; skip to 0002. +create table if not exists public.health_data ( + id integer primary key default 1, + data jsonb not null, + updated_at timestamptz default now() +); +alter table public.health_data enable row level security; +``` + +- [ ] **Step 3: Run the check on the scratch project to verify it FAILS** + +In the scratch project apply only `0001` (SQL editor), then run `rls_check.sql`. +Expected: error `column "user_id" of relation "health_data" does not exist` (the failing-test state). + +- [ ] **Step 4: Write the migration** — `supabase/migrations/0002_per_user_health_data.sql` + +```sql +-- Per-user health data with row-level security. +-- Safe to run once on the live database. The legacy shared row (id = 1) is left in +-- place but becomes invisible to every role (user_id null, is_demo false), so it can +-- be assigned to its owner later or used for rollback. +begin; + +-- id stays the primary key but stops defaulting to 1. +create sequence if not exists public.health_data_id_seq owned by public.health_data.id; +select setval('public.health_data_id_seq', + greatest((select coalesce(max(id), 1) from public.health_data), 1)); +alter table public.health_data + alter column id set default nextval('public.health_data_id_seq'); + +alter table public.health_data + add column user_id uuid references auth.users (id) on delete cascade, + add column is_demo boolean not null default false; + +-- Plain unique constraint (NULLs are distinct) so PostgREST on_conflict=user_id works. +alter table public.health_data + add constraint health_data_user_id_key unique (user_id); +-- At most one demo row. +create unique index health_data_single_demo on public.health_data (is_demo) where is_demo; +-- A demo row has no owner. +alter table public.health_data + add constraint health_data_demo_has_no_owner check (not is_demo or user_id is null); + +drop policy if exists "allow_public_read" on public.health_data; +drop policy if exists "allow_auth_write" on public.health_data; + +create policy "own_row_select" on public.health_data + for select to authenticated using (user_id = (select auth.uid())); +create policy "demo_row_select" on public.health_data + for select to authenticated using (is_demo); +create policy "own_row_insert" on public.health_data + for insert to authenticated + with check (user_id = (select auth.uid()) and not is_demo); +create policy "own_row_update" on public.health_data + for update to authenticated + using (user_id = (select auth.uid())) + with check (user_id = (select auth.uid()) and not is_demo); +create policy "own_row_delete" on public.health_data + for delete to authenticated using (user_id = (select auth.uid())); + +commit; +``` + +- [ ] **Step 5: Write the rollback** — `supabase/rollback/0002_down.sql` + +```sql +-- Emergency revert: restores the old shared, publicly readable behavior. +-- Columns are kept; the legacy row (id = 1) is still intact, so the old app works again. +begin; +drop policy if exists "own_row_select" on public.health_data; +drop policy if exists "demo_row_select" on public.health_data; +drop policy if exists "own_row_insert" on public.health_data; +drop policy if exists "own_row_update" on public.health_data; +drop policy if exists "own_row_delete" on public.health_data; +create policy "allow_public_read" on public.health_data for select using (true); +create policy "allow_auth_write" on public.health_data + for all using (auth.role() = 'authenticated'); +commit; +``` + +- [ ] **Step 6: Apply 0002 on the scratch project and verify the check PASSES** + +Run `0002_per_user_health_data.sql`, then `rls_check.sql`. +Expected: final result row `ALL RLS CHECKS PASSED`. If `insert into auth.users (id, email)` is rejected for a missing NOT NULL column, add that column to the insert (Supabase versions differ) and re-run. + +- [ ] **Step 7: Update the docs** + +In `README.md`, replace the whole "#### 1. Create the Supabase table" block (the SQL snippet and its heading) with: + +```markdown +#### 1. Create the Supabase table + +In your Supabase SQL editor, run the files in `supabase/migrations/` in order +(`0001_initial_health_data.sql`, then `0002_per_user_health_data.sql`). Each user gets +their own row protected by row-level security. Optionally run `supabase/seed_demo.sql` +to add the synthetic demo dataset that new accounts browse before uploading their own data. +``` + +In `README.md`, replace the "**Data note:**" paragraph with: + +```markdown +**Data note:** each account's health data is stored in its own row in a shared Supabase instance, protected by row-level security so only the signed-in owner can read or write it. If you want your data to never leave your own infrastructure, use the self-hosted path below. +``` + +In `CLAUDE.md`, replace the whole "### Database" subsection (heading through the RLS SQL block) with: + +```markdown +### Database + +Single table `health_data` (see `supabase/migrations/`). One row per user (`user_id`, unique) plus at most one read-only synthetic demo row (`is_demo = true`). RLS: a signed-in user can read/write only their own row and read the demo row; anonymous users get nothing. The API forwards the caller's JWT to PostgREST so RLS enforces this. Upsert from the client with `onConflict: 'user_id'`. +``` + +- [ ] **Step 8: Commit** + +```bash +git add supabase README.md CLAUDE.md +git commit -m "feat(db): per-user health_data with RLS, demo row, rollback and RLS check" +``` + +--- + +### Task 3: Supabase HTTP helper and JWT verification + +**Files:** +- Create: `requirements-dev.txt`, `pytest.ini` +- Create: `api/supabase_http.py`, `api/auth.py` +- Create: `tests/api/conftest.py`, `tests/api/test_auth.py` + +**Interfaces:** +- Produces (`api/supabase_http.py`): + - `supabase_env() -> tuple[str | None, str | None]` — `(url, anon_key)` with trailing `/` stripped, or `(None, None)` when unset or placeholder. + - `supabase_configured() -> bool` + - `http_get_json(url: str, headers: dict, timeout: int = 3) -> tuple[int, object | None]` — `(status, parsed_json)`; `(code, None)` on HTTP error; `(0, None)` on network/parse failure. Never raises. +- Produces (`api/auth.py`): + - `class AuthError(Exception)` + - `class AuthContext` with attributes `user_id: str | None`, `token: str | None` + - `extract_bearer_token(headers) -> str | None` (headers is any object with `.get`) + - `verify_token(token: str) -> str` — returns the user id or raises `AuthError` + - `authenticate(headers) -> AuthContext` — local-dev `AuthContext()` (both `None`) when Supabase is not configured; otherwise verified context or raises `AuthError`. +- Produces (`tests/api/conftest.py`): fixtures `fake_supabase` (monkeypatches `api.supabase_http.http_get_json`; returns a state dict) and `call` (HTTP round-trip helper, used in Task 5). + +- [ ] **Step 1: Add test tooling** + +`requirements-dev.txt`: +```text +pytest>=8 +``` + +`pytest.ini`: +```ini +[pytest] +pythonpath = . +testpaths = tests +``` + +- [ ] **Step 2: Write shared fixtures** — `tests/api/conftest.py` + +```python +import json +import threading +import urllib.error +import urllib.request +from http.server import HTTPServer + +import pytest + + +@pytest.fixture +def fake_supabase(monkeypatch): + """Configure Supabase env and replace http_get_json with an RLS-emulating fake.""" + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + state = { + "users": {"token-a": "user-a", "token-b": "user-b"}, + "rows": {"user-a": {"who": "a"}, "user-b": {"who": "b"}}, + "demo": {"who": "demo"}, + "calls": [], + } + + def fake_get(url, headers, timeout=3): + state["calls"].append((url, dict(headers))) + token = headers.get("Authorization", "").removeprefix("Bearer ") + uid = state["users"].get(token) + if "/auth/v1/user" in url: + return (200, {"id": uid}) if uid else (401, None) + if not uid: # anon / bad token sees nothing under RLS + return 200, [] + if "user_id=eq." in url: + requested = url.split("user_id=eq.")[1].split("&")[0] + row = state["rows"].get(requested) if requested == uid else None + return 200, ([{"data": row}] if row else []) + if "is_demo=eq.true" in url: + return 200, ([{"data": state["demo"]}] if state["demo"] else []) + return 200, [] + + monkeypatch.setattr("api.supabase_http.http_get_json", fake_get) + return state + + +@pytest.fixture +def no_supabase(monkeypatch): + monkeypatch.delenv("SUPABASE_URL", raising=False) + monkeypatch.delenv("SUPABASE_ANON_KEY", raising=False) + + +@pytest.fixture +def call(): + """call(handler_cls, method='GET', headers=None, body=None) -> (status, json).""" + servers = [] + + def _call(handler_cls, method="GET", headers=None, body=None): + server = HTTPServer(("127.0.0.1", 0), handler_cls) + servers.append(server) + threading.Thread(target=server.serve_forever, daemon=True).start() + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}/", + data=data, + method=method, + headers={"Content-Type": "application/json", **(headers or {})}, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + yield _call + for s in servers: + s.shutdown() + s.server_close() +``` + +- [ ] **Step 3: Write the failing tests** — `tests/api/test_auth.py` + +```python +import pytest + +from api.auth import AuthError, authenticate, extract_bearer_token, verify_token +from api.supabase_http import supabase_configured, supabase_env + + +def test_extract_bearer_token_valid(): + assert extract_bearer_token({"Authorization": "Bearer abc.def"}) == "abc.def" + + +@pytest.mark.parametrize("value", [None, "", "Bearer", "Bearer ", "Basic abc", "abc"]) +def test_extract_bearer_token_rejects_malformed(value): + headers = {} if value is None else {"Authorization": value} + assert extract_bearer_token(headers) is None + + +def test_supabase_env_unset(no_supabase): + assert supabase_env() == (None, None) + assert supabase_configured() is False + + +def test_supabase_env_placeholder_is_unconfigured(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://your-supabase.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "your_supabase_anon_key") + assert supabase_configured() is False + + +def test_supabase_env_strips_trailing_slash(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co/") + monkeypatch.setenv("SUPABASE_ANON_KEY", "k") + assert supabase_env() == ("https://example.supabase.co", "k") + + +def test_verify_token_returns_user_id(fake_supabase): + assert verify_token("token-a") == "user-a" + + +def test_verify_token_rejects_unknown_token(fake_supabase): + with pytest.raises(AuthError): + verify_token("nope") + + +def test_authenticate_local_dev_needs_no_token(no_supabase): + ctx = authenticate({}) + assert ctx.user_id is None and ctx.token is None + + +def test_authenticate_requires_token_when_configured(fake_supabase): + with pytest.raises(AuthError): + authenticate({}) + + +def test_authenticate_returns_verified_context(fake_supabase): + ctx = authenticate({"Authorization": "Bearer token-b"}) + assert ctx.user_id == "user-b" and ctx.token == "token-b" + + +def test_authenticate_rejects_bad_token(fake_supabase): + with pytest.raises(AuthError): + authenticate({"Authorization": "Bearer nope"}) +``` + +- [ ] **Step 4: Run to verify failure** + +Run: `python -m pytest tests/api/test_auth.py -v` +Expected: collection error `ModuleNotFoundError: No module named 'api.auth'`. + +- [ ] **Step 5: Implement** — `api/supabase_http.py` + +```python +import json +import os +import urllib.error +import urllib.request + + +def supabase_env(): + """Return (url, anon_key), or (None, None) when Supabase is not configured.""" + url = os.environ.get("SUPABASE_URL", "").strip().rstrip("/") + anon_key = os.environ.get("SUPABASE_ANON_KEY", "").strip() + if not url or not anon_key: + return None, None + if "your-supabase" in url or "your_supabase" in anon_key.lower(): + return None, None + return url, anon_key + + +def supabase_configured(): + return supabase_env()[0] is not None + + +def http_get_json(url, headers, timeout=3): + """GET a URL; return (status, parsed_json). Never raises: failures give (code, None) or (0, None).""" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, None + except (urllib.error.URLError, TimeoutError, ValueError): + return 0, None +``` + +`api/auth.py`: + +```python +import api.supabase_http as supabase_http + + +class AuthError(Exception): + pass + + +class AuthContext: + """Who is calling. Both fields are None in local dev (Supabase not configured).""" + + def __init__(self, user_id=None, token=None): + self.user_id = user_id + self.token = token + + +def extract_bearer_token(headers): + scheme, _, token = (headers.get("Authorization") or "").strip().partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return None + return token.strip() + + +def verify_token(token): + """Ask Supabase Auth who owns this token. Returns the user id or raises AuthError.""" + url, anon_key = supabase_http.supabase_env() + status, body = supabase_http.http_get_json( + f"{url}/auth/v1/user", + {"apikey": anon_key, "Authorization": f"Bearer {token}"}, + ) + if status != 200 or not body or not body.get("id"): + raise AuthError("Invalid or expired session.") + return body["id"] + + +def authenticate(headers): + if not supabase_http.supabase_configured(): + return AuthContext() + token = extract_bearer_token(headers) + if not token: + raise AuthError("Sign in required.") + return AuthContext(user_id=verify_token(token), token=token) +``` + +- [ ] **Step 6: Run to verify pass** + +Run: `python -m pytest tests/api/test_auth.py -v` +Expected: all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add requirements-dev.txt pytest.ini api/supabase_http.py api/auth.py tests/api +git commit -m "feat(api): verify Supabase JWTs and add Supabase env/http helper" +``` + +--- + +### Task 4: Per-user data loading in `chat_core` + +**Files:** +- Modify: `api/chat_core.py:47-86` (replace `fetch_supabase_health_data` and `load_health_data`) +- Create: `tests/api/test_chat_core_data.py` + +**Interfaces:** +- Consumes: `api.supabase_http.{supabase_env, supabase_configured, http_get_json}`, `api.auth.AuthContext`. +- Produces: `fetch_supabase_health_data(auth) -> dict | None` — caller's row, else the demo row, else `None`; queries use the caller's JWT. `load_health_data(local_path=None, auth=None) -> dict | None` — when Supabase is configured, only Supabase (never the local file, never without a token); otherwise the local file exactly as before. + +- [ ] **Step 1: Write the failing tests** — `tests/api/test_chat_core_data.py` + +```python +import json + +from api.auth import AuthContext +from api.chat_core import fetch_supabase_health_data, load_health_data + + +def test_user_gets_their_own_row(fake_supabase): + ctx = AuthContext(user_id="user-a", token="token-a") + assert fetch_supabase_health_data(ctx) == {"who": "a"} + + +def test_user_without_row_gets_demo(fake_supabase): + fake_supabase["users"]["token-c"] = "user-c" + ctx = AuthContext(user_id="user-c", token="token-c") + assert fetch_supabase_health_data(ctx) == {"who": "demo"} + + +def test_no_row_and_no_demo_returns_none(fake_supabase): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["demo"] = None + ctx = AuthContext(user_id="user-c", token="token-c") + assert fetch_supabase_health_data(ctx) is None + + +def test_queries_use_the_callers_jwt_not_the_anon_key(fake_supabase): + fetch_supabase_health_data(AuthContext(user_id="user-a", token="token-a")) + for url, headers in fake_supabase["calls"]: + assert headers["Authorization"] == "Bearer token-a" + assert headers["apikey"] == "anon-key" + assert "limit=1" not in url + + +def test_never_requests_another_users_row(fake_supabase): + fetch_supabase_health_data(AuthContext(user_id="user-a", token="token-a")) + assert all("user-b" not in url for url, _ in fake_supabase["calls"]) + + +def test_configured_without_auth_returns_none_and_skips_local_file(fake_supabase, tmp_path): + local = tmp_path / "health_data.json" + local.write_text(json.dumps({"who": "local"})) + assert load_health_data(local_path=local) is None + assert fake_supabase["calls"] == [] + + +def test_configured_does_not_fall_back_to_local_file(fake_supabase, tmp_path): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["demo"] = None + local = tmp_path / "health_data.json" + local.write_text(json.dumps({"who": "local"})) + ctx = AuthContext(user_id="user-c", token="token-c") + assert load_health_data(local_path=local, auth=ctx) is None + + +def test_local_dev_reads_local_file(no_supabase, tmp_path): + local = tmp_path / "health_data.json" + local.write_text(json.dumps({"who": "local"})) + assert load_health_data(local_path=local) == {"who": "local"} + + +def test_local_dev_missing_file_returns_none(no_supabase, tmp_path): + assert load_health_data(local_path=tmp_path / "missing.json") is None +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/api/test_chat_core_data.py -v` +Expected: FAIL (`fetch_supabase_health_data() takes 0 positional arguments but 1 was given`, and `test_configured_without_auth...` fails because the old code reads the local file). + +- [ ] **Step 3: Implement** — in `api/chat_core.py`, add `import api.supabase_http as supabase_http` below the existing `from datetime import ...` import, then replace lines 47-86 (`fetch_supabase_health_data` and `load_health_data`) with: + +```python +def fetch_supabase_health_data(auth): + """Return the caller's own health data, else the shared demo data, else None. + + Queries run with the caller's JWT, so row-level security decides what is visible. + """ + supabase_url, anon_key = supabase_http.supabase_env() + if not supabase_url or auth is None or not auth.token or not auth.user_id: + return None + + headers = {"apikey": anon_key, "Authorization": f"Bearer {auth.token}"} + queries = [ + f"user_id=eq.{urllib.parse.quote(auth.user_id, safe='')}", + "is_demo=eq.true", + ] + for query in queries: + status, rows = supabase_http.http_get_json( + f"{supabase_url}/rest/v1/health_data?select=data&{query}", headers + ) + if status == 200 and rows: + return rows[0].get("data") + return None + + +def load_health_data(local_path=None, auth=None): + # When Supabase is configured it is the only source: no anon reads, no local fallback. + if supabase_http.supabase_configured(): + return fetch_supabase_health_data(auth) + + health_file = pathlib.Path(local_path) if local_path else ROOT / "health_data.json" + if not health_file.exists(): + return None + + with open(health_file) as f: + return json.load(f) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `python -m pytest tests -v` +Expected: all tests PASS (Task 3 tests still green). + +- [ ] **Step 5: Commit** + +```bash +git add api/chat_core.py tests/api/test_chat_core_data.py +git commit -m "feat(api): load health data per user via the caller's JWT, with demo fallback" +``` + +--- + +### Task 5: Authenticated `/api/health` and `/api/chat` + +**Files:** +- Modify: `api/health.py` (whole file), `api/chat.py:26-63` +- Create: `tests/api/test_handlers.py` + +**Interfaces:** +- Consumes: `api.auth.{authenticate, AuthError}`, `api.chat_core.load_health_data(local_path=None, auth=None)`. +- Produces: both handlers return `401 {"error": ""}` on `AuthError` before reading any data or calling any model; otherwise unchanged behavior. `_send_json(self, data, status=200)`. + +- [ ] **Step 1: Write the failing tests** — `tests/api/test_handlers.py` + +```python +import api.chat as chat_module +import api.health as health_module + + +def bearer(token): + return {"Authorization": f"Bearer {token}"} + + +def test_health_requires_token(fake_supabase, call): + status, body = call(health_module.handler) + assert status == 401 and "error" in body + + +def test_health_rejects_bad_token(fake_supabase, call): + status, _ = call(health_module.handler, headers=bearer("nope")) + assert status == 401 + + +def test_health_returns_only_callers_data(fake_supabase, call): + status, body = call(health_module.handler, headers=bearer("token-a")) + assert status == 200 and body == {"who": "a"} + + +def test_health_new_user_gets_demo(fake_supabase, call): + fake_supabase["users"]["token-c"] = "user-c" + status, body = call(health_module.handler, headers=bearer("token-c")) + assert status == 200 and body == {"who": "demo"} + + +def test_health_local_dev_needs_no_token(no_supabase, call, monkeypatch): + monkeypatch.setattr(health_module, "load_health_data", lambda auth=None: {"who": "local"}) + status, body = call(health_module.handler) + assert status == 200 and body == {"who": "local"} + + +def test_health_no_data_message_preserved(fake_supabase, call): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["demo"] = None + status, body = call(health_module.handler, headers=bearer("token-c")) + assert status == 200 and body == {"error": "No health data found."} + + +def test_chat_requires_token_and_never_calls_the_model(fake_supabase, call, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "server-key") + calls = [] + monkeypatch.setattr(chat_module, "chat_with_claude", lambda *a, **k: calls.append(a) or "x") + status, body = call( + chat_module.handler, "POST", body={"message": "hi", "chatMode": "claude"} + ) + assert status == 401 and "error" in body + assert calls == [] + + +def test_chat_uses_the_callers_own_data(fake_supabase, call, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "server-key") + seen = {} + + def fake_claude(message, history, health_data, api_key, model): + seen["health_data"] = health_data + return "stub reply" + + monkeypatch.setattr(chat_module, "chat_with_claude", fake_claude) + status, body = call( + chat_module.handler, + "POST", + headers=bearer("token-b"), + body={"message": "hi", "chatMode": "claude"}, + ) + assert status == 200 and body["reply"] == "stub reply" + assert seen["health_data"] == {"who": "b"} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/api/test_handlers.py -v` +Expected: FAIL (handlers return 200 / read the wrong data; `monkeypatch.setattr(health_module, "load_health_data", ...)` also needs the handler to accept `auth`). + +- [ ] **Step 3: Implement** — `api/health.py` (full file): + +```python +from http.server import BaseHTTPRequestHandler +import json + +from api.auth import AuthError, authenticate +from api.chat_core import load_health_data + + +class handler(BaseHTTPRequestHandler): + def do_GET(self): + try: + auth = authenticate(self.headers) + except AuthError as e: + self._send_json({"error": str(e)}, 401) + return + + data = load_health_data(auth=auth) + if data is None: + data = {"error": "No health data found."} + self._send_json(data) + + def _send_json(self, data, status=200): + response = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + def log_message(self, format, *args): + pass +``` + +`api/chat.py`: add `from api.auth import AuthError, authenticate` after `import os`; then replace the start of `do_POST` and `_send_json`: + +```python + def do_POST(self): + try: + auth = authenticate(self.headers) + except AuthError as e: + self._send_json({"error": str(e)}, 401) + return + + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + + health_data = load_health_data(auth=auth) +``` + +(the rest of `do_POST` is unchanged), and: + +```python + def _send_json(self, data, status=200): + response = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `python -m pytest tests -v` +Expected: all tests PASS. + +- [ ] **Step 5: Confirm local dev still imports cleanly** + +Run: `python -c "import server"` (from the repo root) +Expected: no error (`server.py` still calls `load_health_data(local_path=...)`, which remains valid). + +- [ ] **Step 6: Commit** + +```bash +git add api/health.py api/chat.py tests/api/test_handlers.py +git commit -m "feat(api): require a verified Supabase session for /api/health and /api/chat" +``` + +--- + +### Task 6: Synthetic demo dataset generator + +**Files:** +- Create: `scripts/make_demo_seed.py`, `tests/scripts/test_make_demo_seed.py` +- Create (generated): `supabase/seed_demo.sql` + +**Interfaces:** +- Produces: `build_demo_health_data(days=120, end=date(2026, 9, 30), seed=42) -> dict` in the documented `HealthData` shape (`daily` dict keyed by ISO date, `summary`, `events`, `generated_at`); `build_seed_sql(data) -> str` (idempotent upsert of the demo row); `python scripts/make_demo_seed.py` writes `supabase/seed_demo.sql`. + +- [ ] **Step 1: Write the failing tests** — `tests/scripts/test_make_demo_seed.py` + +```python +from datetime import date + +from scripts.make_demo_seed import build_demo_health_data, build_seed_sql + +DAILY_KEYS = { + "date", "steps", "resting_hr", "hrv", "sleep_hours", + "active_calories", "exercise_minutes", "spo2", +} + + +def test_is_deterministic(): + assert build_demo_health_data() == build_demo_health_data() + + +def test_daily_shape_and_length(): + data = build_demo_health_data(days=30, end=date(2026, 9, 30)) + assert len(data["daily"]) == 30 + assert "2026-09-30" in data["daily"] and "2026-09-01" in data["daily"] + for key, entry in data["daily"].items(): + assert set(entry) == DAILY_KEYS and entry["date"] == key + + +def test_values_are_plausible(): + for entry in build_demo_health_data()["daily"].values(): + assert 1000 <= entry["steps"] <= 30000 + assert 4 <= entry["sleep_hours"] <= 10 + assert 40 <= entry["resting_hr"] <= 90 + assert 0 < entry["hrv"] < 150 + + +def test_summary_matches_daily(): + data = build_demo_health_data() + daily = list(data["daily"].values()) + s = data["summary"] + assert s["total_days"] == len(daily) + assert s["best_sleep"] == max(d["sleep_hours"] for d in daily) + assert s["worst_sleep"] == min(d["sleep_hours"] for d in daily) + assert s["best_steps_day"] == max(daily, key=lambda d: d["steps"])["date"] + assert s["avg_steps"] == round(sum(d["steps"] for d in daily) / len(daily)) + + +def test_events_are_labeled_as_sample_data(): + data = build_demo_health_data() + assert data["events"] and all("Sample" in e["label"] for e in data["events"]) + assert {"label", "start", "end", "color", "icon"} <= set(data["events"][0]) + + +def test_seed_sql_is_an_idempotent_demo_upsert(): + sql = build_seed_sql({"note": "it's fine"}) + assert "insert into public.health_data (is_demo, data)" in sql + assert "on conflict (is_demo) where is_demo do update" in sql + assert "it''s fine" in sql # single quotes escaped +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/scripts -v` +Expected: collection error `ModuleNotFoundError: No module named 'scripts'`. + +- [ ] **Step 3: Implement** — `scripts/make_demo_seed.py` (with an empty `scripts/__init__.py`) + +```python +"""Generate supabase/seed_demo.sql: a synthetic, deterministic demo dataset. + +Run from the repo root: python scripts/make_demo_seed.py +""" +import json +import pathlib +import random +from datetime import date, timedelta + +ROOT = pathlib.Path(__file__).parent.parent + + +def build_demo_health_data(days=120, end=date(2026, 9, 30), seed=42): + rng = random.Random(seed) + daily = {} + for i in range(days): + key = (end - timedelta(days=days - 1 - i)).isoformat() + daily[key] = { + "date": key, + "steps": int(min(25000, max(1500, rng.gauss(8000, 2200)))), + "resting_hr": round(min(80, max(48, rng.gauss(61, 3))), 1), + "hrv": round(min(100, max(20, rng.gauss(48, 8))), 1), + "sleep_hours": round(min(10, max(4, rng.gauss(7.1, 0.9))), 2), + "active_calories": int(max(100, rng.gauss(420, 90))), + "exercise_minutes": int(max(0, rng.gauss(35, 15))), + "spo2": round(min(100, max(94, rng.gauss(97.5, 0.6))), 1), + } + + rows = list(daily.values()) + + def avg(field): + return sum(r[field] for r in rows) / len(rows) + + summary = { + "avg_steps": round(avg("steps")), + "avg_sleep_hours": round(avg("sleep_hours"), 1), + "avg_resting_hr": round(avg("resting_hr"), 1), + "avg_hrv": round(avg("hrv"), 1), + "best_sleep": max(r["sleep_hours"] for r in rows), + "worst_sleep": min(r["sleep_hours"] for r in rows), + "best_steps_day": max(rows, key=lambda r: r["steps"])["date"], + "total_days": len(rows), + } + exam_start = end - timedelta(days=20) + events = [ + { + "label": "Sample exam week", + "start": exam_start.isoformat(), + "end": (exam_start + timedelta(days=6)).isoformat(), + "color": "#ef4444", + "icon": "books", + } + ] + return { + "daily": daily, + "summary": summary, + "events": events, + "generated_at": "2026-09-30T00:00:00", + } + + +def build_seed_sql(data): + payload = json.dumps(data, separators=(",", ":")).replace("'", "''") + return ( + "-- Synthetic demo data. Generated by scripts/make_demo_seed.py; do not edit by hand.\n" + "insert into public.health_data (is_demo, data)\n" + f"values (true, '{payload}'::jsonb)\n" + "on conflict (is_demo) where is_demo do update\n" + " set data = excluded.data, updated_at = now();\n" + ) + + +if __name__ == "__main__": + out = ROOT / "supabase" / "seed_demo.sql" + out.write_text(build_seed_sql(build_demo_health_data()), encoding="utf-8") + print(f"Wrote {out}") +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `python -m pytest tests -v` +Expected: all tests PASS. + +- [ ] **Step 5: Generate the seed and apply it to the scratch project** + +```bash +python scripts/make_demo_seed.py +``` +Paste `supabase/seed_demo.sql` into the scratch project's SQL editor and run it twice (idempotent). Then `select count(*) from public.health_data where is_demo;` → expected `1`. Re-run `supabase/tests/rls_check.sql` → expected `ALL RLS CHECKS PASSED` (the check inserts its own demo row inside the rolled-back transaction, so delete the seeded row first if the unique index complains: `delete from public.health_data where is_demo;`, then re-seed afterwards). + +- [ ] **Step 6: Commit** + +```bash +git add scripts supabase/seed_demo.sql tests/scripts +git commit -m "feat(db): deterministic synthetic demo dataset and seed SQL" +``` + +--- + +### Task 7: Patch the legacy `index.html` + +**Files:** +- Modify: `index.html` (5 spots: helper before `// ── Auth ──`, `doReparse` ~line 1701, upload ~1902, `sendMessage` ~2144, `loadAppData` ~2285) + +**Interfaces:** +- Consumes: global `supabaseClient` (set in `initSupabase`), `currentUser`. +- Produces: `async function authHeaders()` returning `{ Authorization: 'Bearer ' }` or `{}`. + +There is no JS test harness for the legacy page (it is replaced in later plans); this task is verified manually in Task 9. Make the edits exactly: + +- [ ] **Step 1: Add the helper.** Immediately above the line `// ── Auth ─────────────────────────────────────────────────` (before `async function initSupabase()`), insert: + +```js +async function authHeaders() { + if (!supabaseClient) return {}; + const { data: { session } } = await supabaseClient.auth.getSession(); + return session?.access_token ? { Authorization: 'Bearer ' + session.access_token } : {}; +} + +``` + +- [ ] **Step 2: Upload by `user_id`.** Replace + +```js + const { error } = await supabaseClient.from('health_data').upsert({ id: 1, data: result }, { onConflict: 'id' }); +``` +with +```js + const { error } = await supabaseClient.from('health_data').upsert({ user_id: currentUser.id, data: result }, { onConflict: 'user_id' }); +``` + +- [ ] **Step 3: Send the token on chat.** In `sendMessage`, replace + +```js + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + message: msg, +``` +with +```js + method:'POST', headers:{'Content-Type':'application/json', ...(await authHeaders())}, + body: JSON.stringify({ + message: msg, +``` + +- [ ] **Step 4: Send the token when loading data.** In `loadAppData`, replace + +```js + fetch('/api/health'), + fetch('/api/config') +``` +with +```js + authHeaders().then(h => fetch('/api/health', { headers: h })), + fetch('/api/config') +``` + +- [ ] **Step 5: Keep the (unused) reparse path consistent.** In `doReparse`, replace `const hr = await fetch('/api/health');` with `const hr = await fetch('/api/health', { headers: await authHeaders() });`. + +- [ ] **Step 6: Verify no stale call sites remain** + +Run: `grep -n "id: 1\|onConflict: 'id'\|fetch('/api/health')" index.html` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add index.html +git commit -m "fix(legacy-ui): send Supabase token to the API and upsert by user_id" +``` + +--- + +### Task 8: CI + +**Files:** +- Create: `.github/workflows/tests.yml` + +- [ ] **Step 1: Write the workflow** + +```yaml +name: tests +on: + pull_request: + push: + branches: [master] +jobs: + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements-dev.txt + - run: python -m pytest -v +``` + +- [ ] **Step 2: Run the suite locally one last time** + +Run: `python -m pytest -v` +Expected: all tests PASS. + +- [ ] **Step 3: Commit and open the PR** + +```bash +git add .github +git commit -m "ci: run pytest on pull requests" +git push -u origin feat/per-user-data +gh pr create --base master --title "Per-user health data and authenticated API (Step 0)" --body "Implements Step 0 of docs/superpowers/specs/2026-10-03-somada-modernization-design.md. Do not merge until the deploy checklist in the plan (Task 9) is ready: the migration and the code deploy must go out together." +``` + +--- + +### Task 9: Deploy and verify end to end (manual, requires a short maintenance window) + +**Files:** +- None. This is the cutover checklist. Because the old frontend/API and the new schema are not mutually compatible, do steps 3-5 back to back, at a quiet time. Expect a few minutes of errors for anyone with the app open. + +- [ ] **Step 1: Verify on a Vercel preview against the scratch project.** In Vercel, set the *Preview* environment's `SUPABASE_URL` / `SUPABASE_ANON_KEY` to the scratch project (and an `ANTHROPIC_API_KEY`). Open the PR's preview URL. Create two accounts (A and B). + - A with no upload sees the demo banner and the synthetic demo data. + - A uploads an `export.xml`; the dashboard shows A's data. + - B signs in on another browser: B sees demo data, **not** A's data. B uploads; A still sees A's data (refresh). + - Chat answers for A reference A's metrics. + - In DevTools console, signed out: `fetch('/api/health').then(r => r.status)` → `401`. + - In the Supabase SQL editor on scratch: `select user_id, is_demo from public.health_data;` → one row per account plus the demo row. + +- [ ] **Step 2: Announce the short maintenance window** to the ~10 users (existing users will need to re-upload; their data was never stored per user). + +- [ ] **Step 3: Apply `0002_per_user_health_data.sql` and then `supabase/seed_demo.sql` to production** (SQL editor). + +- [ ] **Step 4: Merge the PR to `master`** so Vercel deploys production. + +- [ ] **Step 5: Smoke test production** with two real accounts, repeating the Step 1 checks. Also confirm anonymous access is closed: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" https://somada.vercel.app/api/health +``` +Expected: `401`. + +- [ ] **Step 6: If something is badly broken**, run `supabase/rollback/0002_down.sql` and redeploy the previous Vercel deployment (Vercel dashboard → Deployments → previous → Promote). The legacy row (`id = 1`) was never deleted, so the old behavior returns. + +- [ ] **Step 7 (optional): Assign the legacy row to its owner.** If row `id = 1` is yours, in the SQL editor: `update public.health_data set user_id = '' where id = 1;`. Otherwise leave it orphaned (invisible) or delete it after confirming your backup from Task 1. + +--- + +## Self-Review + +**Spec coverage (Step 0 section):** +- Migration with `user_id`, RLS scoped to `auth.uid()`, public read removed → Task 2. +- `api/auth.py` verifying `Authorization: Bearer `, per-user reads, 401 on missing/invalid token, no `limit=1` fallback → Tasks 3-5 (the `limit=1` absence is asserted in `test_queries_use_the_callers_jwt_not_the_anon_key`). +- Legacy `index.html` minimally patched (token on API calls, upsert with `user_id`) → Task 7. +- Existing legacy row handling → Task 1 (backup) + Task 2 (orphan, non-destructive) + Task 9 Step 7. +- BYOK keys browser-only → untouched; `chat.py` still receives them per request and does not log them (`log_message` is a no-op). +- Demo behavior (a gap the spec did not cover, resolved with the user: dedicated synthetic demo row) → Tasks 2 and 6. +- `server.py` local dev unchanged → Task 5 Step 5. +- CI (spec "Testing and CI") → Task 8, pytest only; Vitest/Playwright arrive with `web/`. +- Deliberately deferred: per-user rate limiting on `/api/chat` (spec lists it under the API layer, not under Step 0) — add to the Step 2 plan. + +**Placeholder scan:** none; every code step has complete code. + +**Type consistency:** `AuthContext(user_id, token)`, `authenticate(headers)`, `AuthError`, `supabase_env()`, `supabase_configured()`, `http_get_json(url, headers, timeout)`, `load_health_data(local_path=None, auth=None)` and `fetch_supabase_health_data(auth)` are named identically in the interfaces, tests and implementations. The tests patch `api.supabase_http.http_get_json`, and both `auth.py` and `chat_core.py` call it via the `supabase_http.` module attribute so the patch takes effect. + +**Known risks to watch during execution:** (1) the `auth.users` insert in `rls_check.sql` may need extra NOT NULL columns on some Supabase versions (called out in Task 2 Step 6); (2) the live schema may differ from the README (Task 1 gates this); (3) Vercel treats every `.py` under `api/` as a function, but `chat_core.py` already lives there without a handler and deploys fine, so `auth.py` and `supabase_http.py` follow the same pattern — confirm on the preview deploy in Task 9 Step 1. diff --git a/docs/superpowers/specs/2026-10-03-somada-modernization-design.md b/docs/superpowers/specs/2026-10-03-somada-modernization-design.md new file mode 100644 index 0000000..80702bc --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-somada-modernization-design.md @@ -0,0 +1,100 @@ +# Somada Modernization — Design + +Date: 2026-10-03 +Status: Draft for review + +## Goals + +1. Close the privacy hole: today every user shares one publicly readable row. +2. Replace the single 2,325-line `index.html` with a typed, tested, componentized frontend. +3. Address the three known user complaints: generic AI chat, confusing navigation, painful onboarding. +4. Keep the live app working for existing users throughout (staged migration, no big-bang cutover). + +## Current state (as found in the repo) + +- Frontend: one `index.html`, vanilla JS, Chart.js and Supabase loaded from CDNs. No build step, no TypeScript, no tests. +- Backend: Python Vercel functions in `api/` (`chat.py`, `chat_core.py` at 634 lines, `health.py`, `config.py`, `supabase_config.py`), plus local-only `server.py` and `parse_health.py`. +- Data: one Supabase table `health_data(id integer PK default 1, data jsonb, updated_at)`. The frontend upserts `{ id: 1 }` (`index.html:1902`); `api/chat_core.py` reads `id=eq.1`. The read policy is `USING (true)`. +- AI: Gemini (Vertex) and Claude (`claude-sonnet-4-6`), server keys or BYOK. Health context is already injected into the system prompt. +- `LIFE_EVENTS` is duplicated in `index.html` and `parse_health.py`. + +Note: the live schema may differ from the README. It must be verified before the Step 0 migration is finalized. + +## Target architecture + +```text +Somada/ +|- web/ # NEW: Vite + React + TypeScript (replaces index.html) +|- api/ # Python functions, kept and authenticated +|- supabase/migrations/ # NEW: schema as versioned SQL +|- index.html # legacy; live until cutover, then deleted +``` + +## Step 0 — Privacy fix (ships first) + +- Migration adds `user_id uuid references auth.users`, makes it the primary key, and drops `id=1`. +- RLS: select/insert/update only where `user_id = auth.uid()`. Remove `allow_public_read`. +- API: shared `api/auth.py` verifies the `Authorization: Bearer ` header against Supabase and returns `user_id`. `chat.py` and `health.py` load only that user's row. Missing or invalid token returns 401. No `limit=1` fallback. +- Legacy `index.html` gets a minimal patch: send the token on API calls, upsert with `user_id`. +- Existing row: either assign to the owner's account or wipe and have users re-upload. Decided after the live schema check. +- BYOK keys remain browser-only; the server never stores or logs them. + +## New frontend (`web/`) + +Stack: Vite, React, TypeScript (strict), React Router, TanStack Query, a charting library behind a thin wrapper (Recharts or Chart.js), Vitest, one Playwright smoke test. Supabase JS from npm. + +```text +web/src/ +|- lib/health/ # parse worker, types (HealthData, DailyEntry), summary calc +|- lib/api/ # typed /api/* client (attaches JWT) +|- lib/supabase/ # client, auth hook +|- features/ +| |- onboarding/ # guided upload, sample-data preview +| |- dashboard/ # metric views, time windows, event annotations +| |- insights/ # chat, provider picker, BYOK +| |- settings/ +|- app/ # shell, routing, navigation +``` + +Decisions: + +- **Parsing** runs in a streaming Web Worker, unit-tested against a small fixture export. The `HealthData` JSON shape is unchanged so the API and existing data stay compatible. +- **Life events** become user data stored in the same row and editable in the UI, removing the duplicated constant and the "edit code, re-upload" workflow. +- **Navigation**: persistent shell (Dashboard, Insights, Settings) with plain-language metric explainers (e.g. "What is HRV?"). +- **Onboarding**: step-by-step export guide, parse progress, and a first-chart preview before saving. +- **Chat grounding**: the chat sends the active date range and selected events as context to `/api/chat`. +- **Deploy**: Vercel builds `web/` to `dist`; `vercel.json` routes `/api/*` to Python and the rest to the SPA, with the legacy page at `/legacy` until cutover. + +## API and AI layer + +- `config.py` and `supabase_config.py` remain public (no sensitive data). +- `server.py` and `parse_health.py` are removed at cutover; local dev uses `vercel dev`. +- Per-user rate limiting on `/api/chat` to protect shared server keys. +- Model IDs come from env vars with no hard-coded defaults, so upgrades are config changes. Current defaults (`claude-sonnet-4-6`, Gemini 2.5 Flash) are checked against the latest model IDs during implementation. +- `build_system_prompt` is covered by tests: fixed fixture in, prompt contains the 30-day summary, events and selected date range. +- Agentic retrieval (Option B in `design/Context.md`) is out of scope; the API shape leaves room for it. + +## Testing and CI + +- Vitest: parser, summary math, API client. +- Pytest: JWT auth, prompt building. +- Playwright: sign in with a test account, upload a fixture, see charts. +- GitHub Actions: lint, typecheck, tests on every PR. + +## Rollout (one PR per step) + +1. Step 0: migration, API auth, legacy patch. Verify with a second test account. +2. Scaffold `web/` with auth and shell on a preview URL. +3. Port parsing and onboarding. +4. Port the dashboard. +5. Port insights with grounded chat. +6. Cutover: root serves `web/`; delete `index.html`, `server.py`, `parse_health.py`; update README and CLAUDE.md. + +## Out of scope + +Physician PDF export and share links (natural follow-up once data is per-user), agentic retrieval, relational schema redesign, porting the API to TypeScript. + +## Open items + +- Live Supabase schema and row contents (blocks the Step 0 migration decision). +- Charting library choice (Recharts vs Chart.js wrapper), to settle in the implementation plan. From 69c6cb7dae6cee23446d34416d9bc07b50a2f2ec Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:39:54 -0400 Subject: [PATCH 02/14] feat(db): per-user health_data with RLS, demo row, rollback and RLS check Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 18 +---- README.md | 23 ++----- .../migrations/0001_initial_health_data.sql | 7 ++ .../migrations/0002_per_user_health_data.sql | 44 ++++++++++++ supabase/rollback/0002_down.sql | 12 ++++ supabase/tests/rls_check.sql | 68 +++++++++++++++++++ 6 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 supabase/migrations/0001_initial_health_data.sql create mode 100644 supabase/migrations/0002_per_user_health_data.sql create mode 100644 supabase/rollback/0002_down.sql create mode 100644 supabase/tests/rls_check.sql diff --git a/CLAUDE.md b/CLAUDE.md index 15563e1..31d5b13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,23 +66,7 @@ Users can also save their own API key (Vertex or Claude) in the browser from Set ### Database -Single table: - -```sql -CREATE TABLE health_data ( - id integer PRIMARY KEY DEFAULT 1, - data jsonb NOT NULL, - updated_at timestamptz DEFAULT now() -); - -ALTER TABLE health_data ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "allow_public_read" ON health_data - FOR SELECT USING (true); - -CREATE POLICY "allow_auth_write" ON health_data - FOR ALL USING (auth.role() = 'authenticated'); -``` +Single table `health_data` (see `supabase/migrations/`). One row per user (`user_id`, unique) plus at most one read-only synthetic demo row (`is_demo = true`). RLS: a signed-in user can read/write only their own row and read the demo row; anonymous users get nothing. The API forwards the caller's JWT to PostgREST so RLS enforces this. Upsert from the client with `onConflict: 'user_id'`. --- diff --git a/README.md b/README.md index 4116568..f4a4cb3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Want to explore without setting anything up? Use the hosted version at [somada.a - AI chat (Gemini and Claude) is available out of the box — no API key needed. - You can also bring your own Vertex AI or Anthropic key in Settings if you prefer to route chat through your own account. -**Data note:** your health data is stored in a shared Supabase instance. If you want your data to never leave your own infrastructure, use the self-hosted path below. +**Data note:** each account's health data is stored in its own row in a shared Supabase instance, protected by row-level security so only the signed-in owner can read or write it. If you want your data to never leave your own infrastructure, use the self-hosted path below. --- @@ -24,23 +24,10 @@ The repo is open source. Fork or clone it and run everything yourself. Your data #### 1. Create the Supabase table -Run this in your Supabase SQL editor: - -```sql -CREATE TABLE health_data ( - id integer PRIMARY KEY DEFAULT 1, - data jsonb NOT NULL, - updated_at timestamptz DEFAULT now() -); - -ALTER TABLE health_data ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "allow_public_read" ON health_data - FOR SELECT USING (true); - -CREATE POLICY "allow_auth_write" ON health_data - FOR ALL USING (auth.role() = 'authenticated'); -``` +In your Supabase SQL editor, run the files in `supabase/migrations/` in order +(`0001_initial_health_data.sql`, then `0002_per_user_health_data.sql`). Each user gets +their own row protected by row-level security. Optionally run `supabase/seed_demo.sql` +to add the synthetic demo dataset that new accounts browse before uploading their own data. #### 2. Deploy to Vercel diff --git a/supabase/migrations/0001_initial_health_data.sql b/supabase/migrations/0001_initial_health_data.sql new file mode 100644 index 0000000..acac998 --- /dev/null +++ b/supabase/migrations/0001_initial_health_data.sql @@ -0,0 +1,7 @@ +-- FRESH INSTALLS ONLY. Existing deployments already have this table; skip to 0002. +create table if not exists public.health_data ( + id integer primary key default 1, + data jsonb not null, + updated_at timestamptz default now() +); +alter table public.health_data enable row level security; diff --git a/supabase/migrations/0002_per_user_health_data.sql b/supabase/migrations/0002_per_user_health_data.sql new file mode 100644 index 0000000..743b567 --- /dev/null +++ b/supabase/migrations/0002_per_user_health_data.sql @@ -0,0 +1,44 @@ +-- Per-user health data with row-level security. +-- Safe to run once on the live database. The legacy shared row (id = 1) is left in +-- place but becomes invisible to every role (user_id null, is_demo false), so it can +-- be assigned to its owner later or used for rollback. +begin; + +-- id stays the primary key but stops defaulting to 1. +create sequence if not exists public.health_data_id_seq owned by public.health_data.id; +select setval('public.health_data_id_seq', + greatest((select coalesce(max(id), 1) from public.health_data), 1)); +alter table public.health_data + alter column id set default nextval('public.health_data_id_seq'); + +alter table public.health_data + add column user_id uuid references auth.users (id) on delete cascade, + add column is_demo boolean not null default false; + +-- Plain unique constraint (NULLs are distinct) so PostgREST on_conflict=user_id works. +alter table public.health_data + add constraint health_data_user_id_key unique (user_id); +-- At most one demo row. +create unique index health_data_single_demo on public.health_data (is_demo) where is_demo; +-- A demo row has no owner. +alter table public.health_data + add constraint health_data_demo_has_no_owner check (not is_demo or user_id is null); + +drop policy if exists "allow_public_read" on public.health_data; +drop policy if exists "allow_auth_write" on public.health_data; + +create policy "own_row_select" on public.health_data + for select to authenticated using (user_id = (select auth.uid())); +create policy "demo_row_select" on public.health_data + for select to authenticated using (is_demo); +create policy "own_row_insert" on public.health_data + for insert to authenticated + with check (user_id = (select auth.uid()) and not is_demo); +create policy "own_row_update" on public.health_data + for update to authenticated + using (user_id = (select auth.uid())) + with check (user_id = (select auth.uid()) and not is_demo); +create policy "own_row_delete" on public.health_data + for delete to authenticated using (user_id = (select auth.uid())); + +commit; diff --git a/supabase/rollback/0002_down.sql b/supabase/rollback/0002_down.sql new file mode 100644 index 0000000..1ca0d9f --- /dev/null +++ b/supabase/rollback/0002_down.sql @@ -0,0 +1,12 @@ +-- Emergency revert: restores the old shared, publicly readable behavior. +-- Columns are kept; the legacy row (id = 1) is still intact, so the old app works again. +begin; +drop policy if exists "own_row_select" on public.health_data; +drop policy if exists "demo_row_select" on public.health_data; +drop policy if exists "own_row_insert" on public.health_data; +drop policy if exists "own_row_update" on public.health_data; +drop policy if exists "own_row_delete" on public.health_data; +create policy "allow_public_read" on public.health_data for select using (true); +create policy "allow_auth_write" on public.health_data + for all using (auth.role() = 'authenticated'); +commit; diff --git a/supabase/tests/rls_check.sql b/supabase/tests/rls_check.sql new file mode 100644 index 0000000..6e35e12 --- /dev/null +++ b/supabase/tests/rls_check.sql @@ -0,0 +1,68 @@ +-- Run in the SQL editor of the SCRATCH project after applying 0001 and 0002. +-- Everything happens in one transaction that is rolled back. A failed assertion +-- raises an exception; success prints ALL RLS CHECKS PASSED at the end. +begin; + +insert into auth.users (id, email) values + ('00000000-0000-0000-0000-0000000000a1', 'a@test.local'), + ('00000000-0000-0000-0000-0000000000b2', 'b@test.local'), + ('00000000-0000-0000-0000-0000000000c3', 'c@test.local'); + +insert into public.health_data (user_id, data) values + ('00000000-0000-0000-0000-0000000000a1', '{"who":"a"}'), + ('00000000-0000-0000-0000-0000000000b2', '{"who":"b"}'); +insert into public.health_data (is_demo, data) values (true, '{"who":"demo"}'); + +set local role authenticated; +select set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-0000000000a1', true); +select set_config('request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-0000000000a1","role":"authenticated"}', true); + +do $$ +declare n int; +begin + select count(*) into n from public.health_data where data->>'who' = 'b'; + if n <> 0 then raise exception 'FAIL: user A can read user B row'; end if; + + select count(*) into n from public.health_data where data->>'who' = 'a'; + if n <> 1 then raise exception 'FAIL: user A cannot read own row'; end if; + + select count(*) into n from public.health_data where is_demo; + if n <> 1 then raise exception 'FAIL: user A cannot read demo row'; end if; + + update public.health_data set data = '{"who":"hacked"}' where data->>'who' = 'b'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A updated user B row'; end if; + + update public.health_data set data = '{"who":"hacked"}' where is_demo; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A updated the demo row'; end if; + + begin + insert into public.health_data (user_id, data) + values ('00000000-0000-0000-0000-0000000000c3', '{}'); + raise exception 'FAIL: user A inserted a row for user C'; + exception when insufficient_privilege then null; + end; + + begin + insert into public.health_data (is_demo, data) values (true, '{}'); + raise exception 'FAIL: user A inserted a demo row'; + exception when insufficient_privilege or unique_violation then null; + end; +end $$; + +reset role; +set local role anon; +do $$ +declare n int; +begin + begin + select count(*) into n from public.health_data; + if n <> 0 then raise exception 'FAIL: anon can read % rows', n; end if; + exception when insufficient_privilege then null; + end; +end $$; + +rollback; +select 'ALL RLS CHECKS PASSED' as result; From da9952d28c0ab9d82e8688d46322d4648d5540ad Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:42:33 -0400 Subject: [PATCH 03/14] fix(db): strengthen RLS check, grant sequence usage, harden rollback Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/0002_per_user_health_data.sql | 1 + supabase/rollback/0002_down.sql | 5 ++- supabase/tests/rls_check.sql | 37 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/supabase/migrations/0002_per_user_health_data.sql b/supabase/migrations/0002_per_user_health_data.sql index 743b567..fe5c563 100644 --- a/supabase/migrations/0002_per_user_health_data.sql +++ b/supabase/migrations/0002_per_user_health_data.sql @@ -10,6 +10,7 @@ select setval('public.health_data_id_seq', greatest((select coalesce(max(id), 1) from public.health_data), 1)); alter table public.health_data alter column id set default nextval('public.health_data_id_seq'); +grant usage, select on sequence public.health_data_id_seq to authenticated; alter table public.health_data add column user_id uuid references auth.users (id) on delete cascade, diff --git a/supabase/rollback/0002_down.sql b/supabase/rollback/0002_down.sql index 1ca0d9f..9e07476 100644 --- a/supabase/rollback/0002_down.sql +++ b/supabase/rollback/0002_down.sql @@ -1,6 +1,9 @@ --- Emergency revert: restores the old shared, publicly readable behavior. +-- EMERGENCY USE ONLY. Restores the old shared, publicly readable behavior. +-- WARNING: re-creating allow_public_read exposes EVERY per-user row (not just id = 1) +-- to anon. Only run this if you accept all stored health data being publicly readable. -- Columns are kept; the legacy row (id = 1) is still intact, so the old app works again. begin; +alter table public.health_data alter column id set default 1; drop policy if exists "own_row_select" on public.health_data; drop policy if exists "demo_row_select" on public.health_data; drop policy if exists "own_row_insert" on public.health_data; diff --git a/supabase/tests/rls_check.sql b/supabase/tests/rls_check.sql index 6e35e12..4427921 100644 --- a/supabase/tests/rls_check.sql +++ b/supabase/tests/rls_check.sql @@ -12,6 +12,8 @@ insert into public.health_data (user_id, data) values ('00000000-0000-0000-0000-0000000000a1', '{"who":"a"}'), ('00000000-0000-0000-0000-0000000000b2', '{"who":"b"}'); insert into public.health_data (is_demo, data) values (true, '{"who":"demo"}'); +-- Legacy shared row: no owner, not demo. Must be invisible to every non-superuser role. +insert into public.health_data (id, data) values (1, '{"who":"legacy"}'); set local role authenticated; select set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-0000000000a1', true); @@ -30,10 +32,22 @@ begin select count(*) into n from public.health_data where is_demo; if n <> 1 then raise exception 'FAIL: user A cannot read demo row'; end if; + select count(*) into n from public.health_data where data->>'who' = 'legacy'; + if n <> 0 then raise exception 'FAIL: user A can read the legacy row'; end if; + + -- Positive: A can update their own row (proves RLS, not missing grants, is what blocks below). + update public.health_data set data = '{"who":"a","v":2}' where data->>'who' = 'a'; + get diagnostics n = row_count; + if n <> 1 then raise exception 'FAIL: user A cannot update own row'; end if; + update public.health_data set data = '{"who":"hacked"}' where data->>'who' = 'b'; get diagnostics n = row_count; if n <> 0 then raise exception 'FAIL: user A updated user B row'; end if; + update public.health_data set data = '{"who":"hacked"}' where data->>'who' = 'legacy'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A updated the legacy row'; end if; + update public.health_data set data = '{"who":"hacked"}' where is_demo; get diagnostics n = row_count; if n <> 0 then raise exception 'FAIL: user A updated the demo row'; end if; @@ -52,6 +66,25 @@ begin end; end $$; +-- Positive: a user with no row yet (C) can insert their own row and read it back. +-- Runs after the "A inserts a row for C" negative above, while C still had no row. +select set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-0000000000c3', true); +select set_config('request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-0000000000c3","role":"authenticated"}', true); + +do $$ +declare n int; +begin + insert into public.health_data (user_id, data) + values ('00000000-0000-0000-0000-0000000000c3', '{"who":"c"}'); + + select count(*) into n from public.health_data where data->>'who' = 'c'; + if n <> 1 then raise exception 'FAIL: user C cannot read own newly inserted row'; end if; + + select count(*) into n from public.health_data where data->>'who' in ('a', 'b', 'legacy'); + if n <> 0 then raise exception 'FAIL: user C can read other rows'; end if; +end $$; + reset role; set local role anon; do $$ @@ -60,6 +93,10 @@ begin begin select count(*) into n from public.health_data; if n <> 0 then raise exception 'FAIL: anon can read % rows', n; end if; + + update public.health_data set data = '{"who":"hacked"}' where data->>'who' = 'legacy'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: anon updated the legacy row'; end if; exception when insufficient_privilege then null; end; end $$; From 8e44525174bf46cd31fe5c7326b887e63ad1622e Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:45:47 -0400 Subject: [PATCH 04/14] feat(api): verify Supabase JWTs and add Supabase env/http helper Co-Authored-By: Claude Sonnet 5.5 --- api/auth.py | 41 ++++++++++++++++++++++++ api/supabase_http.py | 31 ++++++++++++++++++ pytest.ini | 3 ++ requirements-dev.txt | 1 + tests/api/conftest.py | 73 ++++++++++++++++++++++++++++++++++++++++++ tests/api/test_auth.py | 60 ++++++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 api/auth.py create mode 100644 api/supabase_http.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/api/conftest.py create mode 100644 tests/api/test_auth.py diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..f3f2fb7 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,41 @@ +import api.supabase_http as supabase_http + + +class AuthError(Exception): + pass + + +class AuthContext: + """Who is calling. Both fields are None in local dev (Supabase not configured).""" + + def __init__(self, user_id=None, token=None): + self.user_id = user_id + self.token = token + + +def extract_bearer_token(headers): + scheme, _, token = (headers.get("Authorization") or "").strip().partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return None + return token.strip() + + +def verify_token(token): + """Ask Supabase Auth who owns this token. Returns the user id or raises AuthError.""" + url, anon_key = supabase_http.supabase_env() + status, body = supabase_http.http_get_json( + f"{url}/auth/v1/user", + {"apikey": anon_key, "Authorization": f"Bearer {token}"}, + ) + if status != 200 or not body or not body.get("id"): + raise AuthError("Invalid or expired session.") + return body["id"] + + +def authenticate(headers): + if not supabase_http.supabase_configured(): + return AuthContext() + token = extract_bearer_token(headers) + if not token: + raise AuthError("Sign in required.") + return AuthContext(user_id=verify_token(token), token=token) diff --git a/api/supabase_http.py b/api/supabase_http.py new file mode 100644 index 0000000..0c4954b --- /dev/null +++ b/api/supabase_http.py @@ -0,0 +1,31 @@ +import json +import os +import urllib.error +import urllib.request + + +def supabase_env(): + """Return (url, anon_key), or (None, None) when Supabase is not configured.""" + url = os.environ.get("SUPABASE_URL", "").strip().rstrip("/") + anon_key = os.environ.get("SUPABASE_ANON_KEY", "").strip() + if not url or not anon_key: + return None, None + if "your-supabase" in url or "your_supabase" in anon_key.lower(): + return None, None + return url, anon_key + + +def supabase_configured(): + return supabase_env()[0] is not None + + +def http_get_json(url, headers, timeout=3): + """GET a URL; return (status, parsed_json). Never raises: failures give (code, None) or (0, None).""" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, None + except (urllib.error.URLError, TimeoutError, ValueError): + return 0, None diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..cc52e8c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=8 diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..b3821e3 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,73 @@ +import json +import threading +import urllib.error +import urllib.request +from http.server import HTTPServer + +import pytest + + +@pytest.fixture +def fake_supabase(monkeypatch): + """Configure Supabase env and replace http_get_json with an RLS-emulating fake.""" + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + state = { + "users": {"token-a": "user-a", "token-b": "user-b"}, + "rows": {"user-a": {"who": "a"}, "user-b": {"who": "b"}}, + "demo": {"who": "demo"}, + "calls": [], + } + + def fake_get(url, headers, timeout=3): + state["calls"].append((url, dict(headers))) + token = headers.get("Authorization", "").removeprefix("Bearer ") + uid = state["users"].get(token) + if "/auth/v1/user" in url: + return (200, {"id": uid}) if uid else (401, None) + if not uid: # anon / bad token sees nothing under RLS + return 200, [] + if "user_id=eq." in url: + requested = url.split("user_id=eq.")[1].split("&")[0] + row = state["rows"].get(requested) if requested == uid else None + return 200, ([{"data": row}] if row else []) + if "is_demo=eq.true" in url: + return 200, ([{"data": state["demo"]}] if state["demo"] else []) + return 200, [] + + monkeypatch.setattr("api.supabase_http.http_get_json", fake_get) + return state + + +@pytest.fixture +def no_supabase(monkeypatch): + monkeypatch.delenv("SUPABASE_URL", raising=False) + monkeypatch.delenv("SUPABASE_ANON_KEY", raising=False) + + +@pytest.fixture +def call(): + """call(handler_cls, method='GET', headers=None, body=None) -> (status, json).""" + servers = [] + + def _call(handler_cls, method="GET", headers=None, body=None): + server = HTTPServer(("127.0.0.1", 0), handler_cls) + servers.append(server) + threading.Thread(target=server.serve_forever, daemon=True).start() + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}/", + data=data, + method=method, + headers={"Content-Type": "application/json", **(headers or {})}, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + yield _call + for s in servers: + s.shutdown() + s.server_close() diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..07aaeb5 --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,60 @@ +import pytest + +from api.auth import AuthError, authenticate, extract_bearer_token, verify_token +from api.supabase_http import supabase_configured, supabase_env + + +def test_extract_bearer_token_valid(): + assert extract_bearer_token({"Authorization": "Bearer abc.def"}) == "abc.def" + + +@pytest.mark.parametrize("value", [None, "", "Bearer", "Bearer ", "Basic abc", "abc"]) +def test_extract_bearer_token_rejects_malformed(value): + headers = {} if value is None else {"Authorization": value} + assert extract_bearer_token(headers) is None + + +def test_supabase_env_unset(no_supabase): + assert supabase_env() == (None, None) + assert supabase_configured() is False + + +def test_supabase_env_placeholder_is_unconfigured(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://your-supabase.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "your_supabase_anon_key") + assert supabase_configured() is False + + +def test_supabase_env_strips_trailing_slash(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co/") + monkeypatch.setenv("SUPABASE_ANON_KEY", "k") + assert supabase_env() == ("https://example.supabase.co", "k") + + +def test_verify_token_returns_user_id(fake_supabase): + assert verify_token("token-a") == "user-a" + + +def test_verify_token_rejects_unknown_token(fake_supabase): + with pytest.raises(AuthError): + verify_token("nope") + + +def test_authenticate_local_dev_needs_no_token(no_supabase): + ctx = authenticate({}) + assert ctx.user_id is None and ctx.token is None + + +def test_authenticate_requires_token_when_configured(fake_supabase): + with pytest.raises(AuthError): + authenticate({}) + + +def test_authenticate_returns_verified_context(fake_supabase): + ctx = authenticate({"Authorization": "Bearer token-b"}) + assert ctx.user_id == "user-b" and ctx.token == "token-b" + + +def test_authenticate_rejects_bad_token(fake_supabase): + with pytest.raises(AuthError): + authenticate({"Authorization": "Bearer nope"}) From 8061590506a97fb94e7ec46815a8ddab628fa950 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:49:09 -0400 Subject: [PATCH 05/14] fix(api): add robustness to Supabase JWT verification and HTTP handling - api/auth.py verify_token: guard against non-dict JSON responses using isinstance check - api/supabase_http.py http_get_json: catch OSError and http.client.HTTPException to ensure no exceptions escape (ConnectionResetError, RemoteDisconnected, IncompleteRead, etc) - add tests for edge cases: non-dict/empty responses, network errors Co-Authored-By: Claude Sonnet 5.5 --- api/auth.py | 2 +- api/supabase_http.py | 3 ++- tests/api/test_auth.py | 30 +++++++++++++++++++++++++++ tests/api/test_supabase_http.py | 36 +++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_supabase_http.py diff --git a/api/auth.py b/api/auth.py index f3f2fb7..4eee53a 100644 --- a/api/auth.py +++ b/api/auth.py @@ -27,7 +27,7 @@ def verify_token(token): f"{url}/auth/v1/user", {"apikey": anon_key, "Authorization": f"Bearer {token}"}, ) - if status != 200 or not body or not body.get("id"): + if status != 200 or not isinstance(body, dict) or not body.get("id"): raise AuthError("Invalid or expired session.") return body["id"] diff --git a/api/supabase_http.py b/api/supabase_http.py index 0c4954b..3121b8f 100644 --- a/api/supabase_http.py +++ b/api/supabase_http.py @@ -1,3 +1,4 @@ +import http.client import json import os import urllib.error @@ -27,5 +28,5 @@ def http_get_json(url, headers, timeout=3): return resp.status, json.loads(resp.read()) except urllib.error.HTTPError as e: return e.code, None - except (urllib.error.URLError, TimeoutError, ValueError): + except (urllib.error.URLError, OSError, http.client.HTTPException, ValueError): return 0, None diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 07aaeb5..b899bbe 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -58,3 +58,33 @@ def test_authenticate_returns_verified_context(fake_supabase): def test_authenticate_rejects_bad_token(fake_supabase): with pytest.raises(AuthError): authenticate({"Authorization": "Bearer nope"}) + + +def test_verify_token_rejects_non_dict_body(fake_supabase, monkeypatch): + """Test that verify_token handles non-dict JSON responses (e.g., list).""" + monkeypatch.setattr( + "api.supabase_http.http_get_json", + lambda url, headers, timeout=3: (200, [1]) + ) + with pytest.raises(AuthError): + verify_token("token-a") + + +def test_verify_token_rejects_missing_id(fake_supabase, monkeypatch): + """Test that verify_token rejects dict response without id field.""" + monkeypatch.setattr( + "api.supabase_http.http_get_json", + lambda url, headers, timeout=3: (200, {}) + ) + with pytest.raises(AuthError): + verify_token("token-a") + + +def test_verify_token_rejects_network_error(fake_supabase, monkeypatch): + """Test that verify_token rejects network error responses.""" + monkeypatch.setattr( + "api.supabase_http.http_get_json", + lambda url, headers, timeout=3: (0, None) + ) + with pytest.raises(AuthError): + verify_token("token-a") diff --git a/tests/api/test_supabase_http.py b/tests/api/test_supabase_http.py new file mode 100644 index 0000000..2fde18b --- /dev/null +++ b/tests/api/test_supabase_http.py @@ -0,0 +1,36 @@ +import http.client +import urllib.error + +import pytest + +from api.supabase_http import http_get_json + + +def test_http_get_json_handles_connection_reset(monkeypatch): + """Test that http_get_json returns (0, None) on ConnectionResetError.""" + def mock_urlopen(req, timeout=3): + raise ConnectionResetError("Connection reset by peer") + + monkeypatch.setattr("urllib.request.urlopen", mock_urlopen) + status, body = http_get_json("http://example.com", {}) + assert status == 0 and body is None + + +def test_http_get_json_handles_remote_disconnected(monkeypatch): + """Test that http_get_json returns (0, None) on http.client.RemoteDisconnected.""" + def mock_urlopen(req, timeout=3): + raise http.client.RemoteDisconnected("Remote end closed connection") + + monkeypatch.setattr("urllib.request.urlopen", mock_urlopen) + status, body = http_get_json("http://example.com", {}) + assert status == 0 and body is None + + +def test_http_get_json_handles_http_error(monkeypatch): + """Test that http_get_json returns (code, None) on HTTPError.""" + def mock_urlopen(req, timeout=3): + raise urllib.error.HTTPError("http://example.com", 401, "Unauthorized", {}, None) + + monkeypatch.setattr("urllib.request.urlopen", mock_urlopen) + status, body = http_get_json("http://example.com", {}) + assert status == 401 and body is None From 745e4c016d134dd51b0edd6f0d3fae0fea220cae Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:52:28 -0400 Subject: [PATCH 06/14] feat(api): load health data per user via the caller's JWT, with demo fallback Co-Authored-By: Claude Sonnet 5.5 --- api/chat_core.py | 50 ++++++++++++-------------- tests/api/test_chat_core_data.py | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 tests/api/test_chat_core_data.py diff --git a/api/chat_core.py b/api/chat_core.py index bcf0712..eea9011 100644 --- a/api/chat_core.py +++ b/api/chat_core.py @@ -7,6 +7,8 @@ import urllib.request from datetime import datetime, timedelta +import api.supabase_http as supabase_http + ROOT = pathlib.Path(__file__).parent.parent VERTEX_API_BASE = "https://aiplatform.googleapis.com/v1" ANTHROPIC_API_BASE = "https://api.anthropic.com/v1" @@ -44,39 +46,33 @@ def get_claude_model(): return (os.environ.get("CLAUDE_MODEL") or "").strip() or DEFAULT_CLAUDE_MODEL -def fetch_supabase_health_data(): - supabase_url = os.environ.get("SUPABASE_URL", "").strip() - supabase_anon_key = os.environ.get("SUPABASE_ANON_KEY", "").strip() - if not supabase_url or not supabase_anon_key: - return None - if "your-supabase" in supabase_url or "your_supabase" in supabase_anon_key.lower(): +def fetch_supabase_health_data(auth): + """Return the caller's own health data, else the shared demo data, else None. + + Queries run with the caller's JWT, so row-level security decides what is visible. + """ + supabase_url, anon_key = supabase_http.supabase_env() + if not supabase_url or auth is None or not auth.token or not auth.user_id: return None - urls = [ - f"{supabase_url}/rest/v1/health_data?select=data&id=eq.1", - f"{supabase_url}/rest/v1/health_data?select=data&limit=1", + headers = {"apikey": anon_key, "Authorization": f"Bearer {auth.token}"} + queries = [ + f"user_id=eq.{urllib.parse.quote(auth.user_id, safe='')}", + "is_demo=eq.true", ] - headers = { - "apikey": supabase_anon_key, - "Authorization": f"Bearer {supabase_anon_key}", - } - - for url in urls: - try: - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=3) as resp: - rows = json.loads(resp.read()) - if rows: - return rows[0].get("data") - except Exception: - continue + for query in queries: + status, rows = supabase_http.http_get_json( + f"{supabase_url}/rest/v1/health_data?select=data&{query}", headers + ) + if status == 200 and rows: + return rows[0].get("data") return None -def load_health_data(local_path=None): - data = fetch_supabase_health_data() - if data is not None: - return data +def load_health_data(local_path=None, auth=None): + # When Supabase is configured it is the only source: no anon reads, no local fallback. + if supabase_http.supabase_configured(): + return fetch_supabase_health_data(auth) health_file = pathlib.Path(local_path) if local_path else ROOT / "health_data.json" if not health_file.exists(): diff --git a/tests/api/test_chat_core_data.py b/tests/api/test_chat_core_data.py new file mode 100644 index 0000000..ff6fbd0 --- /dev/null +++ b/tests/api/test_chat_core_data.py @@ -0,0 +1,61 @@ +import json + +from api.auth import AuthContext +from api.chat_core import fetch_supabase_health_data, load_health_data + + +def test_user_gets_their_own_row(fake_supabase): + ctx = AuthContext(user_id="user-a", token="token-a") + assert fetch_supabase_health_data(ctx) == {"who": "a"} + + +def test_user_without_row_gets_demo(fake_supabase): + fake_supabase["users"]["token-c"] = "user-c" + ctx = AuthContext(user_id="user-c", token="token-c") + assert fetch_supabase_health_data(ctx) == {"who": "demo"} + + +def test_no_row_and_no_demo_returns_none(fake_supabase): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["demo"] = None + ctx = AuthContext(user_id="user-c", token="token-c") + assert fetch_supabase_health_data(ctx) is None + + +def test_queries_use_the_callers_jwt_not_the_anon_key(fake_supabase): + fetch_supabase_health_data(AuthContext(user_id="user-a", token="token-a")) + for url, headers in fake_supabase["calls"]: + assert headers["Authorization"] == "Bearer token-a" + assert headers["apikey"] == "anon-key" + assert "limit=1" not in url + + +def test_never_requests_another_users_row(fake_supabase): + fetch_supabase_health_data(AuthContext(user_id="user-a", token="token-a")) + assert all("user-b" not in url for url, _ in fake_supabase["calls"]) + + +def test_configured_without_auth_returns_none_and_skips_local_file(fake_supabase, tmp_path): + local = tmp_path / "health_data.json" + local.write_text(json.dumps({"who": "local"})) + assert load_health_data(local_path=local) is None + assert fake_supabase["calls"] == [] + + +def test_configured_does_not_fall_back_to_local_file(fake_supabase, tmp_path): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["demo"] = None + local = tmp_path / "health_data.json" + local.write_text(json.dumps({"who": "local"})) + ctx = AuthContext(user_id="user-c", token="token-c") + assert load_health_data(local_path=local, auth=ctx) is None + + +def test_local_dev_reads_local_file(no_supabase, tmp_path): + local = tmp_path / "health_data.json" + local.write_text(json.dumps({"who": "local"})) + assert load_health_data(local_path=local) == {"who": "local"} + + +def test_local_dev_missing_file_returns_none(no_supabase, tmp_path): + assert load_health_data(local_path=tmp_path / "missing.json") is None From 2acecf7d7c1053d980e469b24a4e349491261fe7 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:56:47 -0400 Subject: [PATCH 07/14] feat(api): require a verified Supabase session for /api/health and /api/chat Co-Authored-By: Claude Sonnet 5.5 --- api/chat.py | 13 +++++-- api/health.py | 13 +++++-- tests/api/test_handlers.py | 70 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 tests/api/test_handlers.py diff --git a/api/chat.py b/api/chat.py index 8eb2bdc..9282a6a 100644 --- a/api/chat.py +++ b/api/chat.py @@ -2,6 +2,7 @@ import json import os +from api.auth import AuthError, authenticate from api.chat_core import ( chat_with_claude, chat_with_vertex, @@ -25,10 +26,16 @@ def resolve_provider(body): class handler(BaseHTTPRequestHandler): def do_POST(self): + try: + auth = authenticate(self.headers) + except AuthError as e: + self._send_json({"error": str(e)}, 401) + return + length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) - health_data = load_health_data() + health_data = load_health_data(auth=auth) if not health_data: self._send_json({"reply": "No health data available."}) return @@ -54,9 +61,9 @@ def do_POST(self): self._send_json({"reply": reply, "mode": mode, "model": model}) - def _send_json(self, data): + def _send_json(self, data, status=200): response = json.dumps(data).encode() - self.send_response(200) + self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(response))) self.end_headers() diff --git a/api/health.py b/api/health.py index 50b5fac..d5f97a2 100644 --- a/api/health.py +++ b/api/health.py @@ -1,19 +1,26 @@ from http.server import BaseHTTPRequestHandler import json +from api.auth import AuthError, authenticate from api.chat_core import load_health_data class handler(BaseHTTPRequestHandler): def do_GET(self): - data = load_health_data() + try: + auth = authenticate(self.headers) + except AuthError as e: + self._send_json({"error": str(e)}, 401) + return + + data = load_health_data(auth=auth) if data is None: data = {"error": "No health data found."} self._send_json(data) - def _send_json(self, data): + def _send_json(self, data, status=200): response = json.dumps(data).encode() - self.send_response(200) + self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(response))) self.end_headers() diff --git a/tests/api/test_handlers.py b/tests/api/test_handlers.py new file mode 100644 index 0000000..3083755 --- /dev/null +++ b/tests/api/test_handlers.py @@ -0,0 +1,70 @@ +import api.chat as chat_module +import api.health as health_module + + +def bearer(token): + return {"Authorization": f"Bearer {token}"} + + +def test_health_requires_token(fake_supabase, call): + status, body = call(health_module.handler) + assert status == 401 and "error" in body + + +def test_health_rejects_bad_token(fake_supabase, call): + status, _ = call(health_module.handler, headers=bearer("nope")) + assert status == 401 + + +def test_health_returns_only_callers_data(fake_supabase, call): + status, body = call(health_module.handler, headers=bearer("token-a")) + assert status == 200 and body == {"who": "a"} + + +def test_health_new_user_gets_demo(fake_supabase, call): + fake_supabase["users"]["token-c"] = "user-c" + status, body = call(health_module.handler, headers=bearer("token-c")) + assert status == 200 and body == {"who": "demo"} + + +def test_health_local_dev_needs_no_token(no_supabase, call, monkeypatch): + monkeypatch.setattr(health_module, "load_health_data", lambda auth=None: {"who": "local"}) + status, body = call(health_module.handler) + assert status == 200 and body == {"who": "local"} + + +def test_health_no_data_message_preserved(fake_supabase, call): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["demo"] = None + status, body = call(health_module.handler, headers=bearer("token-c")) + assert status == 200 and body == {"error": "No health data found."} + + +def test_chat_requires_token_and_never_calls_the_model(fake_supabase, call, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "server-key") + calls = [] + monkeypatch.setattr(chat_module, "chat_with_claude", lambda *a, **k: calls.append(a) or "x") + status, body = call( + chat_module.handler, "POST", body={"message": "hi", "chatMode": "claude"} + ) + assert status == 401 and "error" in body + assert calls == [] + + +def test_chat_uses_the_callers_own_data(fake_supabase, call, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "server-key") + seen = {} + + def fake_claude(message, history, health_data, api_key, model): + seen["health_data"] = health_data + return "stub reply" + + monkeypatch.setattr(chat_module, "chat_with_claude", fake_claude) + status, body = call( + chat_module.handler, + "POST", + headers=bearer("token-b"), + body={"message": "hi", "chatMode": "claude"}, + ) + assert status == 200 and body["reply"] == "stub reply" + assert seen["health_data"] == {"who": "b"} From d84f6e16148a1f50f4101d673f7614d7bf3e43bf Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 00:59:47 -0400 Subject: [PATCH 08/14] feat(db): deterministic synthetic demo dataset and seed SQL Adds build_demo_health_data() and build_seed_sql() in scripts/make_demo_seed.py to generate a deterministic, synthetic demo dataset for testing and seeding. The generated supabase/seed_demo.sql provides an idempotent upsert of the demo row that works across environments. Features: - 120 days of plausible health data (steps, sleep, HRV, etc.) from Gaussian distributions - Deterministic generation (seed=42) for reproducible testing - Summary statistics auto-calculated from daily data - Sample event (exam week) included in the demo - Idempotent SQL upsert with on conflict clause Includes: - scripts/__init__.py (empty package marker) - scripts/make_demo_seed.py (generator code) - tests/scripts/test_make_demo_seed.py (6 tests covering shape, determinism, plausibility, and SQL format) - supabase/seed_demo.sql (generated seed data) Co-Authored-By: Claude Sonnet 5.5 --- scripts/__init__.py | 0 scripts/make_demo_seed.py | 76 ++++++++++++++++++++++++++++ supabase/seed_demo.sql | 5 ++ tests/scripts/test_make_demo_seed.py | 52 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 scripts/make_demo_seed.py create mode 100644 supabase/seed_demo.sql create mode 100644 tests/scripts/test_make_demo_seed.py diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/make_demo_seed.py b/scripts/make_demo_seed.py new file mode 100644 index 0000000..c3110b9 --- /dev/null +++ b/scripts/make_demo_seed.py @@ -0,0 +1,76 @@ +"""Generate supabase/seed_demo.sql: a synthetic, deterministic demo dataset. + +Run from the repo root: python scripts/make_demo_seed.py +""" +import json +import pathlib +import random +from datetime import date, timedelta + +ROOT = pathlib.Path(__file__).parent.parent + + +def build_demo_health_data(days=120, end=date(2026, 9, 30), seed=42): + rng = random.Random(seed) + daily = {} + for i in range(days): + key = (end - timedelta(days=days - 1 - i)).isoformat() + daily[key] = { + "date": key, + "steps": int(min(25000, max(1500, rng.gauss(8000, 2200)))), + "resting_hr": round(min(80, max(48, rng.gauss(61, 3))), 1), + "hrv": round(min(100, max(20, rng.gauss(48, 8))), 1), + "sleep_hours": round(min(10, max(4, rng.gauss(7.1, 0.9))), 2), + "active_calories": int(max(100, rng.gauss(420, 90))), + "exercise_minutes": int(max(0, rng.gauss(35, 15))), + "spo2": round(min(100, max(94, rng.gauss(97.5, 0.6))), 1), + } + + rows = list(daily.values()) + + def avg(field): + return sum(r[field] for r in rows) / len(rows) + + summary = { + "avg_steps": round(avg("steps")), + "avg_sleep_hours": round(avg("sleep_hours"), 1), + "avg_resting_hr": round(avg("resting_hr"), 1), + "avg_hrv": round(avg("hrv"), 1), + "best_sleep": max(r["sleep_hours"] for r in rows), + "worst_sleep": min(r["sleep_hours"] for r in rows), + "best_steps_day": max(rows, key=lambda r: r["steps"])["date"], + "total_days": len(rows), + } + exam_start = end - timedelta(days=20) + events = [ + { + "label": "Sample exam week", + "start": exam_start.isoformat(), + "end": (exam_start + timedelta(days=6)).isoformat(), + "color": "#ef4444", + "icon": "books", + } + ] + return { + "daily": daily, + "summary": summary, + "events": events, + "generated_at": "2026-09-30T00:00:00", + } + + +def build_seed_sql(data): + payload = json.dumps(data, separators=(",", ":")).replace("'", "''") + return ( + "-- Synthetic demo data. Generated by scripts/make_demo_seed.py; do not edit by hand.\n" + "insert into public.health_data (is_demo, data)\n" + f"values (true, '{payload}'::jsonb)\n" + "on conflict (is_demo) where is_demo do update\n" + " set data = excluded.data, updated_at = now();\n" + ) + + +if __name__ == "__main__": + out = ROOT / "supabase" / "seed_demo.sql" + out.write_text(build_seed_sql(build_demo_health_data()), encoding="utf-8") + print(f"Wrote {out}") diff --git a/supabase/seed_demo.sql b/supabase/seed_demo.sql new file mode 100644 index 0000000..0a44aa9 --- /dev/null +++ b/supabase/seed_demo.sql @@ -0,0 +1,5 @@ +-- Synthetic demo data. Generated by scripts/make_demo_seed.py; do not edit by hand. +insert into public.health_data (is_demo, data) +values (true, '{"daily":{"2026-06-03":{"date":"2026-06-03","steps":7683,"resting_hr":60.5,"hrv":47.1,"sleep_hours":7.73,"active_calories":408,"exercise_minutes":12,"spo2":97.7},"2026-06-04":{"date":"2026-06-04","steps":7411,"resting_hr":60.3,"hrv":48.9,"sleep_hours":7.31,"active_calories":524,"exercise_minutes":44,"spo2":97.6},"2026-06-05":{"date":"2026-06-05","steps":6375,"resting_hr":58.0,"hrv":50.0,"sleep_hours":8.28,"active_calories":423,"exercise_minutes":33,"spo2":97.8},"2026-06-06":{"date":"2026-06-06","steps":4802,"resting_hr":60.1,"hrv":51.9,"sleep_hours":7.89,"active_calories":398,"exercise_minutes":40,"spo2":97.6},"2026-06-07":{"date":"2026-06-07","steps":9721,"resting_hr":57.7,"hrv":52.5,"sleep_hours":5.74,"active_calories":184,"exercise_minutes":25,"spo2":97.0},"2026-06-08":{"date":"2026-06-08","steps":9927,"resting_hr":63.0,"hrv":38.2,"sleep_hours":7.86,"active_calories":329,"exercise_minutes":33,"spo2":97.3},"2026-06-09":{"date":"2026-06-09","steps":8251,"resting_hr":63.5,"hrv":53.1,"sleep_hours":7.41,"active_calories":478,"exercise_minutes":42,"spo2":97.1},"2026-06-10":{"date":"2026-06-10","steps":6421,"resting_hr":59.6,"hrv":52.0,"sleep_hours":6.87,"active_calories":630,"exercise_minutes":22,"spo2":96.8},"2026-06-11":{"date":"2026-06-11","steps":9690,"resting_hr":65.3,"hrv":52.0,"sleep_hours":7.85,"active_calories":548,"exercise_minutes":33,"spo2":96.6},"2026-06-12":{"date":"2026-06-12","steps":6829,"resting_hr":63.9,"hrv":36.5,"sleep_hours":7.13,"active_calories":442,"exercise_minutes":30,"spo2":97.9},"2026-06-13":{"date":"2026-06-13","steps":9277,"resting_hr":68.0,"hrv":53.0,"sleep_hours":6.55,"active_calories":369,"exercise_minutes":22,"spo2":98.1},"2026-06-14":{"date":"2026-06-14","steps":6752,"resting_hr":60.8,"hrv":54.0,"sleep_hours":6.45,"active_calories":393,"exercise_minutes":7,"spo2":96.9},"2026-06-15":{"date":"2026-06-15","steps":6750,"resting_hr":62.2,"hrv":57.5,"sleep_hours":7.08,"active_calories":443,"exercise_minutes":37,"spo2":98.2},"2026-06-16":{"date":"2026-06-16","steps":9965,"resting_hr":61.8,"hrv":39.9,"sleep_hours":7.91,"active_calories":454,"exercise_minutes":53,"spo2":97.5},"2026-06-17":{"date":"2026-06-17","steps":12296,"resting_hr":59.9,"hrv":60.7,"sleep_hours":7.2,"active_calories":373,"exercise_minutes":18,"spo2":97.4},"2026-06-18":{"date":"2026-06-18","steps":11131,"resting_hr":63.4,"hrv":53.5,"sleep_hours":4.96,"active_calories":483,"exercise_minutes":43,"spo2":97.2},"2026-06-19":{"date":"2026-06-19","steps":6619,"resting_hr":61.0,"hrv":61.8,"sleep_hours":6.15,"active_calories":381,"exercise_minutes":55,"spo2":97.2},"2026-06-20":{"date":"2026-06-20","steps":7198,"resting_hr":61.3,"hrv":38.1,"sleep_hours":7.3,"active_calories":311,"exercise_minutes":48,"spo2":97.5},"2026-06-21":{"date":"2026-06-21","steps":13023,"resting_hr":61.8,"hrv":58.9,"sleep_hours":5.93,"active_calories":409,"exercise_minutes":39,"spo2":98.5},"2026-06-22":{"date":"2026-06-22","steps":4301,"resting_hr":64.0,"hrv":52.7,"sleep_hours":8.48,"active_calories":484,"exercise_minutes":35,"spo2":97.2},"2026-06-23":{"date":"2026-06-23","steps":5254,"resting_hr":61.6,"hrv":46.5,"sleep_hours":8.92,"active_calories":365,"exercise_minutes":39,"spo2":96.6},"2026-06-24":{"date":"2026-06-24","steps":7129,"resting_hr":61.8,"hrv":54.6,"sleep_hours":8.4,"active_calories":416,"exercise_minutes":18,"spo2":97.8},"2026-06-25":{"date":"2026-06-25","steps":9137,"resting_hr":62.5,"hrv":42.4,"sleep_hours":8.12,"active_calories":427,"exercise_minutes":45,"spo2":98.3},"2026-06-26":{"date":"2026-06-26","steps":9340,"resting_hr":61.9,"hrv":65.2,"sleep_hours":7.32,"active_calories":393,"exercise_minutes":36,"spo2":98.4},"2026-06-27":{"date":"2026-06-27","steps":8261,"resting_hr":62.6,"hrv":57.6,"sleep_hours":6.64,"active_calories":264,"exercise_minutes":39,"spo2":97.6},"2026-06-28":{"date":"2026-06-28","steps":6662,"resting_hr":63.6,"hrv":52.9,"sleep_hours":6.2,"active_calories":466,"exercise_minutes":32,"spo2":96.6},"2026-06-29":{"date":"2026-06-29","steps":8996,"resting_hr":60.9,"hrv":42.0,"sleep_hours":7.57,"active_calories":463,"exercise_minutes":26,"spo2":97.7},"2026-06-30":{"date":"2026-06-30","steps":10233,"resting_hr":58.9,"hrv":51.0,"sleep_hours":7.1,"active_calories":623,"exercise_minutes":7,"spo2":97.9},"2026-07-01":{"date":"2026-07-01","steps":7326,"resting_hr":60.7,"hrv":63.2,"sleep_hours":7.09,"active_calories":621,"exercise_minutes":28,"spo2":97.7},"2026-07-02":{"date":"2026-07-02","steps":6920,"resting_hr":58.9,"hrv":48.5,"sleep_hours":6.85,"active_calories":438,"exercise_minutes":3,"spo2":98.7},"2026-07-03":{"date":"2026-07-03","steps":7632,"resting_hr":66.2,"hrv":39.9,"sleep_hours":7.36,"active_calories":704,"exercise_minutes":21,"spo2":96.6},"2026-07-04":{"date":"2026-07-04","steps":6797,"resting_hr":62.4,"hrv":53.5,"sleep_hours":8.26,"active_calories":396,"exercise_minutes":10,"spo2":97.2},"2026-07-05":{"date":"2026-07-05","steps":10728,"resting_hr":62.4,"hrv":32.3,"sleep_hours":7.07,"active_calories":545,"exercise_minutes":66,"spo2":97.8},"2026-07-06":{"date":"2026-07-06","steps":8680,"resting_hr":57.3,"hrv":41.3,"sleep_hours":7.14,"active_calories":465,"exercise_minutes":43,"spo2":97.2},"2026-07-07":{"date":"2026-07-07","steps":5465,"resting_hr":58.7,"hrv":38.7,"sleep_hours":7.68,"active_calories":211,"exercise_minutes":30,"spo2":97.8},"2026-07-08":{"date":"2026-07-08","steps":11348,"resting_hr":61.2,"hrv":56.0,"sleep_hours":6.74,"active_calories":353,"exercise_minutes":24,"spo2":98.4},"2026-07-09":{"date":"2026-07-09","steps":10187,"resting_hr":62.5,"hrv":74.6,"sleep_hours":7.07,"active_calories":475,"exercise_minutes":39,"spo2":97.4},"2026-07-10":{"date":"2026-07-10","steps":13095,"resting_hr":65.5,"hrv":36.8,"sleep_hours":6.74,"active_calories":458,"exercise_minutes":46,"spo2":96.7},"2026-07-11":{"date":"2026-07-11","steps":3037,"resting_hr":55.3,"hrv":47.4,"sleep_hours":7.01,"active_calories":451,"exercise_minutes":23,"spo2":96.8},"2026-07-12":{"date":"2026-07-12","steps":3549,"resting_hr":62.0,"hrv":51.0,"sleep_hours":7.99,"active_calories":491,"exercise_minutes":32,"spo2":98.3},"2026-07-13":{"date":"2026-07-13","steps":7699,"resting_hr":59.0,"hrv":43.8,"sleep_hours":6.57,"active_calories":225,"exercise_minutes":37,"spo2":97.6},"2026-07-14":{"date":"2026-07-14","steps":7203,"resting_hr":58.9,"hrv":51.0,"sleep_hours":8.65,"active_calories":423,"exercise_minutes":27,"spo2":97.1},"2026-07-15":{"date":"2026-07-15","steps":7846,"resting_hr":57.2,"hrv":47.0,"sleep_hours":7.16,"active_calories":586,"exercise_minutes":49,"spo2":98.1},"2026-07-16":{"date":"2026-07-16","steps":6431,"resting_hr":63.0,"hrv":38.9,"sleep_hours":7.38,"active_calories":457,"exercise_minutes":23,"spo2":98.7},"2026-07-17":{"date":"2026-07-17","steps":9248,"resting_hr":55.3,"hrv":52.4,"sleep_hours":6.73,"active_calories":420,"exercise_minutes":41,"spo2":97.7},"2026-07-18":{"date":"2026-07-18","steps":3513,"resting_hr":57.5,"hrv":54.2,"sleep_hours":8.27,"active_calories":591,"exercise_minutes":62,"spo2":96.3},"2026-07-19":{"date":"2026-07-19","steps":9692,"resting_hr":55.3,"hrv":60.9,"sleep_hours":7.33,"active_calories":317,"exercise_minutes":65,"spo2":97.9},"2026-07-20":{"date":"2026-07-20","steps":3839,"resting_hr":61.9,"hrv":42.2,"sleep_hours":7.2,"active_calories":378,"exercise_minutes":55,"spo2":96.6},"2026-07-21":{"date":"2026-07-21","steps":8698,"resting_hr":66.4,"hrv":41.4,"sleep_hours":7.29,"active_calories":438,"exercise_minutes":24,"spo2":97.9},"2026-07-22":{"date":"2026-07-22","steps":8213,"resting_hr":58.2,"hrv":62.1,"sleep_hours":7.81,"active_calories":404,"exercise_minutes":25,"spo2":97.0},"2026-07-23":{"date":"2026-07-23","steps":10708,"resting_hr":60.6,"hrv":51.7,"sleep_hours":6.93,"active_calories":475,"exercise_minutes":33,"spo2":98.0},"2026-07-24":{"date":"2026-07-24","steps":7596,"resting_hr":63.5,"hrv":53.4,"sleep_hours":7.0,"active_calories":342,"exercise_minutes":20,"spo2":97.1},"2026-07-25":{"date":"2026-07-25","steps":5834,"resting_hr":63.6,"hrv":47.6,"sleep_hours":8.51,"active_calories":625,"exercise_minutes":35,"spo2":96.6},"2026-07-26":{"date":"2026-07-26","steps":7160,"resting_hr":60.8,"hrv":36.1,"sleep_hours":6.87,"active_calories":445,"exercise_minutes":28,"spo2":97.0},"2026-07-27":{"date":"2026-07-27","steps":6330,"resting_hr":66.4,"hrv":46.7,"sleep_hours":6.35,"active_calories":386,"exercise_minutes":49,"spo2":97.1},"2026-07-28":{"date":"2026-07-28","steps":9060,"resting_hr":63.2,"hrv":54.0,"sleep_hours":8.36,"active_calories":370,"exercise_minutes":51,"spo2":97.0},"2026-07-29":{"date":"2026-07-29","steps":7127,"resting_hr":64.6,"hrv":54.6,"sleep_hours":7.1,"active_calories":312,"exercise_minutes":43,"spo2":97.1},"2026-07-30":{"date":"2026-07-30","steps":5967,"resting_hr":59.1,"hrv":50.0,"sleep_hours":5.19,"active_calories":452,"exercise_minutes":37,"spo2":97.1},"2026-07-31":{"date":"2026-07-31","steps":10281,"resting_hr":62.8,"hrv":43.1,"sleep_hours":6.5,"active_calories":482,"exercise_minutes":12,"spo2":97.3},"2026-08-01":{"date":"2026-08-01","steps":6611,"resting_hr":61.2,"hrv":49.7,"sleep_hours":7.13,"active_calories":522,"exercise_minutes":38,"spo2":97.3},"2026-08-02":{"date":"2026-08-02","steps":5336,"resting_hr":63.2,"hrv":52.2,"sleep_hours":8.41,"active_calories":352,"exercise_minutes":35,"spo2":97.5},"2026-08-03":{"date":"2026-08-03","steps":7807,"resting_hr":61.0,"hrv":34.3,"sleep_hours":7.84,"active_calories":478,"exercise_minutes":51,"spo2":98.8},"2026-08-04":{"date":"2026-08-04","steps":7298,"resting_hr":60.9,"hrv":48.1,"sleep_hours":8.85,"active_calories":264,"exercise_minutes":42,"spo2":96.6},"2026-08-05":{"date":"2026-08-05","steps":2320,"resting_hr":54.9,"hrv":36.9,"sleep_hours":8.06,"active_calories":341,"exercise_minutes":31,"spo2":96.8},"2026-08-06":{"date":"2026-08-06","steps":9344,"resting_hr":57.7,"hrv":58.5,"sleep_hours":6.21,"active_calories":351,"exercise_minutes":36,"spo2":97.5},"2026-08-07":{"date":"2026-08-07","steps":11134,"resting_hr":61.4,"hrv":38.3,"sleep_hours":6.6,"active_calories":368,"exercise_minutes":45,"spo2":97.7},"2026-08-08":{"date":"2026-08-08","steps":7737,"resting_hr":63.6,"hrv":43.8,"sleep_hours":6.98,"active_calories":436,"exercise_minutes":57,"spo2":97.4},"2026-08-09":{"date":"2026-08-09","steps":7228,"resting_hr":57.9,"hrv":53.5,"sleep_hours":6.57,"active_calories":450,"exercise_minutes":6,"spo2":98.1},"2026-08-10":{"date":"2026-08-10","steps":5069,"resting_hr":58.7,"hrv":56.5,"sleep_hours":5.9,"active_calories":412,"exercise_minutes":36,"spo2":96.9},"2026-08-11":{"date":"2026-08-11","steps":10954,"resting_hr":65.5,"hrv":32.2,"sleep_hours":5.8,"active_calories":492,"exercise_minutes":15,"spo2":97.3},"2026-08-12":{"date":"2026-08-12","steps":8285,"resting_hr":63.1,"hrv":46.2,"sleep_hours":7.19,"active_calories":350,"exercise_minutes":54,"spo2":97.2},"2026-08-13":{"date":"2026-08-13","steps":8747,"resting_hr":59.5,"hrv":48.9,"sleep_hours":6.95,"active_calories":405,"exercise_minutes":19,"spo2":98.0},"2026-08-14":{"date":"2026-08-14","steps":11110,"resting_hr":61.7,"hrv":53.9,"sleep_hours":7.85,"active_calories":503,"exercise_minutes":39,"spo2":97.6},"2026-08-15":{"date":"2026-08-15","steps":9900,"resting_hr":61.2,"hrv":40.2,"sleep_hours":7.12,"active_calories":250,"exercise_minutes":34,"spo2":97.7},"2026-08-16":{"date":"2026-08-16","steps":10719,"resting_hr":61.5,"hrv":55.4,"sleep_hours":7.1,"active_calories":327,"exercise_minutes":13,"spo2":96.9},"2026-08-17":{"date":"2026-08-17","steps":4994,"resting_hr":62.9,"hrv":46.3,"sleep_hours":7.61,"active_calories":426,"exercise_minutes":50,"spo2":98.1},"2026-08-18":{"date":"2026-08-18","steps":6580,"resting_hr":60.2,"hrv":43.7,"sleep_hours":5.64,"active_calories":480,"exercise_minutes":52,"spo2":97.5},"2026-08-19":{"date":"2026-08-19","steps":6914,"resting_hr":64.3,"hrv":29.2,"sleep_hours":7.26,"active_calories":432,"exercise_minutes":26,"spo2":98.3},"2026-08-20":{"date":"2026-08-20","steps":10135,"resting_hr":60.2,"hrv":47.3,"sleep_hours":6.75,"active_calories":373,"exercise_minutes":15,"spo2":98.3},"2026-08-21":{"date":"2026-08-21","steps":10261,"resting_hr":63.4,"hrv":39.2,"sleep_hours":8.97,"active_calories":598,"exercise_minutes":37,"spo2":97.0},"2026-08-22":{"date":"2026-08-22","steps":6093,"resting_hr":62.3,"hrv":40.7,"sleep_hours":7.07,"active_calories":517,"exercise_minutes":12,"spo2":98.7},"2026-08-23":{"date":"2026-08-23","steps":11446,"resting_hr":57.3,"hrv":36.7,"sleep_hours":6.84,"active_calories":327,"exercise_minutes":31,"spo2":96.0},"2026-08-24":{"date":"2026-08-24","steps":7496,"resting_hr":66.4,"hrv":39.1,"sleep_hours":6.85,"active_calories":285,"exercise_minutes":44,"spo2":97.4},"2026-08-25":{"date":"2026-08-25","steps":12269,"resting_hr":61.6,"hrv":45.0,"sleep_hours":7.6,"active_calories":374,"exercise_minutes":14,"spo2":97.7},"2026-08-26":{"date":"2026-08-26","steps":7615,"resting_hr":61.5,"hrv":51.0,"sleep_hours":6.64,"active_calories":457,"exercise_minutes":61,"spo2":96.8},"2026-08-27":{"date":"2026-08-27","steps":11822,"resting_hr":60.3,"hrv":41.9,"sleep_hours":9.28,"active_calories":433,"exercise_minutes":55,"spo2":98.0},"2026-08-28":{"date":"2026-08-28","steps":4302,"resting_hr":61.4,"hrv":43.8,"sleep_hours":5.89,"active_calories":260,"exercise_minutes":36,"spo2":97.9},"2026-08-29":{"date":"2026-08-29","steps":8859,"resting_hr":60.1,"hrv":41.6,"sleep_hours":6.21,"active_calories":370,"exercise_minutes":19,"spo2":97.4},"2026-08-30":{"date":"2026-08-30","steps":7949,"resting_hr":58.3,"hrv":46.1,"sleep_hours":6.42,"active_calories":419,"exercise_minutes":42,"spo2":97.6},"2026-08-31":{"date":"2026-08-31","steps":9038,"resting_hr":56.0,"hrv":45.0,"sleep_hours":7.35,"active_calories":477,"exercise_minutes":11,"spo2":97.6},"2026-09-01":{"date":"2026-09-01","steps":10654,"resting_hr":60.5,"hrv":47.2,"sleep_hours":7.51,"active_calories":442,"exercise_minutes":45,"spo2":97.5},"2026-09-02":{"date":"2026-09-02","steps":8335,"resting_hr":58.7,"hrv":46.6,"sleep_hours":8.23,"active_calories":401,"exercise_minutes":32,"spo2":97.2},"2026-09-03":{"date":"2026-09-03","steps":9731,"resting_hr":58.4,"hrv":55.3,"sleep_hours":6.26,"active_calories":324,"exercise_minutes":38,"spo2":97.6},"2026-09-04":{"date":"2026-09-04","steps":8654,"resting_hr":64.2,"hrv":44.7,"sleep_hours":7.5,"active_calories":338,"exercise_minutes":53,"spo2":97.9},"2026-09-05":{"date":"2026-09-05","steps":9180,"resting_hr":61.6,"hrv":41.7,"sleep_hours":6.81,"active_calories":465,"exercise_minutes":34,"spo2":97.6},"2026-09-06":{"date":"2026-09-06","steps":5008,"resting_hr":61.5,"hrv":42.5,"sleep_hours":6.12,"active_calories":406,"exercise_minutes":7,"spo2":97.9},"2026-09-07":{"date":"2026-09-07","steps":9874,"resting_hr":60.8,"hrv":40.0,"sleep_hours":6.25,"active_calories":406,"exercise_minutes":0,"spo2":97.6},"2026-09-08":{"date":"2026-09-08","steps":9461,"resting_hr":60.1,"hrv":44.0,"sleep_hours":7.26,"active_calories":451,"exercise_minutes":51,"spo2":97.5},"2026-09-09":{"date":"2026-09-09","steps":6482,"resting_hr":59.6,"hrv":49.5,"sleep_hours":8.5,"active_calories":391,"exercise_minutes":19,"spo2":97.0},"2026-09-10":{"date":"2026-09-10","steps":3388,"resting_hr":62.0,"hrv":37.1,"sleep_hours":5.99,"active_calories":242,"exercise_minutes":18,"spo2":97.8},"2026-09-11":{"date":"2026-09-11","steps":5115,"resting_hr":55.8,"hrv":49.4,"sleep_hours":6.79,"active_calories":458,"exercise_minutes":46,"spo2":97.5},"2026-09-12":{"date":"2026-09-12","steps":5144,"resting_hr":60.6,"hrv":52.0,"sleep_hours":6.58,"active_calories":290,"exercise_minutes":51,"spo2":97.3},"2026-09-13":{"date":"2026-09-13","steps":7099,"resting_hr":61.0,"hrv":56.2,"sleep_hours":7.34,"active_calories":390,"exercise_minutes":45,"spo2":98.8},"2026-09-14":{"date":"2026-09-14","steps":11053,"resting_hr":63.0,"hrv":39.0,"sleep_hours":10,"active_calories":312,"exercise_minutes":29,"spo2":97.1},"2026-09-15":{"date":"2026-09-15","steps":11579,"resting_hr":62.3,"hrv":36.6,"sleep_hours":7.34,"active_calories":469,"exercise_minutes":30,"spo2":96.8},"2026-09-16":{"date":"2026-09-16","steps":6404,"resting_hr":59.7,"hrv":48.2,"sleep_hours":6.63,"active_calories":486,"exercise_minutes":20,"spo2":96.5},"2026-09-17":{"date":"2026-09-17","steps":6373,"resting_hr":65.0,"hrv":43.4,"sleep_hours":9.21,"active_calories":464,"exercise_minutes":17,"spo2":97.0},"2026-09-18":{"date":"2026-09-18","steps":8535,"resting_hr":57.5,"hrv":67.0,"sleep_hours":6.21,"active_calories":481,"exercise_minutes":56,"spo2":97.4},"2026-09-19":{"date":"2026-09-19","steps":5809,"resting_hr":60.2,"hrv":50.9,"sleep_hours":7.89,"active_calories":425,"exercise_minutes":13,"spo2":97.5},"2026-09-20":{"date":"2026-09-20","steps":6518,"resting_hr":54.5,"hrv":42.4,"sleep_hours":5.81,"active_calories":472,"exercise_minutes":64,"spo2":98.6},"2026-09-21":{"date":"2026-09-21","steps":6738,"resting_hr":59.6,"hrv":53.5,"sleep_hours":8.06,"active_calories":382,"exercise_minutes":32,"spo2":98.8},"2026-09-22":{"date":"2026-09-22","steps":7763,"resting_hr":59.5,"hrv":49.0,"sleep_hours":7.62,"active_calories":328,"exercise_minutes":31,"spo2":96.8},"2026-09-23":{"date":"2026-09-23","steps":7387,"resting_hr":66.6,"hrv":53.9,"sleep_hours":7.02,"active_calories":336,"exercise_minutes":30,"spo2":98.1},"2026-09-24":{"date":"2026-09-24","steps":6764,"resting_hr":65.4,"hrv":37.0,"sleep_hours":6.84,"active_calories":576,"exercise_minutes":4,"spo2":98.2},"2026-09-25":{"date":"2026-09-25","steps":3926,"resting_hr":60.7,"hrv":46.6,"sleep_hours":7.84,"active_calories":136,"exercise_minutes":6,"spo2":97.2},"2026-09-26":{"date":"2026-09-26","steps":8081,"resting_hr":58.3,"hrv":46.2,"sleep_hours":6.03,"active_calories":383,"exercise_minutes":22,"spo2":97.6},"2026-09-27":{"date":"2026-09-27","steps":9279,"resting_hr":65.3,"hrv":42.7,"sleep_hours":6.81,"active_calories":424,"exercise_minutes":30,"spo2":97.5},"2026-09-28":{"date":"2026-09-28","steps":4563,"resting_hr":62.1,"hrv":40.7,"sleep_hours":6.24,"active_calories":277,"exercise_minutes":52,"spo2":97.4},"2026-09-29":{"date":"2026-09-29","steps":10528,"resting_hr":61.8,"hrv":34.4,"sleep_hours":6.12,"active_calories":488,"exercise_minutes":23,"spo2":96.8},"2026-09-30":{"date":"2026-09-30","steps":7605,"resting_hr":60.5,"hrv":39.9,"sleep_hours":6.81,"active_calories":376,"exercise_minutes":0,"spo2":97.8}},"summary":{"avg_steps":7909,"avg_sleep_hours":7.2,"avg_resting_hr":61.1,"avg_hrv":47.5,"best_sleep":10,"worst_sleep":4.96,"best_steps_day":"2026-07-10","total_days":120},"events":[{"label":"Sample exam week","start":"2026-09-10","end":"2026-09-16","color":"#ef4444","icon":"books"}],"generated_at":"2026-09-30T00:00:00"}'::jsonb) +on conflict (is_demo) where is_demo do update + set data = excluded.data, updated_at = now(); diff --git a/tests/scripts/test_make_demo_seed.py b/tests/scripts/test_make_demo_seed.py new file mode 100644 index 0000000..31e2d5d --- /dev/null +++ b/tests/scripts/test_make_demo_seed.py @@ -0,0 +1,52 @@ +from datetime import date + +from scripts.make_demo_seed import build_demo_health_data, build_seed_sql + +DAILY_KEYS = { + "date", "steps", "resting_hr", "hrv", "sleep_hours", + "active_calories", "exercise_minutes", "spo2", +} + + +def test_is_deterministic(): + assert build_demo_health_data() == build_demo_health_data() + + +def test_daily_shape_and_length(): + data = build_demo_health_data(days=30, end=date(2026, 9, 30)) + assert len(data["daily"]) == 30 + assert "2026-09-30" in data["daily"] and "2026-09-01" in data["daily"] + for key, entry in data["daily"].items(): + assert set(entry) == DAILY_KEYS and entry["date"] == key + + +def test_values_are_plausible(): + for entry in build_demo_health_data()["daily"].values(): + assert 1000 <= entry["steps"] <= 30000 + assert 4 <= entry["sleep_hours"] <= 10 + assert 40 <= entry["resting_hr"] <= 90 + assert 0 < entry["hrv"] < 150 + + +def test_summary_matches_daily(): + data = build_demo_health_data() + daily = list(data["daily"].values()) + s = data["summary"] + assert s["total_days"] == len(daily) + assert s["best_sleep"] == max(d["sleep_hours"] for d in daily) + assert s["worst_sleep"] == min(d["sleep_hours"] for d in daily) + assert s["best_steps_day"] == max(daily, key=lambda d: d["steps"])["date"] + assert s["avg_steps"] == round(sum(d["steps"] for d in daily) / len(daily)) + + +def test_events_are_labeled_as_sample_data(): + data = build_demo_health_data() + assert data["events"] and all("Sample" in e["label"] for e in data["events"]) + assert {"label", "start", "end", "color", "icon"} <= set(data["events"][0]) + + +def test_seed_sql_is_an_idempotent_demo_upsert(): + sql = build_seed_sql({"note": "it's fine"}) + assert "insert into public.health_data (is_demo, data)" in sql + assert "on conflict (is_demo) where is_demo do update" in sql + assert "it''s fine" in sql # single quotes escaped From c1cb4945836255e1d92047d67fcbe866cd2d2dcc Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 01:04:29 -0400 Subject: [PATCH 09/14] fix(legacy-ui): send Supabase token to the API and upsert by user_id Co-Authored-By: Claude Sonnet 5.5 --- index.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 9e9f1c3..79cad24 100644 --- a/index.html +++ b/index.html @@ -1698,7 +1698,7 @@

Life Events

if (data.ok) { el.className = 'status-msg success'; el.textContent = 'Done! Reloading data...'; - const hr = await fetch('/api/health'); + const hr = await fetch('/api/health', { headers: await authHeaders() }); healthData = await hr.json(); if (!healthData.error) { setTimeout(() => { el.className='status-msg success'; el.textContent='Data updated successfully.'; }, 500); @@ -1899,7 +1899,7 @@

Life Events

// ── Step 3: Save to Supabase ──────────────────────── renderProgress(90, 'Saving to Supabase...'); if (!currentUser) throw { msg: 'Not signed in — please log in before uploading.', detail: null }; - const { error } = await supabaseClient.from('health_data').upsert({ id: 1, data: result }, { onConflict: 'id' }); + const { error } = await supabaseClient.from('health_data').upsert({ user_id: currentUser.id, data: result }, { onConflict: 'user_id' }); if (error) throw { msg: 'Failed to save to Supabase', detail: error.message }; clearInterval(timerInterval); @@ -2142,7 +2142,7 @@

Life Events

chatHistory.push({ role:'user', content:msg }); try { const resp = await fetch('/api/chat', { - method:'POST', headers:{'Content-Type':'application/json'}, + method:'POST', headers:{'Content-Type':'application/json', ...(await authHeaders())}, body: JSON.stringify({ message: msg, history: requestHistory, @@ -2165,6 +2165,12 @@

Life Events

} } +async function authHeaders() { + if (!supabaseClient) return {}; + const { data: { session } } = await supabaseClient.auth.getSession(); + return session?.access_token ? { Authorization: 'Bearer ' + session.access_token } : {}; +} + // ── Auth ───────────────────────────────────────────────── async function initSupabase() { try { @@ -2282,7 +2288,7 @@

Life Events

async function loadAppData() { try { const [healthResp, configResp] = await Promise.all([ - fetch('/api/health'), + authHeaders().then(h => fetch('/api/health', { headers: h })), fetch('/api/config') ]); const hd = await healthResp.json(); From 5946f38c499da8c5c0d4dc9e9d34decfbc6faac9 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 01:06:57 -0400 Subject: [PATCH 10/14] ci: run pytest on pull requests Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/tests.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a4f7a15 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,15 @@ +name: tests +on: + pull_request: + push: + branches: [master] +jobs: + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements-dev.txt + - run: python -m pytest -v From 946fda07a6542f595165734735cb0b51a38c7744 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 01:14:19 -0400 Subject: [PATCH 11/14] fix(api): never serve demo data on own-row failure; mark demo rows is_demo Only fall through to the demo row when the own-row query returned 200 with no rows; any other status returns None. Demo data now carries is_demo: true so the client can tell the truth. Adds URL-shape tests. Co-Authored-By: Claude Sonnet 5.5 --- api/chat_core.py | 28 ++++++++++++++-------- tests/api/conftest.py | 4 ++++ tests/api/test_auth.py | 5 ++++ tests/api/test_chat_core_data.py | 40 +++++++++++++++++++++++++++++++- tests/api/test_handlers.py | 2 +- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/api/chat_core.py b/api/chat_core.py index eea9011..1196731 100644 --- a/api/chat_core.py +++ b/api/chat_core.py @@ -56,16 +56,24 @@ def fetch_supabase_health_data(auth): return None headers = {"apikey": anon_key, "Authorization": f"Bearer {auth.token}"} - queries = [ - f"user_id=eq.{urllib.parse.quote(auth.user_id, safe='')}", - "is_demo=eq.true", - ] - for query in queries: - status, rows = supabase_http.http_get_json( - f"{supabase_url}/rest/v1/health_data?select=data&{query}", headers - ) - if status == 200 and rows: - return rows[0].get("data") + base = f"{supabase_url}/rest/v1/health_data?select=data" + + status, rows = supabase_http.http_get_json( + f"{base}&user_id=eq.{urllib.parse.quote(auth.user_id, safe='')}", headers + ) + if status != 200: + # Never mask an outage as the user's data by serving the demo row. + return None + if rows: + return rows[0].get("data") + + # Signed in, query succeeded, no row yet: fall back to the shared demo data. + status, rows = supabase_http.http_get_json(f"{base}&is_demo=eq.true", headers) + if status == 200 and rows: + data = rows[0].get("data") + if isinstance(data, dict): + return {**data, "is_demo": True} + return data return None diff --git a/tests/api/conftest.py b/tests/api/conftest.py index b3821e3..4b5b968 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -17,12 +17,16 @@ def fake_supabase(monkeypatch): "rows": {"user-a": {"who": "a"}, "user-b": {"who": "b"}}, "demo": {"who": "demo"}, "calls": [], + "force_status": {}, # url substring -> (status, body) to simulate failures } def fake_get(url, headers, timeout=3): state["calls"].append((url, dict(headers))) token = headers.get("Authorization", "").removeprefix("Bearer ") uid = state["users"].get(token) + for needle, forced in state["force_status"].items(): + if needle in url: + return forced if "/auth/v1/user" in url: return (200, {"id": uid}) if uid else (401, None) if not uid: # anon / bad token sees nothing under RLS diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index b899bbe..72fd56d 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -88,3 +88,8 @@ def test_verify_token_rejects_network_error(fake_supabase, monkeypatch): ) with pytest.raises(AuthError): verify_token("token-a") + + +def test_verify_token_hits_the_auth_user_endpoint(fake_supabase): + verify_token("token-a") + assert fake_supabase["calls"][0][0] == "https://example.supabase.co/auth/v1/user" diff --git a/tests/api/test_chat_core_data.py b/tests/api/test_chat_core_data.py index ff6fbd0..bae6f81 100644 --- a/tests/api/test_chat_core_data.py +++ b/tests/api/test_chat_core_data.py @@ -1,5 +1,7 @@ import json +import pytest + from api.auth import AuthContext from api.chat_core import fetch_supabase_health_data, load_health_data @@ -12,7 +14,7 @@ def test_user_gets_their_own_row(fake_supabase): def test_user_without_row_gets_demo(fake_supabase): fake_supabase["users"]["token-c"] = "user-c" ctx = AuthContext(user_id="user-c", token="token-c") - assert fetch_supabase_health_data(ctx) == {"who": "demo"} + assert fetch_supabase_health_data(ctx) == {"who": "demo", "is_demo": True} def test_no_row_and_no_demo_returns_none(fake_supabase): @@ -59,3 +61,39 @@ def test_local_dev_reads_local_file(no_supabase, tmp_path): def test_local_dev_missing_file_returns_none(no_supabase, tmp_path): assert load_health_data(local_path=tmp_path / "missing.json") is None + + +def test_own_row_has_no_is_demo_key(fake_supabase): + ctx = AuthContext(user_id="user-a", token="token-a") + assert "is_demo" not in fetch_supabase_health_data(ctx) + + +@pytest.mark.parametrize("forced", [(503, None), (0, None), (500, None), (401, None)]) +def test_failed_own_row_query_does_not_serve_demo(fake_supabase, forced): + fake_supabase["force_status"]["user_id=eq.user-a"] = forced + ctx = AuthContext(user_id="user-a", token="token-a") + assert fetch_supabase_health_data(ctx) is None + assert all("is_demo" not in url for url, _ in fake_supabase["calls"]) + + +def test_failed_demo_query_returns_none(fake_supabase): + fake_supabase["users"]["token-c"] = "user-c" + fake_supabase["force_status"]["is_demo=eq.true"] = (503, None) + ctx = AuthContext(user_id="user-c", token="token-c") + assert fetch_supabase_health_data(ctx) is None + + +def test_own_row_request_url_shape(fake_supabase): + fetch_supabase_health_data(AuthContext(user_id="user-a", token="token-a")) + assert [url for url, _ in fake_supabase["calls"]] == [ + "https://example.supabase.co/rest/v1/health_data?select=data&user_id=eq.user-a" + ] + + +def test_demo_fallback_request_url_shape(fake_supabase): + fake_supabase["users"]["token-c"] = "user-c" + fetch_supabase_health_data(AuthContext(user_id="user-c", token="token-c")) + assert [url for url, _ in fake_supabase["calls"]] == [ + "https://example.supabase.co/rest/v1/health_data?select=data&user_id=eq.user-c", + "https://example.supabase.co/rest/v1/health_data?select=data&is_demo=eq.true", + ] diff --git a/tests/api/test_handlers.py b/tests/api/test_handlers.py index 3083755..bb12221 100644 --- a/tests/api/test_handlers.py +++ b/tests/api/test_handlers.py @@ -24,7 +24,7 @@ def test_health_returns_only_callers_data(fake_supabase, call): def test_health_new_user_gets_demo(fake_supabase, call): fake_supabase["users"]["token-c"] = "user-c" status, body = call(health_module.handler, headers=bearer("token-c")) - assert status == 200 and body == {"who": "demo"} + assert status == 200 and body == {"who": "demo", "is_demo": True} def test_health_local_dev_needs_no_token(no_supabase, call, monkeypatch): From d0a5e8c12b63717a83443629ebd3ead48fe61656 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 01:14:56 -0400 Subject: [PATCH 12/14] fix(legacy-ui): sync hasOwnData flag from server is_demo marker Co-Authored-By: Claude Sonnet 5.5 --- index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/index.html b/index.html index 79cad24..a84a99e 100644 --- a/index.html +++ b/index.html @@ -2295,6 +2295,10 @@

Life Events

const config = await configResp.json(); chatConfig = config.chat || { serverVertex:false, serverModel:'gemini-2.5-flash', serverClaude:false, claudeModel:'claude-sonnet-4-6', byokSupported:true }; if (!hd.error) healthData = hd; + if (currentUser && !hd.error) { + if (hd.is_demo) localStorage.removeItem('hasOwnData_' + currentUser.id); + else localStorage.setItem('hasOwnData_' + currentUser.id, '1'); + } syncChatMode(); } catch(e) { /* server not running */ } } From b6464b69e0081d41d0830a51cd4bfd8bd5e61ce5 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 01:15:57 -0400 Subject: [PATCH 13/14] fix(db): scope rollback policies to legacy row, make 0002 idempotent, extend RLS checks Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/0002_per_user_health_data.sql | 18 ++++++-- supabase/rollback/0002_down.sql | 17 ++++--- supabase/tests/rls_check.sql | 45 +++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/supabase/migrations/0002_per_user_health_data.sql b/supabase/migrations/0002_per_user_health_data.sql index fe5c563..090a0ef 100644 --- a/supabase/migrations/0002_per_user_health_data.sql +++ b/supabase/migrations/0002_per_user_health_data.sql @@ -1,5 +1,5 @@ -- Per-user health data with row-level security. --- Safe to run once on the live database. The legacy shared row (id = 1) is left in +-- Safe to run on the live database, and safe to re-run (e.g. after 0002_down.sql). The legacy shared row (id = 1) is left in -- place but becomes invisible to every role (user_id null, is_demo false), so it can -- be assigned to its owner later or used for rollback. begin; @@ -12,21 +12,31 @@ alter table public.health_data alter column id set default nextval('public.health_data_id_seq'); grant usage, select on sequence public.health_data_id_seq to authenticated; +-- Idempotent: columns, constraints and indexes survive 0002_down.sql, so this file +-- can be re-run after a rollback. alter table public.health_data - add column user_id uuid references auth.users (id) on delete cascade, - add column is_demo boolean not null default false; + add column if not exists user_id uuid references auth.users (id) on delete cascade; +alter table public.health_data + add column if not exists is_demo boolean not null default false; -- Plain unique constraint (NULLs are distinct) so PostgREST on_conflict=user_id works. +alter table public.health_data drop constraint if exists health_data_user_id_key; alter table public.health_data add constraint health_data_user_id_key unique (user_id); -- At most one demo row. -create unique index health_data_single_demo on public.health_data (is_demo) where is_demo; +create unique index if not exists health_data_single_demo on public.health_data (is_demo) where is_demo; -- A demo row has no owner. +alter table public.health_data drop constraint if exists health_data_demo_has_no_owner; alter table public.health_data add constraint health_data_demo_has_no_owner check (not is_demo or user_id is null); drop policy if exists "allow_public_read" on public.health_data; drop policy if exists "allow_auth_write" on public.health_data; +drop policy if exists "own_row_select" on public.health_data; +drop policy if exists "demo_row_select" on public.health_data; +drop policy if exists "own_row_insert" on public.health_data; +drop policy if exists "own_row_update" on public.health_data; +drop policy if exists "own_row_delete" on public.health_data; create policy "own_row_select" on public.health_data for select to authenticated using (user_id = (select auth.uid())); diff --git a/supabase/rollback/0002_down.sql b/supabase/rollback/0002_down.sql index 9e07476..bdf390d 100644 --- a/supabase/rollback/0002_down.sql +++ b/supabase/rollback/0002_down.sql @@ -1,7 +1,7 @@ --- EMERGENCY USE ONLY. Restores the old shared, publicly readable behavior. --- WARNING: re-creating allow_public_read exposes EVERY per-user row (not just id = 1) --- to anon. Only run this if you accept all stored health data being publicly readable. --- Columns are kept; the legacy row (id = 1) is still intact, so the old app works again. +-- EMERGENCY USE ONLY. Restores the old app's behavior for the legacy row only. +-- The recreated policies are scoped to the legacy row (user_id is null and not is_demo, +-- i.e. id = 1, the only row the old app reads and upserts). Per-user rows and the demo row +-- stay private/protected. Columns and constraints are kept; re-run 0002 to go back (it is idempotent). begin; alter table public.health_data alter column id set default 1; drop policy if exists "own_row_select" on public.health_data; @@ -9,7 +9,12 @@ drop policy if exists "demo_row_select" on public.health_data; drop policy if exists "own_row_insert" on public.health_data; drop policy if exists "own_row_update" on public.health_data; drop policy if exists "own_row_delete" on public.health_data; -create policy "allow_public_read" on public.health_data for select using (true); +drop policy if exists "allow_public_read" on public.health_data; +drop policy if exists "allow_auth_write" on public.health_data; +create policy "allow_public_read" on public.health_data + for select using (user_id is null and not is_demo); create policy "allow_auth_write" on public.health_data - for all using (auth.role() = 'authenticated'); + for all to authenticated + using (user_id is null and not is_demo) + with check (user_id is null and not is_demo); commit; diff --git a/supabase/tests/rls_check.sql b/supabase/tests/rls_check.sql index 4427921..33ac360 100644 --- a/supabase/tests/rls_check.sql +++ b/supabase/tests/rls_check.sql @@ -64,6 +64,40 @@ begin raise exception 'FAIL: user A inserted a demo row'; exception when insufficient_privilege or unique_violation then null; end; + + -- A cannot delete B's row, the demo row, or the legacy row. + delete from public.health_data where data->>'who' = 'b'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A deleted user B row'; end if; + + delete from public.health_data where is_demo; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A deleted the demo row'; end if; + + -- A cannot reassign their row to B (denied or 0 rows are both fine; success is not). + begin + update public.health_data set user_id = '00000000-0000-0000-0000-0000000000b2' + where user_id = '00000000-0000-0000-0000-0000000000a1'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A reassigned their row to user B'; end if; + exception when insufficient_privilege or unique_violation then null; + end; + + -- A cannot turn their own row into a demo row. + begin + update public.health_data set is_demo = true + where user_id = '00000000-0000-0000-0000-0000000000a1'; + get diagnostics n = row_count; + if n <> 0 then raise exception 'FAIL: user A set is_demo on own row'; end if; + exception when insufficient_privilege or check_violation or unique_violation then null; + end; + + -- Client upsert path for a user with an existing row (on_conflict=user_id). + insert into public.health_data (user_id, data) + values ('00000000-0000-0000-0000-0000000000a1', '{"who":"a2"}') + on conflict (user_id) do update set data = excluded.data; + select count(*) into n from public.health_data where data->>'who' = 'a2'; + if n <> 1 then raise exception 'FAIL: user A upsert over existing row did not take effect'; end if; end $$; -- Positive: a user with no row yet (C) can insert their own row and read it back. @@ -101,5 +135,16 @@ begin end; end $$; +-- Separate block so an earlier permission error cannot skip this check. +do $$ +begin + begin + insert into public.health_data (user_id, data) + values ('00000000-0000-0000-0000-0000000000a1', '{"who":"anon"}'); + raise exception 'FAIL: anon inserted a row'; + exception when insufficient_privilege then null; + end; +end $$; + rollback; select 'ALL RLS CHECKS PASSED' as result; From 1aa0ce7fc289ceaa88475050a9a063557409be8c Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sat, 3 Oct 2026 01:15:58 -0400 Subject: [PATCH 14/14] chore: harden CI permissions/timeout and update docs Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/tests.yml | 3 +++ CLAUDE.md | 2 ++ .../plans/2026-10-03-step0-per-user-privacy-fix.md | 5 +++-- .../specs/2026-10-03-somada-modernization-design.md | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4f7a15..452b79d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,9 +3,12 @@ on: pull_request: push: branches: [master] +permissions: + contents: read jobs: api: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/CLAUDE.md b/CLAUDE.md index 31d5b13..8a73f3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ health-dashboard/ |- parse_health.py # Local-only Apple Health XML parser |- vercel.json # SPA rewrites for Vercel |- .env # Local env vars (gitignored) +|- supabase/ # migrations/, rollback/, tests/ (rls_check.sql), seed_demo.sql `- api/ |- health.py # GET /api/health |- chat.py # POST /api/chat @@ -38,6 +39,7 @@ health-dashboard/ - `python parse_health.py export.xml` generates `health_data.json`. - `python server.py` serves the app at `http://localhost:8080`. - `server.py` mirrors the main `/api/*` routes for local testing only. +- Do not set `SUPABASE_URL`/`SUPABASE_ANON_KEY` when running `server.py` locally; if set, `server.py` has no auth and will return no data. --- diff --git a/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md b/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md index e9277ac..ded7796 100644 --- a/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md +++ b/docs/superpowers/plans/2026-10-03-step0-per-user-privacy-fix.md @@ -1178,6 +1178,7 @@ gh pr create --base master --title "Per-user health data and authenticated API ( - [ ] **Step 1: Verify on a Vercel preview against the scratch project.** In Vercel, set the *Preview* environment's `SUPABASE_URL` / `SUPABASE_ANON_KEY` to the scratch project (and an `ANTHROPIC_API_KEY`). Open the PR's preview URL. Create two accounts (A and B). - A with no upload sees the demo banner and the synthetic demo data. + - A browser that already has localStorage `hasOwnData_=1` but no row yet must show the demo banner (the flag is now synced from the server). - A uploads an `export.xml`; the dashboard shows A's data. - B signs in on another browser: B sees demo data, **not** A's data. B uploads; A still sees A's data (refresh). - Chat answers for A reference A's metrics. @@ -1197,9 +1198,9 @@ curl -s -o /dev/null -w "%{http_code}\n" https://somada.vercel.app/api/health ``` Expected: `401`. -- [ ] **Step 6: If something is badly broken**, run `supabase/rollback/0002_down.sql` and redeploy the previous Vercel deployment (Vercel dashboard → Deployments → previous → Promote). The legacy row (`id = 1`) was never deleted, so the old behavior returns. +- [ ] **Step 6: If something is badly broken**, run `supabase/rollback/0002_down.sql` and redeploy the previous Vercel deployment (Vercel dashboard → Deployments → previous → Promote). The legacy row (`id = 1`) was never deleted, so the old behavior returns. The rollback script now only re-exposes the legacy row (per-user and demo rows stay private); to retry the migration after a rollback, simply re-run 0002 (it is idempotent). -- [ ] **Step 7 (optional): Assign the legacy row to its owner.** If row `id = 1` is yours, in the SQL editor: `update public.health_data set user_id = '' where id = 1;`. Otherwise leave it orphaned (invisible) or delete it after confirming your backup from Task 1. +- [ ] **Step 7 (optional): Assign the legacy row to its owner.** If row `id = 1` is yours, in the SQL editor: `update public.health_data set user_id = '' where id = 1;`. Otherwise leave it orphaned (invisible) or delete it after confirming your backup from Task 1. If the owner already re-uploaded under their account, delete that new row first (or skip this step): `user_id` is unique. --- diff --git a/docs/superpowers/specs/2026-10-03-somada-modernization-design.md b/docs/superpowers/specs/2026-10-03-somada-modernization-design.md index 80702bc..3c0d05d 100644 --- a/docs/superpowers/specs/2026-10-03-somada-modernization-design.md +++ b/docs/superpowers/specs/2026-10-03-somada-modernization-design.md @@ -98,3 +98,4 @@ Physician PDF export and share links (natural follow-up once data is per-user), - Live Supabase schema and row contents (blocks the Step 0 migration decision). - Charting library choice (Recharts vs Chart.js wrapper), to settle in the implementation plan. +- Deviation: the plan keeps `id` as the primary key (identity sequence) with `user_id` as a unique column rather than making `user_id` the primary key, so the demo row (null user_id) and the legacy row can coexist and rollback works.