From 5c025c114c7a4ac76215e1b744276b0cfd9b2c0d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:38:59 +0000 Subject: [PATCH 01/12] Add App Store subscriptions and one effective-entitlement function app_store_subscriptions records each App Store subscription with its product, expiry, environment, revocation and family-sharing state, written only by the service role through apply_app_store_transaction, which orders updates by expiry and signedDate and records notificationUUIDs once. effective_entitlement() returns the highest active tier across Stripe, promo, trial, legacy and App Store sources; with no App Store row it keeps the rules _shared/entitlements.ts applied. Product ids are placeholders until App Store Connect has the real ones. Co-Authored-By: Claude Opus 5.5 --- .../20261014123154_app_store_entitlements.sql | 383 +++++++++++++++++ supabase/tests/account_deletion_test.sql | 6 + .../tests/app_store_entitlements_test.sql | 401 ++++++++++++++++++ 3 files changed, 790 insertions(+) create mode 100644 supabase/migrations/20261014123154_app_store_entitlements.sql create mode 100644 supabase/tests/app_store_entitlements_test.sql diff --git a/supabase/migrations/20261014123154_app_store_entitlements.sql b/supabase/migrations/20261014123154_app_store_entitlements.sql new file mode 100644 index 000000000..4591495fd --- /dev/null +++ b/supabase/migrations/20261014123154_app_store_entitlements.sql @@ -0,0 +1,383 @@ +-- App Store subscriptions next to Stripe, and one function that computes the +-- plan every quota and model check reads. +-- +-- Sources of access: +-- user_settings trial, promo and admin grants, legacy access, and the +-- 'pro' flag the Stripe webhook sets (unchanged) +-- subscriptions Stripe periods, counted only while that flag is 'pro' +-- (unchanged) +-- app_store_subscriptions App Store auto-renewable subscriptions, written only by +-- the app-store-notifications and app-store-purchase +-- functions after verifying Apple's signature +-- +-- effective_entitlement() returns the highest active tier across them. For a +-- user with no App Store row it returns what _shared/entitlements.ts and +-- src/hooks/use-subscription.ts computed before this migration. + +-- ============================================================ +-- PRODUCTS +-- ============================================================ + +-- Product ids map to tiers here so a renamed or added product needs no code +-- change. The rows below are PLACEHOLDERS: the real ids exist only once the +-- subscriptions are created in App Store Connect. Until a real id is listed, +-- purchases of it are refused and grant nothing. +CREATE TABLE public.app_store_products ( + product_id text PRIMARY KEY, + tier text NOT NULL CHECK (tier IN ('basic', 'pro')), + created_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO public.app_store_products (product_id, tier) VALUES + ('com.adam.debatelab.basic.monthly', 'basic'), + ('com.adam.debatelab.pro.monthly', 'pro'); + +ALTER TABLE public.app_store_products ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.app_store_products FROM PUBLIC, anon, authenticated; +GRANT SELECT ON TABLE public.app_store_products TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.app_store_products TO service_role; + +-- The catalog is not secret, and effective_entitlement() runs with the caller's +-- privileges, so signed-in users read it. +CREATE POLICY "app_store_products_select" ON public.app_store_products + FOR SELECT TO authenticated + USING (true); + +-- ============================================================ +-- SUBSCRIPTIONS +-- ============================================================ + +-- One row per subscription (original transaction). Every value comes from a +-- JWS transaction or renewal info that Apple signed. The row is linked to a +-- user once, through the transaction's appAccountToken, which the app sets to +-- the Supabase user id at purchase; later transactions only update it. +CREATE TABLE public.app_store_subscriptions ( + original_transaction_id text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + product_id text NOT NULL REFERENCES public.app_store_products(product_id), + transaction_id text NOT NULL, + environment text NOT NULL CHECK (environment IN ('Production', 'Sandbox')), + purchased_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + revocation_reason integer, + family_shared boolean NOT NULL DEFAULT false, + -- signedDate of the transaction above, which orders updates to it. + transaction_signed_at timestamptz NOT NULL, + auto_renew boolean, + -- Set while Apple's Billing Grace Period runs after a failed renewal. + grace_period_expires_at timestamptz, + -- signedDate of the renewal info the two columns above came from. + renewal_signed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX app_store_subscriptions_user_id_idx ON public.app_store_subscriptions (user_id); + +ALTER TABLE public.app_store_subscriptions ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.app_store_subscriptions FROM PUBLIC, anon, authenticated; +GRANT SELECT ON TABLE public.app_store_subscriptions TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.app_store_subscriptions TO service_role; + +CREATE POLICY "app_store_subscriptions_select_own" ON public.app_store_subscriptions + FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); + +-- ============================================================ +-- NOTIFICATIONS +-- ============================================================ + +-- One row per App Store Server Notification processed, keyed by its +-- notificationUUID, so Apple's retries apply nothing twice. It holds no user id. +CREATE TABLE public.app_store_notifications ( + notification_uuid uuid PRIMARY KEY, + notification_type text NOT NULL, + subtype text, + environment text NOT NULL, + original_transaction_id text, + signed_at timestamptz NOT NULL, + result text, + received_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.app_store_notifications ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.app_store_notifications FROM PUBLIC, anon, authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.app_store_notifications TO service_role; + +-- ============================================================ +-- EFFECTIVE ENTITLEMENT +-- ============================================================ + +-- The plan for one user at p_at. Runs with the caller's privileges: the service +-- role reads any user, a signed-in user reads only their own rows through RLS, +-- so asking about someone else returns the fail-closed 'expired' row. +-- +-- Tiers rank pro > legacy_free > basic > trial > expired; the highest active +-- one wins, and on a tie the later expiry. Daily limits: pro and legacy_free +-- 50, basic 20, trial 5, expired 0, always replaced by daily_limit_override. +-- A missing user_settings row is untrusted (the auth trigger provisions one for +-- every account), so it fails closed whatever else the user holds. +CREATE FUNCTION public.effective_entitlement(p_user_id uuid, p_at timestamptz DEFAULT now()) +RETURNS TABLE ( + tier text, + daily_limit integer, + can_access_pro_models boolean, + source text, + expires_at timestamptz, + trial_ends_at timestamptz, + grace_period_ends_at timestamptz, + has_stripe_billing boolean, + has_app_store_subscription boolean +) +LANGUAGE plpgsql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $$ +#variable_conflict use_column +DECLARE + v_settings record; + v_raw_tier text; + v_gift_trial boolean; + v_stripe_end timestamptz; + v_store_tier text; + v_store_end timestamptz; +BEGIN + has_stripe_billing := EXISTS ( + SELECT 1 FROM public.subscriptions AS stripe + WHERE stripe.user_id = p_user_id AND stripe.stripe_customer_id IS NOT NULL + ); + + SELECT product.tier, greatest(store.expires_at, coalesce(store.grace_period_expires_at, store.expires_at)) + INTO v_store_tier, v_store_end + FROM public.app_store_subscriptions AS store + JOIN public.app_store_products AS product ON product.product_id = store.product_id + WHERE store.user_id = p_user_id + AND store.revoked_at IS NULL + AND greatest(store.expires_at, coalesce(store.grace_period_expires_at, store.expires_at)) > p_at + ORDER BY product.tier = 'pro' DESC, + greatest(store.expires_at, coalesce(store.grace_period_expires_at, store.expires_at)) DESC + LIMIT 1; + has_app_store_subscription := v_store_tier IS NOT NULL; + + SELECT settings.subscription_tier, settings.daily_limit_override, settings.trial_ends_at, settings.grace_period_ends_at + INTO v_settings + FROM public.user_settings AS settings + WHERE settings.user_id = p_user_id; + + IF NOT FOUND THEN + tier := 'expired'; + daily_limit := 0; + can_access_pro_models := false; + RETURN NEXT; + RETURN; + END IF; + + trial_ends_at := v_settings.trial_ends_at; + grace_period_ends_at := v_settings.grace_period_ends_at; + v_raw_tier := coalesce(nullif(v_settings.subscription_tier, ''), 'trial'); + v_gift_trial := v_settings.trial_ends_at > p_at; + + -- The rules _shared/entitlements.ts applied to user_settings and Stripe. + IF v_raw_tier = 'legacy_free' AND v_settings.grace_period_ends_at > p_at THEN + tier := 'legacy_free'; source := 'legacy'; expires_at := v_settings.grace_period_ends_at; + ELSIF v_raw_tier = 'pro' AND v_settings.grace_period_ends_at > p_at THEN + tier := 'pro'; source := 'promo'; expires_at := v_settings.grace_period_ends_at; + ELSE + IF v_raw_tier = 'pro' THEN + SELECT max(stripe.current_period_end) INTO v_stripe_end + FROM public.subscriptions AS stripe + WHERE stripe.user_id = p_user_id + AND stripe.status IN ('active', 'trialing') + AND stripe.current_period_end > p_at; + END IF; + + IF v_stripe_end IS NOT NULL THEN + tier := 'pro'; source := 'stripe'; expires_at := v_stripe_end; + ELSIF v_gift_trial THEN + tier := 'trial'; source := 'trial'; expires_at := v_settings.trial_ends_at; + ELSE + tier := 'expired'; + END IF; + END IF; + + IF v_store_tier IS NOT NULL AND ( + (CASE v_store_tier WHEN 'pro' THEN 4 ELSE 2 END) > + (CASE tier WHEN 'pro' THEN 4 WHEN 'legacy_free' THEN 3 WHEN 'trial' THEN 1 ELSE 0 END) + OR (v_store_tier = tier AND v_store_end > expires_at) + ) THEN + tier := v_store_tier; source := 'app_store'; expires_at := v_store_end; + END IF; + + daily_limit := coalesce( + v_settings.daily_limit_override, + CASE tier WHEN 'pro' THEN 50 WHEN 'legacy_free' THEN 50 WHEN 'basic' THEN 20 WHEN 'trial' THEN 5 ELSE 0 END + ); + can_access_pro_models := tier IN ('pro', 'legacy_free'); + RETURN NEXT; +END; +$$; + +REVOKE ALL ON FUNCTION public.effective_entitlement(uuid, timestamptz) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.effective_entitlement(uuid, timestamptz) TO authenticated, service_role; + +-- ============================================================ +-- APPLYING SIGNED APP STORE DATA +-- ============================================================ + +-- Applies one verified transaction, with optional renewal info and the +-- notification it came in. The caller (an Edge Function with the service role) +-- has already verified every JWS, the bundle id and the environment, and +-- passes the decoded fields as camelCase JSON with ISO timestamps. +-- +-- p_user_id is the transaction's appAccountToken, or null. It links a new row +-- to a user and must match an existing row's user; data for a linked row may +-- come without it. +-- +-- Ordering: a transaction replaces the stored one when it expires later (a +-- renewal, upgrade or new period), or when it is the same transaction signed +-- later (a refund, revocation or its reversal). A refund of an older +-- period therefore leaves the current period alone, and an exact replay +-- changes nothing. Renewal info replaces the stored renewal info when it was +-- signed later. +-- +-- Returns applied, stale (nothing newer), duplicate (notification seen), +-- ignored (no transaction), unknown_product, unlinked (no such user to link +-- to) or user_mismatch (the token names another user than the row's). +CREATE FUNCTION public.apply_app_store_transaction( + p_user_id uuid, + p_transaction jsonb, + p_renewal jsonb DEFAULT NULL, + p_notification jsonb DEFAULT NULL +) +RETURNS text +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + v_original_id text := p_transaction->>'originalTransactionId'; + v_expires timestamptz := (p_transaction->>'expiresDate')::timestamptz; + v_signed timestamptz := (p_transaction->>'signedDate')::timestamptz; + v_renewal_signed timestamptz := (p_renewal->>'signedDate')::timestamptz; + v_row public.app_store_subscriptions%ROWTYPE; + v_result text; + v_changed boolean := false; +BEGIN + IF p_notification IS NOT NULL THEN + INSERT INTO public.app_store_notifications + (notification_uuid, notification_type, subtype, environment, original_transaction_id, signed_at) + VALUES ( + (p_notification->>'notificationUUID')::uuid, + p_notification->>'notificationType', + p_notification->>'subtype', + p_notification->>'environment', + v_original_id, + (p_notification->>'signedDate')::timestamptz + ) + ON CONFLICT (notification_uuid) DO NOTHING; + IF NOT FOUND THEN + RETURN 'duplicate'; + END IF; + END IF; + + IF p_transaction IS NULL THEN + v_result := 'ignored'; + ELSIF NOT EXISTS ( + SELECT 1 FROM public.app_store_products AS product WHERE product.product_id = p_transaction->>'productId' + ) THEN + v_result := 'unknown_product'; + ELSE + SELECT * INTO v_row FROM public.app_store_subscriptions + WHERE original_transaction_id = v_original_id + FOR UPDATE; + + IF NOT FOUND THEN + IF p_user_id IS NULL THEN + v_result := 'unlinked'; + ELSE + BEGIN + INSERT INTO public.app_store_subscriptions ( + original_transaction_id, user_id, product_id, transaction_id, environment, + purchased_at, expires_at, revoked_at, revocation_reason, family_shared, + transaction_signed_at, auto_renew, grace_period_expires_at, renewal_signed_at + ) VALUES ( + v_original_id, + p_user_id, + p_transaction->>'productId', + p_transaction->>'transactionId', + p_transaction->>'environment', + (p_transaction->>'purchaseDate')::timestamptz, + v_expires, + (p_transaction->>'revocationDate')::timestamptz, + (p_transaction->>'revocationReason')::integer, + coalesce((p_transaction->>'familyShared')::boolean, false), + v_signed, + (p_renewal->>'autoRenew')::boolean, + (p_renewal->>'gracePeriodExpiresDate')::timestamptz, + v_renewal_signed + ) + ON CONFLICT (original_transaction_id) DO NOTHING; + IF FOUND THEN + v_result := 'applied'; + ELSE + -- Another request inserted it first; update that row below. + SELECT * INTO v_row FROM public.app_store_subscriptions + WHERE original_transaction_id = v_original_id + FOR UPDATE; + END IF; + EXCEPTION WHEN foreign_key_violation THEN + v_result := 'unlinked'; + END; + END IF; + END IF; + + IF v_result IS NULL THEN + IF p_user_id IS NOT NULL AND p_user_id <> v_row.user_id THEN + v_result := 'user_mismatch'; + ELSE + UPDATE public.app_store_subscriptions SET + product_id = p_transaction->>'productId', + transaction_id = p_transaction->>'transactionId', + environment = p_transaction->>'environment', + purchased_at = (p_transaction->>'purchaseDate')::timestamptz, + expires_at = v_expires, + revoked_at = (p_transaction->>'revocationDate')::timestamptz, + revocation_reason = (p_transaction->>'revocationReason')::integer, + family_shared = coalesce((p_transaction->>'familyShared')::boolean, false), + transaction_signed_at = v_signed, + updated_at = now() + WHERE original_transaction_id = v_original_id + AND ( + v_expires > v_row.expires_at + OR (p_transaction->>'transactionId' = v_row.transaction_id AND v_signed > v_row.transaction_signed_at) + ); + v_changed := FOUND; + + IF p_renewal IS NOT NULL THEN + UPDATE public.app_store_subscriptions SET + auto_renew = (p_renewal->>'autoRenew')::boolean, + grace_period_expires_at = (p_renewal->>'gracePeriodExpiresDate')::timestamptz, + renewal_signed_at = v_renewal_signed, + updated_at = now() + WHERE original_transaction_id = v_original_id + AND (v_row.renewal_signed_at IS NULL OR v_renewal_signed > v_row.renewal_signed_at); + v_changed := v_changed OR FOUND; + END IF; + + v_result := CASE WHEN v_changed THEN 'applied' ELSE 'stale' END; + END IF; + END IF; + END IF; + + IF p_notification IS NOT NULL THEN + UPDATE public.app_store_notifications SET result = v_result + WHERE notification_uuid = (p_notification->>'notificationUUID')::uuid; + END IF; + RETURN v_result; +END; +$$; + +REVOKE ALL ON FUNCTION public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb) TO service_role; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index d68c77fc2..2aabd2561 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -118,6 +118,12 @@ INSERT INTO public.subscriptions (user_id, stripe_customer_id, stripe_subscripti VALUES ('ac000000-0000-4000-8000-00000000000a', 'cus_leaver', 'sub_leaver', 'active', now(), now() + interval '30 days'); -- A checkout customer whose subscription the webhook has not recorded yet. INSERT INTO public.stripe_customers (user_id, customer_id) VALUES ('ac000000-0000-4000-8000-00000000000a', 'cus_leaver_checkout'); +-- An App Store subscription. Deletion cannot cancel it; Apple keeps billing +-- until the user cancels in their Apple account settings. +INSERT INTO public.app_store_subscriptions (original_transaction_id, user_id, product_id, transaction_id, environment, + purchased_at, expires_at, transaction_signed_at) + VALUES ('leaver-original', 'ac000000-0000-4000-8000-00000000000a', 'com.adam.debatelab.pro.monthly', 'leaver-transaction', + 'Production', now(), now() + interval '30 days', now()); -- Admin-authored rows: the leaver is an admin whose catalog work must survive. INSERT INTO public.promo_codes (id, code, type, created_by) diff --git a/supabase/tests/app_store_entitlements_test.sql b/supabase/tests/app_store_entitlements_test.sql new file mode 100644 index 000000000..0d00ce58c --- /dev/null +++ b/supabase/tests/app_store_entitlements_test.sql @@ -0,0 +1,401 @@ +BEGIN; +SELECT plan(73); + +-- Returns how many rows a statement changed; a privilege or RLS rejection changes none. +CREATE FUNCTION pg_temp.rows_changed(statement text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE changed bigint; +BEGIN + EXECUTE statement; + GET DIAGNOSTICS changed = ROW_COUNT; + RETURN changed; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +-- Returns how many rows a query exposes; a privilege rejection exposes none. +CREATE FUNCTION pg_temp.visible_rows(query text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE visible bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM (%s) AS rows', query) INTO visible; + RETURN visible; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +-- tier/daily_limit/can_access_pro_models/source, at p_at. +CREATE FUNCTION pg_temp.plan_of(p_user_id uuid, p_at timestamptz DEFAULT now()) +RETURNS text LANGUAGE sql AS $$ + SELECT format('%s/%s/%s/%s', e.tier, e.daily_limit, e.can_access_pro_models::text, coalesce(e.source, '-')) + FROM public.effective_entitlement(p_user_id, p_at) AS e +$$; + +-- Sets the user_settings fields the plan reads. +CREATE FUNCTION pg_temp.settings(p_user_id uuid, p_tier text, p_trial_ends timestamptz, p_grace_ends timestamptz, p_override integer DEFAULT NULL) +RETURNS void LANGUAGE sql AS $$ + UPDATE public.user_settings SET subscription_tier = p_tier, trial_ends_at = p_trial_ends, + grace_period_ends_at = p_grace_ends, daily_limit_override = p_override + WHERE user_id = p_user_id +$$; + +CREATE FUNCTION pg_temp.stripe(p_user_id uuid, p_status subscription_status, p_period_end timestamptz) +RETURNS void LANGUAGE sql AS $$ + INSERT INTO public.subscriptions (user_id, stripe_customer_id, stripe_subscription_id, status, current_period_start, current_period_end) + VALUES (p_user_id, 'cus_' || p_user_id, 'sub_' || gen_random_uuid(), p_status, now() - interval '1 day', p_period_end) +$$; + +CREATE FUNCTION pg_temp.store( + p_user_id uuid, p_tier text, p_expires timestamptz, + p_grace timestamptz DEFAULT NULL, p_revoked timestamptz DEFAULT NULL, p_family boolean DEFAULT false +) +RETURNS void LANGUAGE sql AS $$ + INSERT INTO public.app_store_subscriptions (original_transaction_id, user_id, product_id, transaction_id, environment, + purchased_at, expires_at, revoked_at, family_shared, transaction_signed_at, grace_period_expires_at, renewal_signed_at) + VALUES (gen_random_uuid()::text, p_user_id, 'com.adam.debatelab.' || p_tier || '.monthly', gen_random_uuid()::text, 'Production', + now() - interval '1 day', p_expires, p_revoked, p_family, now() - interval '1 day', p_grace, now() - interval '1 day') +$$; + +-- A camelCase transaction as the Edge Functions pass it. +CREATE FUNCTION pg_temp.tx(p_original text, p_transaction text, p_tier text, p_expires timestamptz, p_signed timestamptz, p_revoked timestamptz DEFAULT NULL) +RETURNS jsonb LANGUAGE sql AS $$ + SELECT jsonb_strip_nulls(jsonb_build_object( + 'originalTransactionId', p_original, 'transactionId', p_transaction, + 'productId', 'com.adam.debatelab.' || p_tier || '.monthly', 'environment', 'Production', + 'purchaseDate', p_expires - interval '30 days', 'expiresDate', p_expires, 'signedDate', p_signed, + 'revocationDate', p_revoked, 'revocationReason', CASE WHEN p_revoked IS NULL THEN NULL ELSE 1 END, + 'familyShared', false + )) +$$; + +INSERT INTO auth.users (id) SELECT ('e0000000-0000-4000-8000-0000000000' || lpad(n::text, 2, '0'))::uuid FROM generate_series(1, 40) AS n; + +-- ============================================================ +-- Privileges +-- ============================================================ + +SELECT ok( + has_function_privilege('authenticated', 'public.effective_entitlement(uuid, timestamptz)', 'EXECUTE') + AND has_function_privilege('service_role', 'public.effective_entitlement(uuid, timestamptz)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.effective_entitlement(uuid, timestamptz)', 'EXECUTE'), + 'signed-in users and the service role read plans; anonymous callers cannot' +); +SELECT ok( + has_function_privilege('service_role', 'public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb)', 'EXECUTE') + AND NOT has_function_privilege('authenticated', 'public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb)', 'EXECUTE'), + 'only the service role applies App Store transactions' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.app_store_subscriptions', 'INSERT') + AND NOT has_table_privilege('authenticated', 'public.app_store_subscriptions', 'UPDATE') + AND NOT has_table_privilege('authenticated', 'public.app_store_subscriptions', 'DELETE') + AND NOT has_table_privilege('authenticated', 'public.app_store_products', 'INSERT') + AND NOT has_table_privilege('authenticated', 'public.app_store_products', 'UPDATE') + AND NOT has_table_privilege('authenticated', 'public.app_store_products', 'DELETE') + AND NOT has_table_privilege('anon', 'public.app_store_subscriptions', 'SELECT') + AND NOT has_table_privilege('anon', 'public.app_store_products', 'SELECT'), + 'clients cannot write App Store entitlements or products, and anonymous callers cannot read them' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.app_store_notifications', 'SELECT') + AND NOT has_table_privilege('anon', 'public.app_store_notifications', 'SELECT') + AND has_table_privilege('service_role', 'public.app_store_notifications', 'INSERT'), + 'notification records belong to the service role' +); +SELECT results_eq( + $$SELECT relrowsecurity FROM pg_class WHERE oid IN ( + 'public.app_store_products'::regclass, 'public.app_store_subscriptions'::regclass, 'public.app_store_notifications'::regclass)$$, + ARRAY[true, true, true], + 'every App Store table enables RLS' +); + +-- ============================================================ +-- Stripe, promo, trial and legacy access: the rules _shared/entitlements.ts +-- applied before this migration, each with no App Store row. +-- ============================================================ + +DELETE FROM public.user_settings WHERE user_id = 'e0000000-0000-4000-8000-000000000001'; +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000001', 'pro', now() + interval '30 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000001'), 'expired/0/false/-', + 'a missing settings row fails closed instead of granting a trial, even with an App Store subscription'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000002', 'pro', NULL, NULL); +SELECT pg_temp.stripe('e0000000-0000-4000-8000-000000000002', 'active', now() + interval '30 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000002'), 'pro/50/true/stripe', + 'Stripe alone: an active subscription with the pro flag grants Pro'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000002', now() + interval '31 days'), 'expired/0/false/-', + 'Stripe alone: Pro ends with the paid period'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000003', 'pro', NULL, NULL); +SELECT pg_temp.stripe('e0000000-0000-4000-8000-000000000003', 'past_due', now() + interval '30 days'); +SELECT pg_temp.stripe('e0000000-0000-4000-8000-000000000003', 'canceled', now() + interval '30 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000003'), 'expired/0/false/-', + 'Stripe alone: past-due and canceled subscriptions grant nothing'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000004', 'expired', NULL, NULL); +SELECT pg_temp.stripe('e0000000-0000-4000-8000-000000000004', 'active', now() + interval '30 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000004'), 'expired/0/false/-', + 'Stripe alone: an active subscription counts only while the webhook flag says pro'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000005', 'pro', now() + interval '1 day', now() - interval '1 day'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000005'), 'trial/5/false/trial', + 'a lapsed pro subscription falls back to an active gift trial'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000006', 'pro', NULL, now() + interval '1 day'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000006'), 'pro/50/true/promo', + 'a promo grace period grants pro model access'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000007', 'legacy_free', NULL, now() + interval '1 day'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000007'), 'legacy_free/50/true/legacy', + 'legacy free users in their grace period get pro-level access'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000008', 'legacy_free', now() + interval '1 day', now() - interval '1 day'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000008'), 'trial/5/false/trial', + 'an ended legacy grace period falls back to an active gift trial'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000009', 'trial', now() + interval '1 day', NULL); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000009'), 'trial/5/false/trial', 'an active trial grants five requests a day'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000009', now() + interval '2 days'), 'expired/0/false/-', 'an ended trial grants nothing'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000010', 'trial', now() + interval '1 day', NULL, 3); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000010'), 'trial/3/false/trial', 'daily_limit_override wins over the tier default'); + + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000012', 'expired', NULL, NULL, 15); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000012'), 'expired/15/false/-', + 'an override still applies to expired access, as the extra-limit promo needs'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000013', NULL, now() + interval '1 day', NULL); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000013'), 'trial/5/false/trial', 'a null tier reads as a trial'); + +-- ============================================================ +-- App Store access, alone and with the other sources +-- ============================================================ + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000014', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000014', 'basic', now() + interval '10 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000014'), 'basic/20/false/app_store', + 'App Store alone: Basic grants twenty requests a day without Pro models'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000015', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000015', 'pro', now() + interval '10 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000015'), 'pro/50/true/app_store', 'App Store alone: Pro grants Pro'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000015', now() + interval '11 days'), 'expired/0/false/-', + 'App Store alone: access ends at expiresDate'); +SELECT is( + (SELECT e.expires_at FROM public.effective_entitlement('e0000000-0000-4000-8000-000000000015') AS e), + (SELECT s.expires_at FROM public.app_store_subscriptions AS s WHERE s.user_id = 'e0000000-0000-4000-8000-000000000015'), + 'the plan reports the winning source''s expiry' +); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000016', 'pro', NULL, NULL); +SELECT pg_temp.stripe('e0000000-0000-4000-8000-000000000016', 'active', now() + interval '30 days'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000016', 'pro', now() + interval '60 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000016'), 'pro/50/true/app_store', + 'both at once: two Pro subscriptions report the one that lasts longer'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000016', now() + interval '45 days'), 'pro/50/true/app_store', + 'both at once: Pro continues on the App Store after Stripe ends'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000017', 'pro', NULL, NULL); +SELECT pg_temp.stripe('e0000000-0000-4000-8000-000000000017', 'active', now() + interval '30 days'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000017', 'basic', now() + interval '60 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000017'), 'pro/50/true/stripe', + 'both at once: Stripe Pro outranks App Store Basic'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000017', now() + interval '45 days'), 'basic/20/false/app_store', + 'both at once: Basic remains after Stripe Pro ends'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000018', 'trial', now() + interval '1 day', NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000018', 'basic', now() + interval '10 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000018'), 'basic/20/false/app_store', 'Basic outranks a trial'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000019', 'legacy_free', NULL, now() + interval '5 days'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000019', 'basic', now() + interval '10 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000019'), 'legacy_free/50/true/legacy', + 'legacy Pro-level access outranks Basic'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000020', 'trial', now() + interval '1 day', NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000020', 'pro', now() - interval '1 hour'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000020'), 'trial/5/false/trial', + 'an expired App Store subscription falls back to the other sources'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000021', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000021', 'pro', now() - interval '1 hour', now() + interval '6 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000021'), 'pro/50/true/app_store', + 'grace period: access continues while Apple retries billing'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000021', now() + interval '7 days'), 'expired/0/false/-', + 'grace period: access ends when the grace period expires'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000022', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000022', 'pro', now() + interval '10 days', NULL, now() - interval '1 hour'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000022'), 'expired/0/false/-', 'a revoked or refunded subscription grants nothing'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000023', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000023', 'basic', now() + interval '10 days', NULL, NULL, true); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000023'), 'basic/20/false/app_store', 'a family-shared subscription grants its tier'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000024', 'expired', NULL, NULL, 7); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000024', 'pro', now() + interval '10 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000024'), 'pro/7/true/app_store', 'daily_limit_override also replaces the App Store default'); + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000025', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000025', 'basic', now() + interval '10 days'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000025', 'pro', now() + interval '5 days'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000025'), 'pro/50/true/app_store', 'of two App Store subscriptions the higher tier wins'); + +SELECT results_eq( + $$SELECT has_stripe_billing, has_app_store_subscription FROM public.effective_entitlement('e0000000-0000-4000-8000-000000000016') + UNION ALL SELECT has_stripe_billing, has_app_store_subscription FROM public.effective_entitlement('e0000000-0000-4000-8000-000000000022') + UNION ALL SELECT has_stripe_billing, has_app_store_subscription FROM public.effective_entitlement('e0000000-0000-4000-8000-000000000001')$$, + $$VALUES (true, true), (false, false), (false, true)$$, + 'the plan reports Stripe billing and a live App Store subscription, which account deletion must send to Apple' +); + +-- ============================================================ +-- Applying signed transactions +-- ============================================================ + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000030', 'expired', NULL, NULL); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000030', + pg_temp.tx('o1', 't1', 'pro', now() + interval '30 days', now() - interval '2 hours')), + 'applied', 'a linked first purchase is applied'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000030'), 'pro/50/true/app_store', 'the purchase grants Pro'); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000030', + pg_temp.tx('o1', 't1', 'pro', now() + interval '30 days', now() - interval '2 hours')), + 'stale', 'replaying the same signed transaction changes nothing'); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000030', + pg_temp.tx('o1', 't1', 'pro', now() + interval '30 days', now() - interval '3 hours', now() - interval '3 hours')), + 'stale', 'an older signature of the same transaction is ignored'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000030'), 'pro/50/true/app_store', 'so it cannot revoke access'); + +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000030', + pg_temp.tx('o1', 't2', 'pro', now() + interval '60 days', now() - interval '1 hour')), + 'applied', 'a renewal that expires later is applied'); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000030', + pg_temp.tx('o1', 't1', 'pro', now() + interval '30 days', now(), now())), + 'stale', 'a refund of an older period does not replace the current one'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000030'), 'pro/50/true/app_store', 'so the current period keeps Pro'); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000030', + pg_temp.tx('o1', 't2', 'pro', now() + interval '60 days', now(), now())), + 'applied', 'a refund of the current transaction is applied'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000030'), 'expired/0/false/-', 'and removes access'); +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o1', 't3', 'basic', now() + interval '90 days', now() + interval '1 minute')), + 'applied', 'a later transaction for a linked subscription applies without a token'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000030'), 'basic/20/false/app_store', 'and restores access at its new tier'); + +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000031', + pg_temp.tx('o1', 't4', 'pro', now() + interval '120 days', now() + interval '2 minutes')), + 'user_mismatch', 'a token naming another user cannot take over a subscription'); +SELECT results_eq( + $$SELECT user_id, transaction_id FROM public.app_store_subscriptions WHERE original_transaction_id = 'o1'$$, + $$VALUES ('e0000000-0000-4000-8000-000000000030'::uuid, 't3'::text)$$, + 'and the subscription stays with its owner, unchanged' +); + +SELECT is(public.apply_app_store_transaction(NULL, pg_temp.tx('o2', 't1', 'pro', now() + interval '30 days', now())), + 'unlinked', 'a new subscription without a token is not linked to anyone'); +SELECT is(public.apply_app_store_transaction('e0000000-0000-4000-8000-0000000000ff', pg_temp.tx('o3', 't1', 'pro', now() + interval '30 days', now())), + 'unlinked', 'a token that names no account is not linked'); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000031', + pg_temp.tx('o4', 't1', 'pro', now() + interval '30 days', now()) || '{"productId": "com.example.unknown"}'), + 'unknown_product', 'an unknown product is refused'); +SELECT is( + (SELECT count(*) FROM public.app_store_subscriptions WHERE original_transaction_id IN ('o2', 'o3', 'o4')), + 0::bigint, 'and none of those write a subscription'); + +-- Notifications: idempotent on notificationUUID. +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000032', 'expired', NULL, NULL); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000032', + pg_temp.tx('o5', 't1', 'pro', now() + interval '30 days', now() - interval '1 hour'), NULL, + '{"notificationUUID": "e5000000-0000-4000-8000-000000000001", "notificationType": "SUBSCRIBED", "subtype": "INITIAL_BUY", "environment": "Production", "signedDate": "2026-10-14T00:00:00Z"}'), + 'applied', 'a notification applies its transaction'); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000032', + pg_temp.tx('o5', 't1', 'pro', now() + interval '30 days', now(), now()), NULL, + '{"notificationUUID": "e5000000-0000-4000-8000-000000000001", "notificationType": "SUBSCRIBED", "subtype": "INITIAL_BUY", "environment": "Production", "signedDate": "2026-10-14T00:00:00Z"}'), + 'duplicate', 'a notification delivered twice is applied once'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000032'), 'pro/50/true/app_store', 'so the replayed payload changes nothing'); +SELECT results_eq( + $$SELECT notification_type, result FROM public.app_store_notifications WHERE notification_uuid = 'e5000000-0000-4000-8000-000000000001'$$, + $$VALUES ('SUBSCRIBED'::text, 'applied'::text)$$, + 'the notification is recorded once with its outcome' +); +SELECT is( + public.apply_app_store_transaction(NULL, NULL, NULL, + '{"notificationUUID": "e5000000-0000-4000-8000-000000000002", "notificationType": "TEST", "environment": "Sandbox", "signedDate": "2026-10-14T00:00:00Z"}'), + 'ignored', 'a notification without a transaction is recorded and ignored'); + +-- Renewal info: billing grace and its end. +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o5', 't1', 'pro', now() - interval '1 hour', now() - interval '1 hour'), + jsonb_build_object('signedDate', now(), 'autoRenew', true, 'gracePeriodExpiresDate', now() + interval '6 days')), + 'applied', 'renewal info with a grace period is applied'); +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o5', 't1', 'pro', now() - interval '1 hour', now() - interval '1 hour'), + jsonb_build_object('signedDate', now() - interval '1 minute', 'autoRenew', false)), + 'stale', 'older renewal info is ignored'); +SELECT results_eq( + $$SELECT auto_renew, grace_period_expires_at > now() FROM public.app_store_subscriptions WHERE original_transaction_id = 'o5'$$, + $$VALUES (true, true)$$, + 'so the grace period survives it' +); + +-- ============================================================ +-- Isolation: users read only their own entitlements +-- ============================================================ + +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000035', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000035', 'basic', now() + interval '10 days'); +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000036', 'expired', NULL, NULL); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000036', 'pro', now() + interval '10 days'); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'e0000000-0000-4000-8000-000000000035', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); + +SELECT is(pg_temp.visible_rows($$SELECT 1 FROM public.app_store_subscriptions$$), 1::bigint, + 'a user sees only their own App Store subscription'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000035'), 'basic/20/false/app_store', + 'a user reads their own plan through RLS'); +SELECT results_eq( + $$SELECT tier, has_app_store_subscription FROM public.effective_entitlement('e0000000-0000-4000-8000-000000000036')$$, + $$VALUES ('expired'::text, false)$$, + 'asking for another user''s plan reveals nothing about it' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.app_store_subscriptions SET expires_at = now() + interval '1 year'$$), + 0::bigint, 'a user cannot extend their own subscription'); +SELECT is( + pg_temp.rows_changed($$INSERT INTO public.app_store_subscriptions (original_transaction_id, user_id, product_id, transaction_id, + environment, purchased_at, expires_at, transaction_signed_at) + VALUES ('forged', 'e0000000-0000-4000-8000-000000000035', 'com.adam.debatelab.pro.monthly', 'forged', 'Production', now(), now() + interval '1 year', now())$$), + 0::bigint, 'a user cannot insert a subscription'); +SELECT is(pg_temp.rows_changed($$DELETE FROM public.app_store_subscriptions$$), 0::bigint, 'a user cannot delete subscriptions'); +SELECT is(pg_temp.visible_rows($$SELECT 1 FROM public.app_store_notifications$$), 0::bigint, 'a user sees no notification records'); +SELECT throws_ok( + $$SELECT public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000035', + pg_temp.tx('o9', 't1', 'pro', now() + interval '30 days', now()))$$, + '42501', NULL, 'a user cannot apply a transaction'); +SELECT is(pg_temp.visible_rows($$SELECT 1 FROM public.app_store_products$$) > 0, true, 'a user can read the product catalog'); + +RESET ROLE; +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000036'), 'pro/50/true/app_store', + 'the service path still reads the other user''s Pro plan'); + +SELECT * FROM finish(); +ROLLBACK; From 8ebe1a8dd2ac119c35c846d961475a7581802890 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:43:54 +0000 Subject: [PATCH 02/12] Verify App Store JWS against Apple's pinned root _shared/app-store.ts checks signed notifications, transactions and renewal info the way Apple's App Store Server Library does offline: ES256, a three-certificate x5c chain whose intermediate is signed by Apple Root CA - G3 (pinned as a PEM constant), Apple's leaf and intermediate OIDs, validity at signedDate, then bundle id, environment and, in Production, the app's Apple id. Roots are an argument, never configuration; tests sign with a chain they generate. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/app-store.test.ts | 162 ++++++++ supabase/functions/_shared/app-store.ts | 375 ++++++++++++++++++ supabase/functions/_shared/apple-root-ca.ts | 27 ++ .../_shared/testing/app-store-chain.ts | 164 ++++++++ 4 files changed, 728 insertions(+) create mode 100644 supabase/functions/_shared/app-store.test.ts create mode 100644 supabase/functions/_shared/app-store.ts create mode 100644 supabase/functions/_shared/apple-root-ca.ts create mode 100644 supabase/functions/_shared/testing/app-store-chain.ts diff --git a/supabase/functions/_shared/app-store.test.ts b/supabase/functions/_shared/app-store.test.ts new file mode 100644 index 000000000..0c25dc355 --- /dev/null +++ b/supabase/functions/_shared/app-store.test.ts @@ -0,0 +1,162 @@ +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19"; +import { + APPLE_ROOTS, + type AppStoreConfig, + AppStoreVerificationError, + type VerificationCode, + verifyNotification, + verifyTransaction, +} from "./app-store.ts"; +import { APPLE_ROOT_CA_G3_SHA256 } from "./apple-root-ca.ts"; +import { + createTestChain, + notificationPayload, + renewalPayload, + signJws, + TEST_APP_APPLE_ID, + TEST_BUNDLE_ID, + transactionPayload, +} from "./testing/app-store-chain.ts"; + +const config: AppStoreConfig = { bundleId: TEST_BUNDLE_ID, environments: ["Production"], appAppleId: TEST_APP_APPLE_ID }; +const chain = await createTestChain(); +const roots = [chain.rootDer]; + +async function rejectsWith(run: () => Promise, code: VerificationCode) { + const error = await assertRejects(run, AppStoreVerificationError); + assertEquals(error.code, code); +} + +async function signedNotification(data: Record = {}, overrides: Record = {}) { + return await signJws(chain, notificationPayload({ + signedTransactionInfo: await signJws(chain, transactionPayload()), + signedRenewalInfo: await signJws(chain, renewalPayload()), + ...data, + }, overrides)); +} + +Deno.test("the pinned root is Apple Root CA - G3", async () => { + assertEquals(APPLE_ROOTS.length, 1); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", APPLE_ROOTS[0])); + assertEquals(Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""), APPLE_ROOT_CA_G3_SHA256); +}); + +Deno.test("a notification signed through the pinned root verifies with its transaction and renewal info", async () => { + const verified = await verifyNotification(await signedNotification(), config, roots); + assertEquals(verified?.notificationType, "SUBSCRIBED"); + assertEquals(verified?.environment, "Production"); + assertEquals(verified?.transaction?.record.originalTransactionId, "2000000000000001"); + assertEquals(verified?.transaction?.record.familyShared, false); + assertEquals(verified?.renewal?.autoRenew, true); +}); + +Deno.test("production verification pins Apple's root, so a self-made chain fails", async () => { + await rejectsWith(async () => await verifyNotification(await signedNotification(), config, APPLE_ROOTS), "invalid_chain"); +}); + +Deno.test("a chain from another root fails", async () => { + const other = await createTestChain(); + await rejectsWith(async () => await verifyTransaction(await signJws(other, transactionPayload()), config, roots), "invalid_chain"); +}); + +Deno.test("a valid outer payload cannot smuggle a transaction signed by another root", async () => { + const other = await createTestChain(); + const jws = await signedNotification({ signedTransactionInfo: await signJws(other, transactionPayload()) }); + await rejectsWith(() => verifyNotification(jws, config, roots), "invalid_chain"); +}); + +Deno.test("a pinned root in the third x5c slot is not enough when the intermediate is foreign", async () => { + const other = await createTestChain(); + const mixed = { ...other, x5c: [other.x5c[0], other.x5c[1], chain.x5c[2]] }; + await rejectsWith(async () => await verifyTransaction(await signJws(mixed, transactionPayload()), config, roots), "invalid_chain"); +}); + +Deno.test("certificates without Apple's App Store OIDs fail", async () => { + for (const options of [{ leafWithoutOid: true }, { intermediateWithoutOid: true }]) { + const marked = await createTestChain(options); + await rejectsWith( + async () => await verifyTransaction(await signJws(marked, transactionPayload()), config, [marked.rootDer]), + "invalid_chain", + ); + } +}); + +Deno.test("a leaf that was not valid at signedDate fails", async () => { + const expired = await createTestChain({ leafNotBefore: new Date(Date.now() - 10 * 86_400_000), leafNotAfter: new Date(Date.now() - 86_400_000) }); + await rejectsWith( + async () => await verifyTransaction(await signJws(expired, transactionPayload()), config, [expired.rootDer]), + "invalid_chain", + ); +}); + +Deno.test("a chain that is not three certificates long fails", async () => { + const short = { ...chain, x5c: chain.x5c.slice(0, 2) }; + await rejectsWith(async () => await verifyTransaction(await signJws(short, transactionPayload()), config, roots), "invalid_chain"); +}); + +Deno.test("an algorithm other than ES256 fails before the chain is read", async () => { + await rejectsWith(async () => await verifyTransaction(await signJws(chain, transactionPayload(), { alg: "none" }), config, roots), "invalid_signature"); +}); + +Deno.test("a payload changed after signing fails", async () => { + const [head, , signature] = (await signJws(chain, transactionPayload())).split("."); + const forged = btoa(JSON.stringify(transactionPayload({ expiresDate: Date.now() + 1e12 }))).replace(/=+$/, ""); + await rejectsWith(() => verifyTransaction(`${head}.${forged}.${signature}`, config, roots), "invalid_signature"); +}); + +Deno.test("a transaction for another bundle fails", async () => { + await rejectsWith( + async () => await verifyTransaction(await signJws(chain, transactionPayload({ bundleId: "com.example.other" })), config, roots), + "wrong_app", + ); +}); + +Deno.test("a notification for another bundle or app id fails", async () => { + await rejectsWith(async () => await verifyNotification(await signedNotification({ bundleId: "com.example.other" }), config, roots), "wrong_app"); + await rejectsWith(async () => await verifyNotification(await signedNotification({ appAppleId: 1 }), config, roots), "wrong_app"); +}); + +Deno.test("signed data for an environment that is not allowed fails", async () => { + await rejectsWith(async () => await verifyNotification(await signedNotification({ environment: "Sandbox" }), config, roots), "wrong_environment"); + await rejectsWith( + async () => await verifyTransaction(await signJws(chain, transactionPayload({ environment: "Sandbox" })), config, roots), + "wrong_environment", + ); + await rejectsWith( + async () => await verifyTransaction(await signJws(chain, transactionPayload({ environment: "Xcode" })), { ...config, environments: ["Sandbox"] }, roots), + "wrong_environment", + ); +}); + +Deno.test("renewal info for another subscription fails", async () => { + const jws = await signedNotification({ signedRenewalInfo: await signJws(chain, renewalPayload({ originalTransactionId: "999" })) }); + await rejectsWith(() => verifyNotification(jws, config, roots), "malformed"); +}); + +Deno.test("a transaction that is not an auto-renewable subscription is unsupported", async () => { + await rejectsWith( + async () => await verifyTransaction(await signJws(chain, transactionPayload({ type: "Consumable" })), config, roots), + "unsupported", + ); +}); + +Deno.test("appAccountToken and family sharing are read from the signed transaction", async () => { + const token = "E0000000-0000-4000-8000-000000000030"; + const verified = await verifyTransaction( + await signJws(chain, transactionPayload({ appAccountToken: token, inAppOwnershipType: "FAMILY_SHARED", revocationDate: Date.now(), revocationReason: 1 })), + config, + roots, + ); + assertEquals(verified.appAccountToken, token.toLowerCase()); + assertEquals(verified.record.familyShared, true); + assertEquals(verified.record.revocationReason, 1); + await rejectsWith( + async () => await verifyTransaction(await signJws(chain, transactionPayload({ appAccountToken: "not-a-uuid" })), config, roots), + "malformed", + ); +}); + +Deno.test("a notification without app data is not for this service", async () => { + const jws = await signJws(chain, { notificationType: "EXTERNAL_PURCHASE_TOKEN", notificationUUID: crypto.randomUUID(), signedDate: Date.now() }); + assertEquals(await verifyNotification(jws, config, roots), null); +}); diff --git a/supabase/functions/_shared/app-store.ts b/supabase/functions/_shared/app-store.ts new file mode 100644 index 000000000..80dfba304 --- /dev/null +++ b/supabase/functions/_shared/app-store.ts @@ -0,0 +1,375 @@ +// Verification of App Store signed data (JWS): App Store Server Notifications +// V2 and the signed transactions and renewal info StoreKit hands the app. +// +// The checks follow Apple's App Store Server Library (SignedDataVerifier in +// app-store-server-library-node, jws_verification.ts) in its offline mode: +// - the header's alg is ES256 and x5c holds exactly three certificates; +// - the leaf is signed by the intermediate and the intermediate by a pinned +// root. The third x5c entry is never trusted, only the pinned root; +// - the leaf carries Apple's OID 1.2.840.113635.100.6.11.1 and the +// intermediate 1.2.840.113635.100.6.2.1 and is a CA; +// - both certificates are valid at the payload's signedDate, within 60 s; +// - the JWS signature verifies with the leaf key; +// - bundle id, environment and, in Production, the app's Apple id match. +// It does not run Apple's optional OCSP revocation check. +// +// Roots come only from the caller. Production code passes APPLE_ROOTS; tests +// pass a root they generated. There is no environment variable for a root. + +import "https://esm.sh/reflect-metadata@0.2.2"; +import * as x509 from "https://esm.sh/@peculiar/x509@2.1.0"; +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { APPLE_ROOT_CA_G3_PEM } from "./apple-root-ca.ts"; + +export type AppStoreEnvironment = "Production" | "Sandbox"; + +export interface AppStoreConfig { + bundleId: string; + environments: readonly AppStoreEnvironment[]; + /** The app's Apple id, checked on Production notifications. */ + appAppleId?: number; +} + +export type VerificationCode = + | "malformed" + | "invalid_signature" + | "invalid_chain" + | "wrong_app" + | "wrong_environment" + /** Signed correctly, but not an auto-renewable subscription. */ + | "unsupported"; + +export class AppStoreVerificationError extends Error { + constructor(readonly code: VerificationCode, message: string) { + super(message); + this.name = "AppStoreVerificationError"; + } +} + +export class AppStoreConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "AppStoreConfigError"; + } +} + +const LEAF_OID = "1.2.840.113635.100.6.11.1"; +const INTERMEDIATE_OID = "1.2.840.113635.100.6.2.1"; +const MAX_SKEW_MS = 60_000; +const ENVIRONMENTS: readonly AppStoreEnvironment[] = ["Production", "Sandbox"]; + +/** DER bytes of the pinned Apple roots. */ +export const APPLE_ROOTS: readonly Uint8Array[] = [new Uint8Array(new x509.X509Certificate(APPLE_ROOT_CA_G3_PEM).rawData)]; + +/** + * Reads APP_STORE_BUNDLE_ID, APP_STORE_ENVIRONMENTS (comma-separated, default + * Production) and APP_STORE_APP_APPLE_ID, which Production requires. + */ +export function appStoreConfigFromEnv(): AppStoreConfig { + const bundleId = Deno.env.get("APP_STORE_BUNDLE_ID")?.trim(); + if (!bundleId) throw new AppStoreConfigError("APP_STORE_BUNDLE_ID is not set"); + const environments = (Deno.env.get("APP_STORE_ENVIRONMENTS") ?? "Production") + .split(",").map((value) => value.trim()).filter(Boolean); + if (!environments.length || !environments.every((value) => (ENVIRONMENTS as readonly string[]).includes(value))) { + throw new AppStoreConfigError("APP_STORE_ENVIRONMENTS must list Production and/or Sandbox"); + } + const rawAppleId = Deno.env.get("APP_STORE_APP_APPLE_ID")?.trim(); + const appAppleId = rawAppleId ? Number(rawAppleId) : undefined; + if (rawAppleId && !Number.isSafeInteger(appAppleId)) throw new AppStoreConfigError("APP_STORE_APP_APPLE_ID must be a number"); + if (environments.includes("Production") && appAppleId === undefined) { + throw new AppStoreConfigError("APP_STORE_APP_APPLE_ID is required when Production is allowed"); + } + return { bundleId, environments: environments as AppStoreEnvironment[], appAppleId }; +} + +function base64UrlDecode(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); +} + +function decodeJson(segment: string): Record { + try { + const value = JSON.parse(new TextDecoder().decode(base64UrlDecode(segment))); + if (value && typeof value === "object" && !Array.isArray(value)) return value; + } catch { + // Reported below. + } + throw new AppStoreVerificationError("malformed", "JWS segment is not a JSON object"); +} + +function validAt(cert: x509.X509Certificate, at: number): boolean { + return cert.notBefore.getTime() - MAX_SKEW_MS <= at && at <= cert.notAfter.getTime() + MAX_SKEW_MS; +} + +async function signedBy(cert: x509.X509Certificate, issuer: x509.X509Certificate): Promise { + if (cert.issuer !== issuer.subject) return false; + try { + return await cert.verify({ publicKey: issuer.publicKey, signatureOnly: true }); + } catch { + return false; + } +} + +/** Verifies a JWS signed by the App Store and returns its payload. */ +export async function verifyAppStoreJws(jws: string, roots: readonly Uint8Array[]): Promise> { + const parts = typeof jws === "string" ? jws.split(".") : []; + if (parts.length !== 3 || parts.some((part) => !part)) throw new AppStoreVerificationError("malformed", "Not a compact JWS"); + const header = decodeJson(parts[0]); + const payload = decodeJson(parts[1]); + if (header.alg !== "ES256") throw new AppStoreVerificationError("invalid_signature", "JWS alg must be ES256"); + + const chain = header.x5c; + if (!Array.isArray(chain) || chain.length !== 3 || !chain.every((entry) => typeof entry === "string")) { + throw new AppStoreVerificationError("invalid_chain", "x5c must hold three certificates"); + } + let leaf: x509.X509Certificate; + let intermediate: x509.X509Certificate; + try { + leaf = new x509.X509Certificate(chain[0]); + intermediate = new x509.X509Certificate(chain[1]); + } catch { + throw new AppStoreVerificationError("invalid_chain", "x5c holds an unreadable certificate"); + } + + const signedDate = payload.signedDate; + if (typeof signedDate !== "number" || !Number.isFinite(signedDate)) { + throw new AppStoreVerificationError("malformed", "Payload has no signedDate"); + } + + const pinned = roots.map((der) => new x509.X509Certificate(der)); + let chainsToRoot = false; + for (const root of pinned) { + if (await signedBy(intermediate, root)) chainsToRoot = true; + } + if (!chainsToRoot || !(await signedBy(leaf, intermediate))) { + throw new AppStoreVerificationError("invalid_chain", "Certificate chain does not lead to a pinned root"); + } + const intermediateConstraints = intermediate.getExtension(x509.BasicConstraintsExtension); + if (!leaf.getExtension(LEAF_OID) || !intermediate.getExtension(INTERMEDIATE_OID) || !intermediateConstraints?.ca) { + throw new AppStoreVerificationError("invalid_chain", "Certificates lack Apple's App Store markers"); + } + if (!validAt(leaf, signedDate) || !validAt(intermediate, signedDate)) { + throw new AppStoreVerificationError("invalid_chain", "Certificate not valid at signedDate"); + } + + let key: CryptoKey; + try { + key = await leaf.publicKey.export({ name: "ECDSA", namedCurve: "P-256" }, ["verify"]); + } catch { + throw new AppStoreVerificationError("invalid_signature", "Leaf key is not P-256"); + } + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + base64UrlDecode(parts[2]), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`), + ); + if (!valid) throw new AppStoreVerificationError("invalid_signature", "JWS signature does not verify"); + return payload; +} + +// ============================================================ +// Decoded App Store data +// ============================================================ + +/** A verified auto-renewable subscription transaction, as apply_app_store_transaction takes it. */ +export interface TransactionRecord { + originalTransactionId: string; + transactionId: string; + productId: string; + environment: AppStoreEnvironment; + purchaseDate: string; + expiresDate: string; + signedDate: string; + revocationDate?: string; + revocationReason?: number; + familyShared: boolean; +} + +export interface RenewalRecord { + signedDate: string; + autoRenew: boolean; + gracePeriodExpiresDate?: string; +} + +export interface VerifiedTransaction { + record: TransactionRecord; + /** Lowercased appAccountToken, or null when the purchase set none. */ + appAccountToken: string | null; +} + +export interface VerifiedNotification { + notificationUUID: string; + notificationType: string; + subtype?: string; + environment: AppStoreEnvironment; + signedDate: string; + transaction: VerifiedTransaction | null; + renewal: RenewalRecord | null; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function text(value: unknown, field: string): string { + if (typeof value !== "string" || !value) throw new AppStoreVerificationError("malformed", `${field} is missing`); + return value; +} + +function time(value: unknown, field: string): string { + if (typeof value !== "number" || !Number.isFinite(value)) throw new AppStoreVerificationError("malformed", `${field} is missing`); + return new Date(value).toISOString(); +} + +function optionalTime(value: unknown, field: string): string | undefined { + return value === undefined || value === null ? undefined : time(value, field); +} + +function checkEnvironment(value: unknown, config: AppStoreConfig): AppStoreEnvironment { + if (!(config.environments as readonly unknown[]).includes(value)) { + throw new AppStoreVerificationError("wrong_environment", "Signed data is for another environment"); + } + return value as AppStoreEnvironment; +} + +/** + * Verifies a signed transaction. Only auto-renewable subscriptions for this + * bundle in an allowed environment pass. + */ +export async function verifyTransaction( + jws: string, + config: AppStoreConfig, + roots: readonly Uint8Array[], +): Promise { + const payload = await verifyAppStoreJws(jws, roots); + if (payload.bundleId !== config.bundleId) throw new AppStoreVerificationError("wrong_app", "Transaction is for another app"); + const environment = checkEnvironment(payload.environment, config); + if (payload.type !== "Auto-Renewable Subscription") { + throw new AppStoreVerificationError("unsupported", "Transaction is not an auto-renewable subscription"); + } + const token = payload.appAccountToken; + if (token !== undefined && (typeof token !== "string" || !UUID.test(token))) { + throw new AppStoreVerificationError("malformed", "appAccountToken is not a UUID"); + } + const revocationReason = payload.revocationReason; + return { + appAccountToken: typeof token === "string" ? token.toLowerCase() : null, + record: { + originalTransactionId: text(payload.originalTransactionId, "originalTransactionId"), + transactionId: text(payload.transactionId, "transactionId"), + productId: text(payload.productId, "productId"), + environment, + purchaseDate: time(payload.purchaseDate, "purchaseDate"), + expiresDate: time(payload.expiresDate, "expiresDate"), + signedDate: time(payload.signedDate, "signedDate"), + revocationDate: optionalTime(payload.revocationDate, "revocationDate"), + revocationReason: typeof revocationReason === "number" ? revocationReason : undefined, + familyShared: payload.inAppOwnershipType === "FAMILY_SHARED", + }, + }; +} + +/** Verifies signed renewal info belonging to the given subscription. */ +export async function verifyRenewalInfo( + jws: string, + transaction: TransactionRecord, + roots: readonly Uint8Array[], +): Promise { + const payload = await verifyAppStoreJws(jws, roots); + if (payload.originalTransactionId !== transaction.originalTransactionId) { + throw new AppStoreVerificationError("malformed", "Renewal info belongs to another subscription"); + } + if (payload.environment !== transaction.environment) { + throw new AppStoreVerificationError("wrong_environment", "Renewal info is for another environment"); + } + return { + signedDate: time(payload.signedDate, "signedDate"), + autoRenew: payload.autoRenewStatus === 1, + gracePeriodExpiresDate: optionalTime(payload.gracePeriodExpiresDate, "gracePeriodExpiresDate"), + }; +} + +/** + * Verifies an App Store Server Notification V2 signedPayload and the + * transaction and renewal info inside it. Returns null for a notification + * without app data (summary and external purchase notifications), which this + * service does not use. + */ +export async function verifyNotification( + signedPayload: string, + config: AppStoreConfig, + roots: readonly Uint8Array[], +): Promise { + const payload = await verifyAppStoreJws(signedPayload, roots); + const data = payload.data; + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const appData = data as Record; + if (appData.bundleId !== config.bundleId) throw new AppStoreVerificationError("wrong_app", "Notification is for another app"); + const environment = checkEnvironment(appData.environment, config); + if (environment === "Production" && appData.appAppleId !== config.appAppleId) { + throw new AppStoreVerificationError("wrong_app", "Notification is for another app id"); + } + const notificationUUID = text(payload.notificationUUID, "notificationUUID"); + if (!UUID.test(notificationUUID)) throw new AppStoreVerificationError("malformed", "notificationUUID is not a UUID"); + + let transaction: VerifiedTransaction | null = null; + let renewal: RenewalRecord | null = null; + if (appData.signedTransactionInfo !== undefined) { + transaction = await verifyTransaction(text(appData.signedTransactionInfo, "signedTransactionInfo"), config, roots); + if (transaction.record.environment !== environment) { + throw new AppStoreVerificationError("wrong_environment", "Transaction is for another environment"); + } + if (appData.signedRenewalInfo !== undefined) { + renewal = await verifyRenewalInfo(text(appData.signedRenewalInfo, "signedRenewalInfo"), transaction.record, roots); + } + } + + const subtype = payload.subtype; + return { + notificationUUID: notificationUUID.toLowerCase(), + notificationType: text(payload.notificationType, "notificationType"), + subtype: typeof subtype === "string" ? subtype : undefined, + environment, + signedDate: time(payload.signedDate, "signedDate"), + transaction, + renewal, + }; +} + +// ============================================================ +// Storing verified data +// ============================================================ + +export interface NotificationRecord { + notificationUUID: string; + notificationType: string; + subtype?: string; + environment: AppStoreEnvironment; + signedDate: string; +} + +export interface ApplyArgs { + /** The verified appAccountToken, or the authenticated caller it matched. */ + userId: string | null; + transaction: TransactionRecord | null; + renewal: RenewalRecord | null; + notification?: NotificationRecord | null; +} + +/** What apply_app_store_transaction did; see its comment in the migration. */ +export type ApplyResult = "applied" | "stale" | "duplicate" | "ignored" | "unknown_product" | "unlinked" | "user_mismatch"; + +export type ApplyTransaction = (args: ApplyArgs) => Promise; + +/** Applies verified data with the service-role client. */ +export function applyTransactionWith(client: SupabaseClient): ApplyTransaction { + return async ({ userId, transaction, renewal, notification }) => { + const { data, error } = await client.rpc("apply_app_store_transaction", { + p_user_id: userId, + p_transaction: transaction, + p_renewal: renewal, + p_notification: notification ?? null, + }); + if (error) throw new Error(`apply_app_store_transaction failed: ${error.code ?? error.message}`); + return data as ApplyResult; + }; +} diff --git a/supabase/functions/_shared/apple-root-ca.ts b/supabase/functions/_shared/apple-root-ca.ts new file mode 100644 index 000000000..14aa64924 --- /dev/null +++ b/supabase/functions/_shared/apple-root-ca.ts @@ -0,0 +1,27 @@ +// Apple Root CA - G3, the root of every App Store JWS chain, pinned here so a +// signature verifies only if it chains to Apple. Downloaded from +// https://www.apple.com/certificateauthority/AppleRootCA-G3.cer (Apple PKI). +// SHA-256 fingerprint of the DER certificate: +// 63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79 +// apple-root-ca.test.ts fails if this text no longer decodes to that certificate. + +export const APPLE_ROOT_CA_G3_SHA256 = + "63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179"; + +export const APPLE_ROOT_CA_G3_PEM = ` +-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE----- +`; diff --git a/supabase/functions/_shared/testing/app-store-chain.ts b/supabase/functions/_shared/testing/app-store-chain.ts new file mode 100644 index 000000000..20fc218c6 --- /dev/null +++ b/supabase/functions/_shared/testing/app-store-chain.ts @@ -0,0 +1,164 @@ +// Test support: a self-made certificate chain shaped like Apple's (root, +// intermediate with Apple's WWDR OID, leaf with the App Store receipt-signing +// OID) and a signer for JWS in the App Store's format. Tests inject the root +// into the verifier; production code pins Apple's root and never sees it. +import "https://esm.sh/reflect-metadata@0.2.2"; +import * as x509 from "https://esm.sh/@peculiar/x509@2.1.0"; + +const ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" } as const; +const LEAF_OID = "1.2.840.113635.100.6.11.1"; +const INTERMEDIATE_OID = "1.2.840.113635.100.6.2.1"; +const DAY = 24 * 60 * 60 * 1000; + +export interface ChainOptions { + /** Leave the App Store OID off the leaf. */ + leafWithoutOid?: boolean; + /** Leave Apple's WWDR OID off the intermediate. */ + intermediateWithoutOid?: boolean; + /** Validity window of the leaf. */ + leafNotBefore?: Date; + leafNotAfter?: Date; +} + +export interface TestChain { + rootDer: Uint8Array; + /** Base64 DER of leaf, intermediate and root, as x5c carries them. */ + x5c: string[]; + leafKeys: CryptoKeyPair; +} + +function base64(bytes: ArrayBuffer | Uint8Array): string { + return btoa(String.fromCharCode(...new Uint8Array(bytes))); +} + +function base64Url(bytes: ArrayBuffer | Uint8Array): string { + return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +const marker = (oid: string) => new x509.Extension(oid, false, new Uint8Array([0x05, 0x00])); + +export async function createTestChain(options: ChainOptions = {}): Promise { + const now = Date.now(); + const rootKeys = await crypto.subtle.generateKey(ALG, true, ["sign", "verify"]) as CryptoKeyPair; + const intermediateKeys = await crypto.subtle.generateKey(ALG, true, ["sign", "verify"]) as CryptoKeyPair; + const leafKeys = await crypto.subtle.generateKey(ALG, true, ["sign", "verify"]) as CryptoKeyPair; + + const root = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: "01", + name: "CN=Test Root CA", + notBefore: new Date(now - 365 * DAY), + notAfter: new Date(now + 365 * DAY), + signingAlgorithm: ALG, + keys: rootKeys, + extensions: [new x509.BasicConstraintsExtension(true, undefined, true)], + }); + const intermediate = await x509.X509CertificateGenerator.create({ + serialNumber: "02", + subject: "CN=Test WWDR CA", + issuer: root.subject, + notBefore: new Date(now - 365 * DAY), + notAfter: new Date(now + 365 * DAY), + signingAlgorithm: ALG, + publicKey: intermediateKeys.publicKey, + signingKey: rootKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(true, 0, true), + ...(options.intermediateWithoutOid ? [] : [marker(INTERMEDIATE_OID)]), + ], + }); + const leaf = await x509.X509CertificateGenerator.create({ + serialNumber: "03", + subject: "CN=Test App Store Signing", + issuer: intermediate.subject, + notBefore: options.leafNotBefore ?? new Date(now - 30 * DAY), + notAfter: options.leafNotAfter ?? new Date(now + 30 * DAY), + signingAlgorithm: ALG, + publicKey: leafKeys.publicKey, + signingKey: intermediateKeys.privateKey, + extensions: options.leafWithoutOid ? [] : [marker(LEAF_OID)], + }); + + return { + rootDer: new Uint8Array(root.rawData), + x5c: [leaf, intermediate, root].map((cert) => base64(cert.rawData)), + leafKeys, + }; +} + +/** Signs a payload as the App Store does: ES256 with the chain in x5c. */ +export async function signJws( + chain: TestChain, + payload: Record, + header: Record = {}, +): Promise { + const encoder = new TextEncoder(); + const head = base64Url(encoder.encode(JSON.stringify({ alg: "ES256", x5c: chain.x5c, ...header }))); + const body = base64Url(encoder.encode(JSON.stringify(payload))); + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + chain.leafKeys.privateKey, + encoder.encode(`${head}.${body}`), + ); + return `${head}.${body}.${base64Url(signature)}`; +} + +export const TEST_BUNDLE_ID = "com.adam.debatelab"; +export const TEST_APP_APPLE_ID = 1234567890; +export const PRO_PRODUCT = "com.adam.debatelab.pro.monthly"; + +/** A JWSTransactionDecodedPayload for an active monthly subscription. */ +export function transactionPayload(overrides: Record = {}): Record { + const now = Date.now(); + return { + transactionId: "2000000000000002", + originalTransactionId: "2000000000000001", + bundleId: TEST_BUNDLE_ID, + productId: PRO_PRODUCT, + purchaseDate: now - DAY, + originalPurchaseDate: now - DAY, + expiresDate: now + 29 * DAY, + quantity: 1, + type: "Auto-Renewable Subscription", + inAppOwnershipType: "PURCHASED", + signedDate: now, + environment: "Production", + transactionReason: "PURCHASE", + ...overrides, + }; +} + +/** A JWSRenewalInfoDecodedPayload for the transaction above. */ +export function renewalPayload(overrides: Record = {}): Record { + return { + originalTransactionId: "2000000000000001", + autoRenewProductId: PRO_PRODUCT, + productId: PRO_PRODUCT, + autoRenewStatus: 1, + signedDate: Date.now(), + environment: "Production", + ...overrides, + }; +} + +/** A responseBodyV2DecodedPayload wrapping signed transaction and renewal info. */ +export function notificationPayload( + data: Record, + overrides: Record = {}, +): Record { + return { + notificationType: "SUBSCRIBED", + subtype: "INITIAL_BUY", + notificationUUID: "6a0e1f3c-2b1d-4e5f-9a8b-7c6d5e4f3a2b", + version: "2.0", + signedDate: Date.now(), + data: { + appAppleId: TEST_APP_APPLE_ID, + bundleId: TEST_BUNDLE_ID, + bundleVersion: "1", + environment: "Production", + status: 1, + ...data, + }, + ...overrides, + }; +} From 248d5a200a6918dd95b7c89f4eb07b46ead3169c Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:45:58 +0000 Subject: [PATCH 03/12] Read the server-side plan from effective_entitlement getSubscriptionInfo, which the ai, ai-stream and voice quota checks use, now calls effective_entitlement() instead of recomputing the tier from user_settings and subscriptions, and still fails closed when the plan cannot be read. Handler tests answer that RPC through one helper; the tier rules they used to pin moved to the pgTAP suite. Co-Authored-By: Claude Opus 5.5 --- .../functions/_shared/entitlements.test.ts | 101 +++++++++--------- supabase/functions/_shared/entitlements.ts | 97 ++++------------- .../functions/_shared/testing/entitlement.ts | 28 +++++ supabase/functions/ai-stream/handler_test.ts | 10 +- supabase/functions/ai-stream/index_test.ts | 10 +- supabase/functions/ai/debate-context.test.ts | 4 +- .../functions/ai/generate-honesty_test.ts | 10 +- .../functions/ai/handler-research.test.ts | 30 ++---- supabase/functions/ai/voice.test.ts | 4 +- supabase/functions/voice/index_test.ts | 20 ++-- 10 files changed, 126 insertions(+), 188 deletions(-) create mode 100644 supabase/functions/_shared/testing/entitlement.ts diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index cc4c79baf..ee203e53e 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -1,5 +1,4 @@ -// Entitlement behaviour that ai/ and ai-stream/ now share. The two copies had -// drifted on the gift-trial fallback; these tests pin the surviving semantics. +// Entitlement behaviour that ai/, ai-stream/ and voice/ share. for (const [key, value] of Object.entries({ SUPABASE_URL: "https://supabase.test", @@ -7,19 +6,16 @@ for (const [key, value] of Object.entries({ })) Deno.env.set(key, value); const { getSubscriptionInfo, checkRateLimit } = await import("./entitlements.ts"); +const { entitlementRows } = await import("./testing/entitlement.ts"); function json(body: unknown, status = 200): Response { return Response.json(body, { status }); } -const DAY = 24 * 60 * 60 * 1000; -const future = () => new Date(Date.now() + DAY).toISOString(); -const past = () => new Date(Date.now() - DAY).toISOString(); - interface Routes { - user_settings?: unknown; + entitlement?: unknown; + entitlementStatus?: number; user_roles?: unknown; - subscriptions?: unknown; rate_limits?: unknown; quota?: unknown; } @@ -28,9 +24,10 @@ async function withSupabase(routes: Routes, run: () => Promise): Promise { const url = new URL(input instanceof Request ? input.url : String(input)); - if (url.pathname.includes("/rest/v1/user_settings")) return json(routes.user_settings ?? null); + if (url.pathname.includes("/rpc/effective_entitlement")) { + return json(routes.entitlement ?? entitlementRows("trial"), routes.entitlementStatus ?? 200); + } if (url.pathname.includes("/rest/v1/user_roles")) return json(routes.user_roles ?? { role: "user" }); - if (url.pathname.includes("/rest/v1/subscriptions")) return json(routes.subscriptions ?? []); if (url.pathname.includes("/rest/v1/rate_limits")) return json(routes.rate_limits ?? null); if (url.pathname.includes("/rpc/consume_daily_quota")) return json(routes.quota ?? [{ allowed: true, current_count: 1 }]); throw new Error(`Unexpected request: ${url}`); @@ -46,55 +43,61 @@ function assertEquals(actual: unknown, expected: unknown, message: string) { if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`); } -Deno.test("a missing settings row fails closed instead of granting a trial", async () => { - await withSupabase({ user_settings: null }, async () => { - const info = await getSubscriptionInfo("user-1"); - assertEquals(info.tier, "expired", "tier"); - assertEquals(info.dailyLimit, 0, "dailyLimit"); - assertEquals(info.canAccessProModels, false, "canAccessProModels"); - }); -}); +// The tier rules live in effective_entitlement() and are tested in +// supabase/tests/app_store_entitlements_test.sql. These tests cover what stays +// here: reading the plan, failing closed, and the quota and ban checks. -Deno.test("an unknown tier still honours an active gift trial", async () => { - // ai/ applied this fallback, ai-stream/ did not. The shared module keeps the - // permissive-but-limited branch so a gifted trial is not silently voided. - await withSupabase({ - user_settings: { subscription_tier: "something_new", daily_limit_override: null, trial_ends_at: future(), grace_period_ends_at: null }, - }, async () => { +Deno.test("the plan comes from effective_entitlement for that user", async () => { + let body = ""; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + body = String(init?.body ?? (input instanceof Request ? await input.text() : "")); + return json(entitlementRows("basic", { trial_ends_at: "2099-01-01T00:00:00.000Z" })); + }) as typeof fetch; + try { const info = await getSubscriptionInfo("user-1"); - assertEquals(info.tier, "trial", "tier"); - assertEquals(info.dailyLimit, 5, "dailyLimit"); + assertEquals(JSON.parse(body).p_user_id, "user-1", "p_user_id"); + assertEquals(info.tier, "basic", "tier"); + assertEquals(info.dailyLimit, 20, "dailyLimit"); assertEquals(info.canAccessProModels, false, "canAccessProModels"); - }); + assertEquals(info.trialEndsAt, "2099-01-01T00:00:00.000Z", "trialEndsAt"); + } finally { + globalThis.fetch = originalFetch; + } }); -Deno.test("a lapsed pro subscription falls back to an active gift trial", async () => { - await withSupabase({ - user_settings: { subscription_tier: "pro", daily_limit_override: null, trial_ends_at: future(), grace_period_ends_at: past() }, - subscriptions: [], - }, async () => { - const info = await getSubscriptionInfo("user-1"); - assertEquals(info.tier, "trial", "tier"); - assertEquals(info.canAccessProModels, false, "canAccessProModels"); - }); +Deno.test("a failed or empty plan lookup fails closed instead of granting a trial", async () => { + for (const routes of [ + { entitlement: { code: "PGRST202", message: "not found" }, entitlementStatus: 404 }, + { entitlement: [] }, + ]) { + await withSupabase(routes, async () => { + const info = await getSubscriptionInfo("user-1"); + assertEquals(info.tier, "expired", "tier"); + assertEquals(info.dailyLimit, 0, "dailyLimit"); + assertEquals(info.canAccessProModels, false, "canAccessProModels"); + }); + } }); -Deno.test("a promo grace period grants pro model access", async () => { - await withSupabase({ - user_settings: { subscription_tier: "pro", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: future() }, - }, async () => { - const info = await getSubscriptionInfo("user-1"); - assertEquals(info.tier, "pro", "tier"); - assertEquals(info.dailyLimit, 50, "dailyLimit"); - assertEquals(info.canAccessProModels, true, "canAccessProModels"); +Deno.test("expired access with an extra-limit override is still allowed", async () => { + await withSupabase({ entitlement: entitlementRows("expired", { daily_limit: 15 }) }, async () => { + const result = await checkRateLimit("user-1"); + assertEquals(result.allowed, true, "allowed"); + assertEquals(result.limit, 15, "limit"); }); }); -Deno.test("daily_limit_override wins over the tier default", async () => { +Deno.test("Pro models are refused below Pro before any quota is consumed", async () => { + let quotaConsumed = false; await withSupabase({ - user_settings: { subscription_tier: "trial", daily_limit_override: 3, trial_ends_at: future(), grace_period_ends_at: null }, + entitlement: entitlementRows("basic"), + get quota() { quotaConsumed = true; return [{ allowed: true, current_count: 1 }]; }, }, async () => { - assertEquals((await getSubscriptionInfo("user-1")).dailyLimit, 3, "dailyLimit"); + const result = await checkRateLimit("user-1", "auto-pro"); + assertEquals(result.allowed, false, "allowed"); + assertEquals(result.code, "upgrade_required", "code"); + assertEquals(quotaConsumed, false, "quota must not be consumed"); }); }); @@ -119,7 +122,7 @@ Deno.test("anonymous callers bypass the rate limit", async () => { Deno.test("an exhausted quota reports the limit-reached code", async () => { await withSupabase({ - user_settings: { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: future(), grace_period_ends_at: null }, + entitlement: entitlementRows("trial"), quota: [{ allowed: false, current_count: 5 }], }, async () => { const result = await checkRateLimit("user-1"); @@ -132,7 +135,7 @@ Deno.test("an exhausted quota reports the limit-reached code", async () => { Deno.test("expired access is refused with the trial-expired code", async () => { await withSupabase({ - user_settings: { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: past(), grace_period_ends_at: null }, + entitlement: entitlementRows("expired"), }, async () => { const result = await checkRateLimit("user-1"); assertEquals(result.allowed, false, "allowed"); diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 18f4201a7..ebbb88829 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -27,7 +27,7 @@ export interface RateLimitResult { } export interface SubscriptionInfo { - tier: string; // 'trial' | 'pro' | 'legacy_free' | 'expired' + tier: string; // 'trial' | 'basic' | 'pro' | 'legacy_free' | 'expired' dailyLimit: number; canAccessProModels: boolean; trialEndsAt: string | null; @@ -43,82 +43,27 @@ export async function checkUserBanned(userId: string): Promise { return data?.role === "banned"; } -export async function getSubscriptionInfo(userId: string): Promise { - const { data: user } = await supabase - .from("user_settings") - .select("subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at") - .eq("user_id", userId) - .single(); - - // A settings row is provisioned for every account by the on_auth_user_created - // trigger and can no longer be deleted by the client (see 20260819000000 - // security_hardening.sql). A missing row is therefore untrusted, not a new - // user — fail closed instead of granting a fresh trial. - if (!user) return { tier: "expired", dailyLimit: 0, canAccessProModels: false, trialEndsAt: null, gracePeriodEndsAt: null }; - - const tier = user.subscription_tier || "trial"; - const limitOrDefault = (defaultLimit: number) => user.daily_limit_override ?? defaultLimit; - const now = new Date(); - const expired = { tier: "expired", dailyLimit: limitOrDefault(0), canAccessProModels: false, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - const activeGiftTrial = user.trial_ends_at ? new Date(user.trial_ends_at) : null; - const giftTrialInfo = activeGiftTrial && now < activeGiftTrial - ? { tier: "trial", dailyLimit: limitOrDefault(5), canAccessProModels: false, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at } - : null; - - // Legacy free users in grace period get pro-level access - if (tier === "legacy_free") { - const graceEnd = user.grace_period_ends_at ? new Date(user.grace_period_ends_at) : null; - if (graceEnd && now < graceEnd) { - return { tier: "legacy_free", dailyLimit: limitOrDefault(50), canAccessProModels: true, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - if (giftTrialInfo) return giftTrialInfo; - // Grace period expired — treat as expired - return expired; - } - - // Pro subscribers or promo Pro access. Stripe Pro must have an active - // subscription period; promo Pro uses grace_period_ends_at, with lifetime - // promo codes represented by a far-future date. - if (tier === "pro") { - const promoEnd = user.grace_period_ends_at ? new Date(user.grace_period_ends_at) : null; - if (promoEnd && now < promoEnd) { - return { tier: "pro", dailyLimit: limitOrDefault(50), canAccessProModels: true, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - - const { data: subscriptions } = await supabase - .from("subscriptions") - .select("status, current_period_end") - .eq("user_id", userId) - .in("status", ["active", "trialing"]) - .order("current_period_end", { ascending: false }) - .limit(1); - - const activeSubscription = subscriptions?.find((sub) => - sub.current_period_end && new Date(sub.current_period_end) > now - ); +// A missing or unreadable plan is untrusted, not a new user: fail closed +// instead of granting a fresh trial. +const NO_ACCESS: SubscriptionInfo = { tier: "expired", dailyLimit: 0, canAccessProModels: false, trialEndsAt: null, gracePeriodEndsAt: null }; - if (activeSubscription) { - return { tier: "pro", dailyLimit: limitOrDefault(50), canAccessProModels: true, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - - if (giftTrialInfo) return giftTrialInfo; - return expired; - } - - // Trial users - if (tier === "trial") { - const trialEnd = user.trial_ends_at ? new Date(user.trial_ends_at) : null; - if (trialEnd && now < trialEnd) { - return { tier: "trial", dailyLimit: limitOrDefault(5), canAccessProModels: false, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - // Trial expired - return expired; - } - - if (giftTrialInfo) return giftTrialInfo; - - // Expired - return expired; +/** + * The user's plan from effective_entitlement() (migration + * 20261014123154_app_store_entitlements.sql), which merges Stripe, App Store, + * promo, trial and legacy access into the highest active tier. + */ +export async function getSubscriptionInfo(userId: string): Promise { + const { data, error } = await supabase.rpc("effective_entitlement", { p_user_id: userId }); + if (error) console.error("[ENTITLEMENT] Plan lookup failed:", error.code ?? error.message); + const row = Array.isArray(data) ? data[0] : data; + if (error || !row || typeof row.tier !== "string" || typeof row.daily_limit !== "number") return NO_ACCESS; + return { + tier: row.tier, + dailyLimit: row.daily_limit, + canAccessProModels: row.can_access_pro_models === true, + trialEndsAt: row.trial_ends_at ?? null, + gracePeriodEndsAt: row.grace_period_ends_at ?? null, + }; } export async function getConfiguredDailyLimit(userId: string, fallbackLimit: number, limitType = "daily_requests"): Promise { diff --git a/supabase/functions/_shared/testing/entitlement.ts b/supabase/functions/_shared/testing/entitlement.ts new file mode 100644 index 000000000..4b7ecd80d --- /dev/null +++ b/supabase/functions/_shared/testing/entitlement.ts @@ -0,0 +1,28 @@ +// Test support: the rows effective_entitlement() returns through PostgREST +// (/rest/v1/rpc/effective_entitlement), for handler tests that need a plan. +// The tier rules themselves are tested in supabase/tests/app_store_entitlements_test.sql. + +export type TestTier = "trial" | "basic" | "pro" | "legacy_free" | "expired"; + +const DEFAULTS: Record = { + pro: { daily_limit: 50, can_access_pro_models: true, source: "stripe" }, + legacy_free: { daily_limit: 50, can_access_pro_models: true, source: "legacy" }, + basic: { daily_limit: 20, can_access_pro_models: false, source: "app_store" }, + trial: { daily_limit: 5, can_access_pro_models: false, source: "trial" }, + expired: { daily_limit: 0, can_access_pro_models: false, source: null }, +}; + +export function entitlementRows(tier: TestTier, overrides: Record = {}): Record[] { + return [{ + tier, + ...DEFAULTS[tier], + expires_at: null, + trial_ends_at: null, + grace_period_ends_at: null, + has_stripe_billing: false, + has_app_store_subscription: false, + ...overrides, + }]; +} + +export const ENTITLEMENT_PATH = "/rest/v1/rpc/effective_entitlement"; diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index 853556c43..5bfd70f1c 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -1,3 +1,4 @@ +import { entitlementRows } from "../_shared/testing/entitlement.ts"; const testEnvironment = { SUPABASE_URL: "https://supabase.test", SUPABASE_ANON_KEY: "test-anon-key", @@ -63,13 +64,8 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a headers: { "Content-Type": "application/json" }, }); } - if (url.pathname.includes("/rest/v1/user_settings")) { - return new Response(JSON.stringify({ - subscription_tier: "trial", - daily_limit_override: 50, - trial_ends_at: "2099-01-01T00:00:00.000Z", - grace_period_ends_at: null, - }), { headers: { "Content-Type": "application/json" } }); + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) { + return new Response(JSON.stringify(entitlementRows("trial", { daily_limit: 50 })), { headers: { "Content-Type": "application/json" } }); } if (url.pathname.includes("/rest/v1/rate_limits")) { return new Response(JSON.stringify({ limit_value: 50 }), { diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index 5c4067417..02b3af07c 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -1,3 +1,4 @@ +import { entitlementRows } from "../_shared/testing/entitlement.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -78,14 +79,7 @@ async function runScenario(scenario: Scenario) { if (url.origin === "https://supabase.test") { if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); - if (url.pathname.includes("/rest/v1/user_settings")) { - return jsonResponse({ - subscription_tier: "trial", - daily_limit_override: 50, - trial_ends_at: "2099-01-01T00:00:00.000Z", - grace_period_ends_at: null, - }); - } + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) return jsonResponse(entitlementRows("trial", { daily_limit: 50 })); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { return jsonResponse([{ allowed: true, current_count: 1 }]); diff --git a/supabase/functions/ai/debate-context.test.ts b/supabase/functions/ai/debate-context.test.ts index db2804cf2..27b2363b3 100644 --- a/supabase/functions/ai/debate-context.test.ts +++ b/supabase/functions/ai/debate-context.test.ts @@ -3,6 +3,7 @@ import { createFakeRest } from "../_shared/testing/fake-rest.ts"; import { UNTRUSTED_CONTENT_RULES } from "../_shared/prompts.ts"; +import { entitlementRows } from "../_shared/testing/entitlement.ts"; const environment = { SUPABASE_URL: "https://supabase.test", @@ -70,8 +71,7 @@ function install(options: { stream?: boolean; tables?: typeof tables; reply?: un const tableResponse = rest.handle(url, { ...init, headers: input instanceof Request ? input.headers : init?.headers }); if (tableResponse) return tableResponse; if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); - if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); - if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) return jsonResponse(entitlementRows("trial", { daily_limit: 50 })); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index 759aa374e..e442f6fe9 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_test.ts @@ -1,3 +1,4 @@ +import { entitlementRows } from "../_shared/testing/entitlement.ts"; const environment = { SUPABASE_URL: "https://supabase.test", SUPABASE_ANON_KEY: "test-anon-key", @@ -69,13 +70,8 @@ Deno.test("generate marks every contention when research has no citable sources" if (url.pathname.includes("/rest/v1/user_roles")) { return jsonResponse({ role: "user" }); } - if (url.pathname.includes("/rest/v1/user_settings")) { - return jsonResponse({ - subscription_tier: "trial", - daily_limit_override: 50, - trial_ends_at: "2099-01-01T00:00:00.000Z", - grace_period_ends_at: null, - }); + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) { + return jsonResponse(entitlementRows("trial", { daily_limit: 50 })); } if (url.pathname.includes("/rest/v1/rate_limits")) { return jsonResponse({ limit_value: 50 }); diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 7732ba838..c9ff87c2c 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -1,3 +1,4 @@ +import { entitlementRows } from "../_shared/testing/entitlement.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -186,14 +187,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac if (url.origin === "https://supabase.test") { if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); - if (url.pathname.includes("/rest/v1/user_settings")) { - return jsonResponse({ - subscription_tier: "trial", - daily_limit_override: 50, - trial_ends_at: "2099-01-01T00:00:00.000Z", - grace_period_ends_at: null, - }); - } + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) return jsonResponse(entitlementRows("trial", { daily_limit: 50 })); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { return jsonResponse([{ allowed: true, current_count: 1 }]); @@ -324,14 +318,7 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn if (url.origin === "https://supabase.test") { if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); - if (url.pathname.includes("/rest/v1/user_settings")) { - return jsonResponse({ - subscription_tier: "trial", - daily_limit_override: 50, - trial_ends_at: "2099-01-01T00:00:00.000Z", - grace_period_ends_at: null, - }); - } + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) return jsonResponse(entitlementRows("trial", { daily_limit: 50 })); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { return jsonResponse([{ allowed: true, current_count: 1 }]); @@ -430,13 +417,10 @@ function supabaseTestResponse(url: URL): Response | undefined { if (url.origin !== "https://supabase.test") return undefined; if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); - if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ - subscription_tier: (globalThis as typeof globalThis & { __testTier?: string }).__testTier || "trial", - daily_limit_override: 50, - trial_ends_at: "2099-01-01T00:00:00.000Z", - grace_period_ends_at: null, - }); - if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }]); + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) { + const tier = (globalThis as typeof globalThis & { __testTier?: "trial" | "pro" }).__testTier || "trial"; + return jsonResponse(entitlementRows(tier, { daily_limit: 50 })); + } if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); diff --git a/supabase/functions/ai/voice.test.ts b/supabase/functions/ai/voice.test.ts index af631f2db..57f424f26 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -1,3 +1,4 @@ +import { entitlementRows } from "../_shared/testing/entitlement.ts"; // Voice rounds through the real ai entrypoint: audio tags reach only the // voice payload, typed requests are untouched, and measured delivery facts // reach the judge and the saved round. @@ -42,8 +43,7 @@ function install(reply: unknown): Harness { if (url.origin === "https://supabase.test") { if (url.pathname === "/auth/v1/user") return jsonResponse({ id: USER, aud: "authenticated" }); if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); - if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); - if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); + if (url.pathname.includes("/rest/v1/rpc/effective_entitlement")) return jsonResponse(entitlementRows("trial", { daily_limit: 50 })); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/rpc/sparring_request_started_at")) return jsonResponse(new Date().toISOString()); diff --git a/supabase/functions/voice/index_test.ts b/supabase/functions/voice/index_test.ts index 2d208b05a..9185531a2 100644 --- a/supabase/functions/voice/index_test.ts +++ b/supabase/functions/voice/index_test.ts @@ -1,3 +1,4 @@ +import { entitlementRows, type TestTier } from "../_shared/testing/entitlement.ts"; for (const [key, value] of Object.entries({ SUPABASE_URL: "https://supabase.test", SUPABASE_ANON_KEY: "test-anon-key", @@ -9,7 +10,6 @@ const { createVoiceToken, VOICE_TOKEN_TTL_MS } = await import("../_shared/voice- const USER_ID = "11111111-1111-4111-8111-111111111111"; const TEXT = "[sighs] That is one reading of the evidence. ... It is not the only one."; -const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); interface Harness { /** Every outbound request, in order, as "HOST PATH". */ @@ -25,7 +25,7 @@ interface Options { apiKey?: string | null; user?: Record | null; role?: string; - settings?: Record | null; + tier?: TestTier; rateLimit?: { limit_value: number } | null; quota?: { allowed: boolean; current_count: number }; /** The clip counter row a refund would read. */ @@ -58,13 +58,7 @@ async function withVoice(options: Options, run: (harness: Harness) => Promise { }); Deno.test("trial users get the smaller cap and an operator override wins", async () => { - const trial = { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: FUTURE, grace_period_ends_at: null }; - await withVoice({ settings: trial }, async (harness) => { + await withVoice({ tier: "trial" }, async (harness) => { await handleRequest(await voiceRequest()); assertEquals(harness.quotaRequests[0].p_limit, 12, "trial clip limit"); }); - await withVoice({ settings: trial, rateLimit: { limit_value: 3 } }, async (harness) => { + await withVoice({ tier: "trial", rateLimit: { limit_value: 3 } }, async (harness) => { await handleRequest(await voiceRequest()); assertEquals(harness.quotaRequests[0].p_limit, 3, "overridden clip limit"); }); }); Deno.test("expired and banned accounts get no provider voice", async () => { - const expired = { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: "2026-01-01T00:00:00Z", grace_period_ends_at: null }; - await withVoice({ settings: expired }, async (harness) => { + await withVoice({ tier: "expired" }, async (harness) => { const response = await handleRequest(await voiceRequest()); assertEquals(response.status, 402, "expired status"); assertEquals((await response.json()).code, "voice_limit_reached", "expired code"); From 4ea4523fae18b44ccb0717063a1d5d97fdc5c9ab Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:48:39 +0000 Subject: [PATCH 04/12] Add the App Store notification and purchase endpoints app-store-notifications takes App Store Server Notifications V2, verifies the payload and the transaction and renewal info inside it, and applies them through apply_app_store_transaction, which makes Apple's retries idempotent on notificationUUID. app-store-purchase lets the signed-in app send the signed transaction StoreKit returned; it is applied only when its appAccountToken is the caller's user id, and the answer is the plan. Both wait with 503 until APP_STORE_BUNDLE_ID (and APP_STORE_APP_APPLE_ID for Production) are set. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- supabase/config.toml | 9 + supabase/functions/_shared/app-store.test.ts | 30 +++- .../app-store-notifications/index.ts | 107 +++++++++++ .../app-store-notifications/index_test.ts | 169 ++++++++++++++++++ .../functions/app-store-purchase/index.ts | 124 +++++++++++++ .../app-store-purchase/index_test.ts | 161 +++++++++++++++++ 7 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 supabase/functions/app-store-notifications/index.ts create mode 100644 supabase/functions/app-store-notifications/index_test.ts create mode 100644 supabase/functions/app-store-purchase/index.ts create mode 100644 supabase/functions/app-store-purchase/index_test.ts diff --git a/package.json b/package.json index 7a863ac9b..98b37b6d0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY,APP_STORE_BUNDLE_ID,APP_STORE_ENVIRONMENTS,APP_STORE_APP_APPLE_ID --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke", diff --git a/supabase/config.toml b/supabase/config.toml index 97021b5c5..7414eb2e0 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -47,6 +47,15 @@ verify_jwt = false [functions.stripe-webhook] verify_jwt = false +# App Store Server Notifications come from Apple (no Supabase JWT). The +# handler verifies Apple's signature on every payload instead. +[functions.app-store-notifications] +verify_jwt = false + +# Validates the caller's Bearer token in-function, like the functions above. +[functions.app-store-purchase] +verify_jwt = false + # Demo code sign-in happens before the caller has a Supabase session, so # there is no JWT yet to verify at the gateway. [functions.demo-code-login] diff --git a/supabase/functions/_shared/app-store.test.ts b/supabase/functions/_shared/app-store.test.ts index 0c25dc355..37ee54b45 100644 --- a/supabase/functions/_shared/app-store.test.ts +++ b/supabase/functions/_shared/app-store.test.ts @@ -1,7 +1,9 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19"; +import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@1.0.19"; import { APPLE_ROOTS, type AppStoreConfig, + AppStoreConfigError, + appStoreConfigFromEnv, AppStoreVerificationError, type VerificationCode, verifyNotification, @@ -160,3 +162,29 @@ Deno.test("a notification without app data is not for this service", async () => const jws = await signJws(chain, { notificationType: "EXTERNAL_PURCHASE_TOKEN", notificationUUID: crypto.randomUUID(), signedDate: Date.now() }); assertEquals(await verifyNotification(jws, config, roots), null); }); + +Deno.test("configuration fails closed: a bundle id is required, and Production needs the app's Apple id", () => { + const names = ["APP_STORE_BUNDLE_ID", "APP_STORE_ENVIRONMENTS", "APP_STORE_APP_APPLE_ID"]; + const saved = names.map((name) => Deno.env.get(name)); + const set = (values: Record) => { + for (const name of names) { + const value = values[name]; + if (value === undefined) Deno.env.delete(name); + else Deno.env.set(name, value); + } + }; + try { + set({}); + assertThrows(appStoreConfigFromEnv, AppStoreConfigError); + set({ APP_STORE_BUNDLE_ID: TEST_BUNDLE_ID }); + assertThrows(appStoreConfigFromEnv, AppStoreConfigError, "APP_STORE_APP_APPLE_ID"); + set({ APP_STORE_BUNDLE_ID: TEST_BUNDLE_ID, APP_STORE_ENVIRONMENTS: "Sandbox,Xcode" }); + assertThrows(appStoreConfigFromEnv, AppStoreConfigError); + set({ APP_STORE_BUNDLE_ID: TEST_BUNDLE_ID, APP_STORE_ENVIRONMENTS: "Sandbox" }); + assertEquals(appStoreConfigFromEnv(), { bundleId: TEST_BUNDLE_ID, environments: ["Sandbox"], appAppleId: undefined }); + set({ APP_STORE_BUNDLE_ID: TEST_BUNDLE_ID, APP_STORE_ENVIRONMENTS: "Production, Sandbox", APP_STORE_APP_APPLE_ID: "1234567890" }); + assertEquals(appStoreConfigFromEnv(), { bundleId: TEST_BUNDLE_ID, environments: ["Production", "Sandbox"], appAppleId: 1234567890 }); + } finally { + names.forEach((name, index) => saved[index] === undefined ? Deno.env.delete(name) : Deno.env.set(name, saved[index]!)); + } +}); diff --git a/supabase/functions/app-store-notifications/index.ts b/supabase/functions/app-store-notifications/index.ts new file mode 100644 index 000000000..5d6075f21 --- /dev/null +++ b/supabase/functions/app-store-notifications/index.ts @@ -0,0 +1,107 @@ +// Supabase Edge Function: App Store Server Notifications V2. +// +// Apple POSTs { signedPayload } here. The payload and the transaction and +// renewal info inside it are verified against Apple's pinned root before +// anything is read, then applied through apply_app_store_transaction, which +// records the notificationUUID so a retried delivery applies nothing twice. +// +// Every notification type that carries a signed transaction is applied the +// same way, because the transaction and renewal info are the subscription's +// whole state: SUBSCRIBED and DID_RENEW extend it, DID_CHANGE_RENEWAL_STATUS +// and DID_FAIL_TO_RENEW (with its billing grace period) update the renewal +// info, EXPIRED and GRACE_PERIOD_EXPIRED end it by date, and REFUND and REVOKE +// set its revocation. +// +// Answers: 200 once handled, including duplicates and data this service does +// not use, so Apple stops retrying; 400 for data that fails verification; +// 503 when the function is not configured and 500 when the database fails, +// so Apple retries later. + +import { createAdminClient } from "../_shared/auth.ts"; +import { BodyTooLargeError, discardRequestBody, readBoundedBody } from "../_shared/bounded-body.ts"; +import { + APPLE_ROOTS, + type AppStoreConfig, + AppStoreConfigError, + appStoreConfigFromEnv, + type ApplyTransaction, + applyTransactionWith, + AppStoreVerificationError, + verifyNotification, +} from "../_shared/app-store.ts"; + +const MAX_BODY_BYTES = 64 * 1024; + +export interface NotificationDeps { + roots: readonly Uint8Array[]; + config: () => AppStoreConfig; + apply: ApplyTransaction; +} + +function reply(status: number, body: Record): Response { + return Response.json(body, { status }); +} + +export function createHandler(deps: NotificationDeps) { + return async (req: Request): Promise => { + if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); + + let config: AppStoreConfig; + try { + config = deps.config(); + } catch (error) { + if (!(error instanceof AppStoreConfigError)) throw error; + console.error("[APP STORE] Not configured:", error.message); + await discardRequestBody(req); + return reply(503, { error: "not_configured" }); + } + + let signedPayload: unknown; + try { + signedPayload = (JSON.parse(await readBoundedBody(req, MAX_BODY_BYTES)) as Record | null)?.signedPayload; + } catch (error) { + if (error instanceof BodyTooLargeError) return reply(413, { error: "too_large" }); + return reply(400, { error: "malformed" }); + } + if (typeof signedPayload !== "string") return reply(400, { error: "malformed" }); + + let notification; + try { + notification = await verifyNotification(signedPayload, config, deps.roots); + } catch (error) { + if (!(error instanceof AppStoreVerificationError)) throw error; + if (error.code === "unsupported") return reply(200, { received: true, result: "ignored" }); + console.warn("[APP STORE] Notification rejected:", error.code); + return reply(400, { error: error.code }); + } + if (!notification) return reply(200, { received: true, result: "ignored" }); + + try { + const result = await deps.apply({ + userId: notification.transaction?.appAccountToken ?? null, + transaction: notification.transaction?.record ?? null, + renewal: notification.renewal, + notification: { + notificationUUID: notification.notificationUUID, + notificationType: notification.notificationType, + subtype: notification.subtype, + environment: notification.environment, + signedDate: notification.signedDate, + }, + }); + console.log(`[APP STORE] ${notification.notificationType}${notification.subtype ? `/${notification.subtype}` : ""}: ${result}`); + return reply(200, { received: true, result }); + } catch (error) { + console.error("[APP STORE] Could not apply notification:", error instanceof Error ? error.message : "unknown"); + return reply(500, { error: "storage_failed" }); + } + }; +} + +if (import.meta.main) { + Deno.serve(createHandler({ + roots: APPLE_ROOTS, + config: appStoreConfigFromEnv, + apply: applyTransactionWith(createAdminClient()), + })); +} diff --git a/supabase/functions/app-store-notifications/index_test.ts b/supabase/functions/app-store-notifications/index_test.ts new file mode 100644 index 000000000..3bc545f5d --- /dev/null +++ b/supabase/functions/app-store-notifications/index_test.ts @@ -0,0 +1,169 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19"; +import { + createTestChain, + notificationPayload, + renewalPayload, + signJws, + TEST_APP_APPLE_ID, + TEST_BUNDLE_ID, + transactionPayload, +} from "../_shared/testing/app-store-chain.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { createHandler } = await import("./index.ts"); +const { APPLE_ROOTS, AppStoreConfigError } = await import("../_shared/app-store.ts"); +type NotificationDeps = import("./index.ts").NotificationDeps; +type ApplyArgs = import("../_shared/app-store.ts").ApplyArgs; +type ApplyResult = import("../_shared/app-store.ts").ApplyResult; + +const chain = await createTestChain(); +const config = { bundleId: TEST_BUNDLE_ID, environments: ["Production" as const], appAppleId: TEST_APP_APPLE_ID }; +const USER = "e0000000-0000-4000-8000-000000000030"; + +function harness(results: ApplyResult[] = ["applied"], overrides: Partial = {}) { + const applied: ApplyArgs[] = []; + const handler = createHandler({ + roots: [chain.rootDer], + config: () => config, + apply: (args) => { + applied.push(args); + return Promise.resolve(results[applied.length - 1] ?? "applied"); + }, + ...overrides, + }); + const post = (body: unknown) => handler(new Request("https://edge.test/app-store-notifications", { + method: "POST", + headers: { "content-type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + })); + return { applied, post }; +} + +async function signedNotification( + type: string, + subtype: string | undefined, + transaction: Record = {}, + data: Record = {}, +) { + return await signJws(chain, notificationPayload({ + signedTransactionInfo: await signJws(chain, transactionPayload({ appAccountToken: USER, ...transaction })), + signedRenewalInfo: await signJws(chain, renewalPayload()), + ...data, + }, { notificationType: type, subtype, notificationUUID: crypto.randomUUID() })); +} + +Deno.test("each subscription notification type applies its signed transaction for the token's user", async () => { + const types: Array<[string, string | undefined, Record]> = [ + ["SUBSCRIBED", "INITIAL_BUY", {}], + ["DID_RENEW", undefined, {}], + ["DID_CHANGE_RENEWAL_STATUS", "AUTO_RENEW_DISABLED", {}], + ["DID_FAIL_TO_RENEW", "GRACE_PERIOD", {}], + ["EXPIRED", "VOLUNTARY", { expiresDate: Date.now() - 1000 }], + ["GRACE_PERIOD_EXPIRED", undefined, { expiresDate: Date.now() - 1000 }], + ["REFUND", undefined, { revocationDate: Date.now(), revocationReason: 0 }], + ["REVOKE", undefined, { revocationDate: Date.now(), inAppOwnershipType: "FAMILY_SHARED" }], + ]; + for (const [type, subtype, transaction] of types) { + const { applied, post } = harness(); + const response = await post({ signedPayload: await signedNotification(type, subtype, transaction) }); + assertEquals(response.status, 200, type); + assertEquals(await response.json(), { received: true, result: "applied" }, type); + assertEquals(applied.length, 1, type); + assertEquals(applied[0].userId, USER, type); + assertEquals(applied[0].notification?.notificationType, type, type); + assertEquals(applied[0].notification?.subtype, subtype, type); + assertEquals(applied[0].renewal?.autoRenew, true, type); + } + const { applied, post } = harness(); + await post({ signedPayload: await signedNotification("REFUND", undefined, { revocationDate: 1_800_000_000_000, revocationReason: 1 }) }); + assertEquals(applied[0].transaction?.revocationDate, new Date(1_800_000_000_000).toISOString()); + const revoke = harness(); + await revoke.post({ signedPayload: await signedNotification("REVOKE", undefined, { revocationDate: Date.now(), inAppOwnershipType: "FAMILY_SHARED" }) }); + assertEquals(revoke.applied[0].transaction?.familyShared, true); +}); + +Deno.test("a redelivered notification is acknowledged without being applied twice", async () => { + const { applied, post } = harness(["applied", "duplicate"]); + const signedPayload = await signedNotification("DID_RENEW", undefined); + assertEquals((await (await post({ signedPayload })).json()).result, "applied"); + const again = await post({ signedPayload }); + assertEquals(again.status, 200); + assertEquals((await again.json()).result, "duplicate"); + assertEquals(applied[0].notification?.notificationUUID, applied[1].notification?.notificationUUID); +}); + +Deno.test("an unknown product or an unlinked purchase is acknowledged so Apple stops retrying", async () => { + for (const result of ["unknown_product", "unlinked"] as const) { + const { post } = harness([result]); + const response = await post({ signedPayload: await signedNotification("SUBSCRIBED", "INITIAL_BUY", { productId: "com.example.unknown" }) }); + assertEquals(response.status, 200); + assertEquals((await response.json()).result, result); + } +}); + +Deno.test("a transaction without appAccountToken is passed on unlinked", async () => { + const { applied, post } = harness(["unlinked"]); + const response = await post({ signedPayload: await signedNotification("DID_RENEW", undefined, { appAccountToken: undefined }) }); + assertEquals(response.status, 200); + assertEquals(applied[0].userId, null); +}); + +Deno.test("a notification that fails verification is refused and applies nothing", async () => { + const other = await createTestChain(); + const forged = await signJws(other, notificationPayload({ signedTransactionInfo: await signJws(other, transactionPayload()) })); + const cases: Array<[string, string]> = [ + [forged, "invalid_chain"], + [await signedNotification("DID_RENEW", undefined, {}, { bundleId: "com.example.other" }), "wrong_app"], + [await signedNotification("DID_RENEW", undefined, {}, { environment: "Sandbox" }), "wrong_environment"], + ["not-a-jws", "malformed"], + ]; + for (const [signedPayload, code] of cases) { + const { applied, post } = harness(); + const response = await post({ signedPayload }); + assertEquals([response.status, (await response.json()).error], [400, code], code); + assertEquals(applied.length, 0, code); + } +}); + +Deno.test("production pins Apple's root, so a test-signed notification is refused", async () => { + const { applied, post } = harness(["applied"], { roots: APPLE_ROOTS }); + const response = await post({ signedPayload: await signedNotification("DID_RENEW", undefined) }); + assertEquals(response.status, 400); + assertEquals(applied.length, 0); +}); + +Deno.test("a notification without app data is acknowledged and ignored", async () => { + const { applied, post } = harness(); + const signedPayload = await signJws(chain, { + notificationType: "EXTERNAL_PURCHASE_TOKEN", + notificationUUID: crypto.randomUUID(), + signedDate: Date.now(), + }); + const response = await post({ signedPayload }); + assertEquals(response.status, 200); + assertEquals((await response.json()).result, "ignored"); + assertEquals(applied.length, 0); +}); + +Deno.test("a database failure or missing configuration asks Apple to retry later", async () => { + const failing = harness(["applied"], { apply: () => Promise.reject(new Error("db down")) }); + assertEquals((await failing.post({ signedPayload: await signedNotification("DID_RENEW", undefined) })).status, 500); + const unconfigured = harness(["applied"], { + config: () => { + throw new AppStoreConfigError("APP_STORE_BUNDLE_ID is not set"); + }, + }); + assertEquals((await unconfigured.post({ signedPayload: await signedNotification("DID_RENEW", undefined) })).status, 503); + assertEquals(unconfigured.applied.length, 0); +}); + +Deno.test("a body without signedPayload is refused", async () => { + const { post } = harness(); + assertEquals((await post({})).status, 400); + assertEquals((await post("not json")).status, 400); +}); diff --git a/supabase/functions/app-store-purchase/index.ts b/supabase/functions/app-store-purchase/index.ts new file mode 100644 index 000000000..a49c7c821 --- /dev/null +++ b/supabase/functions/app-store-purchase/index.ts @@ -0,0 +1,124 @@ +// Supabase Edge Function: record an App Store purchase for the signed-in user. +// +// After a StoreKit purchase, or when it restores purchases, the app POSTs +// { signedTransaction, signedRenewalInfo? } (the JWS representations StoreKit +// gives it) with the user's bearer token. The transaction is verified against +// Apple's pinned root, its appAccountToken must equal the caller's user id +// (the app sets it to that at purchase), and it is applied through the same +// apply_app_store_transaction as App Store Server Notifications. The answer is +// the caller's plan afterwards. +// +// The app is not a browser, so no Origin is required; CORS headers are the +// shared ones and allow nothing new. + +import { createAdminClient, HttpError, requireUser } from "../_shared/auth.ts"; +import { getCorsHeaders } from "../_shared/cors.ts"; +import { BodyTooLargeError, readBoundedBody } from "../_shared/bounded-body.ts"; +import { getSubscriptionInfo, type SubscriptionInfo } from "../_shared/entitlements.ts"; +import { + APPLE_ROOTS, + type AppStoreConfig, + AppStoreConfigError, + appStoreConfigFromEnv, + type ApplyTransaction, + applyTransactionWith, + AppStoreVerificationError, + type RenewalRecord, + verifyRenewalInfo, + verifyTransaction, +} from "../_shared/app-store.ts"; + +const MAX_BODY_BYTES = 32 * 1024; + +export interface PurchaseDeps { + roots: readonly Uint8Array[]; + config: () => AppStoreConfig; + authenticate: (req: Request) => Promise<{ id: string }>; + apply: ApplyTransaction; + entitlement: (userId: string) => Promise; +} + +export function createHandler(deps: PurchaseDeps) { + return async (req: Request): Promise => { + const corsHeaders = getCorsHeaders(req.headers.get("origin")); + const reply = (status: number, body: Record) => Response.json(body, { status, headers: corsHeaders }); + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: corsHeaders }); + + let userId: string; + try { + userId = (await deps.authenticate(req)).id; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + return reply(error.status, { error: error.message }); + } + + let config: AppStoreConfig; + try { + config = deps.config(); + } catch (error) { + if (!(error instanceof AppStoreConfigError)) throw error; + console.error("[APP STORE] Not configured:", error.message); + return reply(503, { error: "In-app purchases are not available yet.", code: "not_configured" }); + } + + let body: Record | null; + try { + body = JSON.parse(await readBoundedBody(req, MAX_BODY_BYTES)); + } catch (error) { + if (error instanceof BodyTooLargeError) return reply(413, { error: "The request is too large.", code: "too_large" }); + return reply(400, { error: "The request body must be JSON.", code: "malformed" }); + } + const signedTransaction = body?.signedTransaction; + const signedRenewalInfo = body?.signedRenewalInfo; + if (typeof signedTransaction !== "string" || (signedRenewalInfo !== undefined && typeof signedRenewalInfo !== "string")) { + return reply(400, { error: "signedTransaction is required.", code: "malformed" }); + } + + let renewal: RenewalRecord | null = null; + let transaction; + try { + transaction = await verifyTransaction(signedTransaction, config, deps.roots); + if (signedRenewalInfo) renewal = await verifyRenewalInfo(signedRenewalInfo, transaction.record, deps.roots); + } catch (error) { + if (!(error instanceof AppStoreVerificationError)) throw error; + console.warn("[APP STORE] Purchase rejected:", error.code); + if (error.code === "unsupported") return reply(422, { error: "This product is not a subscription.", code: "unsupported_product" }); + return reply(400, { error: "The purchase could not be verified.", code: error.code }); + } + + // Only the account that made the purchase can claim it: the token is part + // of the signed transaction, so another user cannot forward it. + if (transaction.appAccountToken !== userId.toLowerCase()) { + console.warn("[APP STORE] Purchase belongs to another account"); + return reply(403, { error: "This purchase belongs to another account.", code: "account_mismatch" }); + } + + let result; + try { + result = await deps.apply({ userId, transaction: transaction.record, renewal }); + } catch (error) { + console.error("[APP STORE] Could not apply purchase:", error instanceof Error ? error.message : "unknown"); + return reply(500, { error: "Could not record the purchase. Please try again.", code: "storage_failed" }); + } + if (result === "unknown_product") return reply(422, { error: "This product is not sold here.", code: "unknown_product" }); + if (result === "user_mismatch") return reply(409, { error: "This purchase belongs to another account.", code: "account_mismatch" }); + if (result !== "applied" && result !== "stale") { + console.error("[APP STORE] Unexpected purchase result:", result); + return reply(500, { error: "Could not record the purchase. Please try again.", code: "storage_failed" }); + } + + const plan = await deps.entitlement(userId); + return reply(200, { result, entitlement: plan }); + }; +} + +if (import.meta.main) { + Deno.serve(createHandler({ + roots: APPLE_ROOTS, + config: appStoreConfigFromEnv, + authenticate: async (req) => (await requireUser(req)).user, + apply: applyTransactionWith(createAdminClient()), + entitlement: getSubscriptionInfo, + })); +} diff --git a/supabase/functions/app-store-purchase/index_test.ts b/supabase/functions/app-store-purchase/index_test.ts new file mode 100644 index 000000000..3179e0165 --- /dev/null +++ b/supabase/functions/app-store-purchase/index_test.ts @@ -0,0 +1,161 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19"; +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + TEST_USER, +} from "../_shared/testing/bearer-auth.ts"; +import { + createTestChain, + renewalPayload, + signJws, + TEST_APP_APPLE_ID, + TEST_BUNDLE_ID, + transactionPayload, +} from "../_shared/testing/app-store-chain.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { HttpError, requireUser } = await import("../_shared/auth.ts"); +const { createHandler } = await import("./index.ts"); +type PurchaseDeps = import("./index.ts").PurchaseDeps; +type ApplyArgs = import("../_shared/app-store.ts").ApplyArgs; +type ApplyResult = import("../_shared/app-store.ts").ApplyResult; + +const chain = await createTestChain(); +const config = { bundleId: TEST_BUNDLE_ID, environments: ["Production" as const], appAppleId: TEST_APP_APPLE_ID }; +const PRO = { tier: "pro", dailyLimit: 50, canAccessProModels: true, trialEndsAt: null, gracePeriodEndsAt: null }; + +function harness(result: ApplyResult = "applied", overrides: Partial = {}) { + const applied: ApplyArgs[] = []; + const handler = createHandler({ + roots: [chain.rootDer], + config: () => config, + authenticate: () => Promise.resolve({ id: TEST_USER.id }), + apply: (args) => { + applied.push(args); + return Promise.resolve(result); + }, + entitlement: () => Promise.resolve(PRO), + ...overrides, + }); + const post = (body: unknown) => handler(new Request("https://edge.test/app-store-purchase", { + method: "POST", + headers: { authorization: "Bearer token", "content-type": "application/json" }, + body: JSON.stringify(body), + })); + return { applied, post }; +} + +const ownTransaction = (overrides: Record = {}) => + signJws(chain, transactionPayload({ appAccountToken: TEST_USER.id, ...overrides })); + +Deno.test("app-store-purchase refuses missing, empty, non-Bearer and rejected tokens", async () => { + const handler = createHandler({ + roots: [chain.rootDer], + config: () => config, + authenticate: async (req) => (await requireUser(req)).user, + apply: () => Promise.reject(new Error("must not apply")), + entitlement: () => Promise.reject(new Error("must not read")), + }); + const send = (authorization?: string) => new Request("http://127.0.0.1/functions/v1/app-store-purchase", { + method: "POST", + headers: { "Content-Type": "application/json", ...(authorization === undefined ? {} : { Authorization: authorization }) }, + body: JSON.stringify({ signedTransaction: "x.y.z" }), + }); + await runBearerCases(handler, send, rejectedBearerCases({ + required: { error: "Authentication required" }, + invalid: { error: "Invalid authentication token" }, + })); +}); + +Deno.test("a verified purchase of the caller's own is applied for the caller and returns the plan", async () => { + const { applied, post } = harness(); + const response = await post({ + signedTransaction: await ownTransaction(), + signedRenewalInfo: await signJws(chain, renewalPayload()), + }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { result: "applied", entitlement: PRO }); + assertEquals(applied.length, 1); + assertEquals(applied[0].userId, TEST_USER.id); + assertEquals(applied[0].transaction?.originalTransactionId, "2000000000000001"); + assertEquals(applied[0].renewal?.autoRenew, true); + assertEquals(applied[0].notification, undefined); +}); + +Deno.test("a purchase made by another account cannot be claimed", async () => { + for (const token of ["22222222-2222-4222-8222-222222222222", undefined]) { + const { applied, post } = harness(); + const response = await post({ signedTransaction: await signJws(chain, transactionPayload({ appAccountToken: token })) }); + assertEquals(response.status, 403); + assertEquals((await response.json()).code, "account_mismatch"); + assertEquals(applied.length, 0); + } +}); + +Deno.test("an unverifiable purchase is refused before anything is applied", async () => { + const other = await createTestChain(); + const cases: Array<[string, number, string]> = [ + [await signJws(other, transactionPayload({ appAccountToken: TEST_USER.id })), 400, "invalid_chain"], + [await ownTransaction({ bundleId: "com.example.other" }), 400, "wrong_app"], + [await ownTransaction({ environment: "Sandbox" }), 400, "wrong_environment"], + [await ownTransaction({ type: "Non-Consumable" }), 422, "unsupported_product"], + ["not-a-jws", 400, "malformed"], + ]; + for (const [signedTransaction, status, code] of cases) { + const { applied, post } = harness(); + const response = await post({ signedTransaction }); + assertEquals([response.status, (await response.json()).code], [status, code], code); + assertEquals(applied.length, 0, code); + } +}); + +Deno.test("renewal info for another subscription is refused", async () => { + const { applied, post } = harness(); + const response = await post({ + signedTransaction: await ownTransaction(), + signedRenewalInfo: await signJws(chain, renewalPayload({ originalTransactionId: "999" })), + }); + assertEquals(response.status, 400); + assertEquals(applied.length, 0); +}); + +Deno.test("a replayed purchase changes nothing and still returns the current plan", async () => { + const { post } = harness("stale"); + const response = await post({ signedTransaction: await ownTransaction() }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { result: "stale", entitlement: PRO }); +}); + +Deno.test("an unknown product or a subscription owned by another user is refused", async () => { + for (const [result, status, code] of [["unknown_product", 422, "unknown_product"], ["user_mismatch", 409, "account_mismatch"]] as const) { + const { post } = harness(result); + const response = await post({ signedTransaction: await ownTransaction() }); + assertEquals([response.status, (await response.json()).code], [status, code]); + } +}); + +Deno.test("purchases wait for configuration instead of guessing it", async () => { + const { AppStoreConfigError } = await import("../_shared/app-store.ts"); + const { applied, post } = harness("applied", { + config: () => { + throw new AppStoreConfigError("APP_STORE_BUNDLE_ID is not set"); + }, + }); + const response = await post({ signedTransaction: await ownTransaction() }); + assertEquals(response.status, 503); + assertEquals(applied.length, 0); +}); + +Deno.test("a failed write is reported so the app can retry", async () => { + const { post } = harness("applied", { apply: () => Promise.reject(new HttpError(500, "db down")) }); + const response = await post({ signedTransaction: await ownTransaction() }); + assertEquals(response.status, 500); + assertEquals((await response.json()).code, "storage_failed"); +}); From cb325bdf77df1c892f9c9064edc0b00338fe8698 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:53:46 +0000 Subject: [PATCH 05/12] Read the plan from effective_entitlement on the website and in the app useSubscription, the settings store's Pro-model check, and the app's plan, default-model and feature-config reads all call effective_entitlement through readEntitlement in @debatelab/core, so an App Store subscription unlocks the website too. pro-access.ts, which recomputed Pro access from user_settings and Stripe rows, is gone. Basic gets its label in en, es and hu. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/(tabs)/(you)/you.tsx | 1 + apps/mobile/src/lib/feature-config.ts | 16 +-- apps/mobile/src/lib/settings.tsx | 20 +-- apps/mobile/src/lib/subscription.ts | 73 +++-------- e2e/navigation-performance.spec.ts | 17 +-- packages/core/src/ai/feature-configs.ts | 4 +- packages/core/src/ai/pro-access.ts | 32 ----- packages/core/src/supabase/entitlement.ts | 47 ++++++++ src/hooks/use-subscription.ts | 114 +++++------------- src/lib/stores/settings-store.ts | 19 ++- src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/hu.json | 1 + src/pages/SettingsPage.tsx | 1 + .../settings/mobile-saved-settings.test.ts | 21 ++-- 15 files changed, 133 insertions(+), 235 deletions(-) delete mode 100644 packages/core/src/ai/pro-access.ts create mode 100644 packages/core/src/supabase/entitlement.ts diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index 27d4238c4..cd8d15ce9 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -20,6 +20,7 @@ import { useTheme } from '@/theme/use-theme'; const TIER_LABELS: Record = { trial: 'settings.tierTrial', + basic: 'settings.tierBasic', pro: 'settings.tierPro', legacy_free: 'settings.tierLegacyFree', expired: 'settings.tierExpired', diff --git a/apps/mobile/src/lib/feature-config.ts b/apps/mobile/src/lib/feature-config.ts index ad3bbb2b5..5f0832a07 100644 --- a/apps/mobile/src/lib/feature-config.ts +++ b/apps/mobile/src/lib/feature-config.ts @@ -1,5 +1,5 @@ import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; -import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { readEntitlement } from '@debatelab/core/supabase/entitlement'; import { AUTO_FAST, AUTO_PRO, @@ -76,19 +76,13 @@ function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise { const { data, error } = await supabase .from('user_settings') - .select('feature_configs, subscription_tier, grace_period_ends_at') + .select('feature_configs') .eq('user_id', userId) .maybeSingle(); if (error || !data) return defaultConfig(feature); - // Null when it cannot be told, as on the website, which then keeps its defaults. - const allowProModels = await canAccessProModels(data, async () => { - const { data: rows, error: billingError } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId); - return billingError ? null : rows ?? []; - }); - if (allowProModels === null) return defaultConfig(feature); + // Unknown when the plan cannot be read, as on the website, which then keeps its defaults. + const allowProModels = (await readEntitlement(supabase, userId))?.canAccessProModels; + if (allowProModels === undefined) return defaultConfig(feature); const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); return normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels); } diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index eb91586b3..93ec9de3d 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -1,5 +1,5 @@ import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; -import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { readEntitlement } from '@debatelab/core/supabase/entitlement'; import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -70,21 +70,9 @@ function isEmptyObject(value: unknown): boolean { * their Pro access can't be read right now. */ async function defaultModelFor(userId: string): Promise { - const { data, error } = await supabase - .from('user_settings') - .select('subscription_tier,grace_period_ends_at') - .eq('user_id', userId) - .maybeSingle(); - if (error || !data) return null; - const pro = await canAccessProModels(data, async () => { - const { data: rows, error: billingError } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId); - return billingError ? null : rows ?? []; - }); - if (pro === null) return null; - return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; + const entitlement = await readEntitlement(supabase, userId); + if (!entitlement) return null; + return entitlement.canAccessProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; } /** diff --git a/apps/mobile/src/lib/subscription.ts b/apps/mobile/src/lib/subscription.ts index 886d4b6ae..1ed99e14a 100644 --- a/apps/mobile/src/lib/subscription.ts +++ b/apps/mobile/src/lib/subscription.ts @@ -1,11 +1,13 @@ +import { readEntitlement, type SubscriptionTier } from '@debatelab/core/supabase/entitlement'; + import { supabase } from './supabase'; /** - * The plan and today's AI usage, computed exactly as the website's - * useSubscription (src/hooks/use-subscription.ts) does. That hook is not - * shared yet because it changes with in-app purchase. + * The plan and today's AI usage, read the way the website's useSubscription + * (src/hooks/use-subscription.ts) reads them: the plan from + * effective_entitlement, which also counts App Store subscriptions. */ -export type SubscriptionTier = 'trial' | 'pro' | 'legacy_free' | 'expired'; +export type { SubscriptionTier }; export interface Subscription { tier: SubscriptionTier; @@ -14,61 +16,9 @@ export interface Subscription { trialEndsAt: string | null; } -interface BillingRow { - status?: string | null; - current_period_end?: string | null; -} - export async function fetchSubscription(userId: string): Promise { - const snapshot: Subscription = { tier: 'expired', dailyLimit: 0, dailyUsed: 0, trialEndsAt: null }; - - const { data, error: settingsError } = await supabase - .from('user_settings') - .select('subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at') - .eq('user_id', userId) - .maybeSingle(); - if (settingsError) throw new Error(settingsError.message); - - if (data) { - const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier; - const limitOrDefault = (defaultLimit: number) => (data.daily_limit_override as number | null) ?? defaultLimit; - const now = new Date(); - - const { data: billingData, error: billingError } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId) - .order('current_period_end', { ascending: false, nullsFirst: false }) - .limit(5); - if (billingError) console.warn('Could not load billing state:', billingError.message); - const billingRows: BillingRow[] = billingError ? [] : billingData ?? []; - - const activeStripeSubscription = billingRows.some((row) => - (row.status === 'active' || row.status === 'trialing') && - row.current_period_end && - new Date(row.current_period_end) > now, - ); - const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at as string) : null; - const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at as string) : null; - const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd); - snapshot.trialEndsAt = (data.trial_ends_at as string | null) ?? null; - - if (rawTier === 'legacy_free') { - snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired'; - snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0); - } else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) { - snapshot.tier = 'pro'; - snapshot.dailyLimit = limitOrDefault(50); - } else if (rawTier === 'trial' && trialEnd && now < trialEnd) { - snapshot.tier = 'trial'; - snapshot.dailyLimit = limitOrDefault(5); - } else if (hasActiveTrialGift) { - snapshot.tier = 'trial'; - snapshot.dailyLimit = limitOrDefault(5); - } else { - snapshot.dailyLimit = limitOrDefault(0); - } - } + const entitlement = await readEntitlement(supabase, userId); + if (!entitlement) throw new Error('Could not load the plan'); // Usage resets at midnight UTC. const todayStart = new Date(); @@ -80,5 +30,10 @@ export async function fetchSubscription(userId: string): Promise { .gte('created_at', todayStart.toISOString()); if (usageError) console.warn('Could not load daily usage:', usageError.message); - return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 }; + return { + tier: entitlement.tier, + dailyLimit: entitlement.dailyLimit, + dailyUsed: usageError ? 0 : count || 0, + trialEndsAt: entitlement.trialEndsAt, + }; } diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index c421e266e..f6f291100 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -366,13 +366,14 @@ test('subscription reads stay cached across sibling tool routes', async ({ page await route.fulfill({ json: { announcements: [] } }); return; } + if (url.pathname.endsWith('/rest/v1/rpc/effective_entitlement')) { + // The plan; the settings store reads it too, to pick default models. + subscriptionRequests.push('effective_entitlement'); + await route.fulfill({ json: [{ tier: 'trial', daily_limit: 5, can_access_pro_models: false }] }); + return; + } if (url.pathname.includes('/rest/v1/')) { - const select = url.searchParams.get('select') ?? ''; - if (url.pathname.endsWith('/user_settings') && select.includes('subscription_tier')) { - subscriptionRequests.push('user_settings'); - } else if (url.pathname.endsWith('/subscriptions')) { - subscriptionRequests.push('subscriptions'); - } else if (url.pathname.endsWith('/ai_requests')) { + if (url.pathname.endsWith('/ai_requests')) { subscriptionRequests.push('ai_requests'); } await route.fulfill({ @@ -389,6 +390,7 @@ test('subscription reads stay cached across sibling tool routes', async ({ page await page.goto('/app/generate'); await waitForContent(page, { selector: 'main h1', text: 'Generate' }); await expect.poll(() => subscriptionRequests.filter((name) => name === 'ai_requests').length).toBe(1); + const readsBeforeNavigation = [...subscriptionRequests]; await openHome(page); await homeToolLink(page, 'attack').click(); @@ -396,5 +398,6 @@ test('subscription reads stay cached across sibling tool routes', async ({ page // eslint-disable-next-line no-restricted-properties -- observation window: no extra subscription request may follow. await page.waitForTimeout(100); - expect(subscriptionRequests).toEqual(['user_settings', 'ai_requests']); + expect(readsBeforeNavigation).toContain('effective_entitlement'); + expect(subscriptionRequests).toEqual(readsBeforeNavigation); }); diff --git a/packages/core/src/ai/feature-configs.ts b/packages/core/src/ai/feature-configs.ts index d5ba62f41..f321d804c 100644 --- a/packages/core/src/ai/feature-configs.ts +++ b/packages/core/src/ai/feature-configs.ts @@ -14,8 +14,8 @@ const defaultConfig: ProviderConfig = { * The model settings each tool starts with. The website saves these in * user_settings.feature_configs, and the app writes them too when it saves the * first setting, because the website reads an empty feature_configs as "never - * saved". Both pass DEFAULT_PRO_MODEL when canAccessProModels (pro-access.ts) - * says the student has Pro access. + * saved". Both pass DEFAULT_PRO_MODEL when the plan (supabase/entitlement.ts) + * includes Pro models. */ export const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ generate: { ...defaultConfig, model }, diff --git a/packages/core/src/ai/pro-access.ts b/packages/core/src/ai/pro-access.ts deleted file mode 100644 index 0b954865c..000000000 --- a/packages/core/src/ai/pro-access.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface BillingPeriod { - status?: string | null; - current_period_end?: string | null; -} - -/** - * Whether the student may use Pro models, from their user_settings row: a Pro - * or legacy tier in its grace period, or a Pro tier with an active or trialing - * subscription that has not ended. The website's settings store and the app - * both pick the starting model with it. loadSubscriptions runs only for a Pro - * tier outside its grace period; when it returns null, so does this. - */ -export async function canAccessProModels( - settings: { subscription_tier?: unknown; grace_period_ends_at?: unknown }, - loadSubscriptions: () => Promise, - now: Date = new Date(), -): Promise { - const tier = settings.subscription_tier; - const graceEnd = typeof settings.grace_period_ends_at === 'string' - ? new Date(settings.grace_period_ends_at) - : null; - if ((tier === 'pro' || tier === 'legacy_free') && graceEnd && graceEnd > now) return true; - if (tier !== 'pro') return false; - - const subscriptions = await loadSubscriptions(); - if (!subscriptions) return null; - return subscriptions.some((subscription) => - (subscription.status === 'active' || subscription.status === 'trialing') - && !!subscription.current_period_end - && new Date(subscription.current_period_end) > now - ); -} diff --git a/packages/core/src/supabase/entitlement.ts b/packages/core/src/supabase/entitlement.ts new file mode 100644 index 000000000..ba1321865 --- /dev/null +++ b/packages/core/src/supabase/entitlement.ts @@ -0,0 +1,47 @@ +/** + * The student's plan, read from effective_entitlement() (migration + * 20261014123154_app_store_entitlements.sql). It merges Stripe, App Store, + * promo, trial and legacy access into the highest active tier, so the + * website, the app and the Edge Functions agree on one answer. + */ +export type SubscriptionTier = 'trial' | 'basic' | 'pro' | 'legacy_free' | 'expired'; + +export interface Entitlement { + tier: SubscriptionTier; + dailyLimit: number; + canAccessProModels: boolean; + /** stripe, app_store, promo, legacy or trial; null when nothing is active. */ + source: string | null; + expiresAt: string | null; + trialEndsAt: string | null; + gracePeriodEndsAt: string | null; + hasStripeBilling: boolean; + /** A live App Store subscription, which only Apple's settings can cancel. */ + hasAppStoreSubscription: boolean; +} + +interface RpcClient { + rpc(fn: string, args: Record): PromiseLike<{ data: unknown; error: { message: string } | null }>; +} + +const TIERS: readonly string[] = ['trial', 'basic', 'pro', 'legacy_free', 'expired']; +const textOrNull = (value: unknown) => (typeof value === 'string' ? value : null); + +/** The plan, or null when it can't be read right now. */ +export async function readEntitlement(client: RpcClient, userId: string): Promise { + const { data, error } = await client.rpc('effective_entitlement', { p_user_id: userId }); + if (error) return null; + const row = (Array.isArray(data) ? data[0] : data) as Record | null | undefined; + if (!row || typeof row.tier !== 'string' || !TIERS.includes(row.tier) || typeof row.daily_limit !== 'number') return null; + return { + tier: row.tier as SubscriptionTier, + dailyLimit: row.daily_limit, + canAccessProModels: row.can_access_pro_models === true, + source: textOrNull(row.source), + expiresAt: textOrNull(row.expires_at), + trialEndsAt: textOrNull(row.trial_ends_at), + gracePeriodEndsAt: textOrNull(row.grace_period_ends_at), + hasStripeBilling: row.has_stripe_billing === true, + hasAppStoreSubscription: row.has_app_store_subscription === true, + }; +} diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index 4a599dee2..a39bacd06 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/lib/supabase/client'; import { useAuth } from '@/lib/supabase/AuthProvider'; +import { readEntitlement, type SubscriptionTier } from '@debatelab/core/supabase/entitlement'; -export type SubscriptionTier = 'trial' | 'pro' | 'legacy_free' | 'expired'; +export type { SubscriptionTier }; interface SubscriptionState { tier: SubscriptionTier; @@ -18,12 +19,6 @@ interface SubscriptionState { refresh: () => Promise; } -interface BillingRow { - stripe_customer_id?: string | null; - status?: string | null; - current_period_end?: string | null; -} - type SubscriptionSnapshot = Omit; const SUBSCRIPTION_STALE_TIME_MS = 60_000; @@ -45,88 +40,33 @@ const SIGNED_IN_FALLBACK: SubscriptionSnapshot = { }; async function fetchSubscription(userId: string): Promise { - let snapshot = { ...SIGNED_IN_FALLBACK }; - - try { - const { data, error: settingsError } = await supabase - .from('user_settings') - .select('subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at') - .eq('user_id', userId) - .maybeSingle(); - - if (settingsError) { - console.warn('[useSubscription] Failed to load user settings:', settingsError.message); - throw new Error(settingsError.message); - } - - if (data) { - const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier; - const limitOrDefault = (defaultLimit: number) => data.daily_limit_override ?? defaultLimit; - const now = new Date(); - let billingRows: BillingRow[] = []; - - const { data: billingData, error: billingError } = await supabase - .from('subscriptions') - .select('stripe_customer_id, status, current_period_end') - .eq('user_id', userId) - .order('current_period_end', { ascending: false, nullsFirst: false }) - .limit(5); - - if (billingError) { - console.warn('[useSubscription] Failed to load Stripe billing state:', billingError.message); - } else { - billingRows = billingData || []; - } - - const activeStripeSubscription = billingRows.some((row) => - (row.status === 'active' || row.status === 'trialing') && - row.current_period_end && - new Date(row.current_period_end) > now - ); - const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at) : null; - const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at) : null; - const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd); - - snapshot = { - ...snapshot, - hasStripeBilling: billingRows.some((row) => Boolean(row.stripe_customer_id)), - trialEndsAt: data.trial_ends_at, - gracePeriodEndsAt: data.grace_period_ends_at, - }; - - if (rawTier === 'legacy_free') { - snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired'; - snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0); - } else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) { - snapshot.tier = 'pro'; - snapshot.dailyLimit = limitOrDefault(50); - } else if (rawTier === 'trial' && trialEnd && now < trialEnd) { - snapshot.tier = 'trial'; - snapshot.dailyLimit = limitOrDefault(5); - } else if (hasActiveTrialGift) { - snapshot.tier = 'trial'; - snapshot.dailyLimit = limitOrDefault(5); - } else { - snapshot.dailyLimit = limitOrDefault(0); - } - } - - const todayStart = new Date(); - todayStart.setUTCHours(0, 0, 0, 0); - const { count, error: usageError } = await supabase - .from('ai_requests') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('created_at', todayStart.toISOString()); - if (usageError) { - console.warn('[useSubscription] Failed to load daily usage:', usageError.message); - } + // The plan merges Stripe, App Store, promo and trial access on the server, + // the same answer the AI functions enforce. + const entitlement = await readEntitlement(supabase, userId); + if (!entitlement) { + console.warn('[useSubscription] Failed to load the plan'); + throw new Error('Could not load the plan'); + } - return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 }; - } catch (error) { - console.warn('[useSubscription] Failed to load:', error); - throw error; + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const { count, error: usageError } = await supabase + .from('ai_requests') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .gte('created_at', todayStart.toISOString()); + if (usageError) { + console.warn('[useSubscription] Failed to load daily usage:', usageError.message); } + + return { + tier: entitlement.tier, + hasStripeBilling: entitlement.hasStripeBilling, + dailyLimit: entitlement.dailyLimit, + dailyUsed: usageError ? 0 : count || 0, + trialEndsAt: entitlement.trialEndsAt, + gracePeriodEndsAt: entitlement.gracePeriodEndsAt, + }; } export function useSubscription(): SubscriptionState { diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 2c2a6cd39..ff9da391b 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware'; import { applyAppLanguage } from '@/i18n'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; -import { canAccessProModels as canAccessProModelsFor } from '@debatelab/core/ai/pro-access'; +import { readEntitlement } from '@debatelab/core/supabase/entitlement'; import { AUTO_FAST, AUTO_PRO, @@ -128,16 +128,11 @@ function featureConfigsAreCanonical(value: unknown, expected: Record): Promise { - return canAccessProModelsFor(settings, async () => { - const userId = await getUserId(); - if (!userId) return null; - const { data, error } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId); - return error ? null : data ?? []; - }); +/** Whether the plan includes Pro models, or null when it can't be read. */ +async function canAccessProModels(): Promise { + const userId = await getUserId(); + if (!userId) return null; + return (await readEntitlement(supabase, userId))?.canAccessProModels ?? null; } async function getUserId() { @@ -265,7 +260,7 @@ export const useSettingsStore = create()( .single(); if (error || !data) return; - const allowProModels = await canAccessProModels(data); + const allowProModels = await canAccessProModels(); if (allowProModels === null) return; const mergedConfigs = normalizeFeatureConfigs( data.feature_configs, diff --git a/src/locales/en.json b/src/locales/en.json index b995e085e..3530f9c94 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -441,6 +441,7 @@ "system": "System", "subscription": "Subscription", "tierTrial": "Free Trial", + "tierBasic": "Basic", "tierPro": "Pro", "tierLegacyFree": "Legacy Free", "tierExpired": "Expired", diff --git a/src/locales/es.json b/src/locales/es.json index 99eb6035b..3b275463e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -441,6 +441,7 @@ "system": "Sistema", "subscription": "Suscripción", "tierTrial": "Prueba gratis", + "tierBasic": "Básico", "tierPro": "Pro", "tierLegacyFree": "Gratis heredado", "tierExpired": "Caducado", diff --git a/src/locales/hu.json b/src/locales/hu.json index aa83b303b..5c4a4152c 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -441,6 +441,7 @@ "system": "Rendszer", "subscription": "Előfizetés", "tierTrial": "Ingyenes próba", + "tierBasic": "Alap", "tierPro": "Pro", "tierLegacyFree": "Korábbi ingyenes", "tierExpired": "Lejárt", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e020fbb15..3a97cb672 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -36,6 +36,7 @@ import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; const tierLabels: Record = { trial: { labelKey: 'settings.tierTrial', color: 'bg-blue-500' }, + basic: { labelKey: 'settings.tierBasic', color: 'bg-teal-500' }, pro: { labelKey: 'settings.tierPro', color: 'bg-amber-500' }, legacy_free: { labelKey: 'settings.tierLegacyFree', color: 'bg-green-500' }, expired: { labelKey: 'settings.tierExpired', color: 'bg-red-500' }, diff --git a/src/test/settings/mobile-saved-settings.test.ts b/src/test/settings/mobile-saved-settings.test.ts index 2b23812d1..d8bab437c 100644 --- a/src/test/settings/mobile-saved-settings.test.ts +++ b/src/test/settings/mobile-saved-settings.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; -import { canAccessProModels } from '@debatelab/core/ai/pro-access'; import { DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { resetMockState, setMockTableResponse, supabaseMock } from '@/test/mocks/supabase'; @@ -8,6 +7,12 @@ vi.mock('@/lib/supabase/client', async () => ({ supabase: (await import('@/test/ const { useSettingsStore } = await import('@/lib/stores/settings-store'); +/** effective_entitlement's answer for a plan with or without Pro models. */ +const plan = (pro: boolean) => ({ + data: [{ tier: pro ? 'pro' : 'trial', daily_limit: pro ? 50 : 5, can_access_pro_models: pro }], + error: null as null, +}); + /** Every update the store sent to Supabase. */ const updates = () => supabaseMock.from.mock.results.flatMap((result) => result.value.update.mock.calls); @@ -17,6 +22,7 @@ const updates = () => supabaseMock.from.mock.results.flatMap((result) => result. describe('settings saved by the iOS app', () => { beforeEach(() => { resetMockState(); + supabaseMock.rpc.mockResolvedValue(plan(false)); useSettingsStore.setState({ language: 'en', outputLanguage: 'en', theme: 'light' }); }); @@ -26,7 +32,6 @@ describe('settings saved by the iOS app', () => { language: 'hu', output_language: 'es', theme: 'dark', - subscription_tier: 'trial', feature_configs: getDefaultFeatureConfigs(), }, }); @@ -39,24 +44,22 @@ describe('settings saved by the iOS app', () => { }); it('keeps the Pro model the app saved for a Pro student, without rewriting the row', async () => { - const subscriptions = [{ status: 'active', current_period_end: new Date(Date.now() + 86_400_000).toISOString() }]; - const row = { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'pro' }; - // The app picks its defaults with the same check the website hydrates with. - const pro = await canAccessProModels(row, async () => subscriptions); - setMockTableResponse('subscriptions', { select: subscriptions }); + // The app picks its defaults from the same plan the website hydrates with. + supabaseMock.rpc.mockResolvedValue(plan(true)); setMockTableResponse('user_settings', { - single: { ...row, feature_configs: getDefaultFeatureConfigs(pro ? DEFAULT_PRO_MODEL : undefined) }, + single: { language: 'hu', output_language: 'es', theme: 'dark', feature_configs: getDefaultFeatureConfigs(DEFAULT_PRO_MODEL) }, }); await useSettingsStore.getState().hydrateFromServer(); + expect(supabaseMock.rpc).toHaveBeenCalledWith('effective_entitlement', { p_user_id: 'user_1' }); expect(useSettingsStore.getState().featureConfigs.generate.model).toBe(DEFAULT_PRO_MODEL); expect(updates()).toEqual([]); }); it('replaces them when feature_configs was left empty', async () => { setMockTableResponse('user_settings', { - single: { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'trial', feature_configs: {} }, + single: { language: 'hu', output_language: 'es', theme: 'dark', feature_configs: {} }, }); await useSettingsStore.getState().hydrateFromServer(); From 56f585826862fcc5c251c38d76f2ba36f7f7f851 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:55:51 +0000 Subject: [PATCH 06/12] Order App Store updates by purchase date; flag them in account deletion A yearly-to-monthly upgrade is charged later but expires sooner, so apply_app_store_transaction now orders transactions by purchaseDate, not expiry. account_deletion_plan reports whether Apple may still bill the user (not revoked, auto-renew not off, ended under 60 days ago), and the account function returns it so the app can send the user to Apple's subscription settings. Both changes came from an automated security review of this branch. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/account/index.ts | 15 ++++- supabase/functions/account/index_test.ts | 12 +++- .../20261014123154_app_store_entitlements.sql | 66 ++++++++++++++++--- supabase/tests/account_deletion_test.sql | 5 +- .../tests/app_store_entitlements_test.sql | 47 ++++++++++++- 5 files changed, 130 insertions(+), 15 deletions(-) diff --git a/supabase/functions/account/index.ts b/supabase/functions/account/index.ts index 5b416fc30..629cf8950 100644 --- a/supabase/functions/account/index.ts +++ b/supabase/functions/account/index.ts @@ -17,7 +17,12 @@ import { getCorsHeaders } from "../_shared/cors.ts"; export type AccountUser = { id: string; email?: string | null }; export type StorageObject = { bucket_id: string; name: string }; -export type DeletionPlan = { stripe_customer_ids: string[]; storage_objects: StorageObject[] }; +export type DeletionPlan = { + stripe_customer_ids: string[]; + storage_objects: StorageObject[]; + /** Apple may still bill an App Store subscription; only the user can cancel it. */ + app_store_subscription_active: boolean; +}; export type AccountDeps = { authenticate: (req: Request) => Promise; @@ -134,7 +139,13 @@ export function createHandler(deps: AccountDeps) { await deps.prepareDeletion(user.id); await deps.deleteAuthUser(user.id); console.log(`[ACCOUNT] Deleted account ${user.id}`); - return Response.json({ deleted: true, canceledSubscriptions }, { headers: corsHeaders }); + // The server cannot cancel an App Store subscription, so the app sends + // the user to Apple's subscription settings when this is true. + return Response.json({ + deleted: true, + canceledSubscriptions, + appStoreSubscriptionActive: plan.app_store_subscription_active === true, + }, { headers: corsHeaders }); } throw new HttpError(400, 'action must be "export" or "delete"'); diff --git a/supabase/functions/account/index_test.ts b/supabase/functions/account/index_test.ts index dd9c7ca7a..8d1f540be 100644 --- a/supabase/functions/account/index_test.ts +++ b/supabase/functions/account/index_test.ts @@ -26,6 +26,7 @@ function harness(overrides: Partial = {}) { return Promise.resolve({ stripe_customer_ids: ["cus_1"], storage_objects: [{ bucket_id: "files", name: `${userId}/a.pdf` }], + app_store_subscription_active: false, }); }, stopBilling: (userId, customerIds) => { @@ -67,7 +68,7 @@ Deno.test("delete removes only the caller's account, after billing and files", a const { calls, post } = harness(); const response = await post({ action: "delete", confirmation: caller.email, userId: otherUserId }); assertEquals(response.status, 200); - assertEquals(await response.json(), { deleted: true, canceledSubscriptions: 1 }); + assertEquals(await response.json(), { deleted: true, canceledSubscriptions: 1, appStoreSubscriptionActive: false }); assertEquals(calls, [ `plan:${caller.id}`, `billing:${caller.id}:cus_1`, @@ -77,6 +78,15 @@ Deno.test("delete removes only the caller's account, after billing and files", a ]); }); +Deno.test("delete tells the app when an App Store subscription will keep billing", async () => { + const { post } = harness({ + deletionPlan: () => Promise.resolve({ stripe_customer_ids: [], storage_objects: [], app_store_subscription_active: true }), + }); + const response = await post({ action: "delete", confirmation: caller.email }); + assertEquals(response.status, 200); + assertEquals((await response.json()).appStoreSubscriptionActive, true); +}); + Deno.test("delete without the typed confirmation deletes nothing", async () => { for (const confirmation of [undefined, "", "STUDENT@example.test", "DELETE"]) { const { calls, post } = harness(); diff --git a/supabase/migrations/20261014123154_app_store_entitlements.sql b/supabase/migrations/20261014123154_app_store_entitlements.sql index 4591495fd..816710051 100644 --- a/supabase/migrations/20261014123154_app_store_entitlements.sql +++ b/supabase/migrations/20261014123154_app_store_entitlements.sql @@ -235,11 +235,13 @@ GRANT EXECUTE ON FUNCTION public.effective_entitlement(uuid, timestamptz) TO aut -- to a user and must match an existing row's user; data for a linked row may -- come without it. -- --- Ordering: a transaction replaces the stored one when it expires later (a --- renewal, upgrade or new period), or when it is the same transaction signed --- later (a refund, revocation or its reversal). A refund of an older --- period therefore leaves the current period alone, and an exact replay --- changes nothing. Renewal info replaces the stored renewal info when it was +-- Ordering: a transaction replaces the stored one when Apple charged it later +-- (purchaseDate: a renewal, upgrade, downgrade or resubscription), or when it +-- is the same transaction signed later (a refund, revocation, its reversal or +-- a renewal-date extension). Expiry cannot order them: an upgrade from a +-- yearly to a monthly plan expires sooner. A refund of an older period +-- therefore leaves the current period alone, and an exact replay changes +-- nothing. Renewal info replaces the stored renewal info when it was -- signed later. -- -- Returns applied, stale (nothing newer), duplicate (notification seen), @@ -258,6 +260,7 @@ SET search_path = '' AS $$ DECLARE v_original_id text := p_transaction->>'originalTransactionId'; + v_purchased timestamptz := (p_transaction->>'purchaseDate')::timestamptz; v_expires timestamptz := (p_transaction->>'expiresDate')::timestamptz; v_signed timestamptz := (p_transaction->>'signedDate')::timestamptz; v_renewal_signed timestamptz := (p_renewal->>'signedDate')::timestamptz; @@ -308,7 +311,7 @@ BEGIN p_transaction->>'productId', p_transaction->>'transactionId', p_transaction->>'environment', - (p_transaction->>'purchaseDate')::timestamptz, + v_purchased, v_expires, (p_transaction->>'revocationDate')::timestamptz, (p_transaction->>'revocationReason')::integer, @@ -341,7 +344,7 @@ BEGIN product_id = p_transaction->>'productId', transaction_id = p_transaction->>'transactionId', environment = p_transaction->>'environment', - purchased_at = (p_transaction->>'purchaseDate')::timestamptz, + purchased_at = v_purchased, expires_at = v_expires, revoked_at = (p_transaction->>'revocationDate')::timestamptz, revocation_reason = (p_transaction->>'revocationReason')::integer, @@ -350,7 +353,7 @@ BEGIN updated_at = now() WHERE original_transaction_id = v_original_id AND ( - v_expires > v_row.expires_at + v_purchased > v_row.purchased_at OR (p_transaction->>'transactionId' = v_row.transaction_id AND v_signed > v_row.transaction_signed_at) ); v_changed := FOUND; @@ -381,3 +384,50 @@ $$; REVOKE ALL ON FUNCTION public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.apply_app_store_transaction(uuid, jsonb, jsonb, jsonb) TO service_role; + +-- ============================================================ +-- ACCOUNT DELETION +-- ============================================================ + +-- The server cannot cancel an App Store subscription; only the customer can, +-- in Apple's subscription settings. The plan now also says whether Apple may +-- still bill the user: a subscription that is not revoked, has not turned off +-- auto-renew, and ended less than 60 days ago (Apple's longest billing retry) +-- or not at all. The account function passes that on so the app can send the +-- user to Apple's settings. The rest of the plan is unchanged from +-- 20261001100000_account_deletion_and_export.sql, and CREATE OR REPLACE keeps +-- its service-role-only grants. +CREATE OR REPLACE FUNCTION public.account_deletion_plan(p_user_id uuid) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT jsonb_build_object( + 'stripe_customer_ids', ( + SELECT coalesce(jsonb_agg(DISTINCT customer.id ORDER BY customer.id), '[]'::jsonb) + FROM ( + SELECT subscription.stripe_customer_id AS id + FROM public.subscriptions AS subscription + WHERE subscription.user_id = p_user_id AND subscription.stripe_customer_id IS NOT NULL + UNION + SELECT recorded.customer_id FROM public.stripe_customers AS recorded WHERE recorded.user_id = p_user_id + ) AS customer + ), + 'storage_objects', ( + SELECT coalesce(jsonb_agg(jsonb_build_object('bucket_id', object.bucket_id, 'name', object.name)), '[]'::jsonb) + FROM storage.objects AS object + -- Storage sets the owner from the uploader's session. source_files.storage_key + -- is user-writable, so it must never select objects for deletion. + WHERE object.owner = p_user_id OR object.owner_id = p_user_id::text + ), + 'app_store_subscription_active', EXISTS ( + SELECT 1 FROM public.app_store_subscriptions AS store + WHERE store.user_id = p_user_id + AND store.revoked_at IS NULL + AND store.auto_renew IS DISTINCT FROM false + AND greatest(store.expires_at, coalesce(store.grace_period_expires_at, store.expires_at)) > now() - interval '60 days' + ) + ); +$$; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 2aabd2561..09440456d 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -266,9 +266,10 @@ SELECT is( 'stripe_customer_ids', jsonb_build_array('cus_leaver', 'cus_leaver_checkout'), 'storage_objects', jsonb_build_array( jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/notes.txt') - ) + ), + 'app_store_subscription_active', true ), - 'the plan lists every Stripe customer and only Storage objects the leaver uploaded, not one a source_files key names' + 'the plan lists every Stripe customer and only Storage objects the leaver uploaded, not one a source_files key names, and flags the App Store subscription Apple keeps billing' ); RESET ROLE; diff --git a/supabase/tests/app_store_entitlements_test.sql b/supabase/tests/app_store_entitlements_test.sql index 0d00ce58c..b6fc619ca 100644 --- a/supabase/tests/app_store_entitlements_test.sql +++ b/supabase/tests/app_store_entitlements_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(73); +SELECT plan(79); -- Returns how many rows a statement changed; a privilege or RLS rejection changes none. CREATE FUNCTION pg_temp.rows_changed(statement text) @@ -70,7 +70,7 @@ RETURNS jsonb LANGUAGE sql AS $$ )) $$; -INSERT INTO auth.users (id) SELECT ('e0000000-0000-4000-8000-0000000000' || lpad(n::text, 2, '0'))::uuid FROM generate_series(1, 40) AS n; +INSERT INTO auth.users (id) SELECT ('e0000000-0000-4000-8000-0000000000' || lpad(n::text, 2, '0'))::uuid FROM generate_series(1, 41) AS n; -- ============================================================ -- Privileges @@ -355,6 +355,49 @@ SELECT results_eq( 'so the grace period survives it' ); +-- An upgrade from a yearly to a monthly plan: Apple charged it later, but it +-- expires sooner, so purchaseDate (not expiry) must order the two. +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000033', 'expired', NULL, NULL); +SELECT is( + public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000033', + pg_temp.tx('o6', 't1', 'basic', now() + interval '300 days', now() - interval '2 hours') + || jsonb_build_object('purchaseDate', now() - interval '65 days')), + 'applied', 'a yearly Basic purchase is applied'); +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o6', 't2', 'pro', now() + interval '30 days', now() - interval '1 hour') + || jsonb_build_object('purchaseDate', now())), + 'applied', 'an upgrade charged later replaces it although it expires sooner'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000033'), 'pro/50/true/app_store', 'so the upgrade grants Pro'); +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o6', 't1', 'basic', now() + interval '300 days', now() - interval '30 minutes') + || jsonb_build_object('purchaseDate', now() - interval '65 days')), + 'stale', 'a late copy of the yearly transaction cannot undo the upgrade'); + +-- ============================================================ +-- Account deletion: whether Apple may still bill the user +-- ============================================================ + +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000037', 'pro', now() + interval '10 days'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000038', 'pro', now() + interval '10 days'); +UPDATE public.app_store_subscriptions SET auto_renew = false WHERE user_id = 'e0000000-0000-4000-8000-000000000038'; +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000039', 'pro', now() + interval '10 days', NULL, now() - interval '1 hour'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000040', 'pro', now() - interval '61 days'); +SELECT pg_temp.store('e0000000-0000-4000-8000-000000000041', 'basic', now() - interval '30 days'); +UPDATE public.app_store_subscriptions SET auto_renew = true WHERE user_id = 'e0000000-0000-4000-8000-000000000041'; +SELECT results_eq( + $$SELECT (public.account_deletion_plan(id) ->> 'app_store_subscription_active')::boolean FROM (VALUES + ('e0000000-0000-4000-8000-000000000037'::uuid), ('e0000000-0000-4000-8000-000000000038'::uuid), + ('e0000000-0000-4000-8000-000000000039'::uuid), ('e0000000-0000-4000-8000-000000000040'::uuid), + ('e0000000-0000-4000-8000-000000000009'::uuid)) AS users(id)$$, + $$VALUES (true), (false), (false), (false), (false)$$, + 'deletion flags a renewing App Store subscription, not one with auto-renew off, revoked, long ended, or absent' +); +SELECT is( + (public.account_deletion_plan('e0000000-0000-4000-8000-000000000041') ->> 'app_store_subscription_active')::boolean, + true, 'a lapsed subscription Apple may still be retrying is flagged'); + -- ============================================================ -- Isolation: users read only their own entitlements -- ============================================================ From 3663e4c37d0e9adcfdaf43fc5652b3c7c28a1ee5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:56:37 +0000 Subject: [PATCH 07/12] Document merged entitlements and the App Store functions Billing in docs/architecture.md now describes effective_entitlement, the App Store endpoints and the deletion caveat; supabase/README.md lists both functions, their configuration names, the placeholder products and that deploying them needs Adam's authorization. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 11 +++++++++++ supabase/README.md | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index fa046676f..30bfc7026 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,6 +113,8 @@ Deletion runs in a fixed order, and a failure at any step leaves the account in 3. Call `prepare_account_deletion`, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stay `RESTRICT` so that a raw delete of an auth user cannot remove shared work by accident. 4. Delete the auth user with the Auth admin API. Every other foreign key into `auth.users` cascades or sets null, and the `on_auth_user_deleted` trigger clears the user from announcements, which have no foreign key. +The server cannot cancel an App Store subscription. Deletion removes the user's `app_store_subscriptions` rows, but Apple keeps billing until the user cancels in their Apple ID's subscription settings. The deletion response's `appStoreSubscriptionActive` says when Apple may still bill (not revoked, auto-renew not off, and ended less than 60 days ago, Apple's longest billing retry), and the app must then send the user to those settings. Notifications for the deleted subscription are acknowledged and ignored. + Admin-authored rows such as promo codes, curated sources, announcements, and grants to other users survive with the author cleared. Stripe keeps its own invoices and payments for tax and accounting. A new foreign key to `auth.users` must cascade, set null, or be cleared by `prepare_account_deletion`. `supabase/tests/account_deletion_test.sql` fails if its fixture has no row in that table or if any row still mentions the deleted user. ## Authentication and authorization @@ -130,6 +132,15 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request. +A plan can come from several sources, and one database function merges them. `public.effective_entitlement(user_id)` in [`20261014123154_app_store_entitlements.sql`](../supabase/migrations/20261014123154_app_store_entitlements.sql) returns the highest active tier (pro, then legacy_free, basic, trial, expired), its daily limit (50, 50, 20, 5, 0, always replaced by `daily_limit_override`), whether it includes Pro models, the source and its expiry. The sources are `user_settings` (trial, promo and admin grants, legacy access, and the `pro` flag the Stripe webhook sets), Stripe `subscriptions` (counted only while that flag is `pro`), and `app_store_subscriptions`. A missing settings row fails closed. Every tier read goes through it: the AI and voice quota checks (`_shared/entitlements.ts`), the website's `useSubscription` and settings store, and the app, through `readEntitlement` in [`packages/core/src/supabase/entitlement.ts`](../packages/core/src/supabase/entitlement.ts). It runs with the caller's privileges, so a signed-in user reads only their own plan. The admin pages still show and edit the stored `subscription_tier` column. + +The app sells Basic and Pro through StoreKit, and a subscription bought on either the website or the App Store unlocks both. Two Edge Functions write App Store subscriptions, both through `apply_app_store_transaction`, after verifying Apple's JWS signature chain against the pinned Apple Root CA - G3 ([`_shared/app-store.ts`](../supabase/functions/_shared/app-store.ts)) and the bundle id and environment: + +- `app-store-notifications` receives App Store Server Notifications V2. Each notificationUUID is applied once, and Apple's retries are acknowledged. +- `app-store-purchase` takes the signed transaction the app gets from StoreKit, and records it only when its `appAccountToken` equals the caller's user id. The app sets that token to the user id at purchase. + +A subscription row is linked to a user once, by that token, and later transactions update it in purchase-date order. Product ids map to tiers in `app_store_products`. Until App Store Connect has the real products, that table holds placeholders. + All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. `Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts and normally resolves to GLM 5.3 Flash; manual GLM selection keeps its Pro restriction. Auto Pro remains Pro-only. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. diff --git a/supabase/README.md b/supabase/README.md index bac85dcb0..3f5c0aef9 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -53,13 +53,19 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh - `admin`: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions. - `announcements`: authenticated announcement listing and dismissal. - `redeem-promo`: authenticated atomic promo redemption. -- `account`: authenticated data export and account deletion for the caller only. It never reads a user ID from the request. Deletion cancels Stripe subscriptions first and stops if that fails, then removes Storage objects, clears the user's workspaces, and deletes the auth user. Export and the deletion plan use service-role-only RPCs. See [Account export and deletion](../docs/architecture.md#account-export-and-deletion). +- `account`: authenticated data export and account deletion for the caller only. It never reads a user ID from the request. Deletion cancels Stripe subscriptions first and stops if that fails, then removes Storage objects, clears the user's workspaces, and deletes the auth user. It cannot cancel an App Store subscription; its response's `appStoreSubscriptionActive` tells the app to send the user to Apple's subscription settings. Export and the deletion plan use service-role-only RPCs. See [Account export and deletion](../docs/architecture.md#account-export-and-deletion). ### Billing - `create-checkout`: authenticated Stripe Checkout creation. Price selection is server-controlled and redirects are validated. It records the customer in `stripe_customers` before opening a session so that account deletion can stop its billing. - `create-portal`: authenticated Stripe Billing Portal creation with customer ownership checks. - `stripe-webhook`: Stripe-signature-validated subscription and invoice event handling with idempotency state. +- `app-store-notifications`: App Store Server Notifications V2 from Apple, with no Supabase session. It verifies the signed payload, and the transaction and renewal info inside it, against the pinned Apple Root CA - G3 (`_shared/apple-root-ca.ts`), checks bundle id, environment and, in Production, the app's Apple id, then applies them through `apply_app_store_transaction`, which records each notificationUUID once. It answers 200 for duplicates and data it does not use, 400 for data that fails verification, and 503 or 500 when it is unconfigured or the database fails, so Apple retries. +- `app-store-purchase`: authenticated. The app posts the signed transaction StoreKit returned, and optionally its renewal info. It is verified the same way and applied only when its `appAccountToken` equals the caller's user id. The answer is the caller's plan. It requires no browser Origin and uses the shared CORS headers. + +Both App Store functions answer 503 `not_configured` until `APP_STORE_BUNDLE_ID` is set (and `APP_STORE_APP_APPLE_ID` when Production is allowed). Deploying them, setting those values, and pointing App Store Connect's notification URL at the function all need Adam's authorization. Tests inject a root they generate; production code has no setting that changes the trusted root. + +Every quota and plan check reads `effective_entitlement()`; see [Billing and usage](../docs/architecture.md#billing-and-usage). ## Shared backend modules @@ -124,6 +130,8 @@ The stack begins at port `54321` and is not parallel-safe across worktrees. Coor The database gate proves migration replay and pgTAP assertions locally. It does not prove staging or production state. +`20261014123154_app_store_entitlements.sql` is additive: it creates `app_store_products` (with placeholder product ids), `app_store_subscriptions` (users read their own, only the service role writes) and `app_store_notifications` (service role only), `effective_entitlement()`, and `apply_app_store_transaction()` (service role only), and adds the App Store flag to `account_deletion_plan`. `supabase/tests/app_store_entitlements_test.sql` covers the tier rules, update ordering, idempotency and isolation. + `20260927170000_debate_context_pins.sql` is additive: it creates `debate_context_pins`, the saved results a student pins per debate, with RLS and a composite key to the owner's debate. `20260926090100_legacy_debate_cleanup.sql` defines `private.purge_legacy_debate_material`, which deletes legacy results, chats, flowcharts, sparring rounds, and sources that belong to no debate and had no activity in the 30 days before `20260926090000_current_debates.sql` recorded its rollout time in `private.current_debate_rollout`. The migration does not call it, so applying it deletes nothing; legacy rows stay in the Unsorted view. Running the purge on a remote project is a separate, destructive step that needs explicit authorization for that environment. Work created after the rollout is never touched, however late it runs. @@ -159,6 +167,7 @@ Document names, never values. Depending on deployed features, server-side config - `ELEVENLABS_API_KEY` for the opponent's voice, and optionally `ELEVENLABS_VOICE_ID` to replace the default voice; without the key, voice rounds use the browser's voice; - research provider keys; - Stripe secret, webhook secret, and price configuration, plus `STRIPE_API_URL`, which only the local full-stack gate sets so the `account` function reaches its fake Stripe (leave it unset everywhere else); +- `APP_STORE_BUNDLE_ID`, `APP_STORE_ENVIRONMENTS` (`Production`, `Sandbox`, or both, comma-separated; default `Production`) and `APP_STORE_APP_APPLE_ID` (required when Production is allowed) for the two App Store functions. None of them is a secret, and there is no Apple shared secret: StoreKit 2 data is verified by its signature. They are not in `backend:status`'s expected list until the App Store functions are deployed; - demo-code hashing material; - `WORKSPACE_INVITE_SECRET` for invite derivation, keyed hashes, and target-email encryption; - `WORKSPACE_INVITE_MAILER_URL`, `WORKSPACE_INVITE_MAILER_TOKEN`, and `WORKSPACE_INVITE_BASE_URL` for the operator-supplied email relay, plus `WORKSPACE_INVITE_ALLOWED_ORIGINS` for an exact preview origin; From fe3d217c67a62118b8acbf21aaca3cb3ed56a0ba Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:02:32 +0000 Subject: [PATCH 08/12] Pin the fail-closed plan read at its new source The security-hardening check looked for the old user_settings branch in _shared/entitlements.ts; it now pins the NO_ACCESS fallback the RPC read returns when the plan is missing or unreadable. Co-Authored-By: Claude Opus 5.5 --- src/test/security-hardening.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index e960eda45..c62f04780 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -29,7 +29,8 @@ describe('security hardening regressions', () => { expect(migration).toContain('REVOKE INSERT, UPDATE, DELETE ON public.user_settings'); expect(migration).toContain('GRANT UPDATE (language, theme, feature_configs, custom_system_prompt, updated_at)'); expect(migration).toContain('REVOKE INSERT, UPDATE, DELETE ON public.ai_requests'); - expect(entitlements).toContain('if (!user) return { tier: "expired", dailyLimit: 0'); + expect(entitlements).toContain('const NO_ACCESS: SubscriptionInfo = { tier: "expired", dailyLimit: 0'); + expect(entitlements).toContain('if (error || !row || typeof row.tier !== "string" || typeof row.daily_limit !== "number") return NO_ACCESS;'); for (const source of [ai, stream]) expect(source).toContain('from "../_shared/entitlements.ts"'); }); From 087860691d42a72354a163021fc75f8719b97e52 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:06:36 +0000 Subject: [PATCH 09/12] Note that the entitlement migration must ship first ai, ai-stream, voice and the website now read effective_entitlement and fail closed without it, so the migration has to reach a project before those functions or the frontend do. Co-Authored-By: Claude Opus 5.5 --- supabase/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/README.md b/supabase/README.md index 3f5c0aef9..2615548be 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -130,7 +130,7 @@ The stack begins at port `54321` and is not parallel-safe across worktrees. Coor The database gate proves migration replay and pgTAP assertions locally. It does not prove staging or production state. -`20261014123154_app_store_entitlements.sql` is additive: it creates `app_store_products` (with placeholder product ids), `app_store_subscriptions` (users read their own, only the service role writes) and `app_store_notifications` (service role only), `effective_entitlement()`, and `apply_app_store_transaction()` (service role only), and adds the App Store flag to `account_deletion_plan`. `supabase/tests/app_store_entitlements_test.sql` covers the tier rules, update ordering, idempotency and isolation. +`20261014123154_app_store_entitlements.sql` is additive: it creates `app_store_products` (with placeholder product ids), `app_store_subscriptions` (users read their own, only the service role writes) and `app_store_notifications` (service role only), `effective_entitlement()`, and `apply_app_store_transaction()` (service role only), and adds the App Store flag to `account_deletion_plan`. `supabase/tests/app_store_entitlements_test.sql` covers the tier rules, update ordering, idempotency and isolation. Apply it before anything that calls `effective_entitlement` ships to a project: the AI and voice checks fail closed and the website's plan read fails without it, so every signed-in user would lose AI access. Order: the migration, then `ai`, `ai-stream`, `voice` and `account`, then the frontend (merging to `main` ships it and starts the staging sync). The two App Store functions can follow later. `20260927170000_debate_context_pins.sql` is additive: it creates `debate_context_pins`, the saved results a student pins per debate, with RLS and a composite key to the owner's debate. From 71ee8dd41b365916a2a4ab57bacaf8af12fd99a5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:05:50 +0000 Subject: [PATCH 10/12] Drop a settings hydration that an account switch overtook Hydration now awaits the plan RPC before it applies the row. If trial user A's load was still waiting when B signed in, it replaced B's local settings with A's and saved A's migrated feature_configs to B's row, because the save looked up the signed-in user again. Hydration now reads the account once and passes it to every write, and a generation counter that reset and each new hydration bump makes a superseded run stop after every await. Co-Authored-By: Claude Opus 5.5 --- src/lib/stores/settings-store.ts | 40 +++++++++----- .../settings/settings-account-switch.test.ts | 52 +++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 src/test/settings/settings-account-switch.test.ts diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index ff9da391b..c6d5ecc04 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -129,12 +129,14 @@ function featureConfigsAreCanonical(value: unknown, expected: Record { - const userId = await getUserId(); - if (!userId) return null; +async function canAccessProModels(userId: string): Promise { return (await readEntitlement(supabase, userId))?.canAccessProModels ?? null; } +// Bumped by every reset and every hydration. A hydration that started for an +// earlier account, or was overtaken by a newer one, applies and saves nothing. +let hydrationGeneration = 0; + async function getUserId() { const { data } = await supabase.auth.getUser(); return data.user?.id || null; @@ -142,15 +144,21 @@ async function getUserId() { // The auth trigger provisions every settings row, and clients may only UPDATE // the user-editable columns. An upsert needs INSERT and is always denied. -async function updateSettings(payload: { +type SettingsPayload = { language: string; output_language: string; theme: string; feature_configs: Record; custom_system_prompt: string | null; -}) { +}; + +async function updateSettings(payload: SettingsPayload) { const userId = await getUserId(); if (!userId) return; + await updateSettingsFor(userId, payload); +} + +async function updateSettingsFor(userId: string, payload: SettingsPayload) { const { error } = await supabase.from('user_settings').update({ ...payload, updated_at: new Date().toISOString(), @@ -160,9 +168,7 @@ async function updateSettings(payload: { } } -async function persistMigratedFeatureConfigs(featureConfigs: Record) { - const userId = await getUserId(); - if (!userId) return; +async function persistMigratedFeatureConfigs(userId: string, featureConfigs: Record) { const { error } = await supabase .from('user_settings') .update({ feature_configs: featureConfigs, updated_at: new Date().toISOString() }) @@ -254,14 +260,20 @@ export const useSettingsStore = create()( }, hydrateFromServer: async () => { + // The account is read once, at the start; every later step checks the + // generation, so an account switch during an await drops this run. + const generation = ++hydrationGeneration; + const current = () => generation === hydrationGeneration; + const userId = await getUserId(); + if (!userId || !current()) return; const { data, error } = await supabase .from('user_settings') .select('*') .single(); - if (error || !data) return; + if (error || !data || !current()) return; - const allowProModels = await canAccessProModels(); - if (allowProModels === null) return; + const allowProModels = await canAccessProModels(userId); + if (allowProModels === null || !current()) return; const mergedConfigs = normalizeFeatureConfigs( data.feature_configs, allowProModels, @@ -279,6 +291,7 @@ export const useSettingsStore = create()( const theme = neverSaved ? get().theme : (data.theme as 'light' | 'dark' | 'system') || 'system'; const customSystemPrompt = data.custom_system_prompt || undefined; await applyAppLanguage(language); + if (!current()) return; set({ language, outputLanguage, @@ -288,7 +301,7 @@ export const useSettingsStore = create()( wikiResearchEnabled: get().wikiResearchEnabled, }); if (neverSaved) { - await updateSettings({ + await updateSettingsFor(userId, { language, output_language: outputLanguage, theme, @@ -296,10 +309,11 @@ export const useSettingsStore = create()( custom_system_prompt: customSystemPrompt ?? null, }); } else if (configsNeedMigration) { - await persistMigratedFeatureConfigs(mergedConfigs); + await persistMigratedFeatureConfigs(userId, mergedConfigs); } }, resetLocalData: (preserveLanguage = false) => { + hydrationGeneration += 1; const language = preserveLanguage ? get().language : 'en'; set({ language, diff --git a/src/test/settings/settings-account-switch.test.ts b/src/test/settings/settings-account-switch.test.ts new file mode 100644 index 000000000..2892e99d9 --- /dev/null +++ b/src/test/settings/settings-account-switch.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockState, setMockAuthUser, setMockTableResponse, supabaseMock } from '@/test/mocks/supabase'; + +vi.mock('@/lib/supabase/client', async () => ({ supabase: (await import('@/test/mocks/supabase')).supabaseMock })); + +const { useSettingsStore } = await import('@/lib/stores/settings-store'); + +/** Every update the store sent to Supabase. */ +const updates = () => supabaseMock.from.mock.results.flatMap((result) => result.value.update.mock.calls); + +// AuthProvider resets the store and hydrates the next account when the user +// switches accounts. A hydration still waiting on the previous account's plan +// must not land in the next account's store or row. +describe('settings hydration across an account switch', () => { + beforeEach(() => { + resetMockState(); + useSettingsStore.getState().resetLocalData(); + }); + + it("drops the previous account's settings when its plan arrives after the switch", async () => { + setMockAuthUser({ id: 'user_a' }); + // A legacy model, so hydration would migrate and save the configs. + setMockTableResponse('user_settings', { + single: { + language: 'es', + output_language: 'es', + theme: 'dark', + custom_system_prompt: 'Prompt of A', + feature_configs: { generate: { model: 'gpt-4o' } }, + }, + }); + let resolvePlan!: (value: unknown) => void; + supabaseMock.rpc.mockImplementationOnce(() => new Promise((resolve) => { resolvePlan = resolve; })); + + const hydratingA = useSettingsStore.getState().hydrateFromServer(); + await vi.waitFor(() => expect(supabaseMock.rpc).toHaveBeenCalled()); + + // B signs in: the store resets and B writes a prompt of their own. + useSettingsStore.getState().resetLocalData(); + setMockAuthUser({ id: 'user_b' }); + supabaseMock.from.mockClear(); + useSettingsStore.setState({ customSystemPrompt: 'Prompt of B' }); + + resolvePlan({ data: [{ tier: 'trial', daily_limit: 5, can_access_pro_models: false }], error: null }); + await hydratingA; + + expect(updates()).toEqual([]); + const state = useSettingsStore.getState(); + expect(state.customSystemPrompt).toBe('Prompt of B'); + expect([state.language, state.theme]).toEqual(['en', 'system']); + }); +}); From 6f58fc2d59f111ad913c6da877991f9c50af8cb0 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:06:58 +0000 Subject: [PATCH 11/12] Report undecodable App Store data as malformed A signature segment that is not base64url made atob throw InvalidCharacterError, and a signed date past Date's range made toISOString throw RangeError. Neither was the verifier's error type, so both functions answered 500 instead of their documented 400. Both now raise a malformed verification error. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/app-store.test.ts | 6 ++++++ supabase/functions/_shared/app-store.ts | 14 +++++++++++--- .../app-store-notifications/index_test.ts | 3 +++ .../functions/app-store-purchase/index_test.ts | 3 +++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_shared/app-store.test.ts b/supabase/functions/_shared/app-store.test.ts index 37ee54b45..fcb7f93a6 100644 --- a/supabase/functions/_shared/app-store.test.ts +++ b/supabase/functions/_shared/app-store.test.ts @@ -106,6 +106,12 @@ Deno.test("a payload changed after signing fails", async () => { await rejectsWith(() => verifyTransaction(`${head}.${forged}.${signature}`, config, roots), "invalid_signature"); }); +Deno.test("signed data that cannot be decoded is malformed, not an unexpected error", async () => { + const [head, payload] = (await signJws(chain, transactionPayload())).split("."); + await rejectsWith(() => verifyTransaction(`${head}.${payload}.not!base64`, config, roots), "malformed"); + await rejectsWith(async () => await verifyTransaction(await signJws(chain, transactionPayload({ expiresDate: 1e20 })), config, roots), "malformed"); +}); + Deno.test("a transaction for another bundle fails", async () => { await rejectsWith( async () => await verifyTransaction(await signJws(chain, transactionPayload({ bundleId: "com.example.other" })), config, roots), diff --git a/supabase/functions/_shared/app-store.ts b/supabase/functions/_shared/app-store.ts index 80dfba304..04e4d787e 100644 --- a/supabase/functions/_shared/app-store.ts +++ b/supabase/functions/_shared/app-store.ts @@ -84,7 +84,13 @@ export function appStoreConfigFromEnv(): AppStoreConfig { function base64UrlDecode(value: string): Uint8Array { const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); - return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + let binary: string; + try { + binary = atob(base64); + } catch { + throw new AppStoreVerificationError("malformed", "JWS segment is not base64url"); + } + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } function decodeJson(segment: string): Record { @@ -216,8 +222,10 @@ function text(value: unknown, field: string): string { } function time(value: unknown, field: string): string { - if (typeof value !== "number" || !Number.isFinite(value)) throw new AppStoreVerificationError("malformed", `${field} is missing`); - return new Date(value).toISOString(); + const date = typeof value === "number" ? new Date(value) : null; + // A finite number past Date's range gives an invalid date, which toISOString throws on. + if (!date || Number.isNaN(date.getTime())) throw new AppStoreVerificationError("malformed", `${field} is missing or out of range`); + return date.toISOString(); } function optionalTime(value: unknown, field: string): string | undefined { diff --git a/supabase/functions/app-store-notifications/index_test.ts b/supabase/functions/app-store-notifications/index_test.ts index 3bc545f5d..b763e3376 100644 --- a/supabase/functions/app-store-notifications/index_test.ts +++ b/supabase/functions/app-store-notifications/index_test.ts @@ -121,6 +121,9 @@ Deno.test("a notification that fails verification is refused and applies nothing [await signedNotification("DID_RENEW", undefined, {}, { bundleId: "com.example.other" }), "wrong_app"], [await signedNotification("DID_RENEW", undefined, {}, { environment: "Sandbox" }), "wrong_environment"], ["not-a-jws", "malformed"], + // A signature segment that is not base64url, and a date no Date can hold. + [(await signedNotification("DID_RENEW", undefined)).replace(/[^.]+$/, "not!base64"), "malformed"], + [await signedNotification("DID_RENEW", undefined, { expiresDate: 1e20 }), "malformed"], ]; for (const [signedPayload, code] of cases) { const { applied, post } = harness(); diff --git a/supabase/functions/app-store-purchase/index_test.ts b/supabase/functions/app-store-purchase/index_test.ts index 3179e0165..4ec45cc54 100644 --- a/supabase/functions/app-store-purchase/index_test.ts +++ b/supabase/functions/app-store-purchase/index_test.ts @@ -107,6 +107,9 @@ Deno.test("an unverifiable purchase is refused before anything is applied", asyn [await ownTransaction({ environment: "Sandbox" }), 400, "wrong_environment"], [await ownTransaction({ type: "Non-Consumable" }), 422, "unsupported_product"], ["not-a-jws", 400, "malformed"], + // A signature segment that is not base64url, and a date no Date can hold. + [(await ownTransaction()).replace(/[^.]+$/, "not!base64"), 400, "malformed"], + [await ownTransaction({ expiresDate: 1e20 }), 400, "malformed"], ]; for (const [signedTransaction, status, code] of cases) { const { applied, post } = harness(); From 04e9abf235e6996ada15b2e6e954f498e7f9ebf7 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 16:47:29 +0000 Subject: [PATCH 12/12] Grant nothing for an App Store transaction Apple marks upgraded When a customer upgrades within the subscription group, Apple re-signs the replaced transaction with isUpgraded. The verifier dropped that flag, so if the re-signed copy arrived before the upgrade itself, the replaced plan kept granting access until its old expiry. The verifier now passes isUpgraded through, apply_app_store_transaction stores it in a new upgraded column, and effective_entitlement skips upgraded rows, as StoreKit's currentEntitlements does. The account deletion flag still counts them, because Apple keeps billing the subscription after an upgrade. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- supabase/functions/_shared/app-store.test.ts | 6 +++-- supabase/functions/_shared/app-store.ts | 3 +++ .../20261014123154_app_store_entitlements.sql | 9 +++++++- .../tests/app_store_entitlements_test.sql | 22 ++++++++++++++++++- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 30bfc7026..d453b10b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -139,7 +139,7 @@ The app sells Basic and Pro through StoreKit, and a subscription bought on eithe - `app-store-notifications` receives App Store Server Notifications V2. Each notificationUUID is applied once, and Apple's retries are acknowledged. - `app-store-purchase` takes the signed transaction the app gets from StoreKit, and records it only when its `appAccountToken` equals the caller's user id. The app sets that token to the user id at purchase. -A subscription row is linked to a user once, by that token, and later transactions update it in purchase-date order. Product ids map to tiers in `app_store_products`. Until App Store Connect has the real products, that table holds placeholders. +A subscription row is linked to a user once, by that token, and later transactions update it in purchase-date order. A transaction Apple marks `isUpgraded` grants nothing, as in StoreKit's current entitlements, until the upgrade that replaces it arrives. Product ids map to tiers in `app_store_products`. Until App Store Connect has the real products, that table holds placeholders. All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. diff --git a/supabase/functions/_shared/app-store.test.ts b/supabase/functions/_shared/app-store.test.ts index fcb7f93a6..8739e8226 100644 --- a/supabase/functions/_shared/app-store.test.ts +++ b/supabase/functions/_shared/app-store.test.ts @@ -49,6 +49,7 @@ Deno.test("a notification signed through the pinned root verifies with its trans assertEquals(verified?.environment, "Production"); assertEquals(verified?.transaction?.record.originalTransactionId, "2000000000000001"); assertEquals(verified?.transaction?.record.familyShared, false); + assertEquals(verified?.transaction?.record.isUpgraded, false); assertEquals(verified?.renewal?.autoRenew, true); }); @@ -148,15 +149,16 @@ Deno.test("a transaction that is not an auto-renewable subscription is unsupport ); }); -Deno.test("appAccountToken and family sharing are read from the signed transaction", async () => { +Deno.test("appAccountToken, family sharing and an upgrade are read from the signed transaction", async () => { const token = "E0000000-0000-4000-8000-000000000030"; const verified = await verifyTransaction( - await signJws(chain, transactionPayload({ appAccountToken: token, inAppOwnershipType: "FAMILY_SHARED", revocationDate: Date.now(), revocationReason: 1 })), + await signJws(chain, transactionPayload({ appAccountToken: token, inAppOwnershipType: "FAMILY_SHARED", isUpgraded: true, revocationDate: Date.now(), revocationReason: 1 })), config, roots, ); assertEquals(verified.appAccountToken, token.toLowerCase()); assertEquals(verified.record.familyShared, true); + assertEquals(verified.record.isUpgraded, true); assertEquals(verified.record.revocationReason, 1); await rejectsWith( async () => await verifyTransaction(await signJws(chain, transactionPayload({ appAccountToken: "not-a-uuid" })), config, roots), diff --git a/supabase/functions/_shared/app-store.ts b/supabase/functions/_shared/app-store.ts index 04e4d787e..207c24451 100644 --- a/supabase/functions/_shared/app-store.ts +++ b/supabase/functions/_shared/app-store.ts @@ -190,6 +190,8 @@ export interface TransactionRecord { revocationDate?: string; revocationReason?: number; familyShared: boolean; + /** Apple's isUpgraded: a newer transaction in the group replaces this one. */ + isUpgraded: boolean; } export interface RenewalRecord { @@ -272,6 +274,7 @@ export async function verifyTransaction( revocationDate: optionalTime(payload.revocationDate, "revocationDate"), revocationReason: typeof revocationReason === "number" ? revocationReason : undefined, familyShared: payload.inAppOwnershipType === "FAMILY_SHARED", + isUpgraded: payload.isUpgraded === true, }, }; } diff --git a/supabase/migrations/20261014123154_app_store_entitlements.sql b/supabase/migrations/20261014123154_app_store_entitlements.sql index 816710051..986e0aed5 100644 --- a/supabase/migrations/20261014123154_app_store_entitlements.sql +++ b/supabase/migrations/20261014123154_app_store_entitlements.sql @@ -62,6 +62,10 @@ CREATE TABLE public.app_store_subscriptions ( revoked_at timestamptz, revocation_reason integer, family_shared boolean NOT NULL DEFAULT false, + -- Apple's isUpgraded: the customer upgraded within the subscription group, + -- and a newer transaction replaces this one. Like StoreKit's + -- currentEntitlements, the plan ignores it until that one arrives. + upgraded boolean NOT NULL DEFAULT false, -- signedDate of the transaction above, which orders updates to it. transaction_signed_at timestamptz NOT NULL, auto_renew boolean, @@ -155,6 +159,7 @@ BEGIN JOIN public.app_store_products AS product ON product.product_id = store.product_id WHERE store.user_id = p_user_id AND store.revoked_at IS NULL + AND NOT store.upgraded AND greatest(store.expires_at, coalesce(store.grace_period_expires_at, store.expires_at)) > p_at ORDER BY product.tier = 'pro' DESC, greatest(store.expires_at, coalesce(store.grace_period_expires_at, store.expires_at)) DESC @@ -303,7 +308,7 @@ BEGIN BEGIN INSERT INTO public.app_store_subscriptions ( original_transaction_id, user_id, product_id, transaction_id, environment, - purchased_at, expires_at, revoked_at, revocation_reason, family_shared, + purchased_at, expires_at, revoked_at, revocation_reason, family_shared, upgraded, transaction_signed_at, auto_renew, grace_period_expires_at, renewal_signed_at ) VALUES ( v_original_id, @@ -316,6 +321,7 @@ BEGIN (p_transaction->>'revocationDate')::timestamptz, (p_transaction->>'revocationReason')::integer, coalesce((p_transaction->>'familyShared')::boolean, false), + coalesce((p_transaction->>'isUpgraded')::boolean, false), v_signed, (p_renewal->>'autoRenew')::boolean, (p_renewal->>'gracePeriodExpiresDate')::timestamptz, @@ -349,6 +355,7 @@ BEGIN revoked_at = (p_transaction->>'revocationDate')::timestamptz, revocation_reason = (p_transaction->>'revocationReason')::integer, family_shared = coalesce((p_transaction->>'familyShared')::boolean, false), + upgraded = coalesce((p_transaction->>'isUpgraded')::boolean, false), transaction_signed_at = v_signed, updated_at = now() WHERE original_transaction_id = v_original_id diff --git a/supabase/tests/app_store_entitlements_test.sql b/supabase/tests/app_store_entitlements_test.sql index b6fc619ca..534ae7606 100644 --- a/supabase/tests/app_store_entitlements_test.sql +++ b/supabase/tests/app_store_entitlements_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(79); +SELECT plan(83); -- Returns how many rows a statement changed; a privilege or RLS rejection changes none. CREATE FUNCTION pg_temp.rows_changed(statement text) @@ -375,6 +375,26 @@ SELECT is( || jsonb_build_object('purchaseDate', now() - interval '65 days')), 'stale', 'a late copy of the yearly transaction cannot undo the upgrade'); +-- Apple marks the replaced transaction isUpgraded. When that copy arrives +-- before the upgrade itself, the replaced plan must not keep granting access. +SELECT pg_temp.settings('e0000000-0000-4000-8000-000000000034', 'expired', NULL, NULL); +SELECT public.apply_app_store_transaction('e0000000-0000-4000-8000-000000000034', + pg_temp.tx('o7', 't1', 'basic', now() + interval '300 days', now() - interval '2 hours') + || jsonb_build_object('purchaseDate', now() - interval '65 days')); +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o7', 't1', 'basic', now() + interval '300 days', now() - interval '1 hour') + || jsonb_build_object('purchaseDate', now() - interval '65 days', 'isUpgraded', true)), + 'applied', 'the replaced transaction, signed again as upgraded, is applied'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000034'), 'expired/0/false/-', + 'an upgraded transaction grants nothing, although it has not expired'); +SELECT is( + public.apply_app_store_transaction(NULL, + pg_temp.tx('o7', 't2', 'pro', now() + interval '30 days', now()) + || jsonb_build_object('purchaseDate', now())), + 'applied', 'the upgrade that replaces it is applied'); +SELECT is(pg_temp.plan_of('e0000000-0000-4000-8000-000000000034'), 'pro/50/true/app_store', 'and grants its tier'); + -- ============================================================ -- Account deletion: whether Apple may still bill the user -- ============================================================