132 lines
4.9 KiB
PL/PgSQL
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;
|