From c0650ed5b734798a8ab81c64fc45d1c269e780d2 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 28 Aug 2026 13:15:33 +0100 Subject: [PATCH] feat(content): enforce reviewed catalogue Hide archived and unreviewed catalogue content from existing clients through RLS and guarded recommendation RPCs. Reject stale writes, preserve historical rows, and seed reviewed defaults when the cutover removes a couple's full active selection. --- .../supabase/functions/manage-couple/index.ts | 39 +- .../process-scheduled-releases/index.ts | 23 +- .../functions/submit-response/index.ts | 24 +- .../functions/update-response/index.ts | 38 +- ...260828114256_enforce_allowed_catalogue.sql | 452 ++++++++++++++++++ .../tests/content-review-metadata.test.ts | 318 +++++++++++- apps/supabase/tests/submit-response.test.ts | 17 + apps/supabase/tests/utils.ts | 48 +- docs/product/catalog-content-safety-plan.md | 6 +- docs/schema.md | 16 +- 10 files changed, 924 insertions(+), 57 deletions(-) create mode 100644 apps/supabase/migrations/20260828114256_enforce_allowed_catalogue.sql 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