# MedicOS — Security Two halves with deliberately different postures: - **`/` protocol app** — public reference data, no account, no user data, offline-first. Nothing here is private. - **`/hub/*` Career Hub** — real user data. Certifications, expiration dates, uploaded documents. Treated as confidential. Keeping those two separated is the central design constraint. --- ## Authentication Managed by **Supabase Auth**. This project never implements password handling. - Passwords are hashed by Supabase (Argon2). We never receive, store, or log them. - Email verification required before the hub grants access. - Password reset by emailed one-time link, never by emailing a password. - Sessions are JWTs in `localStorage`, refreshed automatically, cleared on logout. - **MFA** is supported by Supabase Auth and is a configuration change when wanted — the auth layer is not written in a way that would need rebuilding. - Rate limiting on signup / login / reset is provided by Supabase and must be left enabled. Never implement a custom credential store in this codebase. --- ## Authorization — row level security Every user table carries `user_id uuid references auth.users`. RLS is enabled on every one, and every policy checks `auth.uid() = user_id`. This is the actual enforcement boundary. Client-side checks are convenience only and are assumed bypassable. **Rule: no table ships without RLS enabled and a policy written.** A table with RLS on and no policy denies everything, which is the correct failure direction. --- ## The anon key is public — on purpose `config.js` contains the Supabase project URL and the **publishable/anon** key. Both ship to every browser. That is how Supabase works, and it grants nothing on its own: RLS decides what that key can see. **The `service_role` / secret key must never appear in this repository, in `config.js`, or in any deployed file.** It bypasses every policy above. If one is ever pasted in, treat it as compromised and rotate it immediately in the Supabase dashboard. Deploys here are drag-and-drop with no CI gate, so this is a discipline control, not an automated one. --- ## Document storage - One **private** bucket. Never public. - Path layout `{user_id}/{credential_id}/{uuid}.{ext}` — the first path segment is the owner, and storage policies check it against `auth.uid()`. - Reads use **short-lived signed URLs** (1 hour), generated per request. No permanent public URL is ever created for a user document. - **Uploads are validated** on type (PDF, JPEG, PNG, WEBP, HEIC) and size (10 MB). Client validation is a courtesy; the bucket's own limits are the control. - Original filenames are never used as storage paths — they are stored as metadata and escaped on display. - **Malware scanning is not implemented.** Documents are treated as untrusted: never rendered inline as HTML, only downloaded or shown in an image/PDF viewer. Scanning is a later phase; this is a known accepted risk. --- ## Service worker `/hub/*`, `/login`, `/signup`, `/reset` and all Supabase traffic **bypass the service worker**. Authenticated pages are never written to the cache. Without this, a cached hub page could be served to a different user on a shared device, and stale auth logic could survive a fix. The protocol app remains cache-first because it holds nothing private. --- ## Input handling - All user-supplied text is escaped before insertion into the DOM (`esc()` in `assets/shared/util.js`). No `innerHTML` with unescaped user data. - Parameterized queries only, via the Supabase client. No string-built SQL. - Dates and numbers validated before use. --- ## Data minimization Collected: name, email, optional phone, EMS role, primary agency, optional employer and dates, credentials the user chooses to enter, documents the user chooses to upload, reminders they write, and their own career milestones and tallies. **Never collected:** Social Security numbers, bank or brokerage credentials, patient information, or any medical record. There is no code path that accepts them, and none should be added. Phone is optional and is not an identifier. Two fields deserve naming because they sit closest to that line: - `credentials.number` is the certificate number printed on the card. It is not something anyone is looked up by, and the form says in as many words that it is never a Social Security number. - `career_milestones.note` is free text. The field's own hint tells the user to keep patients out of it, because this is not a medical record and is not protected as one — and since M8 that text can be printed onto a summary the user hands to someone else, so the hint says that too. `career_counters` are integers and nothing else: the column is `integer`, so the schema enforces it rather than trusting the UI. Neither is ever displayed to anyone but the account holder. The Experience Summary at `/hub/summary` is rendered in the user's own browser from their own rows and is never published anywhere — there is no share link, no public route, and no anonymous read path to any of this data. Getting it to another person means the user printing it and sending it themselves. --- ## Transport HTTPS only, enforced by Netlify. The service worker and installability both require it, so there is no HTTP fallback to weaken. --- ## Email delivery — the one place RLS does not protect Everything else in this application is guarded by row level security: the browser holds a public key and the database decides what it may see. The scheduled `send-reminders` function is different. It runs with the **service_role key, which bypasses RLS entirely** and can read every account. It is the one component where a mistake is not contained. What keeps that narrow: - **The key never leaves Supabase.** It is injected into the function's environment at runtime. It is not in this repository, not in `config.js`, and not in any file that ships to a browser. - **The function is the only thing that writes `notification_log`.** `authenticated` has a select policy and no insert, update or delete policy. A client that could write its own log rows could silence its own reminders; one that could delete them could replay them. - **`due_notifications()` is revoked from `anon` and `authenticated`.** It is `security definer` and reads across all users, so holding the publishable key must not let a browser call it and enumerate other people's expiry dates. - **The endpoint is deployed with `verify_jwt = false`** so `pg_cron` can reach it, which means the shared secret in `x-reminder-secret` is the only gate on it. The function returns 403 to everyone when `REMINDER_CRON_SECRET` is unset, rather than defaulting to open. ### The unsubscribe token One-click unsubscribe has to work from a mail client with nobody signed in, so it cannot use auth. `profiles.unsubscribe_token` is the whole credential for that one action, and it is deliberately capable of nothing else: `unsubscribe_reminders()` sets `email_reminders = false` and returns a bare boolean. It cannot read a name, a credential, an address, or confirm that any particular account exists. A leaked link costs its holder their own reminder email and nothing more. `/unsubscribe` is served `no-store` with `Referrer-Policy: no-referrer`, so the token stays out of intermediate caches and out of anyone else's access logs. ### What is in an email Credential names, issuers, expiry dates, and the titles and notes of standing reminders — the same fields the reminders page shows. Email is not an encrypted channel and this content is on the low end of what the app holds, but it is not nothing: a credential name identifies somebody as a paramedic. Users can turn it off, and the setting is the same column in both places. No certificate numbers, no documents, no attachments, and no tracking pixel. --- ## Known gaps Recorded honestly rather than left implicit: | Gap | Status | |---|---| | Malware scanning of uploads | Not implemented. Files treated as untrusted. | | Audit log of document access | Not implemented. | | Per-account storage quota | Not enforced. The bucket caps each file at 10 MB; nothing caps the total. Needs a database trigger. | | Orphaned storage objects | Possible if a browser dies mid-delete. `orphaned_objects` in schema.sql lists them; cleanup is manual. | | Account deletion / data export | Not implemented. Needed before any public launch. | | MFA | Supported by the provider, not yet enabled. | | Automated secret scanning on deploy | None. Drag-and-drop deploys have no CI gate. | | Per-user send timezone | `profiles.timezone` is stored but not honoured. Every digest is computed for one Pacific date, so a non-Pacific account can see it a few hours early or late. Never the wrong day's content. | | Email in transit | Delivered over TLS where the receiving server supports it, which is not something this application can enforce. | | Backup / restore of user data | Relies on Supabase's own backups. | --- ## If something is exposed 1. Rotate the affected key in the Supabase dashboard immediately. 2. Check Supabase logs for unexpected access. 3. Revoke sessions (Supabase Auth → users) if account access is suspected. 4. Redeploy without the exposed value.