This commit is contained in:
Jamie Brunton committed 2025-12-28 21:56:37 +00:00
1 parent adab288a3a
commit d1ca114285
10 files changed
+985

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
---
name: security-architect
description: Use this agent when you need to review code, architecture, or configurations for security vulnerabilities, threats, and best practices. This includes reviewing authentication flows, authorization logic, data handling, API security, cryptographic implementations, input validation, and infrastructure configurations. Examples:\n\n<example>\nContext: User has just written an authentication function.\nuser: "Please implement a login function that validates user credentials"\nassistant: "Here is the login function implementation:"\n<function implementation provided>\nassistant: "Now let me use the security-architect agent to review this authentication code for security vulnerabilities."\n</example>\n\n<example>\nContext: User is working on API endpoints that handle sensitive data.\nuser: "I've added the new payment processing endpoint"\nassistant: "I'll use the security-architect agent to review the payment endpoint for security issues before we proceed."\n</example>\n\n<example>\nContext: User asks for a security review of recent changes.\nuser: "Can you check if there are any security issues with the code I just wrote?"\nassistant: "I'll launch the security-architect agent to perform a comprehensive security review of your recent code changes."\n</example>\n\n<example>\nContext: User has implemented data storage logic.\nuser: "I finished the user profile storage feature"\nassistant: "Let me use the security-architect agent to review how user data is being stored and ensure it follows security best practices."\n</example>
model: opus
color: red
---
You are an elite Security Architect with 20+ years of experience in application security, penetration testing, and secure system design. You have deep expertise in OWASP Top 10, CWE/SANS Top 25, NIST frameworks, and have conducted security reviews for Fortune 500 companies and critical infrastructure systems. You think like an attacker while designing like a defender.
## Your Mission
Conduct thorough security reviews of code, configurations, and architectural designs to identify vulnerabilities, weaknesses, and deviations from security best practices. Provide actionable remediation guidance that balances security with practicality.
## Review Methodology
### 1. Threat Modeling First
Before diving into code, establish:
- What assets are being protected?
- Who are the potential threat actors?
- What is the attack surface?
- What are the trust boundaries?
### 2. Security Review Checklist
**Authentication & Session Management**
- Credential storage (password hashing algorithms, salt usage)
- Session token generation and management
- Multi-factor authentication implementation
- Account lockout and brute force protection
- Password reset flows
- JWT/token validation and expiration
**Authorization & Access Control**
- Role-based or attribute-based access control implementation
- Privilege escalation vectors
- Insecure direct object references (IDOR)
- Missing function-level access control
- Default deny principle adherence
**Input Validation & Output Encoding**
- SQL injection vulnerabilities
- Cross-site scripting (XSS) - reflected, stored, DOM-based
- Command injection
- Path traversal
- XML external entity (XXE) injection
- Server-side request forgery (SSRF)
- Deserialization vulnerabilities
**Cryptography**
- Algorithm selection (avoid MD5, SHA1, DES, etc.)
- Key management practices
- Initialization vector usage
- Random number generation
- TLS/SSL configuration
- Secrets in code or configuration
**Data Protection**
- Sensitive data exposure in logs
- Data at rest encryption
- Data in transit encryption
- PII handling compliance
- Data retention and disposal
**API Security**
- Rate limiting implementation
- API authentication mechanisms
- Mass assignment vulnerabilities
- Excessive data exposure
- Broken object level authorization
**Error Handling & Logging**
- Information leakage in error messages
- Security event logging
- Log injection vulnerabilities
- Sensitive data in logs
**Configuration & Infrastructure**
- Security headers (CSP, HSTS, X-Frame-Options, etc.)
- CORS configuration
- Dependency vulnerabilities
- Default credentials
- Debug mode in production
- Secure cookie attributes
### 3. Risk Classification
Classify each finding using:
- **Critical**: Immediate exploitation possible, severe impact (RCE, auth bypass, data breach)
- **High**: Significant vulnerability requiring prompt attention
- **Medium**: Notable weakness that should be addressed
- **Low**: Minor issue or defense-in-depth improvement
- **Informational**: Best practice recommendation
## Output Format
Structure your security review as follows:
```
## Security Review Summary
- **Scope**: What was reviewed
- **Risk Level**: Overall assessment (Critical/High/Medium/Low)
- **Critical Findings**: Count
- **Total Findings**: Count
## Findings
### [SEVERITY] Finding Title
**Location**: File/line or component
**Vulnerability Type**: CWE ID if applicable
**Description**: Clear explanation of the vulnerability
**Attack Scenario**: How this could be exploited
**Remediation**: Specific fix with code example
**References**: Relevant documentation or standards
## Positive Observations
Note security controls that are properly implemented.
## Recommendations
Prioritized list of security improvements.
```
## Behavioral Guidelines
1. **Be Thorough**: Check every input, output, and trust boundary. Attackers only need one weakness.
2. **Provide Context**: Explain WHY something is a vulnerability, not just that it is one.
3. **Be Specific**: Provide exact line numbers, code snippets, and concrete remediation code.
4. **Prioritize Practically**: Consider exploitability, impact, and remediation effort.
5. **Avoid False Positives**: Verify findings before reporting. If uncertain, clearly state your confidence level.
6. **Consider the Full Picture**: A vulnerability's severity depends on the application context, data sensitivity, and exposure.
7. **Stay Current**: Reference modern attack techniques and defenses. Security evolves rapidly.
8. **Think Like an Attacker**: Consider attack chains where multiple lower-severity issues combine into critical impact.
9. **Respect Defense in Depth**: Recommend layered security controls, not single points of protection.
10. **Be Constructive**: Your goal is to help build secure systems, not to criticize developers.
## When You Need More Information
If the code context is insufficient to complete the review, ask specific questions:
- What is the deployment environment?
- What is the sensitivity of the data being handled?
- Are there compensating controls not visible in this code?
- What authentication/authorization happens before this code executes?
## Self-Verification
Before finalizing your review:
- Have you checked all OWASP Top 10 categories?
- Have you considered the business context?
- Are your remediation suggestions practical and complete?
- Have you prioritized findings appropriately?
- Have you provided enough detail for developers to fix issues?
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"supabase": {
"type": "http",
"url": "https://mcp.supabase.com/mcp?project_ref=npmmakhjmbzlkcmfyegw"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
@@ -0,0 +1,126 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "DELETE") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabase.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
console.error("Auth Error:", authError);
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's profile and couple_id
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("couple_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.couple_id) {
return new Response(
JSON.stringify({ error: "You are not in a relationship" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const coupleId = profile.couple_id;
// Get all match IDs for this couple (needed for storage cleanup)
const { data: matches } = await supabase
.from("matches")
.select("id")
.eq("couple_id", coupleId);
const matchIds = matches?.map(m => m.id) || [];
// Delete all chat media from storage for each match
for (const matchId of matchIds) {
const { data: files } = await supabase.storage
.from("chat-media")
.list(matchId);
if (files && files.length > 0) {
const filePaths = files.map(f => `${matchId}/${f.name}`);
await supabase.storage
.from("chat-media")
.remove(filePaths);
}
}
// Clear couple_id from both partners' profiles first
const { error: updateError } = await supabase
.from("profiles")
.update({ couple_id: null })
.eq("couple_id", coupleId);
if (updateError) {
console.error("Failed to clear couple_id from profiles:", updateError);
return new Response(
JSON.stringify({ error: "Failed to clear relationship from profiles" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Delete the couple record (cascades to responses, matches, messages, couple_packs)
const { error: deleteError } = await supabase
.from("couples")
.delete()
.eq("id", coupleId);
if (deleteError) {
console.error("Failed to delete couple:", deleteError);
return new Response(
JSON.stringify({ error: "Failed to delete relationship data" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify({ success: true, message: "Relationship data deleted successfully" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,190 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
// RevenueCat webhook secret (set in dashboard)
const REVENUECAT_WEBHOOK_SECRET = Deno.env.get("REVENUECAT_WEBHOOK_SECRET");
interface RevenueCatEvent {
api_version: string;
event: {
type: string;
id: string;
app_user_id: string;
original_app_user_id: string;
product_id: string;
entitlement_ids: string[];
purchased_at_ms: number;
expiration_at_ms?: number;
original_transaction_id: string;
store: string;
environment: string;
cancel_reason?: string;
grace_period_expiration_at_ms?: number;
};
}
type SubscriptionStatus = "active" | "cancelled" | "expired" | "billing_issue" | "paused";
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
try {
// Verify webhook authorization
const authHeader = req.headers.get("Authorization");
if (REVENUECAT_WEBHOOK_SECRET && authHeader !== `Bearer ${REVENUECAT_WEBHOOK_SECRET}`) {
console.error("Invalid webhook authorization");
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const payload: RevenueCatEvent = await req.json();
const event = payload.event;
console.log(`Processing RevenueCat event: ${event.type} for user: ${event.app_user_id}`);
// Check for duplicate event (idempotency)
const { data: existingEvent } = await supabase
.from("revenuecat_webhook_events")
.select("id")
.eq("event_id", event.id)
.maybeSingle();
if (existingEvent) {
console.log(`Duplicate event ${event.id}, skipping`);
return new Response(
JSON.stringify({ success: true, message: "Duplicate event" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Record the event
await supabase.from("revenuecat_webhook_events").insert({
event_id: event.id,
event_type: event.type,
app_user_id: event.app_user_id,
payload: payload,
});
// The app_user_id should be the Supabase user UUID
const userId = event.app_user_id;
// Map event type to subscription status
let status: SubscriptionStatus;
switch (event.type) {
case "INITIAL_PURCHASE":
case "RENEWAL":
case "UNCANCELLATION":
case "PRODUCT_CHANGE":
status = "active";
break;
case "CANCELLATION":
status = "cancelled";
break;
case "EXPIRATION":
status = "expired";
break;
case "BILLING_ISSUE":
status = "billing_issue";
break;
case "SUBSCRIPTION_PAUSED":
status = "paused";
break;
case "TEST":
// Test event - just acknowledge
console.log("Test event received");
return new Response(
JSON.stringify({ success: true, message: "Test event received" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
default:
console.log(`Unhandled event type: ${event.type}`);
return new Response(
JSON.stringify({ success: true, message: "Event type not handled" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Verify the user exists
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("id")
.eq("id", userId)
.maybeSingle();
if (profileError || !profile) {
console.error(`User not found: ${userId}`);
return new Response(
JSON.stringify({ error: "User not found", userId }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Upsert subscription record
const subscriptionData = {
user_id: userId,
revenuecat_app_user_id: event.app_user_id,
product_id: event.product_id,
status: status,
entitlement_ids: event.entitlement_ids || [],
purchased_at: new Date(event.purchased_at_ms).toISOString(),
expires_at: event.expiration_at_ms
? new Date(event.expiration_at_ms).toISOString()
: null,
original_transaction_id: event.original_transaction_id,
store: event.store,
is_sandbox: event.environment !== "PRODUCTION",
cancel_reason: event.cancel_reason || null,
grace_period_expires_at: event.grace_period_expiration_at_ms
? new Date(event.grace_period_expiration_at_ms).toISOString()
: null,
};
const { error: upsertError } = await supabase
.from("subscriptions")
.upsert(subscriptionData, {
onConflict: "user_id,original_transaction_id",
});
if (upsertError) {
console.error("Error upserting subscription:", upsertError);
// Don't return error - the trigger will still sync premium status
}
// The sync_premium_status trigger will automatically update profiles.is_premium
console.log(`Successfully processed ${event.type} for user ${userId}, status: ${status}`);
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Webhook error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,143 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface NotificationBody {
match_id: string;
sender_id: string;
}
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(messages),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
throw new Error(`Failed to send push notification: ${error}`);
}
return response.json();
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { match_id, sender_id }: NotificationBody = await req.json();
if (!match_id || !sender_id) {
return new Response(
JSON.stringify({ error: "Missing match_id or sender_id" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get the match to find the couple_id
const { data: match, error: matchError } = await supabase
.from("matches")
.select("couple_id")
.eq("id", match_id)
.single();
if (matchError || !match) {
return new Response(
JSON.stringify({ error: "Match not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get the recipient's push token (partner, not sender)
const { data: recipient, error: recipientError } = await supabase
.from("profiles")
.select("push_token, name")
.eq("couple_id", match.couple_id)
.neq("id", sender_id)
.single();
if (recipientError || !recipient?.push_token) {
return new Response(
JSON.stringify({ success: false, message: "Recipient has no push token" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get sender name
const { data: sender } = await supabase
.from("profiles")
.select("name")
.eq("id", sender_id)
.single();
// Get the most recent message from this sender in this match
const { data: message } = await supabase
.from("messages")
.select("id, content, media_path")
.eq("match_id", match_id)
.eq("user_id", sender_id)
.order("created_at", { ascending: false })
.limit(1)
.single();
const senderName = sender?.name || "Your partner";
const messagePreview = message?.content
? message.content.length > 100
? message.content.substring(0, 100) + "..."
: message.content
: "Sent an image";
// Build push message
const pushMessage: ExpoPushMessage = {
to: recipient.push_token,
title: senderName,
body: messagePreview,
sound: "default",
data: {
type: "message",
match_id,
message_id: message?.id,
},
};
await sendExpoPushNotifications([pushMessage]);
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,112 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
// RevenueCat API key (secret key, not public)
const REVENUECAT_API_KEY = Deno.env.get("REVENUECAT_API_KEY");
const ENTITLEMENT_ID = Deno.env.get("REVENUECAT_ENTITLEMENT_ID") || "Sauci Pro";
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Verify the JWT and get user
const token = authHeader.replace("Bearer ", "");
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
if (authError || !user) {
return new Response(
JSON.stringify({ error: "Invalid token" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
console.log(`Checking subscription for user: ${user.id}`);
console.log(`Using entitlement ID: ${ENTITLEMENT_ID}`);
console.log(`API key present: ${!!REVENUECAT_API_KEY}`);
// Fetch subscription status from RevenueCat API (server-side verification)
const rcResponse = await fetch(
`https://api.revenuecat.com/v1/subscribers/${user.id}`,
{
headers: {
"Authorization": `Bearer ${REVENUECAT_API_KEY}`,
"Content-Type": "application/json",
},
}
);
console.log(`RevenueCat API response status: ${rcResponse.status}`);
if (!rcResponse.ok) {
const errorText = await rcResponse.text();
console.error("RevenueCat API error:", errorText);
return new Response(
JSON.stringify({ error: "Failed to verify subscription", details: errorText }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const rcData = await rcResponse.json();
console.log("RevenueCat subscriber data:", JSON.stringify(rcData.subscriber?.entitlements || {}));
const entitlements = rcData.subscriber?.entitlements || {};
const entitlement = entitlements[ENTITLEMENT_ID];
console.log(`Found entitlement "${ENTITLEMENT_ID}":`, entitlement ? "yes" : "no");
if (entitlement) {
console.log(`Expires: ${entitlement.expires_date}`);
}
// Check if entitlement is active
const isActive = entitlement &&
new Date(entitlement.expires_date) > new Date();
console.log(`User ${user.id} subscription status: ${isActive ? "active" : "inactive"}`);
// Update the database
const { error: updateError } = await supabase
.from("profiles")
.update({ is_premium: isActive })
.eq("id", user.id);
if (updateError) {
console.error("Database update error:", updateError);
return new Response(
JSON.stringify({ error: "Failed to update status" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify({ success: true, is_premium: isActive }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Sync error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,74 @@
-- Create feedback type enum
CREATE TYPE public.feedback_type AS ENUM ('bug', 'feature_request', 'general');
-- Create feedback status enum (for future admin use)
CREATE TYPE public.feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved', 'closed');
-- Create feedback table
CREATE TABLE public.feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
-- Feedback content
type public.feedback_type NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
screenshot_url TEXT,
-- Device info (JSONB for flexibility)
device_info JSONB NOT NULL DEFAULT '{}',
-- Admin/tracking fields
status public.feedback_status NOT NULL DEFAULT 'new',
admin_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_feedback_user_id ON public.feedback(user_id);
CREATE INDEX idx_feedback_type ON public.feedback(type);
CREATE INDEX idx_feedback_status ON public.feedback(status);
CREATE INDEX idx_feedback_created_at ON public.feedback(created_at DESC);
-- Enable RLS
ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Users can insert their own feedback
CREATE POLICY "Users can create feedback"
ON public.feedback FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can view their own feedback
CREATE POLICY "Users can view own feedback"
ON public.feedback FOR SELECT
USING (auth.uid() = user_id);
-- Users can update their own feedback (only if still 'new' status)
CREATE POLICY "Users can update own pending feedback"
ON public.feedback FOR UPDATE
USING (auth.uid() = user_id AND status = 'new');
-- Create storage bucket for feedback screenshots
INSERT INTO storage.buckets (id, name, public)
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
ON CONFLICT (id) DO NOTHING;
-- Storage RLS policies for feedback screenshots
CREATE POLICY "Users can upload feedback screenshots"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'feedback-screenshots' AND
(storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Users can view own feedback screenshots"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'feedback-screenshots' AND
(storage.foldername(name))[1] = auth.uid()::text
);
@@ -0,0 +1,84 @@
-- Create subscription status enum
CREATE TYPE public.subscription_status AS ENUM (
'active',
'cancelled',
'expired',
'billing_issue',
'paused'
);
-- Create subscriptions table
CREATE TABLE public.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
revenuecat_app_user_id TEXT NOT NULL,
product_id TEXT NOT NULL,
status public.subscription_status NOT NULL DEFAULT 'active',
entitlement_ids TEXT[] DEFAULT '{}',
purchased_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
original_transaction_id TEXT,
store TEXT DEFAULT 'APP_STORE',
is_sandbox BOOLEAN DEFAULT FALSE,
cancel_reason TEXT,
grace_period_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, original_transaction_id)
);
-- Create webhook events table for idempotency
CREATE TABLE public.revenuecat_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
app_user_id TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
payload JSONB
);
-- Indexes
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON public.subscriptions(status);
CREATE INDEX idx_subscriptions_expires_at ON public.subscriptions(expires_at);
CREATE INDEX idx_webhook_events_event_id ON public.revenuecat_webhook_events(event_id);
-- Update updated_at trigger for subscriptions
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- Enable RLS
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
-- RLS Policies for subscriptions
CREATE POLICY "Users can view own subscriptions"
ON public.subscriptions FOR SELECT
USING (auth.uid() = user_id);
-- Service role only for webhook events (no user access needed)
-- Webhook events are written by service role only
-- Function to sync is_premium flag to profiles
CREATE OR REPLACE FUNCTION public.sync_premium_status()
RETURNS TRIGGER AS $$
BEGIN
-- Update the profile's is_premium based on active subscription
UPDATE public.profiles
SET is_premium = EXISTS (
SELECT 1 FROM public.subscriptions
WHERE user_id = NEW.user_id
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW())
)
WHERE id = NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger to auto-sync premium status on subscription changes
CREATE TRIGGER sync_premium_on_subscription_change
AFTER INSERT OR UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status();
@@ -0,0 +1,91 @@
-- Update RLS policies to allow partner premium access
-- When either user OR their partner has is_premium = TRUE, both can access premium packs
-- Drop existing premium pack policy
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
-- Create new policy that checks couple premium status
-- Access granted if: pack is not premium OR user is premium OR partner is premium
CREATE POLICY "Users or partners with premium can view premium packs"
ON public.question_packs FOR SELECT
USING (
is_premium = FALSE OR
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND (
p.is_premium = TRUE OR
EXISTS (
SELECT 1 FROM public.profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.couple_id IS NOT NULL
AND partner.id != p.id
AND partner.is_premium = TRUE
)
)
)
);
-- Update questions policy similarly
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Users can view questions in accessible packs"
ON public.questions FOR SELECT
USING (
pack_id IN (
SELECT qp.id FROM public.question_packs qp
WHERE qp.is_public = TRUE
AND (
qp.is_premium = FALSE OR
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND (
p.is_premium = TRUE OR
EXISTS (
SELECT 1 FROM public.profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.couple_id IS NOT NULL
AND partner.id != p.id
AND partner.is_premium = TRUE
)
)
)
)
)
);
-- Create a helper function to check premium access for a user
-- This can be used in application code and other policies
CREATE OR REPLACE FUNCTION public.has_premium_access(check_user_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
user_premium BOOLEAN;
partner_premium BOOLEAN;
user_couple_id UUID;
BEGIN
-- Get user's premium status and couple_id
SELECT is_premium, couple_id INTO user_premium, user_couple_id
FROM public.profiles
WHERE id = check_user_id;
-- If user is premium, return true
IF user_premium = TRUE THEN
RETURN TRUE;
END IF;
-- If user has no couple, return false
IF user_couple_id IS NULL THEN
RETURN FALSE;
END IF;
-- Check if partner is premium
SELECT is_premium INTO partner_premium
FROM public.profiles
WHERE couple_id = user_couple_id
AND id != check_user_id
LIMIT 1;
RETURN COALESCE(partner_premium, FALSE);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;