959 lines
39 KiB
PL/PgSQL
959 lines
39 KiB
PL/PgSQL
-- ============================================================================
|
|
-- 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;
|