-- =====================================================================
-- Migration 0011 — Hardening & ops (roadmap Step 12).
--
-- 1. pg_cron schedules: alert detector + chain verifier.
-- 2. Daily off-site hash anchor (writes the day's last row_hash per shop
--    to app.daily_anchors; an external job copies these to S3/Glacier).
-- 3. HMAC + PIN secret rotation procedures with audit.
-- 4. DDL lockdown advisory (event trigger blocking DDL by anyone other
--    than the migration role).
-- 5. NTP / clock-skew guard at INSERT time on app.transactions.
--
-- Threat-model rows: 4, 7, 17, 22, 23, 25.
-- =====================================================================

-- ---------------------------------------------------------------------
-- 1. pg_cron schedules. Supabase ships pg_cron in the `extensions`
--    schema. Each task runs as the table owner thanks to SECURITY DEFINER.
-- ---------------------------------------------------------------------
create extension if not exists pg_cron;

-- Run alert detectors every 5 minutes.
do $$ begin
  perform cron.schedule('app_alert_detectors_5m',
                        '*/5 * * * *',
                        $cmd$ select app.run_alert_detectors(); $cmd$);
exception when others then null;     -- already scheduled
end $$;

-- Verify the hash chain hourly per shop. We don't bail loudly here;
-- run_alert_detectors() raises a chain_break alert if verify_chain fails.
do $$ begin
  perform cron.schedule('app_chain_verify_hourly',
                        '7 * * * *',
                        $cmd$ select 1 from (
                                select s.id, (select bool_and(ok) from app.verify_chain(s.id))
                                from app.shops s
                              ) v; $cmd$);
exception when others then null;
end $$;

-- ---------------------------------------------------------------------
-- 2. Daily off-site anchor.
--    The most important fraud control after recon: every night, copy
--    the last row_hash per shop into a row that is written ONCE,
--    timestamped, and exported to immutable storage. If anyone tampers
--    with history, today's anchor will not chain back to yesterday's.
-- ---------------------------------------------------------------------
create table if not exists app.daily_anchors (
  id          uuid primary key default gen_random_uuid(),
  shop_id     uuid not null references app.shops(id) on delete restrict,
  anchor_date date not null,
  last_txn_id uuid,
  last_ref_no bigint,
  last_row_hash bytea,
  txn_count_to_date bigint not null,
  computed_at timestamptz not null default now(),
  unique (shop_id, anchor_date)
);
alter table app.daily_anchors enable row level security;
alter table app.daily_anchors force  row level security;
revoke insert, update, delete on app.daily_anchors from authenticated;

create or replace function app._daily_anchors_guard()
returns trigger language plpgsql as $$
begin
  raise exception 'daily_anchors are append-only';
end;
$$;
drop trigger if exists trg_daily_anchors_guard on app.daily_anchors;
create trigger trg_daily_anchors_guard before update or delete on app.daily_anchors
  for each row execute function app._daily_anchors_guard();

drop policy if exists daily_anchors_select on app.daily_anchors;
create policy daily_anchors_select on app.daily_anchors
  for select to authenticated
  using (app.has_any_role_in_shop(shop_id,
           array['owner','auditor']::app.business_role[]));
grant select on app.daily_anchors to authenticated;

create or replace function app.write_daily_anchors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
  for r in
    with last_row as (
      select distinct on (shop_id)
             shop_id, id, reference_no, row_hash, occurred_at
      from app.transactions
      where (occurred_at at time zone 'UTC')::date
            = (now() at time zone 'UTC')::date - 1
      order by shop_id, reference_no desc
    )
    select lr.shop_id, lr.id, lr.reference_no, lr.row_hash,
           (now() at time zone 'UTC')::date - 1 as anchor_date,
           (select count(*) from app.transactions t
              where t.shop_id = lr.shop_id
                and t.reference_no <= lr.reference_no) as cnt
    from last_row lr
  loop
    insert into app.daily_anchors(shop_id, anchor_date, last_txn_id,
                                  last_ref_no, last_row_hash, txn_count_to_date)
      values (r.shop_id, r.anchor_date, r.id,
              r.reference_no, r.row_hash, r.cnt)
    on conflict (shop_id, anchor_date) do nothing;
    n := n + 1;
  end loop;

  perform app.log_auth_event('daily_anchor_written', null, null,
    jsonb_build_object('rows', n));
  return n;
end;
$$;
revoke all on function app.write_daily_anchors() from public;
grant execute on function app.write_daily_anchors() to authenticated;

-- 02:15 Beirut time = 23:15 UTC the previous day; at that hour the till
-- is closed and the day's last txn already exists.
do $$ begin
  perform cron.schedule('app_daily_anchor',
                        '15 23 * * *',
                        $cmd$ select app.write_daily_anchors(); $cmd$);
exception when others then null;
end $$;

-- ---------------------------------------------------------------------
-- 3. Secret rotation with audit. The HMAC key was created in 0007;
--    rotating it invalidates all printed receipts but new ones become
--    forgery-resistant. PIN rotation is per-user.
-- ---------------------------------------------------------------------
create table if not exists app.secret_rotations (
  id            uuid primary key default gen_random_uuid(),
  secret_name   text not null,
  rotated_by    uuid not null references auth.users(id) default auth.uid(),
  rotated_at    timestamptz not null default now(),
  reason        text not null check (length(btrim(reason)) >= 5)
);
alter table app.secret_rotations enable row level security;
alter table app.secret_rotations force  row level security;
revoke insert, update, delete on app.secret_rotations from authenticated;
drop policy if exists secret_rotations_select on app.secret_rotations;
create policy secret_rotations_select on app.secret_rotations
  for select to authenticated
  using (app.is_owner_anywhere());
grant select on app.secret_rotations to authenticated;

create or replace function app._secret_rotations_guard()
returns trigger language plpgsql as $$
begin
  raise exception 'secret_rotations are append-only';
end;
$$;
drop trigger if exists trg_secret_rotations_guard on app.secret_rotations;
create trigger trg_secret_rotations_guard before update or delete on app.secret_rotations
  for each row execute function app._secret_rotations_guard();

create or replace function app.log_secret_rotation(p_name text, p_reason text)
returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare rid uuid;
begin
  if not app.is_owner_anywhere() then
    raise exception 'owner role required';
  end if;
  insert into app.secret_rotations(secret_name, reason)
    values (p_name, p_reason) returning id into rid;
  return rid;
end;
$$;
revoke all on function app.log_secret_rotation(text, text) from public;
grant execute on function app.log_secret_rotation(text, text) to authenticated;

-- ---------------------------------------------------------------------
-- 4. DDL lockdown advisory.
--    Anyone with `authenticated` should not be able to issue DDL anyway,
--    but Supabase ships a `service_role` key. This event trigger raises
--    if DDL is attempted from anything other than the migration owner.
-- ---------------------------------------------------------------------
create or replace function app._ddl_lock()
returns event_trigger
language plpgsql
as $$
begin
  -- Allow the role that owns the schema (typically `postgres` running
  -- supabase migrations) and the cron worker. Block everyone else.
  if current_user not in ('postgres', 'supabase_admin') then
    raise exception 'DDL is locked: caller % may not modify schema', current_user;
  end if;
end;
$$;
drop event trigger if exists app_ddl_lock;
create event trigger app_ddl_lock
  on ddl_command_start
  execute function app._ddl_lock();

-- ---------------------------------------------------------------------
-- 5. Clock-skew guard. A till with a manipulated clock can backdate or
--    pre-date transactions to hide them from a shift. Reject inserts
--    whose `occurred_at` is more than 5 minutes off server `now()`.
-- ---------------------------------------------------------------------
create or replace function app._txn_clock_guard()
returns trigger language plpgsql as $$
begin
  if abs(extract(epoch from (new.occurred_at - now()))) > 300 then
    raise exception
      'clock skew rejected: occurred_at=% server now()=%', new.occurred_at, now();
  end if;
  return new;
end;
$$;
drop trigger if exists trg_txn_clock_guard on app.transactions;
-- Fires before the existing txn_before_insert (alphabetical 'a' < 't').
create trigger trg_a_txn_clock_guard before insert on app.transactions
  for each row execute function app._txn_clock_guard();

-- End migration 0011 ----------------------------------------------------
