mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
Launch-critical fixes for the public July-14 signup surface (swarm-audited by
MiniMax M3, Claude-verified against the frontend RPC call path):
- migration: DROP the "anon can insert with check(true)" RLS policy. It let
anon write arbitrary rows straight to the table, bypassing all join_waitlist
validation. The frontend already calls the join_waitlist RPC (rest/v1/rpc/),
never a direct insert, so signup is unaffected. RLS stays enabled with no
permissive INSERT policy = direct anon inserts denied by default.
- join_waitlist: cap email <=254, reject control chars, truncate referrer <=2048.
- waitlist-welcome edge fn: FAIL CLOSED — if WAITLIST_WEBHOOK_SECRET is unset,
return 401 instead of serving (was an open Resend relay). Guard the referral
code against ^[a-z2-9]{7}$ and cap email length before sending.
- vercel.json: add baseline security headers (nosniff, Referrer-Policy,
X-Frame-Options: DENY, HSTS, Permissions-Policy). CSP deferred (would break
the WebGPU hero's inline bootstrap without nonces) — documented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
4.7 KiB
PL/PgSQL
137 lines
4.7 KiB
PL/PgSQL
-- Vestige LAUNCH waitlist (/dashboard/launch) — table + referral loop.
|
|
--
|
|
-- The browser inserts directly with the public anon key. The table is
|
|
-- INSERT-ONLY to anon via RLS, and every read (own referral code, referral
|
|
-- count, total count) goes through a `security definer` RPC that returns a
|
|
-- single value, so the anon key never gets SELECT on the table.
|
|
--
|
|
-- This is the source of truth for the launch page. The older service-role
|
|
-- Edge Function schema lives in supabase/legacy/ and is retired.
|
|
--
|
|
-- Apply with: supabase db push (or paste into the SQL editor — see
|
|
-- docs/launch/waitlist-setup.md, which mirrors this file exactly).
|
|
|
|
create table if not exists public.waitlist (
|
|
id bigint generated always as identity primary key,
|
|
email text not null,
|
|
source text,
|
|
referrer text, -- document.referrer (analytics only)
|
|
referral_code text not null, -- THIS user's own share code
|
|
referred_by text, -- the code that brought them in
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create unique index if not exists waitlist_email_key
|
|
on public.waitlist (lower(email));
|
|
|
|
create unique index if not exists waitlist_referral_code_key
|
|
on public.waitlist (referral_code);
|
|
create index if not exists waitlist_referred_by_idx
|
|
on public.waitlist (referred_by);
|
|
|
|
alter table public.waitlist enable row level security;
|
|
|
|
-- SECURITY: no direct anon INSERT policy. A `with check (true)` policy would
|
|
-- let the anon key write ARBITRARY rows straight into the table, bypassing all
|
|
-- of join_waitlist's validation (email shape, length caps, control-char
|
|
-- rejection, referral-code generation, dedupe). The ONLY anon write path is the
|
|
-- `security definer` join_waitlist RPC (granted to anon below), which enforces
|
|
-- every invariant. RLS is enabled with no permissive INSERT policy, so direct
|
|
-- INSERTs from the anon role are denied by default.
|
|
drop policy if exists "anon can insert" on public.waitlist;
|
|
|
|
create or replace function public.gen_referral_code()
|
|
returns text
|
|
language plpgsql
|
|
as $$
|
|
declare
|
|
alphabet constant text := 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
code text;
|
|
i int;
|
|
begin
|
|
loop
|
|
code := '';
|
|
for i in 1..7 loop
|
|
code := code || substr(alphabet, 1 + floor(random() * length(alphabet))::int, 1);
|
|
end loop;
|
|
exit when not exists (select 1 from public.waitlist where referral_code = code);
|
|
end loop;
|
|
return code;
|
|
end;
|
|
$$;
|
|
|
|
create or replace function public.join_waitlist(
|
|
p_email text,
|
|
p_referred_by text default null,
|
|
p_referrer text default null
|
|
)
|
|
returns table (referral_code text, referrals int, duplicate boolean)
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
v_email text := lower(trim(p_email));
|
|
v_code text;
|
|
v_ref text := nullif(trim(coalesce(p_referred_by, '')), '');
|
|
v_dup boolean := false;
|
|
begin
|
|
-- Abuse / length guards. Per-IP rate limiting needs Supabase edge config
|
|
-- (e.g. a gateway rule or pg_net-based throttle) and is tracked separately;
|
|
-- at the DB layer we cap field lengths and reject control characters so a
|
|
-- single request can't store oversized or malformed payloads.
|
|
if length(v_email) > 254 then
|
|
raise exception 'invalid email';
|
|
end if;
|
|
if v_email ~ '[\x00-\x1f\x7f]' then
|
|
raise exception 'invalid email';
|
|
end if;
|
|
if v_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$' then
|
|
raise exception 'invalid email';
|
|
end if;
|
|
if p_referrer is not null and length(p_referrer) > 2048 then
|
|
p_referrer := left(p_referrer, 2048);
|
|
end if;
|
|
|
|
select w.referral_code into v_code
|
|
from public.waitlist w
|
|
where lower(w.email) = v_email
|
|
limit 1;
|
|
|
|
if v_code is null then
|
|
v_code := public.gen_referral_code();
|
|
insert into public.waitlist (email, source, referrer, referral_code, referred_by)
|
|
values (v_email, 'vestige-launch', p_referrer, v_code,
|
|
case when v_ref = v_code then null else v_ref end);
|
|
else
|
|
v_dup := true;
|
|
end if;
|
|
|
|
return query
|
|
select v_code,
|
|
(select count(*)::int from public.waitlist where referred_by = v_code),
|
|
v_dup;
|
|
end;
|
|
$$;
|
|
|
|
create or replace function public.referral_count(p_code text)
|
|
returns integer
|
|
language sql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
select count(*)::int from public.waitlist where referred_by = p_code;
|
|
$$;
|
|
|
|
create or replace function public.waitlist_count()
|
|
returns integer
|
|
language sql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
select count(*)::int from public.waitlist;
|
|
$$;
|
|
|
|
grant execute on function public.join_waitlist(text, text, text) to anon;
|
|
grant execute on function public.referral_count(text) to anon;
|
|
grant execute on function public.waitlist_count() to anon;
|