Files
Jyotisha/frontend/supabase/migrations/20260807030000_redeem_security.sql
T

132 lines
4.9 KiB
PL/PgSQL

begin;
-- Account-level, cross-instance rate limiting for consecutive failed
-- redemption attempts. The audit table records only who failed and when;
-- neither the plaintext code nor its hash is ever stored here. It is
-- failure-only: a successful redemption deletes the account's rows, so no
-- permanent success row is kept.
create table if not exists public.redemption_attempts (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
create index if not exists redemption_attempts_user_created_idx
on public.redemption_attempts (user_id, created_at desc);
alter table public.redemption_attempts enable row level security;
revoke all on table public.redemption_attempts from anon, authenticated;
grant select on table public.redemption_attempts to service_role;
-- CREATE OR REPLACE cannot change a function's return type, so the previous
-- 3-column redeem_code(text) is dropped and rebuilt inside the same
-- transaction, then its revoke/grant ACLs are restored below.
drop function if exists public.redeem_code(text);
-- redeem_code keeps the redemption-code row lock and the credit_transactions
-- unique constraint. It additionally serializes per-account attempts, counts
-- business failures within a rolling 10-minute window, and returns
-- rate_limited once an account reaches 5 failures. Every business failure
-- writes an audit row; a successful redemption deletes the account's
-- attempts. account_not_eligible is intentionally not fabricated here: there
-- is no account-eligibility restriction model yet. The audit table's
-- identity sequence needs no authenticated USAGE grant because the
-- security-definer function runs as its owner.
create or replace function public.redeem_code(p_code_hash text)
returns table (success boolean, credits integer, awarded_credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := auth.uid();
v_email text := auth.jwt() ->> 'email';
v_code public.redemption_codes%rowtype;
v_balance integer;
v_failed integer;
begin
if v_user_id is null then
return query select false, null::integer, null::integer, 'unauthorized'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0));
delete from public.redemption_attempts
where user_id = v_user_id and created_at < now() - interval '10 minutes';
select count(*) into v_failed
from public.redemption_attempts
where user_id = v_user_id;
if v_failed >= 5 then
return query select false, null::integer, null::integer, 'rate_limited'::text;
return;
end if;
if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'invalid_code'::text;
return;
end if;
select rc.* into v_code
from public.redemption_codes rc
where rc.code_hash = p_code_hash
for update;
if not found then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'invalid_code'::text;
return;
end if;
if v_code.redeemed_by is not null then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'already_redeemed'::text;
return;
end if;
if v_code.revoked_at is not null then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'revoked_code'::text;
return;
end if;
if v_code.expires_at is not null and v_code.expires_at <= now() then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'expired_code'::text;
return;
end if;
select p.credits into v_balance
from public.profiles p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, null::integer, 'profile_missing'::text;
return;
end if;
update public.redemption_codes rc
set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now()
where rc.id = v_code.id;
update public.profiles p
set credits = p.credits + v_code.credits, updated_at = now()
where p.id = v_user_id
returning p.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
) values (
v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
);
delete from public.redemption_attempts where user_id = v_user_id;
return query select true, v_balance, v_code.credits, null::text;
end;
$$;
revoke all on function public.redeem_code(text) from public, anon;
grant execute on function public.redeem_code(text) to authenticated;
commit;