This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
main
+1
View File
@@ -0,0 +1 @@
v2.90.0
+411
View File
@@ -0,0 +1,411 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "ProjectX"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:1420"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = [
"http://localhost:1420",
"http://localhost:1420/auth/callback",
"http://127.0.0.1:1420",
"http://127.0.0.1:1420/auth/callback",
"chatapp://auth/callback",
]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Magic link email template — shows the 6-digit OTP prominently so users
# can paste it into the desktop/mobile app (native webviews can't receive
# the redirect that a browser click would).
[auth.email.template.magic_link]
subject = "ChatApp — Magic Link"
content_path = "./supabase/templates/magic_link.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
+6
View File
@@ -0,0 +1,6 @@
# Supabase Edge Functions env. Loaded by `supabase functions serve`.
# Rotate LIVEKIT_API_SECRET before any prod deployment.
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=devsecret-at-least-32-bytes-long-please
@@ -0,0 +1,118 @@
// Supabase Edge Function — mints a short-lived LiveKit access token after
// verifying that the caller is an accepted member of the target conversation.
//
// The LiveKit secret never leaves the server. Clients POST { conversationId }
// with their Supabase JWT and receive { token, url, roomName, identity }.
// deno-lint-ignore-file no-explicit-any
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.46.0';
import { SignJWT } from 'https://esm.sh/jose@5.9.6';
const CORS_HEADERS = {
'access-control-allow-origin': '*',
'access-control-allow-headers':
'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
};
interface RequestBody {
conversationId?: string;
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
if (req.method !== 'POST') {
return json({ error: 'method-not-allowed' }, 405);
}
const apiKey = Deno.env.get('LIVEKIT_API_KEY');
const apiSecret = Deno.env.get('LIVEKIT_API_SECRET');
const livekitUrl = Deno.env.get('LIVEKIT_URL');
if (!apiKey || !apiSecret || !livekitUrl) {
return json({ error: 'livekit-not-configured' }, 500);
}
const auth = req.headers.get('authorization') ?? '';
const userJwt = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!userJwt) {
return json({ error: 'unauthorized' }, 401);
}
// Per-request client with the caller's JWT so RLS applies.
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
const client = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: auth } },
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: userRes, error: userErr } = await client.auth.getUser();
if (userErr || !userRes.user) {
return json({ error: 'unauthorized' }, 401);
}
const userId = userRes.user.id;
const body = (await req.json().catch(() => ({}))) as RequestBody;
const conversationId = body.conversationId?.trim();
if (!conversationId) {
return json({ error: 'conversation-id-required' }, 400);
}
// Membership check (RLS + explicit accepted flag).
const { data: member, error: mErr } = await client
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId)
.eq('user_id', userId)
.maybeSingle();
if (mErr || !member || !member.accepted) {
return json({ error: 'forbidden' }, 403);
}
const { data: profile } = await client
.from('profiles')
.select('username, display_name, banned')
.eq('user_id', userId)
.maybeSingle();
if (profile?.banned) {
return json({ error: 'banned' }, 403);
}
// LiveKit JWT — HS256 signed with API secret.
const nowSec = Math.floor(Date.now() / 1000);
const claims: Record<string, unknown> = {
iss: apiKey,
sub: userId,
name: profile?.display_name ?? profile?.username ?? userId,
nbf: nowSec,
exp: nowSec + 60 * 10, // 10 min
video: {
room: conversationId,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
},
};
const secretBytes = new TextEncoder().encode(apiSecret);
const token = await new SignJWT(claims as any)
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.sign(secretBytes);
return json({
token,
url: livekitUrl,
roomName: conversationId,
identity: userId,
});
});
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json', ...CORS_HEADERS },
});
}
@@ -0,0 +1,958 @@
-- ============================================================================
-- Milestone 1 initial schema.
--
-- Design decisions (locked in by owner):
-- * Per-device X25519 keypairs. Messages encrypted per recipient device.
-- * Admin flag lives on profiles.
-- * DMs require friendship OR allow_dms_from_strangers on receiver.
-- * Group roles: admin / mod / member.
-- * Edits: own messages only, 24h window.
-- * Soft-delete everywhere.
-- * Reactions: multiple emojis per user-message, same emoji once.
-- * Read receipts: reciprocal opt-out via profiles.show_read_receipts.
-- * Presence state only (no last-seen timestamp).
-- * Attachments: separate table, images for M1.
-- * Server is zero-knowledge: only envelopes hold ciphertext.
-- ============================================================================
-- ============================================================================
-- Extensions
-- ============================================================================
create extension if not exists citext;
create extension if not exists pgcrypto;
-- ============================================================================
-- Enums
-- ============================================================================
create type public.presence_state as enum ('online', 'idle', 'dnd', 'invisible', 'offline');
create type public.conversation_type as enum ('dm', 'group');
create type public.member_role as enum ('admin', 'mod', 'member');
create type public.friendship_status as enum ('pending', 'accepted', 'blocked');
create type public.device_platform as enum ('ios', 'android', 'macos', 'windows', 'linux');
-- ============================================================================
-- profiles
-- One row per auth.users row. username is the login handle (unique, case-
-- insensitive). display_name is free-form.
-- ============================================================================
create table public.profiles (
user_id uuid primary key references auth.users(id) on delete cascade,
username citext not null unique,
display_name text not null,
avatar_url text,
status_message text,
presence_state public.presence_state not null default 'offline',
show_read_receipts boolean not null default true,
allow_dms_from_strangers boolean not null default true,
is_admin boolean not null default false,
banned boolean not null default false,
blocked_from_inviting boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint profiles_username_format check (username ~ '^[a-z0-9_]{3,32}$'),
constraint profiles_display_name_len check (length(display_name) between 1 and 64),
constraint profiles_status_message_len check (status_message is null or length(status_message) <= 128)
);
-- ============================================================================
-- devices
-- Each physical device registers itself and owns its own X25519 public key.
-- Private key never leaves the device. Messages encrypted per recipient
-- device => per-device envelope rows.
-- ============================================================================
create table public.devices (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
platform public.device_platform not null,
public_key bytea not null,
last_seen_at timestamptz not null default now(),
created_at timestamptz not null default now(),
constraint devices_name_len check (length(name) between 1 and 64)
);
create index devices_user_idx on public.devices(user_id);
-- ============================================================================
-- push_tokens
-- ============================================================================
create table public.push_tokens (
device_id uuid primary key references public.devices(id) on delete cascade,
platform public.device_platform not null,
token text not null,
updated_at timestamptz not null default now()
);
-- ============================================================================
-- invites (signup invites, admin-controlled)
-- ============================================================================
create table public.invites (
code text primary key,
created_by uuid references auth.users(id) on delete set null,
uses_limit integer, -- null = unlimited
uses_count integer not null default 0,
expires_at timestamptz, -- null = never
disabled boolean not null default false,
created_at timestamptz not null default now(),
constraint invites_code_len check (length(code) between 4 and 64),
constraint invites_uses_nonneg check (uses_count >= 0 and (uses_limit is null or uses_limit > 0))
);
create index invites_created_by_idx on public.invites(created_by);
-- ============================================================================
-- admin_settings (global feature flags)
-- ============================================================================
create table public.admin_settings (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
);
-- Seed default admin settings.
insert into public.admin_settings (key, value) values
('invites_enabled', 'true'::jsonb),
('default_invite_ttl_days', '30'::jsonb),
('default_invite_uses', '1'::jsonb)
on conflict (key) do nothing;
-- ============================================================================
-- friendships
-- Stored symmetrically: user_lo < user_hi to keep (A,B) == (B,A) one row.
-- requested_by records who initiated the request.
-- ============================================================================
create table public.friendships (
user_lo uuid not null references auth.users(id) on delete cascade,
user_hi uuid not null references auth.users(id) on delete cascade,
status public.friendship_status not null default 'pending',
requested_by uuid not null references auth.users(id) on delete cascade,
created_at timestamptz not null default now(),
accepted_at timestamptz,
primary key (user_lo, user_hi),
constraint friendships_ordered check (user_lo < user_hi)
);
create index friendships_hi_idx on public.friendships(user_hi);
-- ============================================================================
-- conversations
-- ============================================================================
create table public.conversations (
id uuid primary key default gen_random_uuid(),
type public.conversation_type not null,
name text, -- null for DMs
avatar_url text, -- null for DMs
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
constraint conversations_group_has_name check (
type = 'dm' or (type = 'group' and name is not null and length(name) between 1 and 64)
)
);
-- ============================================================================
-- conversation_members
-- accepted=false means the DM is still in the "request" inbox for that user.
-- ============================================================================
create table public.conversation_members (
conversation_id uuid not null references public.conversations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role public.member_role not null default 'member',
accepted boolean not null default true,
joined_at timestamptz not null default now(),
primary key (conversation_id, user_id)
);
create index conversation_members_user_idx on public.conversation_members(user_id);
-- ============================================================================
-- group_invites (invite links to join groups)
-- ============================================================================
create table public.group_invites (
code text primary key,
conversation_id uuid not null references public.conversations(id) on delete cascade,
created_by uuid references auth.users(id) on delete set null,
uses_limit integer,
uses_count integer not null default 0,
expires_at timestamptz,
disabled boolean not null default false,
created_at timestamptz not null default now(),
constraint group_invites_code_len check (length(code) between 4 and 64),
constraint group_invites_uses_nonneg check (uses_count >= 0 and (uses_limit is null or uses_limit > 0))
);
create index group_invites_conversation_idx on public.group_invites(conversation_id);
-- ============================================================================
-- messages (plaintext metadata only — ciphertext lives in envelopes)
-- ============================================================================
create table public.messages (
id uuid primary key default gen_random_uuid(),
conversation_id uuid not null references public.conversations(id) on delete cascade,
sender_id uuid not null references auth.users(id) on delete set null,
reply_to_id uuid references public.messages(id) on delete set null,
edited_at timestamptz,
deleted_at timestamptz,
deleted_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now()
);
create index messages_conversation_created_idx
on public.messages(conversation_id, created_at desc);
-- ============================================================================
-- message_envelopes (per-recipient-device ciphertext)
-- ============================================================================
create table public.message_envelopes (
message_id uuid not null references public.messages(id) on delete cascade,
recipient_device_id uuid not null references public.devices(id) on delete cascade,
ciphertext bytea not null,
nonce bytea not null,
primary key (message_id, recipient_device_id)
);
create index message_envelopes_device_idx on public.message_envelopes(recipient_device_id);
-- ============================================================================
-- message_reactions
-- ============================================================================
create table public.message_reactions (
message_id uuid not null references public.messages(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
emoji text not null,
created_at timestamptz not null default now(),
primary key (message_id, user_id, emoji),
constraint message_reactions_emoji_len check (length(emoji) between 1 and 16)
);
-- ============================================================================
-- message_reads
-- ============================================================================
create table public.message_reads (
message_id uuid not null references public.messages(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
read_at timestamptz not null default now(),
primary key (message_id, user_id)
);
-- ============================================================================
-- message_attachments (encrypted blobs in Supabase Storage)
-- ============================================================================
create table public.message_attachments (
id uuid primary key default gen_random_uuid(),
message_id uuid not null references public.messages(id) on delete cascade,
storage_path text not null,
nonce bytea not null,
mime_type text not null,
size_bytes bigint not null,
width integer,
height integer,
created_at timestamptz not null default now(),
constraint message_attachments_size_pos check (size_bytes > 0)
);
create index message_attachments_message_idx on public.message_attachments(message_id);
-- ============================================================================
-- Helper functions (SECURITY DEFINER to dodge RLS recursion)
-- ============================================================================
create or replace function public.is_conversation_member(cid uuid)
returns boolean language sql security definer stable set search_path = public as $$
select exists (
select 1 from public.conversation_members
where conversation_id = cid and user_id = auth.uid() and accepted
);
$$;
create or replace function public.is_conversation_mod_or_higher(cid uuid)
returns boolean language sql security definer stable set search_path = public as $$
select exists (
select 1 from public.conversation_members
where conversation_id = cid
and user_id = auth.uid()
and accepted
and role in ('admin', 'mod')
);
$$;
create or replace function public.is_conversation_admin(cid uuid)
returns boolean language sql security definer stable set search_path = public as $$
select exists (
select 1 from public.conversation_members
where conversation_id = cid
and user_id = auth.uid()
and accepted
and role = 'admin'
);
$$;
create or replace function public.are_friends(a uuid, b uuid)
returns boolean language sql security definer stable set search_path = public as $$
select exists (
select 1 from public.friendships
where status = 'accepted'
and ((user_lo = least(a,b) and user_hi = greatest(a,b)))
);
$$;
create or replace function public.current_user_is_admin()
returns boolean language sql security definer stable set search_path = public as $$
select coalesce((select is_admin from public.profiles where user_id = auth.uid()), false);
$$;
-- ============================================================================
-- Enable RLS on everything user-facing
-- ============================================================================
alter table public.profiles enable row level security;
alter table public.devices enable row level security;
alter table public.push_tokens enable row level security;
alter table public.invites enable row level security;
alter table public.admin_settings enable row level security;
alter table public.friendships enable row level security;
alter table public.conversations enable row level security;
alter table public.conversation_members enable row level security;
alter table public.group_invites enable row level security;
alter table public.messages enable row level security;
alter table public.message_envelopes enable row level security;
alter table public.message_reactions enable row level security;
alter table public.message_reads enable row level security;
alter table public.message_attachments enable row level security;
-- ============================================================================
-- profiles policies
-- Everyone authenticated can look up any profile (for peer lookup + search).
-- Only the owner can update their row. No direct INSERT (handled by trigger).
-- ============================================================================
create policy profiles_select_all on public.profiles
for select to authenticated using (true);
create policy profiles_update_own on public.profiles
for update to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- ============================================================================
-- devices policies
-- ============================================================================
create policy devices_select_own on public.devices
for select to authenticated using (user_id = auth.uid());
-- Any authenticated user can read public keys of peers they converse with.
-- Simpler v1: expose all public keys. Tighten later.
create policy devices_select_all_public_key on public.devices
for select to authenticated using (true);
create policy devices_insert_own on public.devices
for insert to authenticated with check (user_id = auth.uid());
create policy devices_update_own on public.devices
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy devices_delete_own on public.devices
for delete to authenticated using (user_id = auth.uid());
-- ============================================================================
-- push_tokens policies
-- ============================================================================
create policy push_tokens_select_own on public.push_tokens
for select to authenticated
using (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid()));
create policy push_tokens_write_own on public.push_tokens
for all to authenticated
using (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid()))
with check (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid()));
-- ============================================================================
-- invites policies (admin-managed)
-- ============================================================================
create policy invites_select_own_or_admin on public.invites
for select to authenticated
using (created_by = auth.uid() or public.current_user_is_admin());
create policy invites_insert_own on public.invites
for insert to authenticated
with check (
created_by = auth.uid()
and not coalesce((select blocked_from_inviting from public.profiles where user_id = auth.uid()), false)
and coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true)
);
create policy invites_update_admin on public.invites
for update to authenticated
using (public.current_user_is_admin())
with check (public.current_user_is_admin());
create policy invites_delete_admin on public.invites
for delete to authenticated
using (public.current_user_is_admin());
-- ============================================================================
-- admin_settings policies
-- ============================================================================
create policy admin_settings_select_all on public.admin_settings
for select to authenticated using (true);
create policy admin_settings_write_admin on public.admin_settings
for all to authenticated
using (public.current_user_is_admin())
with check (public.current_user_is_admin());
-- ============================================================================
-- friendships policies
-- ============================================================================
create policy friendships_select_own on public.friendships
for select to authenticated
using (user_lo = auth.uid() or user_hi = auth.uid());
-- Insert only allowed when:
-- - row is ordered (user_lo < user_hi)
-- - one side is the caller
-- - requested_by = caller
-- - status starts at 'pending'
create policy friendships_insert_own on public.friendships
for insert to authenticated
with check (
user_lo < user_hi
and (user_lo = auth.uid() or user_hi = auth.uid())
and requested_by = auth.uid()
and status = 'pending'
);
-- Update allowed for the recipient (the party that did NOT request) to accept or
-- block. Caller can block from their side regardless.
create policy friendships_update_involved on public.friendships
for update to authenticated
using (user_lo = auth.uid() or user_hi = auth.uid())
with check (user_lo = auth.uid() or user_hi = auth.uid());
create policy friendships_delete_own on public.friendships
for delete to authenticated
using (user_lo = auth.uid() or user_hi = auth.uid());
-- ============================================================================
-- conversations policies
-- ============================================================================
create policy conversations_select_member on public.conversations
for select to authenticated using (public.is_conversation_member(id));
create policy conversations_insert_authenticated on public.conversations
for insert to authenticated with check (created_by = auth.uid());
-- Only admins of a group can update metadata (name, avatar).
create policy conversations_update_admin on public.conversations
for update to authenticated
using (type = 'group' and public.is_conversation_admin(id))
with check (type = 'group' and public.is_conversation_admin(id));
-- ============================================================================
-- conversation_members policies
-- ============================================================================
create policy members_select_co on public.conversation_members
for select to authenticated using (public.is_conversation_member(conversation_id));
-- Insert cases:
-- (a) Self-inserting as creator (user_id = auth.uid())
-- (b) Adding a peer to a DM you own
-- - dm conversation
-- - target is a friend OR target allows DMs from strangers
-- (c) Admin/Mod adding a friend to a group
create policy members_insert_complex on public.conversation_members
for insert to authenticated
with check (
-- (a) Self-insert (creator bootstrap or accepting group invite via RPC)
user_id = auth.uid()
-- (b) DM peer add by creator
or (
exists (select 1 from public.conversations c where c.id = conversation_id and c.type = 'dm' and c.created_by = auth.uid())
and (
public.are_friends(auth.uid(), user_id)
or coalesce((select allow_dms_from_strangers from public.profiles where profiles.user_id = conversation_members.user_id), false)
)
)
-- (c) Admin/Mod adding friend to group
or (
public.is_conversation_mod_or_higher(conversation_id)
and public.are_friends(auth.uid(), user_id)
)
);
-- Users can accept their own DM requests (flip accepted to true) and
-- admins/mods can change roles. Rest is restricted via triggers.
create policy members_update_self_or_admin on public.conversation_members
for update to authenticated
using (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id))
with check (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id));
-- Users can remove themselves. Admins/mods can kick.
create policy members_delete_self_or_admin on public.conversation_members
for delete to authenticated
using (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id));
-- ============================================================================
-- group_invites policies
-- ============================================================================
create policy group_invites_select_member on public.group_invites
for select to authenticated
using (public.is_conversation_member(conversation_id));
create policy group_invites_insert_mod on public.group_invites
for insert to authenticated
with check (public.is_conversation_mod_or_higher(conversation_id) and created_by = auth.uid());
create policy group_invites_update_admin on public.group_invites
for update to authenticated
using (public.is_conversation_admin(conversation_id))
with check (public.is_conversation_admin(conversation_id));
create policy group_invites_delete_admin on public.group_invites
for delete to authenticated
using (public.is_conversation_admin(conversation_id));
-- ============================================================================
-- messages policies
-- ============================================================================
create policy messages_select_member on public.messages
for select to authenticated using (public.is_conversation_member(conversation_id));
create policy messages_insert_member on public.messages
for insert to authenticated
with check (
sender_id = auth.uid()
and public.is_conversation_member(conversation_id)
);
-- UPDATE allowed by:
-- - sender editing within 24h (edited_at set)
-- - sender soft-deleting own message (deleted_at set, deleted_by = self)
-- - mod/admin soft-deleting any message in conversation (deleted_at set)
-- Detailed field-level enforcement lives in the messages_update_guard trigger.
create policy messages_update_guarded on public.messages
for update to authenticated
using (
sender_id = auth.uid()
or public.is_conversation_mod_or_higher(conversation_id)
)
with check (
sender_id = auth.uid()
or public.is_conversation_mod_or_higher(conversation_id)
);
-- ============================================================================
-- message_envelopes policies
-- ============================================================================
-- Sender inserts envelopes for all recipient devices (including their own).
create policy envelopes_insert_sender on public.message_envelopes
for insert to authenticated
with check (
exists (
select 1 from public.messages m
where m.id = message_id and m.sender_id = auth.uid()
)
);
-- Reader can fetch envelopes targeted at one of their own devices.
create policy envelopes_select_own_device on public.message_envelopes
for select to authenticated
using (
exists (
select 1 from public.devices d
where d.id = recipient_device_id and d.user_id = auth.uid()
)
);
-- Sender updates envelopes when editing message (same 24h window enforced via trigger).
create policy envelopes_update_sender on public.message_envelopes
for update to authenticated
using (
exists (select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid())
)
with check (
exists (select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid())
);
-- Cascade-delete on message_id handles most cleanup.
-- ============================================================================
-- message_reactions policies
-- ============================================================================
create policy reactions_select_member on public.message_reactions
for select to authenticated
using (exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
));
create policy reactions_insert_own on public.message_reactions
for insert to authenticated
with check (
user_id = auth.uid()
and exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
)
);
create policy reactions_delete_own on public.message_reactions
for delete to authenticated using (user_id = auth.uid());
-- ============================================================================
-- message_reads policies
-- Reciprocal opt-out: a user without show_read_receipts cannot see others' reads
-- and others cannot see theirs.
-- ============================================================================
create policy reads_select_reciprocal on public.message_reads
for select to authenticated
using (
-- Always see your own reads.
user_id = auth.uid()
or (
-- See others' reads only if caller has receipts on AND target has receipts on.
coalesce((select show_read_receipts from public.profiles where profiles.user_id = auth.uid()), true)
and coalesce((select show_read_receipts from public.profiles where profiles.user_id = message_reads.user_id), true)
and exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
)
)
);
create policy reads_insert_own on public.message_reads
for insert to authenticated
with check (
user_id = auth.uid()
and exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
)
);
-- ============================================================================
-- message_attachments policies
-- ============================================================================
create policy attachments_select_member on public.message_attachments
for select to authenticated
using (exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
));
create policy attachments_insert_sender on public.message_attachments
for insert to authenticated
with check (exists (
select 1 from public.messages m
where m.id = message_id and m.sender_id = auth.uid()
));
-- ============================================================================
-- Triggers
-- ============================================================================
-- profiles.updated_at bump
create or replace function public.set_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger profiles_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
-- messages guard: enforce edit-window, deletion rules, envelope-edit-window.
create or replace function public.messages_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
is_mod boolean := public.is_conversation_mod_or_higher(new.conversation_id);
begin
-- Already deleted? Freeze.
if old.deleted_at is not null then
raise exception 'message already deleted';
end if;
-- If deleting (deleted_at transitioning from null to not-null)
if old.deleted_at is null and new.deleted_at is not null then
if new.deleted_by is null then
new.deleted_by := caller;
end if;
if not (old.sender_id = caller or is_mod) then
raise exception 'not allowed to delete this message';
end if;
return new;
end if;
-- Otherwise: edit. Only sender, only within 24h.
if old.sender_id <> caller then
raise exception 'only sender can edit';
end if;
if now() - old.created_at > interval '24 hours' then
raise exception 'edit window (24h) expired';
end if;
new.edited_at := now();
-- Preserve immutable fields.
new.conversation_id := old.conversation_id;
new.sender_id := old.sender_id;
new.created_at := old.created_at;
return new;
end;
$$;
create trigger messages_update_guard_trigger
before update on public.messages
for each row execute function public.messages_update_guard();
-- envelope edit guard (same 24h window)
create or replace function public.envelopes_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
msg_created timestamptz;
msg_sender uuid;
begin
select created_at, sender_id into msg_created, msg_sender
from public.messages where id = new.message_id;
if msg_sender <> caller then
raise exception 'only sender can rewrite envelopes';
end if;
if now() - msg_created > interval '24 hours' then
raise exception 'envelope edit window (24h) expired';
end if;
return new;
end;
$$;
create trigger envelopes_update_guard_trigger
before update on public.message_envelopes
for each row execute function public.envelopes_update_guard();
-- Friendship update guard: only the non-requester can accept; either party can block.
create or replace function public.friendships_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
begin
-- status transition: pending -> accepted
if old.status = 'pending' and new.status = 'accepted' then
if caller = old.requested_by then
raise exception 'requester cannot self-accept';
end if;
new.accepted_at := now();
return new;
end if;
-- any -> blocked by either party: allow
if new.status = 'blocked' then
return new;
end if;
-- blocked -> anything: only blocker can unblock (requested_by holds blocker id
-- after block). To keep simple: allow any involved party to unblock.
if old.status = 'blocked' and new.status <> 'blocked' then
return new;
end if;
-- everything else: freeze
raise exception 'illegal friendship transition: % -> %', old.status, new.status;
end;
$$;
create trigger friendships_update_guard_trigger
before update on public.friendships
for each row execute function public.friendships_update_guard();
-- Signup trigger: consume invite, create profile row.
-- Expects raw_user_meta_data to contain: invite_code, username, display_name.
-- public_key is registered separately via devices table after login.
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
v_invite_code text;
v_username text;
v_display_name text;
v_invite public.invites%rowtype;
begin
v_invite_code := new.raw_user_meta_data->>'invite_code';
v_username := lower(trim(new.raw_user_meta_data->>'username'));
v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), '');
if v_display_name is null then
v_display_name := v_username;
end if;
if v_invite_code is null or length(v_invite_code) = 0 then
raise exception 'invite_code required';
end if;
if v_username is null or v_username !~ '^[a-z0-9_]{3,32}$' then
raise exception 'username invalid (lowercase alphanumeric + underscore, 3-32 chars)';
end if;
if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then
raise exception 'invites globally disabled';
end if;
select * into v_invite from public.invites
where code = v_invite_code
for update;
if not found then
raise exception 'invalid invite';
end if;
if v_invite.disabled then
raise exception 'invite disabled';
end if;
if v_invite.expires_at is not null and v_invite.expires_at < now() then
raise exception 'invite expired';
end if;
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then
raise exception 'invite exhausted';
end if;
update public.invites
set uses_count = uses_count + 1
where code = v_invite.code;
insert into public.profiles (user_id, username, display_name)
values (new.id, v_username, v_display_name);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ============================================================================
-- RPCs (SECURITY DEFINER) for flows too complex for RLS alone
-- ============================================================================
-- Create a DM with a target user. Returns conversation_id.
create or replace function public.create_dm(target_user_id uuid)
returns uuid language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
existing_conv uuid;
new_conv uuid;
target_accepts boolean;
friends boolean;
begin
if caller is null then raise exception 'not authenticated'; end if;
if caller = target_user_id then raise exception 'cannot DM self'; end if;
-- Existing DM between these two?
select c.id into existing_conv
from public.conversations c
join public.conversation_members m1 on m1.conversation_id = c.id and m1.user_id = caller
join public.conversation_members m2 on m2.conversation_id = c.id and m2.user_id = target_user_id
where c.type = 'dm'
limit 1;
if existing_conv is not null then
return existing_conv;
end if;
friends := public.are_friends(caller, target_user_id);
select allow_dms_from_strangers into target_accepts from public.profiles where user_id = target_user_id;
if not friends and not coalesce(target_accepts, false) then
raise exception 'target does not accept DMs from strangers';
end if;
insert into public.conversations (type, created_by) values ('dm', caller) returning id into new_conv;
insert into public.conversation_members (conversation_id, user_id, role, accepted)
values
(new_conv, caller, 'member', true),
(new_conv, target_user_id, 'member', friends);
return new_conv;
end;
$$;
revoke all on function public.create_dm(uuid) from public;
grant execute on function public.create_dm(uuid) to authenticated;
-- Accept a pending DM request (flip own member row to accepted=true).
create or replace function public.accept_dm(conversation_id uuid)
returns void language plpgsql security definer set search_path = public as $$
begin
update public.conversation_members
set accepted = true
where conversation_members.conversation_id = accept_dm.conversation_id
and user_id = auth.uid();
if not found then raise exception 'no pending DM found'; end if;
end;
$$;
revoke all on function public.accept_dm(uuid) from public;
grant execute on function public.accept_dm(uuid) to authenticated;
-- Redeem a group invite code.
create or replace function public.redeem_group_invite(code text)
returns uuid language plpgsql security definer set search_path = public as $$
declare
v_invite public.group_invites%rowtype;
begin
select * into v_invite from public.group_invites where group_invites.code = redeem_group_invite.code for update;
if not found then raise exception 'invalid invite'; end if;
if v_invite.disabled then raise exception 'invite disabled'; end if;
if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'invite expired'; end if;
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'invite exhausted'; end if;
insert into public.conversation_members (conversation_id, user_id, role, accepted)
values (v_invite.conversation_id, auth.uid(), 'member', true)
on conflict do nothing;
update public.group_invites set uses_count = uses_count + 1 where group_invites.code = v_invite.code;
return v_invite.conversation_id;
end;
$$;
revoke all on function public.redeem_group_invite(text) from public;
grant execute on function public.redeem_group_invite(text) to authenticated;
-- Send a friend request (normalises ordering).
create or replace function public.send_friend_request(target_user_id uuid)
returns void language plpgsql security definer set search_path = public as $$
declare
lo uuid := least(auth.uid(), target_user_id);
hi uuid := greatest(auth.uid(), target_user_id);
begin
if auth.uid() is null then raise exception 'not authenticated'; end if;
if auth.uid() = target_user_id then raise exception 'cannot befriend self'; end if;
insert into public.friendships (user_lo, user_hi, requested_by, status)
values (lo, hi, auth.uid(), 'pending')
on conflict (user_lo, user_hi) do nothing;
end;
$$;
revoke all on function public.send_friend_request(uuid) from public;
grant execute on function public.send_friend_request(uuid) to authenticated;
-- ============================================================================
-- Realtime publications
-- ============================================================================
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.message_envelopes;
alter publication supabase_realtime add table public.message_reactions;
alter publication supabase_realtime add table public.message_reads;
alter publication supabase_realtime add table public.conversation_members;
alter publication supabase_realtime add table public.conversations;
alter publication supabase_realtime add table public.profiles;
alter publication supabase_realtime add table public.friendships;
@@ -0,0 +1,270 @@
-- ============================================================================
-- i18n readiness: per-user locale column + error codes in triggers/RPCs.
-- Clients map codes to localized strings via packages/shared/src/i18n/error-map.
-- Keep codes stable — they are the public API between DB and clients.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- profiles.locale
-- ----------------------------------------------------------------------------
alter table public.profiles
add column if not exists locale text not null default 'en';
alter table public.profiles
drop constraint if exists profiles_locale_supported;
alter table public.profiles
add constraint profiles_locale_supported check (locale in ('en', 'de'));
-- ----------------------------------------------------------------------------
-- Rewrite handle_new_user to:
-- * raise error codes instead of prose
-- * accept optional `locale` in user_metadata
-- ----------------------------------------------------------------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
v_invite_code text;
v_username text;
v_display_name text;
v_locale text;
v_invite public.invites%rowtype;
begin
v_invite_code := new.raw_user_meta_data->>'invite_code';
v_username := lower(trim(new.raw_user_meta_data->>'username'));
v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), '');
v_locale := lower(trim(new.raw_user_meta_data->>'locale'));
if v_display_name is null then
v_display_name := v_username;
end if;
if v_locale is null or v_locale not in ('en', 'de') then
v_locale := 'en';
end if;
if v_invite_code is null or length(v_invite_code) = 0 then
raise exception 'ERR_INVITE_CODE_REQUIRED';
end if;
if v_username is null or v_username !~ '^[a-z0-9_]{3,32}$' then
raise exception 'ERR_USERNAME_INVALID';
end if;
if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then
raise exception 'ERR_INVITES_DISABLED';
end if;
select * into v_invite from public.invites
where code = v_invite_code
for update;
if not found then
raise exception 'ERR_INVITE_NOT_FOUND';
end if;
if v_invite.disabled then
raise exception 'ERR_INVITE_DISABLED';
end if;
if v_invite.expires_at is not null and v_invite.expires_at < now() then
raise exception 'ERR_INVITE_EXPIRED';
end if;
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then
raise exception 'ERR_INVITE_EXHAUSTED';
end if;
update public.invites
set uses_count = uses_count + 1
where code = v_invite.code;
insert into public.profiles (user_id, username, display_name, locale)
values (new.id, v_username, v_display_name, v_locale);
return new;
end;
$$;
-- ----------------------------------------------------------------------------
-- messages_update_guard with error codes.
-- ----------------------------------------------------------------------------
create or replace function public.messages_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
is_mod boolean := public.is_conversation_mod_or_higher(new.conversation_id);
begin
if old.deleted_at is not null then
raise exception 'ERR_MESSAGE_DELETED';
end if;
if old.deleted_at is null and new.deleted_at is not null then
if new.deleted_by is null then
new.deleted_by := caller;
end if;
if not (old.sender_id = caller or is_mod) then
raise exception 'ERR_DELETE_FORBIDDEN';
end if;
return new;
end if;
if old.sender_id <> caller then
raise exception 'ERR_EDIT_NOT_SENDER';
end if;
if now() - old.created_at > interval '24 hours' then
raise exception 'ERR_EDIT_WINDOW_EXPIRED';
end if;
new.edited_at := now();
new.conversation_id := old.conversation_id;
new.sender_id := old.sender_id;
new.created_at := old.created_at;
return new;
end;
$$;
-- ----------------------------------------------------------------------------
-- envelopes_update_guard with error codes.
-- ----------------------------------------------------------------------------
create or replace function public.envelopes_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
msg_created timestamptz;
msg_sender uuid;
begin
select created_at, sender_id into msg_created, msg_sender
from public.messages where id = new.message_id;
if msg_sender <> caller then
raise exception 'ERR_ENVELOPE_NOT_SENDER';
end if;
if now() - msg_created > interval '24 hours' then
raise exception 'ERR_ENVELOPE_WINDOW_EXPIRED';
end if;
return new;
end;
$$;
-- ----------------------------------------------------------------------------
-- friendships_update_guard with error codes.
-- ----------------------------------------------------------------------------
create or replace function public.friendships_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
begin
if old.status = 'pending' and new.status = 'accepted' then
if caller = old.requested_by then
raise exception 'ERR_FRIEND_SELF_ACCEPT';
end if;
new.accepted_at := now();
return new;
end if;
if new.status = 'blocked' then
return new;
end if;
if old.status = 'blocked' and new.status <> 'blocked' then
return new;
end if;
raise exception 'ERR_FRIEND_BAD_TRANSITION';
end;
$$;
-- ----------------------------------------------------------------------------
-- RPCs with error codes.
-- ----------------------------------------------------------------------------
create or replace function public.create_dm(target_user_id uuid)
returns uuid language plpgsql security definer set search_path = public as $$
declare
caller uuid := auth.uid();
existing_conv uuid;
new_conv uuid;
target_accepts boolean;
friends boolean;
begin
if caller is null then raise exception 'ERR_NOT_AUTH'; end if;
if caller = target_user_id then raise exception 'ERR_DM_SELF'; end if;
select c.id into existing_conv
from public.conversations c
join public.conversation_members m1 on m1.conversation_id = c.id and m1.user_id = caller
join public.conversation_members m2 on m2.conversation_id = c.id and m2.user_id = target_user_id
where c.type = 'dm'
limit 1;
if existing_conv is not null then
return existing_conv;
end if;
friends := public.are_friends(caller, target_user_id);
select allow_dms_from_strangers into target_accepts from public.profiles where user_id = target_user_id;
if not friends and not coalesce(target_accepts, false) then
raise exception 'ERR_DM_STRANGERS_DISABLED';
end if;
insert into public.conversations (type, created_by) values ('dm', caller) returning id into new_conv;
insert into public.conversation_members (conversation_id, user_id, role, accepted)
values
(new_conv, caller, 'member', true),
(new_conv, target_user_id, 'member', friends);
return new_conv;
end;
$$;
create or replace function public.accept_dm(conversation_id uuid)
returns void language plpgsql security definer set search_path = public as $$
begin
update public.conversation_members
set accepted = true
where conversation_members.conversation_id = accept_dm.conversation_id
and user_id = auth.uid();
if not found then raise exception 'ERR_NO_PENDING_DM'; end if;
end;
$$;
create or replace function public.redeem_group_invite(code text)
returns uuid language plpgsql security definer set search_path = public as $$
declare
v_invite public.group_invites%rowtype;
begin
select * into v_invite from public.group_invites where group_invites.code = redeem_group_invite.code for update;
if not found then raise exception 'ERR_GROUP_INVITE_NOT_FOUND'; end if;
if v_invite.disabled then raise exception 'ERR_GROUP_INVITE_DISABLED'; end if;
if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'ERR_GROUP_INVITE_EXPIRED'; end if;
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'ERR_GROUP_INVITE_EXHAUSTED'; end if;
insert into public.conversation_members (conversation_id, user_id, role, accepted)
values (v_invite.conversation_id, auth.uid(), 'member', true)
on conflict do nothing;
update public.group_invites set uses_count = uses_count + 1 where group_invites.code = v_invite.code;
return v_invite.conversation_id;
end;
$$;
create or replace function public.send_friend_request(target_user_id uuid)
returns void language plpgsql security definer set search_path = public as $$
declare
lo uuid := least(auth.uid(), target_user_id);
hi uuid := greatest(auth.uid(), target_user_id);
begin
if auth.uid() is null then raise exception 'ERR_NOT_AUTH'; end if;
if auth.uid() = target_user_id then raise exception 'ERR_FRIEND_SELF'; end if;
insert into public.friendships (user_lo, user_hi, requested_by, status)
values (lo, hi, auth.uid(), 'pending')
on conflict (user_lo, user_hi) do nothing;
end;
$$;
@@ -0,0 +1,25 @@
-- ============================================================================
-- Add sender_device_id to messages so receivers know which device's public
-- key to verify against when decrypting an envelope.
-- ============================================================================
alter table public.messages
add column if not exists sender_device_id uuid references public.devices(id) on delete set null;
create index if not exists messages_sender_device_idx on public.messages(sender_device_id);
-- Tighten the insert policy: sender_device_id (when set) must belong to the caller.
drop policy if exists messages_insert_member on public.messages;
create policy messages_insert_member on public.messages
for insert to authenticated
with check (
sender_id = auth.uid()
and public.is_conversation_member(conversation_id)
and (
sender_device_id is null
or exists (
select 1 from public.devices d
where d.id = sender_device_id and d.user_id = auth.uid()
)
)
);
@@ -0,0 +1,16 @@
-- ============================================================================
-- Allow the sender of a message to SELECT all its envelopes (needed so
-- ON CONFLICT UPDATE during message edit can detect the existing rows).
-- The existing envelopes_select_own_device policy only lets a device owner
-- read envelopes targeted at their own device, which is why sender-driven
-- upsert was failing for envelopes aimed at other users' devices.
-- ============================================================================
create policy envelopes_select_sender on public.message_envelopes
for select to authenticated
using (
exists (
select 1 from public.messages m
where m.id = message_id and m.sender_id = auth.uid()
)
);
@@ -0,0 +1,49 @@
-- ============================================================================
-- Private storage bucket for encrypted attachment blobs.
-- Files are stored under `{conversation_id}/{attachment_id}.bin`.
-- The conversation_id is parsed from the object name so RLS can reuse the
-- existing is_conversation_member helper.
-- ============================================================================
insert into storage.buckets (id, name, public, file_size_limit)
values ('chat-attachments', 'chat-attachments', false, 10 * 1024 * 1024)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit;
-- Helper: extract conversation_id from the object name safely.
create or replace function public.attachment_object_conv_id(object_name text)
returns uuid
language sql
immutable
as $$
select case
when object_name is null or position('/' in object_name) = 0 then null
else (split_part(object_name, '/', 1))::uuid
end;
$$;
-- Policies on storage.objects for this bucket only.
drop policy if exists "chat_attachments_select_member" on storage.objects;
create policy "chat_attachments_select_member" on storage.objects
for select to authenticated
using (
bucket_id = 'chat-attachments'
and public.is_conversation_member(public.attachment_object_conv_id(name))
);
drop policy if exists "chat_attachments_insert_member" on storage.objects;
create policy "chat_attachments_insert_member" on storage.objects
for insert to authenticated
with check (
bucket_id = 'chat-attachments'
and public.is_conversation_member(public.attachment_object_conv_id(name))
);
drop policy if exists "chat_attachments_delete_sender" on storage.objects;
create policy "chat_attachments_delete_sender" on storage.objects
for delete to authenticated
using (
bucket_id = 'chat-attachments'
and owner = auth.uid()
);
@@ -0,0 +1,7 @@
-- ============================================================================
-- Remove group-invite-code flow. Groups now only grow via direct
-- Admin/Mod adds of accepted friends (conversation_members insert).
-- ============================================================================
drop function if exists public.redeem_group_invite(text);
drop table if exists public.group_invites cascade;
@@ -0,0 +1,11 @@
-- ============================================================================
-- Allow admins to UPDATE any profile row. Needed for the admin UI to ban,
-- block-from-inviting, or promote users. `current_user_is_admin()` already
-- exists from the initial schema.
-- ============================================================================
drop policy if exists profiles_update_admin on public.profiles;
create policy profiles_update_admin on public.profiles
for update to authenticated
using (public.current_user_is_admin())
with check (public.current_user_is_admin());
+6
View File
@@ -0,0 +1,6 @@
-- Dev seed. Applied on `supabase db reset`.
-- Add a long-lived, multi-use invite so you can sign up locally without friction.
insert into public.invites (code, uses_limit, expires_at, disabled)
values ('DEV-INVITE-001', null, null, false)
on conflict (code) do nothing;
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, system-ui, sans-serif; background:#0a0a0f; color:#e5e7eb; padding:32px;">
<div style="max-width:480px; margin:0 auto; background:#111118; border:1px solid #1f2937; border-radius:16px; padding:32px;">
<h1 style="margin:0 0 16px; font-size:20px; color:#fff;">ChatApp — Magic Link</h1>
<p style="margin:0 0 24px; color:#9ca3af;">Use this 6-digit code in the app to sign in:</p>
<div style="font-family: 'JetBrains Mono', monospace; font-size:32px; letter-spacing:8px; background:#0f0f18; border:1px solid #374151; border-radius:12px; padding:20px; text-align:center; color:#fff;">
{{ .Token }}
</div>
<p style="margin:24px 0 8px; font-size:12px; color:#6b7280;">Or click the link below (only works inside a browser, not the desktop app):</p>
<p style="margin:0; font-size:12px;"><a href="{{ .ConfirmationURL }}" style="color:#818cf8;">{{ .ConfirmationURL }}</a></p>
<p style="margin:32px 0 0; font-size:11px; color:#4b5563;">Expires in 1 hour.</p>
</div>
</body>
</html>