DH EMR cloud project

Database setup scripts

These set up and check the Supabase project your organization owns. The step-by-step is in the guides: Setting up DH EMR Clinic and Cloud sync setup for DH EMR Field. In short: open your project's SQL Editor, start a new query, press Copy under setup.sql below, paste, and click Run; then do the same with verify.sql.

Upgrading a project already in use? Run setup.sql after clinic hours. The run briefly locks the records table, so a device syncing at that moment shows a sync error, retries, and succeeds. Nothing is lost, but it is alarming mid-clinic.

setup.sql

Creates the tables, the access rules, the server-side checks behind the Staff screen and the live updates. Safe to run twice; re-running is the upgrade path.

Download 823 lines
-- ============================================================================
-- DH EMR cloud schema v4.1
--
-- One-shot setup for a new org project. Paste the ENTIRE file into the
-- Supabase SQL Editor and click Run. Safe to re-run: tables and columns use
-- IF NOT EXISTS, policies and triggers are drop-then-create.
--
-- Carries forward every v3.0 hardening rule (device kill switch, first
-- admin wins for DEVICES, immutable ids, soft deletes only, server-side
-- synced_at) and adds what the two-mode product needs:
--
--   FIELD MODE  (offline-first, shared publishable key, device identity)
--     Works exactly like v3.0. The org never creates user accounts.
--
--   CLINIC MODE (connected, per-user Supabase Auth, roles, patient flow)
--     Each staff member signs in with their own account. The shared-key
--     (anon) surface for records, devices and config CLOSES the moment the
--     org switches mode; the single exception is the orgMode config row,
--     which stays readable so a device holding only URL + key can discover
--     that it must show a sign-in screen.
--
-- The mode lives in config under key 'orgMode' ({"mode":"field"} or
-- {"mode":"clinic"}); absent means field, so v3.0 orgs upgrade in place
-- with no behavior change.
--
-- STAFF ACCOUNT BOOTSTRAP (deliberate design, do not "simplify"):
-- signing up NEVER grants anything. Every new account starts pending
-- (not activated, not admin). The FIRST admin is created by the operator
-- with one SQL line (SETUP.md section 4) right after running this file;
-- after that, admins approve accounts inside the app. There is no
-- auto-admin path: the publishable key is shared by design and Supabase
-- signups are open by default, so any signup-time privilege grant would
-- be claimable by anyone holding the key, including exactly during the
-- windows (virgin project, sole admin revoked) when it matters most.
-- The device fleet keeps v3.0's first-admin-wins because device
-- registration is the field-mode enrollment path and carries no
-- cross-user authority; staff accounts DO carry authority, so they get
-- the stricter rule.
--
-- AUTHENTICATED ACCESS WORKS IN BOTH MODES (deliberate): an org moving to
-- clinic mode onboards staff and drains every field iPad's unsynced
-- records BEFORE flipping the mode; the flip then closes the anon surface
-- with nothing stranded. Without SQL-editor bootstrap the authenticated
-- surface is inert (no activated accounts can exist), so leaving the
-- Email provider enabled on a field org exposes nothing.
--
-- HONEST LIMIT (field mode, unchanged from v3.0): every device shares one
-- key, so Postgres cannot tell devices apart and anyone holding the key
-- can read everything. Clinic mode is the fix; field mode documents the
-- limit.
-- ============================================================================


-- ============================================================================
-- 1. TABLES
-- ============================================================================

CREATE TABLE IF NOT EXISTS records (
  id UUID PRIMARY KEY,                     -- client-generated, no default
  device_id TEXT,
  site TEXT,
  date TEXT,                               -- encounter date as TEXT; client owns formatting
  mrn TEXT,
  given_name TEXT,
  family_name TEXT,
  name TEXT,
  sex TEXT,
  dob TEXT,
  phone TEXT,
  pregnant TEXT,
  breastfeeding TEXT,
  temp TEXT,
  bp TEXT,
  weight TEXT,
  allergies TEXT,
  current_meds TEXT,
  pmh TEXT,
  chief_concern TEXT,
  labs JSONB DEFAULT '{}'::jsonb,
  lab_comments TEXT,
  urinalysis JSONB DEFAULT '{}'::jsonb,
  blood_glucose TEXT,
  diagnosis TEXT,
  diagnosis_codes JSONB DEFAULT '[]'::jsonb,
  medications JSONB DEFAULT '[]'::jsonb,
  treatment_notes TEXT,
  treatment TEXT,
  procedures JSONB DEFAULT '[]'::jsonb,
  transport TEXT,
  travel_time TEXT,
  access_to_care JSONB,
  referral_type TEXT,
  referral_date TEXT,
  referral_status TEXT,
  imaging JSONB,
  surgery JSONB,
  provider TEXT,
  notes TEXT,
  custom_fields JSONB DEFAULT '{}'::jsonb,
  template_id TEXT,
  template_name TEXT,
  age_estimated BOOLEAN DEFAULT false,
  saved_at TIMESTAMPTZ,                    -- client clock; unreliable, display only
  sync_version INTEGER DEFAULT 1,
  deleted BOOLEAN DEFAULT false,           -- soft delete; nothing hard-deletes
  synced_at TIMESTAMPTZ DEFAULT now(),     -- server clock, trigger-maintained
  -- v4 additions
  user_id UUID,                            -- clinic mode: auth.uid() of the creator
  flow_station TEXT,                       -- clinic mode: current station on the flow board
  flow_updated_at TIMESTAMPTZ              -- when the station last changed
);

-- Idempotent upgrades for projects created on older schemas.
ALTER TABLE records ADD COLUMN IF NOT EXISTS diagnosis_codes JSONB DEFAULT '[]'::jsonb;
ALTER TABLE records ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}'::jsonb;
ALTER TABLE records ADD COLUMN IF NOT EXISTS template_id TEXT;
ALTER TABLE records ADD COLUMN IF NOT EXISTS template_name TEXT;
ALTER TABLE records ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE records ADD COLUMN IF NOT EXISTS flow_station TEXT;
ALTER TABLE records ADD COLUMN IF NOT EXISTS flow_updated_at TIMESTAMPTZ;

CREATE TABLE IF NOT EXISTS devices (
  id TEXT PRIMARY KEY,                     -- client-chosen id; TEXT, not UUID
  name TEXT NOT NULL,
  role TEXT DEFAULT 'standard',            -- 'standard' | 'admin'; trigger-enforced
  org_name TEXT,
  last_sync_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now(),
  revoked_at TIMESTAMPTZ                   -- kill switch: NULL = active
);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
COMMENT ON COLUMN devices.revoked_at IS
  'Set to now() to cut a lost or stolen device off from writing. Records it already pushed are kept. Only the SQL editor can clear this.';

CREATE TABLE IF NOT EXISTS config (
  key TEXT PRIMARY KEY,
  value JSONB,
  updated_at TIMESTAMPTZ DEFAULT now()     -- trigger-maintained in v4 (v3 was default-only)
);

-- Clinic mode staff accounts. One row per auth user.
-- role is the clinical station role (reception | nurse | provider |
-- pharmacy); admin is a separate flag because a provider can also be the
-- org admin. TEXT without a CHECK on purpose, like devices.role: the org
-- customizes its station list in config and the client enforces it.
CREATE TABLE IF NOT EXISTS users_profiles (
  id UUID PRIMARY KEY REFERENCES auth.users (id) ON DELETE CASCADE,
  display_name TEXT NOT NULL DEFAULT '',
  role TEXT DEFAULT 'provider',
  is_admin BOOLEAN NOT NULL DEFAULT false, -- NOT NULL: a NULL here would slip
                                           -- through boolean guards as
                                           -- "unknown" and dodge the
                                           -- last-admin protection
  activated_at TIMESTAMPTZ,                -- NULL = awaiting admin approval
  created_at TIMESTAMPTZ DEFAULT now(),
  revoked_at TIMESTAMPTZ                   -- kill switch, same semantics as devices
);
-- Idempotent hardening for projects that created the table before the
-- NOT NULL constraint existed.
UPDATE users_profiles SET is_admin = false WHERE is_admin IS NULL;
ALTER TABLE users_profiles ALTER COLUMN is_admin SET NOT NULL;
COMMENT ON COLUMN users_profiles.activated_at IS
  'NULL means the account is waiting for approval. The first admin is created from the SQL editor (SETUP.md section 4); after that, admins approve accounts in the app.';
COMMENT ON COLUMN users_profiles.revoked_at IS
  'Set to now() to lock the account out. Only an admin or the SQL editor can clear this.';


-- ============================================================================
-- 2. HELPER FUNCTIONS
--
-- All three are SECURITY DEFINER, and that is REQUIRED, not a convenience:
-- these helpers are called from inside RLS policies on the very tables
-- they read (config policies call dh_org_mode which reads config; profile
-- policies call dh_active_profile which reads users_profiles). As invoker,
-- the inner read would re-enter the same policy and Postgres would abort
-- with infinite recursion. As definer they bypass RLS for a single
-- self-filtered read; none of them contains current_user logic, so the
-- v3.0 "never SECURITY DEFINER" rule (which is about the TRIGGERS below,
-- where current_user distinguishes app callers from the SQL editor) does
-- not apply to them.
--
-- Corollary: never add FORCE ROW LEVEL SECURITY to any of these tables.
-- The definer bypass works because the table owner is exempt from RLS;
-- FORCE removes that exemption and every helper re-enters its own
-- table's policies - all four tables then fail closed with recursion
-- errors.
-- ============================================================================

-- The org's mode. Absent config row means 'field' so v3.0 projects upgrade
-- with no behavior change.
CREATE OR REPLACE FUNCTION dh_org_mode()
RETURNS TEXT
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
  SELECT COALESCE((SELECT value->>'mode' FROM config WHERE key = 'orgMode'), 'field');
$$;

-- Caller has an activated, non-revoked staff account.
CREATE OR REPLACE FUNCTION dh_active_profile()
RETURNS BOOLEAN
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
  SELECT EXISTS (
    SELECT 1 FROM users_profiles p
    WHERE p.id = auth.uid()
      AND p.activated_at IS NOT NULL
      AND p.revoked_at IS NULL
  );
$$;

-- Caller is an active admin.
CREATE OR REPLACE FUNCTION dh_is_admin()
RETURNS BOOLEAN
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
  SELECT EXISTS (
    SELECT 1 FROM users_profiles p
    WHERE p.id = auth.uid()
      AND p.is_admin
      AND p.activated_at IS NOT NULL
      AND p.revoked_at IS NULL
  );
$$;


-- ============================================================================
-- 3. DEVICE RULES TRIGGER (unchanged semantics from v3.0)
--
-- RLS cannot compare OLD and NEW, so the rules that need that live here.
--
-- Deliberately NOT SECURITY DEFINER: inside a SECURITY DEFINER function
-- current_user is the function OWNER, not the caller, so the bypass check
-- below would match on every call and the trigger would enforce nothing at
-- all. As security invoker, current_user is 'anon' or 'authenticated' for
-- an app caller and 'postgres' or 'service_role' from the SQL editor,
-- which is exactly the distinction needed.
-- ============================================================================

CREATE OR REPLACE FUNCTION dh_enforce_device_rules()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
DECLARE
  other_admins INT;
BEGIN
  -- The SQL editor and service_role skip all rules; this is how the
  -- operator promotes, demotes, or clears a revocation.
  IF current_user NOT IN ('anon', 'authenticated') THEN
    RETURN NEW;
  END IF;

  IF TG_OP = 'INSERT' THEN
    -- First admin wins: a brand-new device may register as admin ONLY when
    -- the project has zero active admin devices (genuine first run, so the
    -- org creator gets an admin device without opening the SQL editor).
    -- Silent demotion, no error. Device admin only means "may edit org
    -- config from this device in field mode"; it grants nothing over
    -- other devices or staff accounts.
    IF COALESCE(NEW.role, 'standard') = 'admin' THEN
      SELECT count(*) INTO other_admins
      FROM devices WHERE role = 'admin' AND revoked_at IS NULL;
      IF other_admins > 0 THEN
        NEW.role := 'standard';
      END IF;
    END IF;
    -- A device cannot register itself pre-revoked, or pre-dated.
    NEW.revoked_at := NULL;
    RETURN NEW;
  END IF;

  -- UPDATE rules.
  -- Rewriting a device id would orphan every record that points at it.
  IF NEW.id IS DISTINCT FROM OLD.id THEN
    RAISE EXCEPTION 'A device id cannot be changed.';
  END IF;

  -- Revocation is sticky. Without this the kill switch is decorative.
  IF OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NULL THEN
    RAISE EXCEPTION 'This device has been revoked. Only an administrator can restore it.';
  END IF;

  -- Promotion via the app is allowed only when NO other active admin
  -- device exists (recovery path when the admin fleet is empty or
  -- revoked). Demotion is always allowed: it can only reduce access.
  IF NEW.role IS DISTINCT FROM OLD.role AND NEW.role = 'admin' THEN
    SELECT count(*) INTO other_admins
    FROM devices
    WHERE role = 'admin' AND revoked_at IS NULL AND id <> NEW.id;
    IF other_admins > 0 THEN
      RAISE EXCEPTION 'Only an administrator can make another device an admin. Ask whoever set up this project.';
    END IF;
  END IF;

  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_devices_rules ON devices;
CREATE TRIGGER trg_devices_rules
  BEFORE INSERT OR UPDATE ON devices
  FOR EACH ROW
  EXECUTE FUNCTION dh_enforce_device_rules();


-- ============================================================================
-- 4. USER PROFILE RULES TRIGGER (clinic mode)
--
-- Stricter than the device rules, on purpose (see the bootstrap note in
-- the header): app callers can never grant admin, never activate, never
-- revoke or restore except through an admin account, and signing up never
-- yields anything but a pending row. SECURITY INVOKER for the same reason
-- as the device trigger. Note the SQL-editor path (signup trigger runs as
-- definer) bypasses this function entirely; the signup function itself
-- only ever writes pending rows.
-- ============================================================================

CREATE OR REPLACE FUNCTION dh_enforce_user_rules()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
DECLARE
  other_admins INT;
BEGIN
  IF current_user NOT IN ('anon', 'authenticated') THEN
    RETURN NEW;
  END IF;

  IF TG_OP = 'INSERT' THEN
    -- No self-service privileges, ever. There is deliberately NO
    -- first-admin window here (unlike devices): the first admin comes
    -- from the SQL editor.
    NEW.is_admin := false;
    NEW.activated_at := NULL;
    NEW.revoked_at := NULL;
    RETURN NEW;
  END IF;

  -- UPDATE rules.
  IF NEW.id IS DISTINCT FROM OLD.id THEN
    RAISE EXCEPTION 'An account id cannot be changed.';
  END IF;

  IF NOT dh_is_admin() THEN
    -- Non-admins may edit their own display name and station role only.
    IF NEW.is_admin IS DISTINCT FROM OLD.is_admin THEN
      RAISE EXCEPTION 'Only an administrator can change admin status.';
    END IF;
    IF NEW.activated_at IS DISTINCT FROM OLD.activated_at THEN
      RAISE EXCEPTION 'Only an administrator can approve an account.';
    END IF;
    IF NEW.revoked_at IS DISTINCT FROM OLD.revoked_at THEN
      RAISE EXCEPTION 'Only an administrator can revoke or restore an account.';
    END IF;
  ELSE
    -- The last active admin cannot remove or disable themselves; the org
    -- would be locked out of its own settings and, because there is no
    -- self-service admin path, only the SQL editor could recover it.
    -- Guard all three ways an admin could vanish: demotion, revocation,
    -- deactivation. The comparison is null-strict (IS DISTINCT FROM true)
    -- and the count runs under an advisory lock so two admins removing
    -- themselves at the same moment cannot both slip through.
    IF OLD.id = auth.uid()
       AND OLD.is_admin AND OLD.activated_at IS NOT NULL AND OLD.revoked_at IS NULL
       AND (NEW.is_admin IS DISTINCT FROM true
            OR NEW.activated_at IS NULL
            OR NEW.revoked_at IS NOT NULL)
    THEN
      PERFORM pg_advisory_xact_lock(hashtext('dh_users_admin_guard'));
      SELECT count(*) INTO other_admins
      FROM users_profiles
      WHERE is_admin AND activated_at IS NOT NULL AND revoked_at IS NULL
        AND id <> OLD.id;
      IF other_admins = 0 THEN
        RAISE EXCEPTION 'You are the only administrator. Make someone else an admin first.';
      END IF;
    END IF;
  END IF;

  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_users_rules ON users_profiles;
CREATE TRIGGER trg_users_rules
  BEFORE INSERT OR UPDATE ON users_profiles
  FOR EACH ROW
  EXECUTE FUNCTION dh_enforce_user_rules();


-- ============================================================================
-- 5. PROFILE AUTO-CREATION ON SIGNUP
--
-- Standard Supabase pattern. SECURITY DEFINER is REQUIRED (the trigger
-- fires in auth schema context, which cannot write public tables as the
-- caller), and it is safe ONLY because this function grants nothing: the
-- row it inserts is always pending (is_admin false, activated_at NULL).
-- Do not add privilege logic here; as definer it bypasses the rules
-- trigger's app-caller checks, so anything written here is law.
-- ============================================================================

CREATE OR REPLACE FUNCTION dh_handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
  INSERT INTO users_profiles (id, display_name, is_admin, activated_at)
  VALUES (
    NEW.id,
    COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1), ''),
    false,
    NULL
  )
  ON CONFLICT (id) DO NOTHING;
  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_on_auth_user_created ON auth.users;
CREATE TRIGGER trg_on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW
  EXECUTE FUNCTION dh_handle_new_user();


-- ============================================================================
-- 6. RECORD RULES TRIGGER (v4)
--
-- Identity and attribution are immutable through the app, in both modes.
-- v3 had no trigger on records at all; clinic mode makes one necessary
-- because per-user attribution is only worth having if staff cannot
-- rewrite it. SECURITY INVOKER, same reasoning as the device trigger.
-- ============================================================================

CREATE OR REPLACE FUNCTION dh_enforce_record_rules()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
BEGIN
  IF current_user NOT IN ('anon', 'authenticated') THEN
    RETURN NEW;
  END IF;

  IF TG_OP = 'INSERT' THEN
    -- A device (anon has no auth.uid) can never claim authorship for a
    -- new row; a signed-in insert is already forced to self-attribute by
    -- the auth_insert_records policy.
    IF auth.uid() IS NULL THEN
      NEW.user_id := NULL;
    END IF;
    RETURN NEW;
  END IF;

  -- UPDATE rules.
  -- Rewriting a record id would break sync identity on every device.
  IF NEW.id IS DISTINCT FROM OLD.id THEN
    RAISE EXCEPTION 'A record id cannot be changed.';
  END IF;

  -- Attribution rules. Two failure modes bound this design:
  -- a raise on anon writes would wedge a whole device's sync batch the
  -- first time a field iPad round-trips a clinic-era row whose user_id
  -- it never knew (one poisoned row aborts a PostgREST bulk upsert),
  -- and a naive uuid = auth.uid() comparison is NULL for anon callers,
  -- which plpgsql IF treats as false and the guard silently passes.
  -- So: devices get COERCED (attribution preserved, sync never wedges);
  -- signed-in users may backfill an unattributed row to themselves,
  -- admins may reassign, and everything else raises.
  IF NEW.user_id IS DISTINCT FROM OLD.user_id THEN
    IF auth.uid() IS NULL THEN
      NEW.user_id := OLD.user_id;
    ELSIF NOT (OLD.user_id IS NULL AND NEW.user_id = auth.uid())
          AND NOT dh_is_admin() THEN
      RAISE EXCEPTION 'The author of a visit cannot be changed.';
    END IF;
  END IF;

  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_records_rules ON records;
CREATE TRIGGER trg_records_rules
  BEFORE INSERT OR UPDATE ON records
  FOR EACH ROW
  EXECUTE FUNCTION dh_enforce_record_rules();


-- ============================================================================
-- 7. SERVER TIMESTAMPS
-- ============================================================================

-- Server-side timestamp on every records write (field device clocks are
-- unreliable). Incremental pull keys off this.
CREATE OR REPLACE FUNCTION update_synced_at()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
BEGIN
  NEW.synced_at = now();
  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_records_synced_at ON records;
CREATE TRIGGER trg_records_synced_at
  BEFORE INSERT OR UPDATE ON records
  FOR EACH ROW
  EXECUTE FUNCTION update_synced_at();

-- v4: config.updated_at is now trigger-maintained, so devices can detect
-- config changes reliably (v3 left it to the client and it drifted).
CREATE OR REPLACE FUNCTION dh_touch_config_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_config_updated_at ON config;
CREATE TRIGGER trg_config_updated_at
  BEFORE INSERT OR UPDATE ON config
  FOR EACH ROW
  EXECUTE FUNCTION dh_touch_config_updated_at();


-- ============================================================================
-- 8. CONFIG RULES TRIGGER
--
-- The orgMode row is the master switch, so it gets special handling:
-- - The shared key can never flip the org into clinic mode (that would
--   lock every field device out: a denial of service by anyone holding
--   the key).
-- - Only an admin account can change the mode in either direction.
--   Flipping BACK to field mode through the app is deliberately allowed
--   for admins: it is the recovery path when records were stranded on
--   field devices, and SETUP.md documents the sequence. It reopens the
--   shared-key surface, so the app must confirm loudly.
-- ============================================================================

CREATE OR REPLACE FUNCTION dh_enforce_config_rules()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
BEGIN
  IF current_user NOT IN ('anon', 'authenticated') THEN
    RETURN NEW;
  END IF;

  IF NEW.key = 'orgMode' THEN
    IF COALESCE(NEW.value->>'mode', 'field') <> 'field' AND NOT dh_is_admin() THEN
      RAISE EXCEPTION 'Only an administrator account can switch this organization to clinic mode.';
    END IF;
    IF TG_OP = 'UPDATE'
       AND COALESCE(OLD.value->>'mode', 'field') = 'clinic'
       AND COALESCE(NEW.value->>'mode', 'field') <> 'clinic'
       AND NOT dh_is_admin() THEN
      RAISE EXCEPTION 'Only an administrator account can change this organization''s mode.';
    END IF;
  END IF;

  RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS trg_config_rules ON config;
CREATE TRIGGER trg_config_rules
  BEFORE INSERT OR UPDATE ON config
  FOR EACH ROW
  EXECUTE FUNCTION dh_enforce_config_rules();


-- ============================================================================
-- 9. ROW LEVEL SECURITY
--
-- anon policies = FIELD MODE, gated on dh_org_mode(); they reproduce v3.0
-- exactly while the org is in field mode and evaporate in clinic mode
-- (single exception: the orgMode config row stays anon-readable for
-- bootstrap discovery).
-- authenticated policies = staff accounts; they work in BOTH modes (see
-- header) but are inert until the SQL-editor bootstrap creates an admin.
-- WITH CHECK is always spelled out rather than left implicit, so a later
-- edit to USING cannot silently widen what may be written.
-- No DELETE for app roles anywhere: records use the soft-delete flag.
-- ============================================================================

ALTER TABLE records ENABLE ROW LEVEL SECURITY;
ALTER TABLE devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE config ENABLE ROW LEVEL SECURITY;
ALTER TABLE users_profiles ENABLE ROW LEVEL SECURITY;

-- ---- records: field mode (anon) ----
DROP POLICY IF EXISTS "anon_read_records" ON records;
CREATE POLICY "anon_read_records" ON records
  FOR SELECT TO anon
  USING ((SELECT dh_org_mode()) = 'field');

DROP POLICY IF EXISTS "anon_insert_records" ON records;
CREATE POLICY "anon_insert_records" ON records
  FOR INSERT TO anon
  WITH CHECK (
    (SELECT dh_org_mode()) = 'field'
    AND device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  );

DROP POLICY IF EXISTS "anon_update_records" ON records;
CREATE POLICY "anon_update_records" ON records
  FOR UPDATE TO anon
  USING (
    (SELECT dh_org_mode()) = 'field'
    AND device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  )
  WITH CHECK (
    (SELECT dh_org_mode()) = 'field'
    AND device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  );

-- ---- records: staff accounts (authenticated) ----
-- Helpers are wrapped in scalar subqueries so they evaluate once per
-- statement (initplan), not once per row - same pattern as the anon
-- policies' dh_org_mode() calls.
DROP POLICY IF EXISTS "auth_read_records" ON records;
CREATE POLICY "auth_read_records" ON records
  FOR SELECT TO authenticated
  USING ((SELECT dh_active_profile()));

DROP POLICY IF EXISTS "auth_insert_records" ON records;
CREATE POLICY "auth_insert_records" ON records
  FOR INSERT TO authenticated
  WITH CHECK (
    (SELECT dh_active_profile())
    AND user_id = auth.uid()
    AND device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  );

DROP POLICY IF EXISTS "auth_update_records" ON records;
CREATE POLICY "auth_update_records" ON records
  FOR UPDATE TO authenticated
  USING ((SELECT dh_active_profile()))
  WITH CHECK (
    (SELECT dh_active_profile())
    AND device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  );

-- ---- devices ----
-- anon read is field-mode only. (The device-rules trigger's admin-count
-- SELECT runs as the caller, but anon can only reach that trigger through
-- the field-gated insert/update policies, so gating the read costs
-- nothing and stops clinic-mode fleet enumeration by old key holders.)
DROP POLICY IF EXISTS "anon_read_devices" ON devices;
CREATE POLICY "anon_read_devices" ON devices
  FOR SELECT TO anon
  USING ((SELECT dh_org_mode()) = 'field');

DROP POLICY IF EXISTS "anon_insert_devices" ON devices;
CREATE POLICY "anon_insert_devices" ON devices
  FOR INSERT TO anon
  WITH CHECK ((SELECT dh_org_mode()) = 'field');

DROP POLICY IF EXISTS "anon_update_devices" ON devices;
CREATE POLICY "anon_update_devices" ON devices
  FOR UPDATE TO anon
  USING ((SELECT dh_org_mode()) = 'field')
  WITH CHECK ((SELECT dh_org_mode()) = 'field');

DROP POLICY IF EXISTS "auth_read_devices" ON devices;
CREATE POLICY "auth_read_devices" ON devices
  FOR SELECT TO authenticated
  USING ((SELECT dh_active_profile()));

DROP POLICY IF EXISTS "auth_insert_devices" ON devices;
CREATE POLICY "auth_insert_devices" ON devices
  FOR INSERT TO authenticated
  WITH CHECK ((SELECT dh_active_profile()));

DROP POLICY IF EXISTS "auth_update_devices" ON devices;
CREATE POLICY "auth_update_devices" ON devices
  FOR UPDATE TO authenticated
  USING ((SELECT dh_active_profile()))
  WITH CHECK ((SELECT dh_active_profile()));

-- ---- config ----
-- anon: full read in field mode; in clinic mode exactly ONE row stays
-- visible (orgMode), so a device holding only URL + key can discover it
-- must show the sign-in screen. Config never carries credentials
-- (working rule, enforced by review).
DROP POLICY IF EXISTS "anon_read_config" ON config;
CREATE POLICY "anon_read_config" ON config
  FOR SELECT TO anon
  USING ((SELECT dh_org_mode()) = 'field' OR key = 'orgMode');

DROP POLICY IF EXISTS "anon_write_config" ON config;
CREATE POLICY "anon_write_config" ON config
  FOR INSERT TO anon
  WITH CHECK ((SELECT dh_org_mode()) = 'field');

DROP POLICY IF EXISTS "anon_update_config" ON config;
CREATE POLICY "anon_update_config" ON config
  FOR UPDATE TO anon
  USING ((SELECT dh_org_mode()) = 'field')
  WITH CHECK ((SELECT dh_org_mode()) = 'field');

-- Staff read config once active; pending accounts see only orgMode.
DROP POLICY IF EXISTS "auth_read_config" ON config;
CREATE POLICY "auth_read_config" ON config
  FOR SELECT TO authenticated
  USING ((SELECT dh_active_profile()) OR key = 'orgMode');

-- Staff config writes are ADMIN ONLY - tighter than field mode, where the
-- client gates admin actions (documented honest limit).
DROP POLICY IF EXISTS "auth_write_config" ON config;
CREATE POLICY "auth_write_config" ON config
  FOR INSERT TO authenticated
  WITH CHECK ((SELECT dh_is_admin()));

DROP POLICY IF EXISTS "auth_update_config" ON config;
CREATE POLICY "auth_update_config" ON config
  FOR UPDATE TO authenticated
  USING ((SELECT dh_is_admin()))
  WITH CHECK ((SELECT dh_is_admin()));

-- ---- users_profiles ----
-- Active staff see the roster (the flow board shows names). A pending or
-- revoked account sees ONLY its own row (the app needs it to render the
-- "awaiting approval" screen). anon sees nothing.
DROP POLICY IF EXISTS "auth_read_profiles" ON users_profiles;
CREATE POLICY "auth_read_profiles" ON users_profiles
  FOR SELECT TO authenticated
  USING (id = auth.uid() OR (SELECT dh_active_profile()));

-- Signup normally flows through the auth trigger; direct insert is limited
-- to the caller's own row and the rules trigger forces it pending.
DROP POLICY IF EXISTS "auth_insert_profiles" ON users_profiles;
CREATE POLICY "auth_insert_profiles" ON users_profiles
  FOR INSERT TO authenticated
  WITH CHECK (id = auth.uid());

-- Self-edits (display name, station role) plus admin edits of anyone;
-- the rules trigger decides which FIELDS each caller may change.
DROP POLICY IF EXISTS "auth_update_profiles" ON users_profiles;
CREATE POLICY "auth_update_profiles" ON users_profiles
  FOR UPDATE TO authenticated
  USING (id = auth.uid() OR (SELECT dh_is_admin()))
  WITH CHECK (id = auth.uid() OR (SELECT dh_is_admin()));

-- ---- no hard deletes, any table, any app role ----
DROP POLICY IF EXISTS "no_delete_records" ON records;
CREATE POLICY "no_delete_records" ON records FOR DELETE USING (false);
DROP POLICY IF EXISTS "no_delete_devices" ON devices;
CREATE POLICY "no_delete_devices" ON devices FOR DELETE USING (false);
DROP POLICY IF EXISTS "no_delete_config" ON config;
CREATE POLICY "no_delete_config" ON config FOR DELETE USING (false);
DROP POLICY IF EXISTS "no_delete_profiles" ON users_profiles;
CREATE POLICY "no_delete_profiles" ON users_profiles FOR DELETE USING (false);


-- ============================================================================
-- 10. INDEXES
-- ============================================================================

CREATE INDEX IF NOT EXISTS idx_records_date ON records (date);
CREATE INDEX IF NOT EXISTS idx_records_site ON records (site);
CREATE INDEX IF NOT EXISTS idx_records_deleted ON records (deleted);
CREATE INDEX IF NOT EXISTS idx_records_device_id ON records (device_id);
CREATE INDEX IF NOT EXISTS idx_records_saved_at ON records (saved_at);
CREATE INDEX IF NOT EXISTS idx_records_mrn ON records (mrn);
CREATE INDEX IF NOT EXISTS idx_records_referral_type ON records (referral_type);
CREATE INDEX IF NOT EXISTS idx_records_referral_date ON records (referral_date);
CREATE INDEX IF NOT EXISTS idx_records_provider ON records (provider);
CREATE INDEX IF NOT EXISTS idx_records_synced_at ON records (synced_at);
-- v4: the two indexes that previously existed only in migrations, plus
-- keyset pull and flow board support.
CREATE INDEX IF NOT EXISTS idx_records_template_id ON records (template_id);
CREATE INDEX IF NOT EXISTS idx_devices_active ON devices (id) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_records_synced_at_id ON records (synced_at, id);
CREATE INDEX IF NOT EXISTS idx_records_date_flow ON records (date, flow_station);


-- ============================================================================
-- 11. REALTIME
-- Optional but recommended. Without this a device subscribes successfully
-- and shows Live while never receiving anything, so records only move on
-- the periodic sync. Safe to re-run. devices and users_profiles are
-- deliberately NOT published: revocation lands on the next write or sync.
-- Realtime respects RLS, so clinic-mode anon subscribers receive nothing
-- from records and only the orgMode row from config.
-- ============================================================================

DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
    IF NOT EXISTS (SELECT 1 FROM pg_publication_tables
      WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'records') THEN
      ALTER PUBLICATION supabase_realtime ADD TABLE public.records;
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_publication_tables
      WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'config') THEN
      ALTER PUBLICATION supabase_realtime ADD TABLE public.config;
    END IF;
  END IF;
END $$;

verify.sql

Checks the project after setup.sql. Every row of its output must say PASS. It changes nothing and ends by rolling itself back.

Download 387 lines
-- ============================================================================
-- DH EMR v4.1 verification suite
--
-- Paste the ENTIRE file into the Supabase SQL Editor and click Run AFTER
-- running setup.sql. It seeds throwaway rows, attacks the rules as the
-- anon and authenticated roles, prints a PASS/FAIL table, and ROLLS BACK,
-- so it changes nothing.
--
-- Every row must say PASS. Row 0 is the positive control: if it FAILS,
-- the anon role is missing table grants and every other PASS is
-- meaningless because writes were blocked by permissions, not by policy.
--
-- Section B (rows 9+) seeds rows directly in auth.users to simulate
-- signups. If your Supabase version rejects that insert, row 9 says SKIP
-- (with the database's reason) and the rows after it will show FAIL noise;
-- the section A results above stay valid either way, and the clinic-mode
-- checks can be done by hand per SETUP.md section 5.
--
-- Best run on a project WITHOUT real staff accounts yet (fresh project or
-- field-mode org). On a project with existing active admins, row 14 says
-- SKIP (the sole-admin guard cannot be probed when other admins exist).
-- ============================================================================

BEGIN;

CREATE TEMP TABLE dh_verify (n INT, result TEXT, check_name TEXT, detail TEXT) ON COMMIT DROP;
GRANT INSERT, SELECT ON dh_verify TO anon, authenticated;
CREATE TEMP TABLE dh_env (k TEXT PRIMARY KEY, v TEXT) ON COMMIT DROP;
GRANT SELECT ON dh_env TO anon, authenticated;

-- ---------------------------------------------------------------------------
-- Seed (as the trusted SQL-editor role; triggers bypass on purpose)
-- ---------------------------------------------------------------------------
DELETE FROM config WHERE key = 'orgMode';   -- start in field mode; all rolled back
INSERT INTO devices (id, name, role) VALUES ('dh-verify-admin', 'verify admin device', 'admin');
INSERT INTO devices (id, name, role) VALUES ('dh-verify-std', 'verify standard device', 'standard');
INSERT INTO devices (id, name, role, revoked_at) VALUES ('dh-verify-revoked', 'verify revoked device', 'standard', now());
INSERT INTO records (id, device_id, given_name, mrn)
VALUES ('00000000-0000-4000-8000-00000000d0c1', 'dh-verify-revoked', 'RevokedWrote', 'VERIFY0');

-- ===========================================================================
-- SECTION A: field mode, anon role (v3.0 rules carried forward + v4 gates)
-- ===========================================================================
SET LOCAL ROLE anon;

-- 0. control: an active device can write a record
DO $$ BEGIN
  INSERT INTO records (id, device_id, given_name, mrn)
  VALUES ('00000000-0000-4000-8000-00000000d0c2', 'dh-verify-std', 'Control', 'VERIFY1');
  INSERT INTO dh_verify VALUES (0, 'PASS', 'control: an active device can write a record', '');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (0, 'FAIL', 'control: an active device can write a record', SQLERRM);
END $$;

-- 1. a standard device cannot promote itself to admin
DO $$ BEGIN
  UPDATE devices SET role = 'admin' WHERE id = 'dh-verify-std';
  INSERT INTO dh_verify VALUES (1, 'FAIL', 'a standard device cannot promote itself to admin', 'update was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (1, 'PASS', 'a standard device cannot promote itself to admin', SQLERRM);
END $$;

-- 2. a device id cannot be changed
DO $$ BEGIN
  UPDATE devices SET id = 'dh-verify-std-2' WHERE id = 'dh-verify-std';
  INSERT INTO dh_verify VALUES (2, 'FAIL', 'a device id cannot be changed', 'update was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (2, 'PASS', 'a device id cannot be changed', SQLERRM);
END $$;

-- 3. a record cannot be written for an unregistered device
DO $$ BEGIN
  INSERT INTO records (id, device_id, given_name, mrn)
  VALUES ('00000000-0000-4000-8000-00000000d0c3', 'dh-verify-ghost', 'Ghost', 'VERIFY2');
  INSERT INTO dh_verify VALUES (3, 'FAIL', 'a record cannot be written for an unregistered device', 'insert was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (3, 'PASS', 'a record cannot be written for an unregistered device', SQLERRM);
END $$;

-- 4. a revoked device cannot un-revoke itself
DO $$ BEGIN
  UPDATE devices SET revoked_at = NULL WHERE id = 'dh-verify-revoked';
  INSERT INTO dh_verify VALUES (4, 'FAIL', 'a revoked device cannot un-revoke itself', 'update was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (4, 'PASS', 'a revoked device cannot un-revoke itself', SQLERRM);
END $$;

-- 5. a revoked device cannot write new records
DO $$ BEGIN
  INSERT INTO records (id, device_id, given_name, mrn)
  VALUES ('00000000-0000-4000-8000-00000000d0c4', 'dh-verify-revoked', 'Zombie', 'VERIFY3');
  INSERT INTO dh_verify VALUES (5, 'FAIL', 'a revoked device cannot write new records', 'insert was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (5, 'PASS', 'a revoked device cannot write new records', SQLERRM);
END $$;

-- 6. a revoked device cannot alter records it wrote earlier (silent 0 rows)
DO $$
DECLARE n_updated INT;
BEGIN
  UPDATE records SET notes = 'tampered' WHERE id = '00000000-0000-4000-8000-00000000d0c1';
  GET DIAGNOSTICS n_updated = ROW_COUNT;
  IF n_updated = 0 THEN
    INSERT INTO dh_verify VALUES (6, 'PASS', 'a revoked device cannot alter records it wrote earlier', '0 rows updated');
  ELSE
    INSERT INTO dh_verify VALUES (6, 'FAIL', 'a revoked device cannot alter records it wrote earlier', n_updated || ' rows updated');
  END IF;
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (6, 'PASS', 'a revoked device cannot alter records it wrote earlier', SQLERRM);
END $$;

-- 7. a record id cannot be changed
DO $$ BEGIN
  UPDATE records SET id = '00000000-0000-4000-8000-00000000dead'
  WHERE id = '00000000-0000-4000-8000-00000000d0c2';
  INSERT INTO dh_verify VALUES (7, 'FAIL', 'a record id cannot be changed', 'update was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (7, 'PASS', 'a record id cannot be changed', SQLERRM);
END $$;

-- 8. the shared key cannot switch the org to clinic mode
DO $$ BEGIN
  INSERT INTO config (key, value) VALUES ('orgMode', '{"mode":"clinic"}');
  INSERT INTO dh_verify VALUES (8, 'FAIL', 'the shared key cannot switch the org to clinic mode', 'insert was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (8, 'PASS', 'the shared key cannot switch the org to clinic mode', SQLERRM);
END $$;

RESET ROLE;

-- ===========================================================================
-- SECTION B: staff accounts and clinic mode
-- ===========================================================================

-- Remember whether this project already has active admin accounts (used
-- to SKIP the sole-admin probe rather than report false alarms).
INSERT INTO dh_env
SELECT 'preexisting_admins', count(*)::text
FROM users_profiles
WHERE is_admin AND activated_at IS NOT NULL AND revoked_at IS NULL;

-- Simulate two signups (fires the same trigger a real signup does).
-- Wrapped so a version-sensitive auth.users shape cannot abort the whole
-- transaction and hide section A's results.
DO $$ BEGIN
  INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password,
                          email_confirmed_at, created_at, updated_at,
                          confirmation_token, recovery_token,
                          email_change_token_new, email_change)
  VALUES ('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-0000000000a1',
          'authenticated', 'authenticated', 'dh-verify-lead@example.org', '',
          now(), now(), now(), '', '', '', ''),
         ('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-0000000000a2',
          'authenticated', 'authenticated', 'dh-verify-staff@example.org', '',
          now(), now(), now(), '', '', '', '');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (9, 'SKIP', 'section B skipped: auth.users seeding rejected on this Supabase version', SQLERRM);
END $$;

-- 9. signing up grants nothing: both profiles pending, neither admin
DO $$
DECLARE bad INT;
BEGIN
  IF EXISTS (SELECT 1 FROM dh_verify WHERE n = 9 AND result = 'SKIP') THEN
    NULL;  -- seeding failed; the SKIP row already explains why
  ELSE
    SELECT count(*) INTO bad FROM users_profiles
    WHERE id IN ('00000000-0000-4000-8000-0000000000a1', '00000000-0000-4000-8000-0000000000a2')
      AND (is_admin OR activated_at IS NOT NULL);
    IF bad = 0 AND (SELECT count(*) FROM users_profiles
                    WHERE id IN ('00000000-0000-4000-8000-0000000000a1',
                                 '00000000-0000-4000-8000-0000000000a2')) = 2 THEN
      INSERT INTO dh_verify VALUES (9, 'PASS', 'signing up grants nothing (pending, not admin)', '');
    ELSE
      INSERT INTO dh_verify VALUES (9, 'FAIL', 'signing up grants nothing (pending, not admin)', bad || ' profiles had privileges');
    END IF;
  END IF;
END $$;

-- Act as the pending lead account.
SET LOCAL ROLE authenticated;
SELECT set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
SELECT set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000a1', true);

-- 10. a pending account cannot read records
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM records;
  IF n = 0 THEN
    INSERT INTO dh_verify VALUES (10, 'PASS', 'a pending account cannot read records', '');
  ELSE
    INSERT INTO dh_verify VALUES (10, 'FAIL', 'a pending account cannot read records', n || ' rows visible');
  END IF;
END $$;

-- 11. a pending account cannot approve itself
DO $$ BEGIN
  UPDATE users_profiles SET activated_at = now()
  WHERE id = '00000000-0000-4000-8000-0000000000a1';
  INSERT INTO dh_verify VALUES (11, 'FAIL', 'a pending account cannot approve itself', 'update was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (11, 'PASS', 'a pending account cannot approve itself', SQLERRM);
END $$;

-- 12. a pending account sees only its own profile row
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM users_profiles;
  IF n = 1 THEN
    INSERT INTO dh_verify VALUES (12, 'PASS', 'a pending account sees only its own profile row', '');
  ELSE
    INSERT INTO dh_verify VALUES (12, 'FAIL', 'a pending account sees only its own profile row', n || ' rows visible');
  END IF;
END $$;

RESET ROLE;

-- Bootstrap the first admin exactly as SETUP.md documents.
UPDATE users_profiles SET is_admin = true, activated_at = now()
WHERE id = '00000000-0000-4000-8000-0000000000a1';

SET LOCAL ROLE authenticated;
SELECT set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
SELECT set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000a1', true);

-- 13. the bootstrapped admin can write config
DO $$ BEGIN
  INSERT INTO config (key, value) VALUES ('dh-verify-key', '{"ok":true}');
  INSERT INTO dh_verify VALUES (13, 'PASS', 'the bootstrapped admin can write config', '');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (13, 'FAIL', 'the bootstrapped admin can write config', SQLERRM);
END $$;

-- 14. the only admin cannot revoke, demote, or deactivate themselves
--     (skipped when the project already has other active admins - the
--     guard correctly allows self-removal in that case)
DO $$ BEGIN
  IF (SELECT v FROM dh_env WHERE k = 'preexisting_admins') <> '0' THEN
    INSERT INTO dh_verify VALUES (14, 'SKIP', 'the only admin cannot disable themselves', 'project already has active admins; guard not probeable');
  ELSE
    BEGIN
      UPDATE users_profiles SET revoked_at = now()
      WHERE id = '00000000-0000-4000-8000-0000000000a1';
      INSERT INTO dh_verify VALUES (14, 'FAIL', 'the only admin cannot disable themselves', 'self-revoke was allowed');
    EXCEPTION WHEN OTHERS THEN
      INSERT INTO dh_verify VALUES (14, 'PASS', 'the only admin cannot disable themselves', SQLERRM);
    END;
  END IF;
END $$;

-- 15. an admin can switch the org to clinic mode
DO $$ BEGIN
  INSERT INTO config (key, value) VALUES ('orgMode', '{"mode":"clinic"}')
  ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
  INSERT INTO dh_verify VALUES (15, 'PASS', 'an admin can switch the org to clinic mode', '');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (15, 'FAIL', 'an admin can switch the org to clinic mode', SQLERRM);
END $$;

-- 16. an active staff account reads records in clinic mode
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM records;
  IF n >= 2 THEN
    INSERT INTO dh_verify VALUES (16, 'PASS', 'an active staff account reads records in clinic mode', n || ' rows');
  ELSE
    INSERT INTO dh_verify VALUES (16, 'FAIL', 'an active staff account reads records in clinic mode', n || ' rows visible');
  END IF;
END $$;

RESET ROLE;
-- RESET ROLE does not clear GUCs: blank the JWT claims so the anon checks
-- below run as a true key-only caller, not as the admin's leftover
-- identity. Empty string, not NULL: auth.uid() nullif()s '' to NULL.
SELECT set_config('request.jwt.claims', '', true);
SELECT set_config('request.jwt.claim.sub', '', true);
SET LOCAL ROLE anon;

-- 17. clinic mode: the shared key cannot read records
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM records;
  IF n = 0 THEN
    INSERT INTO dh_verify VALUES (17, 'PASS', 'clinic mode: the shared key cannot read records', '');
  ELSE
    INSERT INTO dh_verify VALUES (17, 'FAIL', 'clinic mode: the shared key cannot read records', n || ' rows visible');
  END IF;
END $$;

-- 18. clinic mode: the shared key cannot write records
DO $$ BEGIN
  INSERT INTO records (id, device_id, given_name, mrn)
  VALUES ('00000000-0000-4000-8000-00000000d0c5', 'dh-verify-std', 'Locked', 'VERIFY4');
  INSERT INTO dh_verify VALUES (18, 'FAIL', 'clinic mode: the shared key cannot write records', 'insert was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (18, 'PASS', 'clinic mode: the shared key cannot write records', SQLERRM);
END $$;

-- 19. clinic mode: the shared key cannot register devices
DO $$ BEGIN
  INSERT INTO devices (id, name) VALUES ('dh-verify-intruder', 'intruder');
  INSERT INTO dh_verify VALUES (19, 'FAIL', 'clinic mode: the shared key cannot register devices', 'insert was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (19, 'PASS', 'clinic mode: the shared key cannot register devices', SQLERRM);
END $$;

-- 20. clinic mode: the shared key sees exactly one config row (orgMode)
DO $$
DECLARE n INT; k TEXT;
BEGIN
  SELECT count(*), min(key) INTO n, k FROM config;
  IF n = 1 AND k = 'orgMode' THEN
    INSERT INTO dh_verify VALUES (20, 'PASS', 'clinic mode: the shared key sees only the orgMode row', '');
  ELSE
    INSERT INTO dh_verify VALUES (20, 'FAIL', 'clinic mode: the shared key sees only the orgMode row', n || ' rows visible');
  END IF;
END $$;

-- 21. clinic mode: the shared key cannot enumerate the device fleet
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM devices;
  IF n = 0 THEN
    INSERT INTO dh_verify VALUES (21, 'PASS', 'clinic mode: the shared key cannot enumerate the device fleet', '');
  ELSE
    INSERT INTO dh_verify VALUES (21, 'FAIL', 'clinic mode: the shared key cannot enumerate the device fleet', n || ' rows visible');
  END IF;
END $$;

RESET ROLE;

-- Revoke the second (still pending) account, then prove stickiness.
UPDATE users_profiles SET revoked_at = now()
WHERE id = '00000000-0000-4000-8000-0000000000a2';

SET LOCAL ROLE authenticated;
SELECT set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a2","role":"authenticated"}', true);
SELECT set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000a2', true);

-- 22. a revoked account cannot read records
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM records;
  IF n = 0 THEN
    INSERT INTO dh_verify VALUES (22, 'PASS', 'a revoked account cannot read records', '');
  ELSE
    INSERT INTO dh_verify VALUES (22, 'FAIL', 'a revoked account cannot read records', n || ' rows visible');
  END IF;
END $$;

-- 23. a revoked account cannot un-revoke itself
DO $$ BEGIN
  UPDATE users_profiles SET revoked_at = NULL
  WHERE id = '00000000-0000-4000-8000-0000000000a2';
  INSERT INTO dh_verify VALUES (23, 'FAIL', 'a revoked account cannot un-revoke itself', 'update was allowed');
EXCEPTION WHEN OTHERS THEN
  INSERT INTO dh_verify VALUES (23, 'PASS', 'a revoked account cannot un-revoke itself', SQLERRM);
END $$;

-- 24. a revoked account cannot see the staff roster (only its own row)
DO $$
DECLARE n INT;
BEGIN
  SELECT count(*) INTO n FROM users_profiles;
  IF n <= 1 THEN
    INSERT INTO dh_verify VALUES (24, 'PASS', 'a revoked account sees at most its own profile row', '');
  ELSE
    INSERT INTO dh_verify VALUES (24, 'FAIL', 'a revoked account sees at most its own profile row', n || ' rows visible');
  END IF;
END $$;

RESET ROLE;

-- ---------------------------------------------------------------------------
-- Results, then roll everything back.
-- ---------------------------------------------------------------------------
SELECT n, result, check_name, left(detail, 80) AS detail
FROM dh_verify ORDER BY n;

ROLLBACK;

rollback-v4-to-v3.sql

Only for an organization that upgraded from the pre-split v3 schema and needs to go back. Read its header before running it.

Download 108 lines
-- ============================================================================
-- Roll a v4.1 project back to v3.0 behavior.
--
-- Use this only if the v4 machinery misbehaves in the field. It restores
-- the exact v3.0 policy surface (shared-key field mode, no mode gate, no
-- staff accounts) and removes every v4 trigger and function.
--
-- Deliberately KEPT, because dropping them destroys data and unused
-- objects cost nothing: the users_profiles table and its rows, the new
-- records columns (user_id, flow_station, flow_updated_at), all indexes,
-- and any orgMode config row (it becomes inert once the functions that
-- read it are gone).
--
-- Safe to re-run. After running this, re-running v3.0 setup.sql is a
-- no-op; re-running v4.1 setup.sql re-applies v4.
-- ============================================================================

-- 1. Restore the v3.0 anon policies (same names, no TO clause, no mode gate).
DROP POLICY IF EXISTS "anon_read_records" ON records;
CREATE POLICY "anon_read_records" ON records
  FOR SELECT USING (true);

DROP POLICY IF EXISTS "anon_insert_records" ON records;
CREATE POLICY "anon_insert_records" ON records
  FOR INSERT WITH CHECK (
    device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  );

DROP POLICY IF EXISTS "anon_update_records" ON records;
CREATE POLICY "anon_update_records" ON records
  FOR UPDATE
  USING (
    device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  )
  WITH CHECK (
    device_id IS NOT NULL
    AND EXISTS (SELECT 1 FROM devices d WHERE d.id = device_id AND d.revoked_at IS NULL)
  );

DROP POLICY IF EXISTS "anon_read_devices" ON devices;
CREATE POLICY "anon_read_devices" ON devices
  FOR SELECT USING (true);

DROP POLICY IF EXISTS "anon_insert_devices" ON devices;
CREATE POLICY "anon_insert_devices" ON devices
  FOR INSERT WITH CHECK (true);

DROP POLICY IF EXISTS "anon_update_devices" ON devices;
CREATE POLICY "anon_update_devices" ON devices
  FOR UPDATE USING (true) WITH CHECK (true);

DROP POLICY IF EXISTS "anon_read_config" ON config;
CREATE POLICY "anon_read_config" ON config
  FOR SELECT USING (true);

DROP POLICY IF EXISTS "anon_write_config" ON config;
CREATE POLICY "anon_write_config" ON config
  FOR INSERT WITH CHECK (true);

DROP POLICY IF EXISTS "anon_update_config" ON config;
CREATE POLICY "anon_update_config" ON config
  FOR UPDATE USING (true) WITH CHECK (true);

-- 2. Remove the v4 staff-account policies (the table itself stays).
DROP POLICY IF EXISTS "auth_read_records" ON records;
DROP POLICY IF EXISTS "auth_insert_records" ON records;
DROP POLICY IF EXISTS "auth_update_records" ON records;
DROP POLICY IF EXISTS "auth_read_devices" ON devices;
DROP POLICY IF EXISTS "auth_insert_devices" ON devices;
DROP POLICY IF EXISTS "auth_update_devices" ON devices;
DROP POLICY IF EXISTS "auth_read_config" ON config;
DROP POLICY IF EXISTS "auth_write_config" ON config;
DROP POLICY IF EXISTS "auth_update_config" ON config;
DROP POLICY IF EXISTS "auth_read_profiles" ON users_profiles;
DROP POLICY IF EXISTS "auth_insert_profiles" ON users_profiles;
DROP POLICY IF EXISTS "auth_update_profiles" ON users_profiles;

-- 3. Remove the v4 triggers and functions. The device-rules trigger and
--    update_synced_at stay: they are v3.0 machinery.
DROP TRIGGER IF EXISTS trg_users_rules ON users_profiles;
DROP TRIGGER IF EXISTS trg_config_rules ON config;
DROP TRIGGER IF EXISTS trg_records_rules ON records;
DROP TRIGGER IF EXISTS trg_config_updated_at ON config;

-- The signup trigger lives on auth.users, which the postgres role does
-- not own on every stack; DROP TRIGGER there can fail with 'must be owner
-- of relation users' and abort the whole rollback. Dropping the function
-- with CASCADE removes the dependent trigger without needing ownership.
DROP FUNCTION IF EXISTS dh_handle_new_user() CASCADE;

DROP FUNCTION IF EXISTS dh_enforce_user_rules();
DROP FUNCTION IF EXISTS dh_enforce_config_rules();
DROP FUNCTION IF EXISTS dh_enforce_record_rules();
DROP FUNCTION IF EXISTS dh_touch_config_updated_at();
DROP FUNCTION IF EXISTS dh_org_mode();
DROP FUNCTION IF EXISTS dh_active_profile();
DROP FUNCTION IF EXISTS dh_is_admin();

-- 4. Confirm: this should return no rows. trg_on_auth_user_created is in
--    the list because its drop is the permission-fragile one; if it shows
--    up here, the signup trigger survived and is still creating profile
--    rows.
SELECT tgname FROM pg_trigger
WHERE tgname IN ('trg_users_rules', 'trg_config_rules', 'trg_records_rules',
                 'trg_config_updated_at', 'trg_on_auth_user_created');

The scripts are published straight from the supabase folder of the DH EMR source, which also holds the same instructions as SETUP.md.

DH EMR is not a certified EHR and is not HIPAA-compliant. It is intended for global-health use outside the US.