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.
This commit is contained in:
jjbrunton committed 2026-08-28 13:15:33 +01:00
1 parent 94c76535a4
commit c0650ed5b7
10 files changed
+924 -57

No files matched your search

+25 -14
View File
@@ -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
@@ -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" } }
@@ -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" } }
@@ -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(
@@ -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;
@@ -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
@@ -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",
+46 -2
View File
@@ -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() {
+4 -2
View File
@@ -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
+10 -6
View File
@@ -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