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;