d3ro-voice/server/supabase/migrations/20260821000028_ad_reward_receipt_replay_protection.sql
2026-08-29 18:33:45 +09:00

253 lines
7.8 KiB
PL/PgSQL

-- ============================================================================
-- Durable AdMob SSV receipt replay protection
--
-- ad_reward_claims remains the ledger of rewards that were actually granted.
-- Every otherwise valid, verified SSV transaction is consumed first in the
-- service-only receipt ledger, including terminal cooldown/cap/tier outcomes.
-- ============================================================================
BEGIN;
CREATE TABLE public.ad_reward_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id text NOT NULL UNIQUE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
network text NOT NULL,
placement text NOT NULL,
ad_unit_id text NOT NULL,
reward_tokens integer NOT NULL CHECK (reward_tokens > 0 AND reward_tokens <= 100),
disposition text NOT NULL DEFAULT 'received'
CHECK (disposition IN (
'received',
'granted',
'unknown_user',
'ineligible_tier',
'daily_cap',
'cooldown',
'duplicate_claim'
)),
claim_id uuid REFERENCES public.ad_reward_claims(id) ON DELETE SET NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
-- Account deletion unlinks the subject while the provider transaction remains
-- as the minimum durable replay barrier.
COMMENT ON COLUMN public.ad_reward_receipts.user_id IS
'Verified callback subject; nulled on account deletion without deleting the transaction replay barrier.';
CREATE INDEX idx_ad_reward_receipts_user_received
ON public.ad_reward_receipts(user_id, received_at DESC);
ALTER TABLE public.ad_reward_receipts ENABLE ROW LEVEL SECURITY;
-- No authenticated policies: verified callback receipts are service-only.
-- Seed the replay barrier with every reward already granted before this
-- migration. ad_reward_claims continues to be the authoritative grant ledger.
INSERT INTO public.ad_reward_receipts (
transaction_id,
user_id,
network,
placement,
ad_unit_id,
reward_tokens,
disposition,
claim_id,
received_at,
processed_at
)
SELECT
claim.transaction_id,
claim.user_id,
claim.network,
claim.placement,
claim.ad_unit_id,
claim.reward_tokens,
'granted',
claim.id,
claim.verified_at,
claim.verified_at
FROM public.ad_reward_claims AS claim
ON CONFLICT (transaction_id) DO NOTHING;
CREATE OR REPLACE FUNCTION public.grant_verified_ad_reward(
p_user_id uuid,
p_network text,
p_placement text,
p_ad_unit_id text,
p_transaction_id text,
p_reward_tokens integer
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_receipt_user_id uuid;
v_receipt_id uuid;
v_claim_id uuid;
v_balance integer;
v_result jsonb;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'invalid_user';
END IF;
IF p_transaction_id IS NULL OR length(trim(p_transaction_id)) < 8
OR length(trim(p_transaction_id)) > 128 THEN
RAISE EXCEPTION 'invalid_transaction';
END IF;
IF p_reward_tokens IS DISTINCT FROM 50 THEN
RAISE EXCEPTION 'invalid_reward_amount';
END IF;
IF length(trim(coalesce(p_network, ''))) NOT BETWEEN 1 AND 80
OR length(trim(coalesce(p_placement, ''))) NOT BETWEEN 1 AND 80
OR length(trim(coalesce(p_ad_unit_id, ''))) NOT BETWEEN 1 AND 80 THEN
RAISE EXCEPTION 'invalid_reward_metadata';
END IF;
-- Consume the provider transaction before looking up any mutable account
-- state. A concurrent replay then blocks on the unique key and can only
-- return duplicate after this transaction commits.
INSERT INTO public.ad_reward_receipts (
transaction_id,
network,
placement,
ad_unit_id,
reward_tokens
) VALUES (
trim(p_transaction_id),
trim(p_network),
trim(p_placement),
trim(p_ad_unit_id),
p_reward_tokens
)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING id INTO v_receipt_id;
IF v_receipt_id IS NULL THEN
RETURN jsonb_build_object('granted', false, 'reason', 'duplicate');
END IF;
-- Serialize distinct transactions for the same subject. FOR KEY SHARE then
-- closes the account-delete race until the receipt is linked or terminally
-- recorded as unknown.
PERFORM pg_advisory_xact_lock(
pg_catalog.hashtextextended('d3ro:ad-reward:' || p_user_id::text, 0)
);
SELECT id INTO v_receipt_user_id
FROM auth.users
WHERE id = p_user_id
FOR KEY SHARE;
IF v_receipt_user_id IS NULL THEN
v_result := jsonb_build_object('granted', false, 'reason', 'unknown_user');
UPDATE public.ad_reward_receipts
SET disposition = 'unknown_user', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
UPDATE public.ad_reward_receipts
SET user_id = v_receipt_user_id
WHERE id = v_receipt_id;
IF NOT EXISTS (
SELECT 1
FROM public.subscriptions
WHERE user_id = p_user_id
AND tier = 'free'
AND coalesce(status, 'active') IN ('active', 'trialing')
) THEN
v_result := jsonb_build_object('granted', false, 'reason', 'ineligible_tier');
UPDATE public.ad_reward_receipts
SET disposition = 'ineligible_tier', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
IF (
SELECT count(*)
FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at >= date_trunc('day', now())
) >= 20 THEN
v_result := jsonb_build_object('granted', false, 'reason', 'daily_cap');
UPDATE public.ad_reward_receipts
SET disposition = 'daily_cap', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
IF EXISTS (
SELECT 1 FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at > now() - interval '15 seconds'
) THEN
v_result := jsonb_build_object('granted', false, 'reason', 'cooldown');
UPDATE public.ad_reward_receipts
SET disposition = 'cooldown', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
INSERT INTO public.ad_reward_claims (
user_id, network, placement, ad_unit_id, transaction_id, reward_tokens
) VALUES (
p_user_id,
trim(p_network),
trim(p_placement),
trim(p_ad_unit_id),
trim(p_transaction_id),
p_reward_tokens
)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING id INTO v_claim_id;
-- This can only occur for a legacy/direct claim that raced the receipt
-- backfill. The new receipt remains consumed, so later retries stay denied.
IF v_claim_id IS NULL THEN
v_result := jsonb_build_object('granted', false, 'reason', 'duplicate');
UPDATE public.ad_reward_receipts
SET disposition = 'duplicate_claim', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
INSERT INTO public.subscriptions (user_id, tier, overage_credits, provider)
VALUES (p_user_id, 'free', p_reward_tokens, 'none')
ON CONFLICT (user_id) DO UPDATE
SET overage_credits = public.subscriptions.overage_credits + EXCLUDED.overage_credits,
updated_at = now()
RETURNING overage_credits INTO v_balance;
v_result := jsonb_build_object(
'granted', true,
'claim_id', v_claim_id,
'tokens_added', p_reward_tokens,
'balance', v_balance
);
UPDATE public.ad_reward_receipts
SET disposition = 'granted',
claim_id = v_claim_id,
processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END;
$$;
REVOKE ALL ON TABLE public.ad_reward_receipts FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.ad_reward_receipts TO service_role;
REVOKE ALL ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) TO service_role;
COMMIT;