253 lines
7.8 KiB
PL/PgSQL
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;
|