feat(launch): waitlist page, raw-WebGPU hero, supabase + vercel infra

The July 14 launch surface, previously uncommitted:
- /dashboard/launch raw-WebGPU particle "memory brain" hero (RawVestigeEngine,
  NeuralWordmark, dendrite sign) + DOM waitlist overlay with share/referral.
- Supabase waitlist client + migrations + welcome edge function; legacy waitlist
  archived under supabase/legacy.
- vercel.json deploy config, root-redirect env wiring, base-path config,
  graph-only route, Playwright launch verifiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-27 11:13:02 -05:00
parent 24ef2d504f
commit 6837198328
27 changed files with 5249 additions and 747 deletions

View file

@ -1,10 +1,16 @@
# Supabase project config for the Vestige Pro waitlist capture.
# Supabase project config for the Vestige LAUNCH waitlist (/dashboard/launch).
#
# Link to a real project with: supabase link --project-ref <your-ref>
# Then deploy: supabase db push && supabase functions deploy waitlist-join
# Apply the schema: supabase db push
# Deploy the welcome email fn: supabase functions deploy waitlist-welcome --no-verify-jwt
#
# The launch page inserts directly with the anon key (insert-only RLS + RPCs);
# see supabase/migrations/0002_launch_waitlist.sql and docs/launch/waitlist-setup.md.
# The legacy service-role waitlist-join function is archived in supabase/legacy/.
project_id = "vestige-waitlist"
[functions.waitlist-join]
# Public form posts here; no Supabase JWT required (we gate with the honeypot,
# email validation, and the service-role insert inside the function).
[functions.waitlist-welcome]
# Invoked by a Database Webhook on INSERT into public.waitlist (no end-user JWT);
# guard with the optional WAITLIST_WEBHOOK_SECRET header instead.
verify_jwt = false

View file

@ -0,0 +1,165 @@
// Vestige launch waitlist — welcome + SHARE email (Supabase Edge Function, Deno).
//
// Fired by a Supabase Database Webhook on INSERT into public.waitlist. It emails
// the new signup a confirmation that contains THEIR personal referral link and a
// one-tap "Share on X" button. That email is the launch-day multiplier: it
// reaches people after they've closed the tab and nudges them to share, which is
// what actually compounds a dev-launch waitlist.
//
// Secrets (set with `supabase secrets set`, NEVER committed):
// RESEND_API_KEY — required to actually send (no key => no-op, 200 OK)
// WAITLIST_FROM — sender, e.g. "Vestige <onboarding@resend.dev>"
// (use a verified domain in production)
// WAITLIST_PUBLIC_URL — the public launch page, e.g.
// "https://samvallad33.github.io/vestige/dashboard/launch"
// WAITLIST_WEBHOOK_SECRET — optional shared secret; if set, the webhook must
// send it as the `x-webhook-secret` header.
//
// Deploy: supabase functions deploy waitlist-welcome --no-verify-jwt
// Wire up: Supabase → Database → Webhooks → on INSERT of public.waitlist →
// Supabase Edge Functions → waitlist-welcome.
const DEFAULT_FROM = "Vestige <onboarding@resend.dev>";
const DEFAULT_PUBLIC_URL = "https://samvallad33.github.io/vestige/dashboard/launch";
interface WebhookPayload {
type?: string; // 'INSERT'
record?: {
email?: string;
referral_code?: string;
};
}
function shareUrl(base: string, code: string): string {
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}ref=${encodeURIComponent(code)}`;
}
function buildEmail(link: string, xIntent: string) {
const text =
`You're on the Vestige waitlist. 🧠\n\n` +
`Vestige is local-first memory for AI coding agents — rendered as a living ` +
`WebGPU particle brain you can watch remember, forget, and reform. ` +
`Launching July 14, 2026.\n\n` +
`Want in earlier? Every friend who joins from your personal link moves you ` +
`up the list:\n\n${link}\n\n` +
`Share it on X: ${xIntent}\n\n` +
`— Sam (building Vestige solo)`;
const html = `<!doctype html>
<html>
<body style="margin:0;background:#02030a;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#eaf3ff;">
<div style="max-width:520px;margin:0 auto;padding:40px 28px;">
<div style="font-weight:800;letter-spacing:.34em;font-size:13px;color:#8fb6ff;margin-bottom:24px;">VESTIGE</div>
<h1 style="font-size:22px;line-height:1.3;margin:0 0 14px;">You're on the waitlist. 🧠</h1>
<p style="font-size:15px;line-height:1.6;color:#b9cdec;margin:0 0 18px;">
Vestige is local-first memory for AI coding agents rendered as a living
WebGPU particle brain you can watch remember, forget, and reform.
<strong style="color:#8fe9ff;">Launching July 14, 2026.</strong>
</p>
<p style="font-size:15px;line-height:1.6;color:#b9cdec;margin:0 0 10px;">
Want in earlier? Every friend who joins from your personal link moves you up the list:
</p>
<div style="background:#0a1020;border:1px solid rgba(140,190,255,.28);border-radius:12px;padding:14px 16px;margin:0 0 20px;font-size:13px;color:#bfe0ff;word-break:break-all;">
${link}
</div>
<a href="${xIntent}" style="display:inline-block;background:#f5f8ff;color:#0a0f1a;font-weight:700;font-size:15px;text-decoration:none;padding:12px 22px;border-radius:12px;">
Share on X
</a>
<p style="font-size:13px;line-height:1.6;color:#7d93b8;margin:28px 0 0;">
Sam, building Vestige solo. Reply any time.
</p>
</div>
</body>
</html>`;
return { text, html };
}
Deno.serve(async (req) => {
// optional shared-secret check
const expected = Deno.env.get("WAITLIST_WEBHOOK_SECRET");
if (expected && req.headers.get("x-webhook-secret") !== expected) {
return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
let payload: WebhookPayload;
try {
payload = await req.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "invalid_json" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const email = payload.record?.email?.trim();
const code = payload.record?.referral_code?.trim();
if (!email || !code) {
// Nothing to send; ack so the webhook doesn't retry forever.
return new Response(JSON.stringify({ ok: true, skipped: "no email/code" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const key = Deno.env.get("RESEND_API_KEY");
if (!key) {
// Email is optional infra — capture already succeeded. Ack with a note.
return new Response(JSON.stringify({ ok: true, skipped: "no RESEND_API_KEY" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const from = Deno.env.get("WAITLIST_FROM") ?? DEFAULT_FROM;
const base = Deno.env.get("WAITLIST_PUBLIC_URL") ?? DEFAULT_PUBLIC_URL;
const link = shareUrl(base, code);
const xText =
"I just joined the Vestige waitlist — local-first memory for AI agents, " +
"rendered as a live WebGPU particle brain. Launching July 14:";
const xIntent =
`https://twitter.com/intent/tweet?text=${encodeURIComponent(xText)}` +
`&url=${encodeURIComponent(link)}`;
const { text, html } = buildEmail(link, xIntent);
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from,
to: email,
subject: "You're on the Vestige waitlist — here's your invite link",
text,
html,
}),
});
if (!res.ok) {
const detail = await res.text();
console.error("resend send failed:", res.status, detail);
// Ack anyway: the signup is committed; we don't want infinite retries.
return new Response(JSON.stringify({ ok: false, error: "send_failed" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
} catch (err) {
console.error("resend send threw:", err);
return new Response(JSON.stringify({ ok: false, error: "send_threw" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true, sent: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});

View file

@ -0,0 +1,37 @@
-- Vestige Pro waitlist capture.
--
-- One row per signup from the public /dashboard/waitlist form. The form POSTs
-- through the `waitlist-join` Edge Function (service-role), NOT directly to this
-- table, so Row-Level Security stays closed to anon clients while the function
-- can insert. We keep the table minimal and queryable for launch-day export.
create table if not exists public.waitlist (
id uuid primary key default gen_random_uuid(),
email text not null,
name text,
plan text, -- 'solo' | 'team' (free-form, not enforced)
priority text, -- 'sync' | 'team-memory' | 'postgres' | 'support'
notes text,
source text default 'vestige-pro-waitlist',
user_agent text,
-- Lowercased email for case-insensitive dedup. Generated so callers can't
-- desync it from `email`.
email_norm text generated always as (lower(trim(email))) stored,
created_at timestamptz not null default now()
);
-- One signup per email address. ON CONFLICT in the function turns a re-submit
-- into a friendly "already on the list" instead of a duplicate row or an error.
create unique index if not exists waitlist_email_norm_key
on public.waitlist (email_norm);
-- Newest-first export / dashboards.
create index if not exists waitlist_created_at_idx
on public.waitlist (created_at desc);
-- RLS ON, no anon policies: the anon/public key can do NOTHING here directly.
-- Only the service-role key used inside the Edge Function bypasses RLS to insert.
alter table public.waitlist enable row level security;
comment on table public.waitlist is
'Vestige Pro launch waitlist. Inserted only via the waitlist-join Edge Function (service-role). RLS blocks all anon access.';

21
supabase/legacy/README.md Normal file
View file

@ -0,0 +1,21 @@
# Legacy waitlist backend (archived)
These files backed the **old** `/dashboard/waitlist` page (Canvas2D grid +
support bot), which POSTed signups to the `waitlist-join` Edge Function using a
service-role insert.
The **live launch page** is `/dashboard/launch`. It uses a different, simpler
architecture: direct browser inserts with the public anon key + insert-only RLS,
plus `security definer` RPCs for the referral loop. See:
- Schema + RPCs: `supabase/migrations/0002_launch_waitlist.sql`
- Go-live runbook: `docs/launch/waitlist-setup.md`
- Welcome/share email: `supabase/functions/waitlist-welcome/`
Both schemas declared a table literally named `waitlist` with **incompatible**
shapes (this one is `uuid` PK with `name/plan/notes`; the launch one is `bigint`
PK with `referral_code/referred_by`). They cannot coexist in the same database
under that name. The launch schema is the source of truth as of 2026-06-26.
Kept for reference / in case the old page is ever revived (it would need its own
table name). Nothing in the app references `waitlist-join` anymore.

View file

@ -0,0 +1,146 @@
// Vestige Pro waitlist capture — Supabase Edge Function (Deno).
//
// The public /dashboard/waitlist form POSTs JSON here. This function:
// 1. Handles CORS preflight (the form is served from a different origin).
// 2. Validates the email and rejects honeypot/bot submissions.
// 3. Inserts one row per email into public.waitlist (dedup on re-submit).
// 4. Optionally sends a confirmation email via Resend.
//
// Secrets (set with `supabase secrets set`, NEVER committed):
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY — auto-injected by Supabase
// RESEND_API_KEY — optional; if unset, skip email
// WAITLIST_FROM_EMAIL — optional; defaults below
// WAITLIST_ALLOWED_ORIGIN — optional CORS allowlist (comma-sep)
import { createClient } from "jsr:@supabase/supabase-js@2";
const DEFAULT_FROM = "Vestige <waitlist@vestige.dev>";
function corsHeaders(origin: string | null): HeadersInit {
const allow = (Deno.env.get("WAITLIST_ALLOWED_ORIGIN") ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// If an allowlist is configured and the origin isn't on it, fall back to the
// first allowed origin (so browsers see a definite, non-wildcard value).
const allowedOrigin =
allow.length === 0 ? "*" : (origin && allow.includes(origin) ? origin : allow[0]);
return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type",
"Content-Type": "application/json",
};
}
interface WaitlistPayload {
name?: string;
email?: string;
plan?: string;
priority?: string;
notes?: string;
source?: string;
companySite?: string; // honeypot — must be empty
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
async function sendConfirmation(email: string, name: string) {
const key = Deno.env.get("RESEND_API_KEY");
if (!key) return; // email is optional; capture still succeeds without it
const from = Deno.env.get("WAITLIST_FROM_EMAIL") ?? DEFAULT_FROM;
const greeting = name ? `Hi ${name},` : "Hi,";
try {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from,
to: email,
subject: "You're on the Vestige Pro list",
text:
`${greeting}\n\nYou're on the Vestige Pro early-access list. ` +
`We'll email you before launch with your invite.\n\n— The Vestige team`,
}),
});
} catch (_err) {
// Never fail the signup because the email provider hiccuped.
}
}
Deno.serve(async (req) => {
const origin = req.headers.get("origin");
const headers = corsHeaders(origin);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ ok: false, error: "method_not_allowed" }), {
status: 405,
headers,
});
}
let body: WaitlistPayload;
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "invalid_json" }), {
status: 400,
headers,
});
}
// Honeypot: real users never fill this hidden field. Pretend success so bots
// get no signal, but write nothing.
if (body.companySite && body.companySite.trim()) {
return new Response(JSON.stringify({ ok: true, deduped: false }), { status: 200, headers });
}
const email = (body.email ?? "").trim();
if (!EMAIL_RE.test(email)) {
return new Response(JSON.stringify({ ok: false, error: "invalid_email" }), {
status: 400,
headers,
});
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
// Insert; on email conflict, treat as already-joined (idempotent, friendly).
const row = {
email,
name: (body.name ?? "").trim() || null,
plan: (body.plan ?? "").trim() || null,
priority: (body.priority ?? "").trim() || null,
notes: (body.notes ?? "").trim() || null,
source: (body.source ?? "vestige-pro-waitlist").trim(),
user_agent: req.headers.get("user-agent")?.slice(0, 400) ?? null,
};
const { error } = await supabase.from("waitlist").insert(row);
if (error) {
// 23505 = unique_violation on email_norm → already on the list.
if (error.code === "23505") {
return new Response(JSON.stringify({ ok: true, deduped: true }), { status: 200, headers });
}
console.error("waitlist insert failed:", error);
return new Response(JSON.stringify({ ok: false, error: "storage_failed" }), {
status: 500,
headers,
});
}
// Fire-and-forget confirmation; the signup is already committed.
await sendConfirmation(email, row.name ?? "");
return new Response(JSON.stringify({ ok: true, deduped: false }), { status: 200, headers });
});

View file

@ -0,0 +1,122 @@
-- 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;
drop policy if exists "anon can insert" on public.waitlist;
create policy "anon can insert"
on public.waitlist
for insert
to anon
with check (true);
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
if v_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$' then
raise exception 'invalid email';
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;