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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: tests
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
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: python -m pytest -v
20 changes: 3 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down Expand Up @@ -66,23 +68,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'`.

---

Expand Down
23 changes: 5 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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

Expand Down
41 changes: 41 additions & 0 deletions api/auth.py
Original file line number Diff line number Diff line change
@@ -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 isinstance(body, dict) 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)
13 changes: 10 additions & 3 deletions api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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()
Expand Down
58 changes: 31 additions & 27 deletions api/chat_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -44,39 +46,41 @@ 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": supabase_anon_key,
"Authorization": f"Bearer {supabase_anon_key}",
}
headers = {"apikey": anon_key, "Authorization": f"Bearer {auth.token}"}
base = f"{supabase_url}/rest/v1/health_data?select=data"

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
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


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():
Expand Down
13 changes: 10 additions & 3 deletions api/health.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
32 changes: 32 additions & 0 deletions api/supabase_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import http.client
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, OSError, http.client.HTTPException, ValueError):
return 0, None
Loading
Loading