diff --git a/apps/supabase/functions/manage-couple/index.ts b/apps/supabase/functions/manage-couple/index.ts index 6aa7b38..08f69dc 100755 --- a/apps/supabase/functions/manage-couple/index.ts +++ b/apps/supabase/functions/manage-couple/index.ts @@ -185,23 +185,34 @@ Deno.serve(async (req) => { const { data: safePacks, error: safePacksError } = await supabase .from("question_packs") - .select("id") + .select("id, category_id, category:categories(content_status)") .lte("avg_intensity", 2) - .eq("is_public", true); + .eq("is_public", true) + .eq("content_status", "allowed"); if (safePacksError) { console.error("Failed to fetch safe packs:", safePacksError); - } else if (safePacks?.length) { - const { error: insertError } = await supabase - .from("couple_packs") - .insert(safePacks.map((pack: { id: string }) => ({ - couple_id: couple.id, - pack_id: pack.id, - enabled: true, - }))); + } else { + const eligibleSafePacks = (safePacks ?? []).filter((pack) => { + const category = Array.isArray(pack.category) + ? pack.category[0] + : pack.category; + return pack.category_id === null + || category?.content_status === "allowed"; + }); - if (insertError) { - console.error("Failed to enable safe packs:", insertError); + if (eligibleSafePacks.length) { + const { error: insertError } = await supabase + .from("couple_packs") + .insert(eligibleSafePacks.map((pack: { id: string }) => ({ + couple_id: couple.id, + pack_id: pack.id, + enabled: true, + }))); + + if (insertError) { + console.error("Failed to enable safe packs:", insertError); + } } } @@ -213,8 +224,8 @@ Deno.serve(async (req) => { }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); - } - } + } + } if (method === "DELETE") { // Get user's current couple diff --git a/apps/supabase/functions/process-scheduled-releases/index.ts b/apps/supabase/functions/process-scheduled-releases/index.ts index 36ba282..d8ba35d 100644 --- a/apps/supabase/functions/process-scheduled-releases/index.ts +++ b/apps/supabase/functions/process-scheduled-releases/index.ts @@ -61,8 +61,9 @@ Deno.serve(async (req) => { // Get all packs due for release const { data: duePacks, error: fetchError } = await supabase .from("question_packs") - .select("id, name, description") + .select("id, name, description, category_id, category:categories(content_status)") .eq("is_public", false) + .eq("content_status", "allowed") .eq("release_notified", false) .not("scheduled_release_at", "is", null) .lte("scheduled_release_at", new Date().toISOString()); @@ -75,14 +76,22 @@ Deno.serve(async (req) => { ); } - if (!duePacks || duePacks.length === 0) { + const eligibleDuePacks = (duePacks ?? []).filter((pack) => { + const category = Array.isArray(pack.category) + ? pack.category[0] + : pack.category; + return pack.category_id === null + || category?.content_status === "allowed"; + }); + + if (eligibleDuePacks.length === 0) { return new Response( JSON.stringify({ released: 0, message: "No packs due for release" }), { headers: { "Content-Type": "application/json" } } ); } - const packIds = duePacks.map(p => p.id); + const packIds = eligibleDuePacks.map(p => p.id); // Make packs public const { error: updateError } = await supabase @@ -133,7 +142,7 @@ Deno.serve(async (req) => { } // Send notification for each released pack - for (const pack of duePacks) { + for (const pack of eligibleDuePacks) { messages.push({ to: user.push_token!, title: "New pack available", @@ -150,12 +159,12 @@ Deno.serve(async (req) => { await sendExpoPushNotifications(messages); } - console.log(`Released ${duePacks.length} packs, sent ${messages.length} notifications`); + console.log(`Released ${eligibleDuePacks.length} packs, sent ${messages.length} notifications`); return new Response( JSON.stringify({ - released: duePacks.length, - packNames: duePacks.map(p => p.name), + released: eligibleDuePacks.length, + packNames: eligibleDuePacks.map(p => p.name), notificationsSent: messages.length, }), { headers: { "Content-Type": "application/json" } } diff --git a/apps/supabase/functions/submit-response/index.ts b/apps/supabase/functions/submit-response/index.ts index 5d493d1..13dae8a 100755 --- a/apps/supabase/functions/submit-response/index.ts +++ b/apps/supabase/functions/submit-response/index.ts @@ -96,11 +96,31 @@ Deno.serve(async (req) => { // Fetch the question to get its type const { data: question, error: questionError } = await supabase .from("questions") - .select("id, question_type") + .select(` + id, + question_type, + content_status, + pack:question_packs!inner( + content_status, + category_id, + category:categories(content_status) + ) + `) .eq("id", question_id) .single(); - if (questionError || !question) { + const questionPack = Array.isArray(question?.pack) + ? question.pack[0] + : question?.pack; + const questionCategory = Array.isArray(questionPack?.category) + ? questionPack.category[0] + : questionPack?.category; + const questionIsAllowed = question?.content_status === "allowed" + && questionPack?.content_status === "allowed" + && (questionPack.category_id === null + || questionCategory?.content_status === "allowed"); + + if (questionError || !question || !questionIsAllowed) { return new Response( JSON.stringify({ error: "Question not found" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } diff --git a/apps/supabase/functions/update-response/index.ts b/apps/supabase/functions/update-response/index.ts index f9e6206..390a34f 100644 --- a/apps/supabase/functions/update-response/index.ts +++ b/apps/supabase/functions/update-response/index.ts @@ -125,11 +125,31 @@ Deno.serve(async (req) => { // Fetch the question to get its type const { data: question, error: questionError } = await supabase .from("questions") - .select("id, question_type") + .select(` + id, + question_type, + content_status, + pack:question_packs!inner( + content_status, + category_id, + category:categories(content_status) + ) + `) .eq("id", question_id) .single(); - if (questionError || !question) { + const questionPack = Array.isArray(question?.pack) + ? question.pack[0] + : question?.pack; + const questionCategory = Array.isArray(questionPack?.category) + ? questionPack.category[0] + : questionPack?.category; + const questionIsAllowed = question?.content_status === "allowed" + && questionPack?.content_status === "allowed" + && (questionPack.category_id === null + || questionCategory?.content_status === "allowed"); + + if (questionError || !question || !questionIsAllowed) { return new Response( JSON.stringify({ error: "Question not found" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } @@ -142,7 +162,7 @@ Deno.serve(async (req) => { .select("answer, response_data") .eq("user_id", user.id) .eq("question_id", question_id) - .maybeSingle(); + .maybeSingle(); if (currentResponseError) { return new Response( @@ -157,14 +177,14 @@ Deno.serve(async (req) => { { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } - + // If answer hasn't changed and no response_data updates, return early if (currentResponse.answer === new_answer && typeof response_data === "undefined") { return new Response( JSON.stringify({ success: true, message: "Answer unchanged" } as UpdateResponseResult), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); - } + } // Check if a match exists for this question const { data: existingMatch } = await supabase @@ -174,7 +194,7 @@ Deno.serve(async (req) => { .eq("question_id", question_id) .maybeSingle(); - // Get partner's response + // Get partner's response const { data: partnerResponse } = await supabase .from("responses") .select("answer, response_data, user_id") @@ -196,7 +216,7 @@ Deno.serve(async (req) => { [user.id]: normalizedResponseData ?? null, [partnerResponse.user_id]: partnerResponse.response_data ?? null, } - : null; + : null; // Case 1: Changing to "no" AND match exists - requires confirmation if (new_answer === "no" && existingMatch) { @@ -247,7 +267,7 @@ Deno.serve(async (req) => { result.match_deleted = true; } - + // Case 2: Changing FROM "no" to yes/maybe - may create a new match if (currentResponse.answer === "no" && new_answer !== "no" && partnerResponse) { if (matchType) { @@ -309,7 +329,7 @@ Deno.serve(async (req) => { .from("responses") .update(responseUpdate) .eq("user_id", user.id) - .eq("question_id", question_id); + .eq("question_id", question_id); if (updateError) { return new Response( diff --git a/apps/supabase/migrations/20260828114256_enforce_allowed_catalogue.sql b/apps/supabase/migrations/20260828114256_enforce_allowed_catalogue.sql new file mode 100644 index 0000000..c08f675 --- /dev/null +++ b/apps/supabase/migrations/20260828114256_enforce_allowed_catalogue.sql @@ -0,0 +1,452 @@ +-- Enforce the reviewed catalogue centrally so already-installed mobile clients +-- cannot retrieve unreviewed or archived curated content. Admin SELECT policies +-- remain separate and continue to expose the full review queue to administrators. + +DO $$ +BEGIN + -- Fresh/local databases apply migrations before optional catalogue fixtures. + -- Empty is valid; any non-empty catalogue must exactly match the audited + -- production snapshot or the migration fails closed. + IF (SELECT count(*) FROM public.categories) = 0 + AND (SELECT count(*) FROM public.question_packs) = 0 + AND (SELECT count(*) FROM public.questions) = 0 + AND (SELECT count(*) FROM public.dare_packs) = 0 + AND (SELECT count(*) FROM public.dares) = 0 THEN + RETURN; + END IF; + + IF (SELECT count(*) FROM public.categories) <> 15 + OR (SELECT count(*) FROM public.question_packs) <> 47 + OR (SELECT count(*) FROM public.questions) <> 2759 + OR (SELECT count(*) FROM public.dare_packs) <> 4 + OR (SELECT count(*) FROM public.dares) <> 28 THEN + RAISE EXCEPTION 'Catalogue changed after the 2026-08-28 review; re-audit before enforcing'; + END IF; + + IF (SELECT count(*) FROM public.question_packs WHERE content_status = 'archived') <> 30 + OR (SELECT count(*) FROM public.question_packs WHERE content_status = 'unreviewed') <> 17 + OR (SELECT count(*) FROM public.questions WHERE content_status = 'archived') <> 2072 + OR (SELECT count(*) FROM public.questions WHERE content_status = 'unreviewed') <> 687 THEN + RAISE EXCEPTION 'Catalogue review state changed after the 2026-08-28 review; re-audit before enforcing'; + END IF; +END; +$$; + +-- Only neutral category labels which contain reviewed relationship/date packs +-- are exposed. Categories containing only archived material are archived too. +UPDATE public.categories +SET + content_status = CASE + WHEN id IN ( + 'c1000000-0000-0000-0000-000000000002'::uuid, -- Adventure & Travel + 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08'::uuid, -- Quality Time + '43106c46-042a-4032-a663-8e8206273927'::uuid, -- Who is More Likely + 'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da'::uuid, -- Social Life + 'ecabc509-9fed-48cb-bd60-c5d830d2491d'::uuid -- Long Distance + ) THEN 'allowed'::public.content_review_status + ELSE 'archived'::public.content_review_status + END, + content_review_reason = CASE + WHEN id IN ( + 'c1000000-0000-0000-0000-000000000002'::uuid, + 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08'::uuid, + '43106c46-042a-4032-a663-8e8206273927'::uuid, + 'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da'::uuid, + 'ecabc509-9fed-48cb-bd60-c5d830d2491d'::uuid + ) THEN 'Reviewed neutral category for store-safe relationship content' + ELSE 'Category contains no store-safe reviewed packs' + END +WHERE content_status = 'unreviewed'; + +-- The fourteen reviewed non-explicit relationship/date packs are allowed. +-- Three explicit packs missed by the first conservative pass are archived. +WITH reviewed_allowed_packs(id) AS ( + VALUES + ('7e9cb72e-95dd-4140-8d58-17206e6fe153'::uuid), -- Between the Lines + ('8da8dcb1-0b9b-4828-9a30-199758b8ade5'::uuid), -- Date Night Ideas + ('e11bdfea-ab07-46a9-952d-5c00c8b4ddbb'::uuid), -- Everyday Moments + ('cf483ec0-502a-41ea-a2a2-2f223e7ec6ce'::uuid), -- Everyday Us + ('877d8a39-5229-4263-bffc-d3043015603f'::uuid), -- Finding Your Vibe + ('1d2ed912-1429-4045-a608-35b8db63f9b4'::uuid), -- Food & Culture + ('45ff272c-be6d-4faa-a932-a5285a19afe6'::uuid), -- Growing Together + ('d6128ed8-e209-43db-88eb-e9d0115ba8d7'::uuid), -- Hosting + ('1ec3f726-b495-423b-a5dc-60f5178fdf21'::uuid), -- Our Future + ('0f2b3400-05d4-40db-b8fa-41c92ab877eb'::uuid), -- Outdoor Explorers + ('48150d71-bf95-49b0-924f-e9bcff63fc03'::uuid), -- Relationship Essentials + ('fd6f2a49-1057-4f30-aab4-7cd185764f7e'::uuid), -- Staying Close + ('aea8bb8f-de02-41ad-bd1f-7616780dde30'::uuid), -- Weekend Adventures (empty) + ('91dacfed-46ae-462d-aae3-5e540fe4a973'::uuid) -- Weekend Warriors +) +UPDATE public.question_packs qp +SET + content_status = CASE + WHEN rap.id IS NOT NULL THEN 'allowed'::public.content_review_status + ELSE 'archived'::public.content_review_status + END, + content_review_reason = CASE + WHEN rap.id IS NOT NULL + THEN 'Reviewed as non-explicit relationship, conversation, or date content' + ELSE 'Explicit pack identified during final enforcement review' + END +FROM (SELECT id FROM public.question_packs WHERE content_status = 'unreviewed') pending +LEFT JOIN reviewed_allowed_packs rap ON rap.id = pending.id +WHERE qp.id = pending.id; + +-- Every question inherits its reviewed pack decision except one suggestive +-- shower prompt, which is archived individually from an otherwise safe pack. +UPDATE public.questions q +SET + content_status = CASE + WHEN qp.content_status = 'allowed' + AND q.id <> '8d49681a-4b27-445e-9fda-915a3a7d7f5c'::uuid + THEN 'allowed'::public.content_review_status + ELSE 'archived'::public.content_review_status + END, + content_review_reason = CASE + WHEN qp.content_status = 'allowed' + AND q.id <> '8d49681a-4b27-445e-9fda-915a3a7d7f5c'::uuid + THEN 'Reviewed with its store-safe relationship pack' + WHEN q.id = '8d49681a-4b27-445e-9fda-915a3a7d7f5c'::uuid + THEN 'Suggestive shower prompt archived during final enforcement review' + ELSE 'Archived with explicit pack during final enforcement review' + END +FROM public.question_packs qp +WHERE q.pack_id = qp.id + AND q.content_status = 'unreviewed'; + +-- Keep only the plainly romantic dare pack. The remaining packs contain sexual, +-- suggestive, dominance, or fantasy prompts and are archived as complete packs. +UPDATE public.dare_packs +SET + content_status = CASE + WHEN id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid + THEN 'allowed'::public.content_review_status + ELSE 'archived'::public.content_review_status + END, + content_review_reason = CASE + WHEN id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid + THEN 'Reviewed as non-sexual romantic gestures' + ELSE 'Pack contains suggestive, sexual, dominance, or fantasy dares' + END +WHERE content_status = 'unreviewed'; + +UPDATE public.dares d +SET + content_status = CASE + WHEN dp.content_status = 'allowed' THEN 'allowed'::public.content_review_status + ELSE 'archived'::public.content_review_status + END, + content_review_reason = CASE + WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack' + ELSE 'Archived with non-store-safe dare pack' + END +FROM public.dare_packs dp +WHERE d.pack_id = dp.id + AND d.content_status = 'unreviewed'; + +DO $$ +BEGIN + IF (SELECT count(*) FROM public.categories) = 0 + AND (SELECT count(*) FROM public.question_packs) = 0 + AND (SELECT count(*) FROM public.questions) = 0 + AND (SELECT count(*) FROM public.dare_packs) = 0 + AND (SELECT count(*) FROM public.dares) = 0 THEN + RETURN; + END IF; + + IF (SELECT count(*) FROM public.categories WHERE content_status = 'allowed') <> 5 + OR (SELECT count(*) FROM public.categories WHERE content_status = 'archived') <> 10 + OR (SELECT count(*) FROM public.question_packs WHERE content_status = 'allowed') <> 14 + OR (SELECT count(*) FROM public.question_packs WHERE content_status = 'archived') <> 33 + OR (SELECT count(*) FROM public.questions WHERE content_status = 'allowed') <> 591 + OR (SELECT count(*) FROM public.questions WHERE content_status = 'archived') <> 2168 + OR (SELECT count(*) FROM public.dare_packs WHERE content_status = 'allowed') <> 1 + OR (SELECT count(*) FROM public.dare_packs WHERE content_status = 'archived') <> 3 + OR (SELECT count(*) FROM public.dares WHERE content_status = 'allowed') <> 7 + OR (SELECT count(*) FROM public.dares WHERE content_status = 'archived') <> 21 + OR EXISTS ( + SELECT 1 FROM public.question_packs qp + LEFT JOIN public.categories c ON c.id = qp.category_id + WHERE qp.content_status = 'allowed' + AND qp.category_id IS NOT NULL + AND c.content_status <> 'allowed' + ) + OR EXISTS ( + SELECT 1 FROM public.questions q + JOIN public.question_packs qp ON qp.id = q.pack_id + WHERE q.content_status = 'allowed' AND qp.content_status <> 'allowed' + ) THEN + RAISE EXCEPTION 'Reviewed catalogue does not match the approved enforcement set'; + END IF; +END; +$$; + +-- Remove archived packs from existing couples' active recommendation sets. +-- Suppress the ordinary partner-notification trigger because this is a global +-- policy cutover, not a user-initiated pack preference change. +CREATE TEMP TABLE catalogue_cutover_couples ON COMMIT DROP AS +SELECT DISTINCT cp.couple_id +FROM public.couple_packs cp +JOIN public.question_packs qp ON qp.id = cp.pack_id +WHERE cp.enabled = true + AND qp.content_status <> 'allowed' + AND NOT EXISTS ( + SELECT 1 + FROM public.couple_packs enabled_cp + JOIN public.question_packs enabled_qp ON enabled_qp.id = enabled_cp.pack_id + WHERE enabled_cp.couple_id = cp.couple_id + AND enabled_cp.enabled = true + AND enabled_qp.content_status = 'allowed' + ); + +ALTER TABLE public.couple_packs DISABLE TRIGGER on_couple_pack_enabled; +UPDATE public.couple_packs cp +SET enabled = false +FROM public.question_packs qp +WHERE qp.id = cp.pack_id + AND qp.content_status <> 'allowed' + AND cp.enabled = true; + +-- If the cutover removed a couple's entire active selection, seed the same +-- reviewed low-intensity defaults used for newly-created couples. Existing +-- allowed selections and intentional all-disabled configurations are untouched. +INSERT INTO public.couple_packs (couple_id, pack_id, enabled) +SELECT affected.couple_id, qp.id, true +FROM catalogue_cutover_couples affected +CROSS JOIN public.question_packs qp +WHERE qp.content_status = 'allowed' + AND qp.is_public = true + AND qp.avg_intensity <= 2 +ON CONFLICT (couple_id, pack_id) +DO UPDATE SET enabled = EXCLUDED.enabled; + +ALTER TABLE public.couple_packs ENABLE TRIGGER on_couple_pack_enabled; + +-- RLS is the compatibility boundary for direct table queries made by existing +-- clients. Permissive admin policies remain in place and still expose all rows. +DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories; +CREATE POLICY "Categories are viewable by everyone" + ON public.categories FOR SELECT + USING (is_public = true AND content_status = 'allowed'); + +DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs; +CREATE POLICY "Anyone can view public packs" + ON public.question_packs FOR SELECT + USING ( + is_public = true + AND content_status = 'allowed' + AND ( + category_id IS NULL + OR EXISTS ( + SELECT 1 FROM public.categories c + WHERE c.id = question_packs.category_id + AND c.is_public = true + AND c.content_status = 'allowed' + ) + ) + ); + +-- This older permissive policy made free non-public packs readable and is +-- redundant with the public-pack policy for catalogue metadata. +DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs; + +DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions; +CREATE POLICY "Anyone can view questions in visible packs" + ON public.questions FOR SELECT + USING ( + content_status = 'allowed' + AND EXISTS ( + SELECT 1 + FROM public.question_packs qp + WHERE qp.id = questions.pack_id + AND qp.is_public = true + AND qp.content_status = 'allowed' + AND ( + qp.category_id IS NULL + OR EXISTS ( + SELECT 1 FROM public.categories c + WHERE c.id = qp.category_id + AND c.is_public = true + AND c.content_status = 'allowed' + ) + ) + ) + ); + +DROP POLICY IF EXISTS "Public dare packs are viewable by everyone" ON public.dare_packs; +CREATE POLICY "Public dare packs are viewable by everyone" + ON public.dare_packs FOR SELECT + USING ( + is_public = true + AND content_status = 'allowed' + AND ( + category_id IS NULL + OR EXISTS ( + SELECT 1 FROM public.categories c + WHERE c.id = dare_packs.category_id + AND c.is_public = true + AND c.content_status = 'allowed' + ) + ) + ); + +DROP POLICY IF EXISTS "Users can view dares in accessible packs" ON public.dares; +CREATE POLICY "Users can view dares in accessible packs" + ON public.dares FOR SELECT + USING ( + content_status = 'allowed' + AND EXISTS ( + SELECT 1 FROM public.dare_packs dp + WHERE dp.id = dares.pack_id + AND dp.is_public = true + AND dp.content_status = 'allowed' + AND ( + dp.is_premium = false + OR EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.is_premium = true + ) + ) + AND ( + dp.category_id IS NULL + OR EXISTS ( + SELECT 1 FROM public.categories c + WHERE c.id = dp.category_id + AND c.is_public = true + AND c.content_status = 'allowed' + ) + ) + ) + ); + +-- Hide historical match rows whose curated source question is no longer +-- eligible. Existing mobile queries use a left relationship, so filtering only +-- the nested question would otherwise leave a null-content match card behind. +DROP POLICY IF EXISTS "Users can view couple matches" ON public.matches; +CREATE POLICY "Users can view couple matches" + ON public.matches FOR SELECT + USING ( + couple_id = public.get_auth_user_couple_id() + AND EXISTS ( + SELECT 1 + FROM public.questions q + JOIN public.question_packs qp ON qp.id = q.pack_id + LEFT JOIN public.categories c ON c.id = qp.category_id + WHERE q.id = matches.question_id + AND q.content_status = 'allowed' + AND qp.content_status = 'allowed' + AND (qp.category_id IS NULL OR c.content_status = 'allowed') + ) + ); + +-- Existing clients may write couple_packs directly. Prevent a stale client from +-- re-enabling an archived pack, while preserving deletion of old preference rows. +DROP POLICY IF EXISTS "Couples can view their own pack settings" ON public.couple_packs; +CREATE POLICY "Couples can view their own pack settings" + ON public.couple_packs FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id + ) + AND EXISTS ( + SELECT 1 FROM public.question_packs qp + WHERE qp.id = couple_packs.pack_id AND qp.content_status = 'allowed' + ) + ); + +DROP POLICY IF EXISTS "Couples can modify their own pack settings" ON public.couple_packs; +CREATE POLICY "Couples can insert allowed pack settings" + ON public.couple_packs FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id + ) + AND EXISTS ( + SELECT 1 FROM public.question_packs qp + WHERE qp.id = couple_packs.pack_id AND qp.content_status = 'allowed' + ) + ); + +CREATE POLICY "Couples can update allowed pack settings" + ON public.couple_packs FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.couple_id = couple_packs.couple_id + ) + AND EXISTS ( + SELECT 1 FROM public.question_packs qp + WHERE qp.id = couple_packs.pack_id AND qp.content_status = 'allowed' + ) + ); + +-- Wrap SECURITY DEFINER catalogue RPCs rather than relying on RLS, which their +-- owner privileges bypass. The original implementations retain all recommendation +-- behavior but are no longer executable by API roles. +ALTER FUNCTION public.get_recommended_questions(uuid) + RENAME TO get_recommended_questions_before_content_enforcement; +REVOKE ALL ON FUNCTION public.get_recommended_questions_before_content_enforcement(uuid) + FROM PUBLIC, anon, authenticated; + +CREATE FUNCTION public.get_recommended_questions(target_pack_id uuid DEFAULT NULL::uuid) +RETURNS TABLE( + id uuid, + text text, + partner_text text, + is_two_part boolean, + pack_id uuid, + intensity integer, + partner_answered boolean, + allowed_couple_genders text[], + target_user_genders text[], + question_type public.question_type, + config jsonb +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ + SELECT candidate.* + FROM public.get_recommended_questions_before_content_enforcement(target_pack_id) candidate + JOIN public.questions q ON q.id = candidate.id + JOIN public.question_packs qp ON qp.id = q.pack_id + LEFT JOIN public.categories c ON c.id = qp.category_id + WHERE q.content_status = 'allowed' + AND qp.content_status = 'allowed' + AND (qp.category_id IS NULL OR c.content_status = 'allowed'); +$$; +REVOKE ALL ON FUNCTION public.get_recommended_questions(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_recommended_questions(uuid) TO anon, authenticated, service_role; + +ALTER FUNCTION public.get_pack_teaser_questions(uuid) + RENAME TO get_pack_teaser_questions_before_content_enforcement; +REVOKE ALL ON FUNCTION public.get_pack_teaser_questions_before_content_enforcement(uuid) + FROM PUBLIC, anon, authenticated; + +CREATE FUNCTION public.get_pack_teaser_questions(target_pack_id uuid) +RETURNS TABLE(id uuid, text text, intensity integer) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ + SELECT candidate.* + FROM public.get_pack_teaser_questions_before_content_enforcement(target_pack_id) candidate + JOIN public.questions q ON q.id = candidate.id + JOIN public.question_packs qp ON qp.id = q.pack_id + LEFT JOIN public.categories c ON c.id = qp.category_id + WHERE q.content_status = 'allowed' + AND qp.content_status = 'allowed' + AND (qp.category_id IS NULL OR c.content_status = 'allowed'); +$$; +REVOKE ALL ON FUNCTION public.get_pack_teaser_questions(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_pack_teaser_questions(uuid) TO anon, authenticated, service_role; diff --git a/apps/supabase/tests/content-review-metadata.test.ts b/apps/supabase/tests/content-review-metadata.test.ts index adf95a6..e7ad235 100644 --- a/apps/supabase/tests/content-review-metadata.test.ts +++ b/apps/supabase/tests/content-review-metadata.test.ts @@ -1,7 +1,9 @@ import { createClient } from "@supabase/supabase-js"; -import { assertEquals, assertMatch } from "std/assert/mod.ts"; +import { assert, assertEquals, assertMatch } from "std/assert/mod.ts"; import { adminClient, + cleanup, + createCouple, createTestUser, SUPABASE_URL, } from "./utils.ts"; @@ -52,6 +54,283 @@ Deno.test("catalogue review migration stages the complete audited archive set", } }); +Deno.test("catalogue enforcement exposes only reviewed allowed content to existing clients", async () => { + const suffix = crypto.randomUUID(); + const email1 = `catalogue-enforcement-1-${suffix}@example.com`; + const email2 = `catalogue-enforcement-2-${suffix}@example.com`; + const userIds: string[] = []; + const entityIds: string[] = []; + let coupleId: string | undefined; + let categoryId: string | undefined; + let packIds: string[] = []; + let questionIds: string[] = []; + let darePackIds: string[] = []; + let dareIds: string[] = []; + + try { + const { data: category, error: categoryInsertError } = await adminClient + .from("categories") + .insert({ name: `Enforcement category ${suffix}`, is_public: true }) + .select("id") + .single(); + if (categoryInsertError) throw categoryInsertError; + categoryId = category.id; + entityIds.push(category.id); + + const { data: packs, error: packInsertError } = await adminClient + .from("question_packs") + .insert([ + { + name: `Allowed pack ${suffix}`, + category_id: categoryId, + is_public: true, + }, + { + name: `Archived pack ${suffix}`, + category_id: categoryId, + is_public: true, + }, + { + name: `Unreviewed pack ${suffix}`, + category_id: categoryId, + is_public: true, + }, + ]) + .select("id"); + if (packInsertError) throw packInsertError; + packIds = packs.map((pack) => pack.id); + entityIds.push(...packIds); + + const { data: questions, error: questionInsertError } = await adminClient + .from("questions") + .insert(packIds.map((packId, index) => ({ + pack_id: packId, + text: `Enforcement question ${index} ${suffix}`, + intensity: 1, + }))) + .select("id"); + if (questionInsertError) throw questionInsertError; + questionIds = questions.map((question) => question.id); + entityIds.push(...questionIds); + + const { data: darePacks, error: darePackInsertError } = await adminClient + .from("dare_packs") + .insert([ + { + name: `Allowed dare pack ${suffix}`, + category_id: categoryId, + is_public: true, + }, + { + name: `Archived dare pack ${suffix}`, + category_id: categoryId, + is_public: true, + }, + ]) + .select("id"); + if (darePackInsertError) throw darePackInsertError; + darePackIds = darePacks.map((pack) => pack.id); + entityIds.push(...darePackIds); + + const { data: dares, error: dareInsertError } = await adminClient + .from("dares") + .insert(darePackIds.map((packId, index) => ({ + pack_id: packId, + text: `Enforcement dare ${index} ${suffix}`, + intensity: 1, + }))) + .select("id"); + if (dareInsertError) throw dareInsertError; + dareIds = dares.map((dare) => dare.id); + entityIds.push(...dareIds); + + const reviewResults = await Promise.all([ + adminClient.from("categories").update({ + content_status: "allowed", + content_review_reason: "Allowed fixture category", + }).eq("id", categoryId), + adminClient.from("question_packs").update({ + content_status: "allowed", + content_review_reason: "Allowed fixture pack", + }).eq("id", packIds[0]), + adminClient.from("question_packs").update({ + content_status: "archived", + content_review_reason: "Archived fixture pack", + }).eq("id", packIds[1]), + adminClient.from("questions").update({ + content_status: "allowed", + content_review_reason: "Allowed fixture question", + }).eq("id", questionIds[0]), + adminClient.from("questions").update({ + content_status: "archived", + content_review_reason: "Archived fixture question", + }).eq("id", questionIds[1]), + adminClient.from("dare_packs").update({ + content_status: "allowed", + content_review_reason: "Allowed fixture dare pack", + }).eq("id", darePackIds[0]), + adminClient.from("dare_packs").update({ + content_status: "archived", + content_review_reason: "Archived fixture dare pack", + }).eq("id", darePackIds[1]), + adminClient.from("dares").update({ + content_status: "allowed", + content_review_reason: "Allowed fixture dare", + }).eq("id", dareIds[0]), + adminClient.from("dares").update({ + content_status: "archived", + content_review_reason: "Archived fixture dare", + }).eq("id", dareIds[1]), + ]); + for (const { error } of reviewResults) if (error) throw error; + + const user1 = await createTestUser(email1); + const user2 = await createTestUser(email2); + userIds.push(user1.id, user2.id); + const couple = await createCouple(user1.id, user2.id); + coupleId = couple.id; + + const { data: matches, error: matchInsertError } = await adminClient + .from("matches") + .insert([ + { + couple_id: coupleId, + question_id: questionIds[0], + match_type: "yes_yes", + }, + { + couple_id: coupleId, + question_id: questionIds[1], + match_type: "yes_yes", + }, + ]) + .select("id, question_id"); + if (matchInsertError) throw matchInsertError; + + const userClient = createClient(SUPABASE_URL, anonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + const { error: signInError } = await userClient.auth.signInWithPassword({ + email: email1, + password: "password123", + }); + if (signInError) throw signInError; + + const [ + { data: visiblePacks, error: packError }, + { data: visibleQuestions, error: questionError }, + { data: visibleDarePacks, error: darePackError }, + { data: visibleDares, error: dareError }, + { data: visibleMatches, error: matchError }, + ] = await Promise.all([ + userClient.from("question_packs").select("id, content_status").in( + "id", + packIds, + ), + userClient.from("questions").select("id, content_status").in( + "id", + questionIds, + ), + userClient.from("dare_packs").select("id, content_status").in( + "id", + darePackIds, + ), + userClient.from("dares").select("id, content_status").in("id", dareIds), + userClient.from("matches").select("id, question_id").in( + "id", + matches.map((match) => match.id), + ), + ]); + if (packError) throw packError; + if (questionError) throw questionError; + if (darePackError) throw darePackError; + if (dareError) throw dareError; + if (matchError) throw matchError; + + assertEquals(visiblePacks.map((row) => row.id), [packIds[0]]); + assertEquals(visibleQuestions.map((row) => row.id), [questionIds[0]]); + assertEquals(visibleDarePacks.map((row) => row.id), [darePackIds[0]]); + assertEquals(visibleDares.map((row) => row.id), [dareIds[0]]); + assertEquals(visibleMatches.map((row) => row.question_id), [ + questionIds[0], + ]); + + const allowedPackId = packIds[0]; + const archivedPackId = packIds[1]; + const { + data: archivedRecommendations, + error: archivedRecommendationError, + } = await userClient.rpc("get_recommended_questions", { + target_pack_id: archivedPackId, + }); + if (archivedRecommendationError) throw archivedRecommendationError; + assertEquals(archivedRecommendations.length, 0); + + const { data: allowedRecommendations, error: allowedRecommendationError } = + await userClient.rpc( + "get_recommended_questions", + { + target_pack_id: allowedPackId, + }, + ); + if (allowedRecommendationError) throw allowedRecommendationError; + assertEquals(allowedRecommendations.map((row: { id: string }) => row.id), [ + questionIds[0], + ]); + + const [ + { data: archivedTeasers, error: archivedTeaserError }, + { data: allowedTeasers, error: allowedTeaserError }, + ] = await Promise.all([ + userClient.rpc("get_pack_teaser_questions", { + target_pack_id: archivedPackId, + }), + userClient.rpc("get_pack_teaser_questions", { + target_pack_id: allowedPackId, + }), + ]); + if (archivedTeaserError) throw archivedTeaserError; + if (allowedTeaserError) throw allowedTeaserError; + assertEquals(archivedTeasers.length, 0); + assertEquals(allowedTeasers.map((row: { id: string }) => row.id), [ + questionIds[0], + ]); + + const { error: archivedPackEnableError } = await userClient + .from("couple_packs") + .insert({ couple_id: coupleId, pack_id: archivedPackId, enabled: true }); + assert(archivedPackEnableError); + + const { error: allowedPackEnableError } = await userClient + .from("couple_packs") + .insert({ couple_id: coupleId, pack_id: allowedPackId, enabled: true }); + if (allowedPackEnableError) throw allowedPackEnableError; + } finally { + await cleanup(userIds, coupleId); + if (entityIds.length > 0) { + await adminClient.from("content_reviews").delete().in( + "entity_id", + entityIds, + ); + } + if (questionIds.length > 0) { + await adminClient.from("questions").delete().in("id", questionIds); + } + if (packIds.length > 0) { + await adminClient.from("question_packs").delete().in("id", packIds); + } + if (dareIds.length > 0) { + await adminClient.from("dares").delete().in("id", dareIds); + } + if (darePackIds.length > 0) { + await adminClient.from("dare_packs").delete().in("id", darePackIds); + } + if (categoryId) { + await adminClient.from("categories").delete().eq("id", categoryId); + } + } +}); + Deno.test("catalogue review metadata is reversible, audited, and non-super-admin decisions are rejected", async () => { const suffix = crypto.randomUUID(); const email = `content-review-${suffix}@example.com`; @@ -113,15 +392,14 @@ Deno.test("catalogue review metadata is reversible, audited, and non-super-admin }); if (signInError) throw signInError; - // The metadata-only migration must not hide content from existing clients. - const { data: stillVisible, error: visibilityError } = await userClient + // Enforcement is fail closed for existing clients: archived rows are hidden + // even when their legacy is_public flag remains true. + const { data: archivedRows, error: visibilityError } = await userClient .from("question_packs") .select("id, content_status") - .eq("id", packId) - .single(); + .eq("id", packId); if (visibilityError) throw visibilityError; - assertEquals(stillVisible.id, packId); - assertEquals(stillVisible.content_status, "archived"); + assertEquals(archivedRows.length, 0); const { error: adminError } = await adminClient.from("admin_users").insert({ user_id: userId, @@ -138,7 +416,9 @@ Deno.test("catalogue review metadata is reversible, audited, and non-super-admin .eq("id", packId); if (!forbiddenDecision) { - throw new Error("Expected pack creator catalogue decision to be rejected"); + throw new Error( + "Expected pack creator catalogue decision to be rejected", + ); } assertMatch(forbiddenDecision.message, /Only super admins/i); @@ -163,7 +443,9 @@ Deno.test("catalogue review metadata is reversible, audited, and non-super-admin const { data: rereviewed, error: rereviewError } = await userClient .from("question_packs") - .update({ content_review_reason: "Test approval independently rechecked" }) + .update({ + content_review_reason: "Test approval independently rechecked", + }) .eq("id", packId) .select("content_status, content_review_reason, content_reviewed_by") .single(); @@ -186,7 +468,9 @@ Deno.test("catalogue review metadata is reversible, audited, and non-super-admin .update({ content_review_reason: "Pack creator forged review reason" }) .eq("id", packId); if (!forgedReason) { - throw new Error("Expected pack creator review metadata change to be rejected"); + throw new Error( + "Expected pack creator review metadata change to be rejected", + ); } assertMatch(forgedReason.message, /Only super admins/i); @@ -195,7 +479,9 @@ Deno.test("catalogue review metadata is reversible, audited, and non-super-admin .update({ content_reviewed_by: null }) .eq("id", packId); if (!forgedReviewer) { - throw new Error("Expected database-managed reviewer metadata to be rejected"); + throw new Error( + "Expected database-managed reviewer metadata to be rejected", + ); } assertMatch(forgedReviewer.message, /database-managed/i); @@ -318,10 +604,12 @@ Deno.test("deleting a reviewer preserves the decision and clears its current rev const reviewerClient = createClient(SUPABASE_URL, anonKey, { auth: { autoRefreshToken: false, persistSession: false }, }); - const { error: signInError } = await reviewerClient.auth.signInWithPassword({ - email, - password: "password123", - }); + const { error: signInError } = await reviewerClient.auth.signInWithPassword( + { + email, + password: "password123", + }, + ); if (signInError) throw signInError; const { data: pack, error: packError } = await adminClient diff --git a/apps/supabase/tests/submit-response.test.ts b/apps/supabase/tests/submit-response.test.ts index 96fa385..b634d72 100644 --- a/apps/supabase/tests/submit-response.test.ts +++ b/apps/supabase/tests/submit-response.test.ts @@ -10,6 +10,7 @@ Deno.test("submit-response: creates match when both users answer yes", async () let coupleId: string | undefined; let questionId: string | undefined; let packId: string | undefined; + let archivedQuestionId: string | undefined; let users: string[] = []; try { @@ -25,6 +26,7 @@ Deno.test("submit-response: creates match when both users answer yes", async () const qData = await createTestQuestion(); questionId = qData.question!.id; packId = qData.packId; + archivedQuestionId = qData.archivedQuestion!.id; // 3. Sign In Users to get tokens const session1 = await signInUser(email1); @@ -33,6 +35,21 @@ Deno.test("submit-response: creates match when both users answer yes", async () assert(session1?.access_token, "User 1 should have access token"); assert(session2?.access_token, "User 2 should have access token"); + // A stale installed client may still hold an archived question ID. The + // service-role Edge Function must reject it instead of bypassing RLS. + const archivedResponse = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Authorization": `Bearer ${session1!.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + question_id: archivedQuestionId, + answer: "yes", + }), + }); + assertEquals(archivedResponse.status, 404); + // 4. User 1 submits 'yes' const res1 = await fetch(FUNCTION_URL, { method: "POST", diff --git a/apps/supabase/tests/utils.ts b/apps/supabase/tests/utils.ts index dc5efdd..3278b9f 100644 --- a/apps/supabase/tests/utils.ts +++ b/apps/supabase/tests/utils.ts @@ -4,10 +4,14 @@ import { assert } from "std/testing/asserts.ts"; export const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "http://127.0.0.1:54321"; export const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); if (!SUPABASE_SERVICE_ROLE_KEY) { throw new Error("SUPABASE_SERVICE_ROLE_KEY is required"); } +if (!SUPABASE_ANON_KEY) { + throw new Error("SUPABASE_ANON_KEY is required"); +} export const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { auth: { @@ -30,7 +34,10 @@ export async function createTestUser(email: string) { } export async function signInUser(email: string) { - const { data, error } = await adminClient.auth.signInWithPassword({ + const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY!, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + const { data, error } = await userClient.auth.signInWithPassword({ email, password: "password123", }); @@ -41,6 +48,7 @@ export async function signInUser(email: string) { export async function createTestQuestion(packId?: string) { let finalPackId = packId; + let createdPack = false; if (!finalPackId) { const { data: pack } = await adminClient.from("question_packs").insert({ name: "Test Pack " + Math.random(), @@ -48,6 +56,7 @@ export async function createTestQuestion(packId?: string) { sort_order: 999 }).select().single(); finalPackId = pack!.id; + createdPack = true; } const { data: question } = await adminClient.from("questions").insert({ @@ -56,7 +65,42 @@ export async function createTestQuestion(packId?: string) { intensity: 1 }).select().single(); - return { question, packId: finalPackId }; + const { data: archivedQuestion } = await adminClient.from("questions").insert({ + pack_id: finalPackId, + text: "Archived Test Question?", + intensity: 1 + }).select().single(); + + if (createdPack) { + const { error: packReviewError } = await adminClient + .from("question_packs") + .update({ + content_status: "allowed", + content_review_reason: "Approved local integration-test fixture", + }) + .eq("id", finalPackId); + if (packReviewError) throw packReviewError; + } + + const { error: questionReviewError } = await adminClient + .from("questions") + .update({ + content_status: "allowed", + content_review_reason: "Approved local integration-test fixture", + }) + .eq("id", question!.id); + if (questionReviewError) throw questionReviewError; + + const { error: archivedQuestionReviewError } = await adminClient + .from("questions") + .update({ + content_status: "archived", + content_review_reason: "Archived local integration-test fixture", + }) + .eq("id", archivedQuestion!.id); + if (archivedQuestionReviewError) throw archivedQuestionReviewError; + + return { question, archivedQuestion, packId: finalPackId }; } export async function createRedemptionCode() { diff --git a/docs/product/catalog-content-safety-plan.md b/docs/product/catalog-content-safety-plan.md index 3e3083c..3b04b1c 100644 --- a/docs/product/catalog-content-safety-plan.md +++ b/docs/product/catalog-content-safety-plan.md @@ -1,8 +1,10 @@ # Universal catalogue safety and store compliance implementation plan Status: product direction confirmed for one universal iOS/Android catalogue. -The additive review-metadata preparation is implemented locally; no production -schema, content visibility, or customer application behaviour has changed. +The additive review metadata and conservative archive classification are live. +The legacy-client cutover uses RLS, guarded RPCs, write rejection, match hiding, +and pack-setting enforcement so installed iOS and Android clients receive the +same explicitly allowed catalogue without a platform-specific fork. ## 1. Outcome and non-negotiable invariants diff --git a/docs/schema.md b/docs/schema.md index a421efb..7201298 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -37,7 +37,7 @@ erDiagram text icon boolean is_premium boolean is_public - content_review_status content_status "metadata only; not yet enforced" + content_review_status content_status "allowed required for customer access" int sort_order timestamptz created_at } @@ -51,7 +51,7 @@ erDiagram text[] allowed_couple_genders "couple composition filter" text[] target_user_genders "individual user filter" text[] required_props "props/accessories required" - content_review_status content_status "metadata only; not yet enforced" + content_review_status content_status "allowed required for customer access" timestamptz created_at } @@ -179,9 +179,12 @@ make an authenticated decision, reviewer identity/time are database-managed, and visible content edits reset an unchanged decision to `unreviewed`. `content_reviews` is the append-only status-change history. Only super-admins -may read it through the authenticated API. The current migration is preparatory: -existing RLS, RPCs, and customer applications do not filter `content_status` -until the separately verified universal enforcement phase. +may read it through the authenticated API. Customer catalogue RLS and the legacy +recommendation/teaser RPCs fail closed: a category, pack, and question or dare +must all be `allowed`. `unreviewed` and `archived` content remains available to +administrators but is not returned to customer clients. Service-role response +functions independently reject questions unless both question and pack are +allowed. ## Key Flows @@ -200,7 +203,8 @@ until the separately verified universal enforcement phase. ### Pack Selection Flow 1. Couples can enable/disable packs via `couple_packs` junction table -2. `get_recommended_questions()` function filters questions by enabled packs +2. `get_recommended_questions()` filters by enabled packs and the universal + allowed-content boundary 3. Two-part questions show `partner_text` to second responder ### Question Targeting System