feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -0,0 +1,253 @@
|
|||
-- ============================================================================
|
||||
-- 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue