initial
This commit is contained in:
@@ -0,0 +1 @@
|
||||
main
|
||||
@@ -0,0 +1 @@
|
||||
v2.90.0
|
||||
@@ -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\"}"
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user