Skip to content

Data & accounts

Supabase provides Postgres and authentication. The schema is 29 tables across 15 idempotent migrations, and every table has row-level security enabled. Personal data is kept to the minimum the product needs: the only PII the platform stores is the email address in Supabase Auth. Payment details live with Stripe; the application keeps only a stripe_customer_id.

Where data lives

The design is local-first wherever that is honest:

Data Signed out Signed in
Course progress localStorage (lib/lessons/progress.ts) Merged with user_progress on sign-in: union of completed lessons, best quiz score per lesson; pushed with a 1.5 s debounce. Fails soft to local on any error.
Saved circuits localStorage (lib/circuit-store.ts), Free limit of 10 circuits table; the limit is enforced by a server-side insert
Analytics 1,000-event ring buffer on the device Same; ingest stores nothing unless explicitly configured
Tutor conversation Sent per request, never stored Same; the agent has no database
Exam attempts, certificates — (sign-in required) exam_attempts, exam_certificates, graded server-side

Schema

Migrations are generated from the hand-edited supabase/schema*.sql files by scripts/supabase-migrations.sh, so the SQL a reviewer reads is the SQL that runs.

Migration Tables and functions
01_core circuits; set_updated_at() trigger
02_billing subscriptions, hardware_usage, hardware_bonus, stripe_events; reserve_hardware_job(), apply_stripe_subscription()
03_exams exam_attempts, exam_certificates
04_orgs orgs, org_members, org_invites, org_assignments, org_progress, org_progress_consent, org_classes, org_class_members; is_org_member(), is_org_manager()
05_competitions competitions, competition_entries, competition_leaderboard view
06_email email_subscriptions (double opt-in consent records)
07_integrations webhook_endpoints, api_keys
08_platform platform_settings (admin runtime configuration, versioned)
09_experiments experiments, experiment_steps; append_experiment_step()
10_analytics analytics_events
11_sessions runtime_sessions (IBM runtime session ownership)
12_runs published_runs (immutable, enforced by trigger), published_runs_public view
13_budgets spend_budgets; spend_in_window(), reserve_hardware_job_within_budget()
14_byok user_provider_credentials
15_hardening user_progress; org-seat integrity triggers; server-side circuit inserts

Row-level security as the authorisation model

Policies exist only where a browser legitimately touches a row with the anon key:

  • circuits: read own and public, write own.
  • user_progress, spend_budgets: own rows.
  • subscriptions, hardware_usage, hardware_bonus: read own, no user writes. The absence of a write policy is the billing security model; a browser can see its quota but cannot grant itself one.
  • Org tables: 21 policies. Members read their org; owners and admins (is_org_manager) write; a member can write only their own progress and consent rows. Creating an org requires the caller's own active subscription at the same plan, which closed an audit finding where a Free user could self-grant University seats.
  • published_runs: world-readable, never editable.

Tables with RLS enabled and no policies at all are service-role only: stripe_events, the exam tables, competitions, email_subscriptions, webhook_endpoints, api_keys, platform_settings, experiments, analytics_events, runtime_sessions and user_provider_credentials. The service-role key is read by 21 server-only modules and never reaches the client; lib/env.ts validates its presence at boot and every store falls back to memory or localStorage without it.

Triggers back up the policies where a policy cannot express the rule: orgs_lock_licence_columns() stops a user changing plan_id, seats_total or owner_id; org_members_inherit_plan() copies a seat's plan from its org; published_runs_no_edit() makes a published run immutable; apply_stripe_subscription() drops a webhook write whose event timestamp is older than the stored one.

Secrets at rest

Two kinds of secret are stored in the database, and both go through lib/platform/secretBox.ts:

  • Admin-set LLM provider keys in platform_settings.
  • Bring-your-own hardware credentials in user_provider_credentials.

secretBox is AES-256-GCM with a random 12-byte IV and an authentication tag, serialised as v1.<iv>.<ciphertext>.<tag> in base64url. The key is SETTINGS_ENCRYPTION_KEY (64 hex characters used raw, or any string hashed with SHA-256), falling back to a SHA-256 derivation from the service-role key. Opening a sealed value with the wrong key or a tampered envelope returns null rather than throwing, and a column check requires the v1. shape so a plaintext secret cannot be inserted by mistake.

For hardware credentials, lib/security/providerCredentials.ts declares each field secret: true|false. Secret fields (IBM token, AWS secret key, Azure client secret) are sealed together; public fields (AWS access key id, Azure tenant and client ids) are stored in the clear so the owner can see which account is attached. The listing function's return type has no field for a secret, so it cannot leak one; resolveProviderCredential is the single plaintext path and is used for one outbound vendor call. The API service never writes a presented credential into its settings or environment, because concurrent requests would leak one user's account to another; each provider call gets an explicit session object.

Authentication

Supabase Auth with magic links; no passwords are stored by the application. The browser client (lib/supabase.ts) is created lazily with persistSession and autoRefreshToken, returns null when unconfigured, and never throws. Tokens are sent as a bearer header, verified server-side with supabase.auth.getUser(token), and the email on the token, lower-cased, is the only email the server trusts. Invite tokens for organisations are random and stored hashed (ADR-0008), accepted only when the verified email matches the invitation.