From 4f40c4e67b7d9c40123f2b81c527e56ba5c199a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:52:52 +0000 Subject: [PATCH 1/5] feat(alpha): Add the cloud workspace alpha waitlist Signed-in users join the private alpha (managed cloud workspaces and SSH Remote Development) from a new Alpha tab on the dashboard, with an explicit consent checkbox. public.alpha_waitlist stores only the account ID, the consent date and form version, the source page, and unsubscribe state. Users read and write only their own row through column grants and RLS; the database clock stamps consent and unsubscribe times; the unsubscribe token is visible to the service role only. The public alpha-unsubscribe Edge Function takes ?token= by GET or the RFC 8058 one-click POST, sets unsubscribed_at once, keeps the account, and returns the same page for unknown tokens. The roadmap links to the alpha tab through sign-in, and a signed-out dashboard visit now keeps its tab through sign-in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V9MDdxQJAhjXsAKyCpVwwt --- .../service-qa/specs/alpha-waitlist.spec.ts | 232 ++++++++++++++++++ supabase/config.toml | 6 + supabase/functions/alpha-unsubscribe/index.ts | 113 +++++++++ supabase/migrations/023_alpha_waitlist.sql | 97 ++++++++ .../database/007_alpha_waitlist.test.sql | 163 ++++++++++++ web/src/pages/dashboard.tsx | 198 ++++++++++++++- web/src/pages/roadmap.mdx | 6 + 7 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 scripts/service-qa/specs/alpha-waitlist.spec.ts create mode 100644 supabase/functions/alpha-unsubscribe/index.ts create mode 100644 supabase/migrations/023_alpha_waitlist.sql create mode 100644 supabase/tests/database/007_alpha_waitlist.test.sql diff --git a/scripts/service-qa/specs/alpha-waitlist.spec.ts b/scripts/service-qa/specs/alpha-waitlist.spec.ts new file mode 100644 index 000000000..6d4c7fd5c --- /dev/null +++ b/scripts/service-qa/specs/alpha-waitlist.spec.ts @@ -0,0 +1,232 @@ +/** + * Alpha waitlist (023_alpha_waitlist.sql + functions/alpha-unsubscribe). + * + * A signed-in user joins with the same upsert the dashboard's Alpha tab + * sends, follows the unsubscribe link from an email (GET, then the RFC 8058 + * one-click POST after rejoining), and rejoins. The link must stop sends, + * keep the account, be idempotent, and look the same for unknown tokens. + */ +import { randomUUID } from "node:crypto"; +import { afterEach, expect, it } from "vitest"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { getFunctionsBaseUrl, getServiceClient } from "../clients"; +import { + createTestUser, + deleteTestUser, + signInWithEmailPassword, +} from "../seed"; +import { recordOutcome } from "../record"; + +// Must match ALPHA_FORM_VERSION in web/src/pages/dashboard.tsx. +const FORM_VERSION = "2026-10-alpha-v1"; +const SOURCE_PAGE = "/dashboard?tab=alpha"; + +const usersToDelete: string[] = []; + +afterEach(async () => { + const admin = getServiceClient(); + while (usersToDelete.length > 0) { + try { + await deleteTestUser(admin, usersToDelete.pop()!); + } catch { + // already deleted + } + } +}); + +type AdminRow = { + consented_at: string; + unsubscribed_at: string | null; + unsubscribe_token: string; + form_version: string; + source_page: string | null; +}; + +async function join(client: SupabaseClient, userId: string): Promise { + const { error } = await client.from("alpha_waitlist").upsert( + { + user_id: userId, + form_version: FORM_VERSION, + source_page: SOURCE_PAGE, + unsubscribed_at: null, + }, + { onConflict: "user_id" }, + ); + expect(error).toBeNull(); +} + +async function adminRow(userId: string): Promise { + const { data, error } = await getServiceClient() + .from("alpha_waitlist") + .select( + "consented_at, unsubscribed_at, unsubscribe_token, form_version, source_page", + ) + .eq("user_id", userId) + .single(); + expect(error).toBeNull(); + return data as AdminRow; +} + +async function unsubscribe( + token: string | null, + method: "GET" | "POST" = "GET", +): Promise<{ status: number; contentType: string; body: string }> { + const query = token === null ? "" : `?token=${encodeURIComponent(token)}`; + const res = await fetch( + `${getFunctionsBaseUrl()}/alpha-unsubscribe${query}`, + { + method, + // RFC 8058: mail providers POST this body to the List-Unsubscribe URL. + ...(method === "POST" + ? { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "List-Unsubscribe=One-Click", + } + : {}), + }, + ); + return { + status: res.status, + contentType: res.headers.get("content-type") ?? "", + body: await res.text(), + }; +} + +it("joins, unsubscribes by link, and rejoins without losing the account", async () => { + const testUser = await createTestUser(); + usersToDelete.push(testUser.user.id); + const userId = testUser.user.id; + const { client } = await signInWithEmailPassword( + testUser.email, + testUser.password, + ); + + // ── Join ──────────────────────────────────────────────────────────────── + await join(client, userId); + + const { data: own, error: ownError } = await client + .from("alpha_waitlist") + .select("user_id, consented_at, form_version, source_page, unsubscribed_at") + .maybeSingle(); + expect(ownError).toBeNull(); + expect(own).toMatchObject({ + user_id: userId, + form_version: FORM_VERSION, + source_page: SOURCE_PAGE, + unsubscribed_at: null, + }); + expect(Date.parse(own!.consented_at)).toBeGreaterThan(Date.now() - 60_000); + + const { error: tokenReadError } = await client + .from("alpha_waitlist") + .select("unsubscribe_token") + .maybeSingle(); + expect(tokenReadError?.code).toBe("42501"); + + // A second account sees nothing of the first. + const other = await createTestUser(); + usersToDelete.push(other.user.id); + const { client: otherClient } = await signInWithEmailPassword( + other.email, + other.password, + ); + const { data: otherView, error: otherError } = await otherClient + .from("alpha_waitlist") + .select("user_id"); + expect(otherError).toBeNull(); + expect(otherView).toEqual([]); + + const joined = await adminRow(userId); + expect(joined.unsubscribe_token).toMatch(/^[0-9a-f-]{36}$/); + + await recordOutcome("alpha-waitlist-01-join", { + expectations: [ + "The dashboard's upsert creates the user's row with form_version, source_page, a fresh consented_at, and unsubscribed_at null.", + "The signed-in user cannot read their unsubscribe_token (PostgREST 42501).", + "A second signed-in user reads zero waitlist rows.", + ], + details: { own, tokenReadError, otherView }, + }); + + // ── Unsubscribe by email link (GET) ───────────────────────────────────── + const first = await unsubscribe(joined.unsubscribe_token); + expect(first.status).toBe(200); + expect(first.contentType).toMatch(/^text\/html/); + expect(first.body).toMatch(/unsubscribed/i); + expect(first.body).not.toContain(joined.unsubscribe_token); + expect(first.body).not.toContain(testUser.email); + + const afterFirst = await adminRow(userId); + expect(afterFirst.unsubscribed_at).not.toBeNull(); + + const second = await unsubscribe(joined.unsubscribe_token); + expect(second).toEqual(first); + const afterSecond = await adminRow(userId); + expect(afterSecond.unsubscribed_at).toBe(afterFirst.unsubscribed_at); + + // The account survives: password sign-in still works. + await expect( + signInWithEmailPassword(testUser.email, testUser.password), + ).resolves.toBeTruthy(); + + await recordOutcome("alpha-waitlist-02-unsubscribe-link", { + expectations: [ + "GET alpha-unsubscribe?token= returns HTTP 200 text/html and sets unsubscribed_at.", + "Repeating the request returns the same page and leaves unsubscribed_at unchanged (idempotent).", + "The account is kept: password sign-in still succeeds after unsubscribing.", + ], + details: { + status: first.status, + contentType: first.contentType, + unsubscribedAt: afterFirst.unsubscribed_at, + unsubscribedAtAfterRepeat: afterSecond.unsubscribed_at, + }, + }); + + // ── Unknown, malformed, and missing tokens look the same ──────────────── + const unknown = await unsubscribe(randomUUID()); + const malformed = await unsubscribe("not-a-token"); + const missing = await unsubscribe(null); + for (const res of [unknown, malformed, missing]) { + expect(res).toEqual(first); + } + + await recordOutcome("alpha-waitlist-03-no-enumeration", { + expectations: [ + "An unknown token, a malformed token, and no token each return the same status, content type, and body as a real unsubscribe.", + ], + details: { + unknown: unknown.status, + malformed: malformed.status, + missing: missing.status, + }, + }); + + // ── Rejoin, then the RFC 8058 one-click POST ──────────────────────────── + await join(client, userId); + const rejoined = await adminRow(userId); + expect(rejoined.unsubscribed_at).toBeNull(); + expect(Date.parse(rejoined.consented_at)).toBeGreaterThan( + Date.parse(joined.consented_at), + ); + expect(rejoined.unsubscribe_token).toBe(joined.unsubscribe_token); + + const oneClick = await unsubscribe(joined.unsubscribe_token, "POST"); + expect(oneClick.status).toBe(200); + const afterOneClick = await adminRow(userId); + expect(afterOneClick.unsubscribed_at).not.toBeNull(); + + await recordOutcome("alpha-waitlist-04-rejoin", { + expectations: [ + "Rejoining clears unsubscribed_at and records a later consented_at than the first join.", + "The unsubscribe token is unchanged by rejoining, so links in earlier emails still work.", + "The RFC 8058 one-click POST to the same URL unsubscribes again.", + ], + details: { + firstConsent: joined.consented_at, + rejoinConsent: rejoined.consented_at, + oneClickStatus: oneClick.status, + unsubscribedAt: afterOneClick.unsubscribed_at, + }, + }); +}, 90_000); diff --git a/supabase/config.toml b/supabase/config.toml index 261bc8e90..77a51ec14 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -293,6 +293,12 @@ verify_jwt = false # check inside the function (STRIPE_WEBHOOK_SECRET), not a Supabase JWT. verify_jwt = false +[functions.alpha-unsubscribe] +# Opened from the unsubscribe link in alpha waitlist emails, by a person or +# by a mail provider's one-click POST. The token in the URL is the +# credential, not a Supabase JWT. +verify_jwt = false + [functions.merge-queue-worker] # Invoked by cron / the webhook nudge with the service-role key. verify_jwt = true diff --git a/supabase/functions/alpha-unsubscribe/index.ts b/supabase/functions/alpha-unsubscribe/index.ts new file mode 100644 index 000000000..6c62a13d4 --- /dev/null +++ b/supabase/functions/alpha-unsubscribe/index.ts @@ -0,0 +1,113 @@ +// One-click unsubscribe for alpha waitlist emails (prds/marketing.md, "Alpha +// waitlist"). Each email links to +// +// /alpha-unsubscribe?token= +// +// GET (a person clicking the link) and POST (a mail provider's RFC 8058 +// List-Unsubscribe-Post request) both set unsubscribed_at, which stops every +// later send. The account and the waitlist row stay, so the user can rejoin +// from the dashboard. +// +// There is no Supabase JWT on these requests (verify_jwt = false in +// config.toml); the token is the credential. Every request gets the same +// page whether or not the token exists, so the endpoint cannot be used to +// test tokens. Repeating a request changes nothing. +// +// One PostgREST call as the service role, so this uses fetch rather than +// supabase-js. + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const webUrl = Deno.env.get("WEB_URL") ?? "https://treq.dev"; + +function page(title: string, body: string, status: number): Response { + const html = ` + + + + + +${title} + + + +${body} + + +`; + return new Response(html, { + status, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + // The token is in this page's URL; never pass it on in a Referer. + "Referrer-Policy": "no-referrer", + "X-Robots-Tag": "noindex", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'", + }, + }); +} + +const unsubscribed = () => + page( + "Unsubscribed from Treq alpha emails", + `

You're unsubscribed

+

Treq will not send you any more alpha emails. Your Treq account is unchanged.

+

Changed your mind? Rejoin the alpha from your dashboard.

`, + 200, + ); + +const failed = () => + page( + "Unsubscribe failed", + `

Something went wrong

+

Your unsubscribe was not saved. Open the link again in a few minutes.

`, + 500, + ); + +Deno.serve(async (req) => { + if (req.method !== "GET" && req.method !== "POST") { + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET, POST" }, + }); + } + + const token = new URL(req.url).searchParams.get("token") ?? ""; + if (!UUID_RE.test(token)) return unsubscribed(); + + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + + // The is.null filter keeps the first unsubscribe time on repeat clicks. + // An unknown token matches no row, which is not an error. + try { + const res = await fetch( + `${supabaseUrl}/rest/v1/alpha_waitlist?unsubscribe_token=eq.${token}&unsubscribed_at=is.null`, + { + method: "PATCH", + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + "Content-Type": "application/json", + Prefer: "return=minimal", + }, + body: JSON.stringify({ unsubscribed_at: new Date().toISOString() }), + }, + ); + await res.body?.cancel(); + if (!res.ok) { + console.error(`alpha-unsubscribe: update failed with HTTP ${res.status}`); + return failed(); + } + } catch (err) { + console.error(`alpha-unsubscribe: update failed: ${(err as Error).message}`); + return failed(); + } + return unsubscribed(); +}); diff --git a/supabase/migrations/023_alpha_waitlist.sql b/supabase/migrations/023_alpha_waitlist.sql new file mode 100644 index 000000000..797c2dd54 --- /dev/null +++ b/supabase/migrations/023_alpha_waitlist.sql @@ -0,0 +1,97 @@ +-- Private alpha waitlist for managed cloud workspaces and SSH Remote +-- Development (prds/marketing.md, "Alpha waitlist"). +-- +-- A signed-in user joins from the dashboard after ticking an explicit +-- consent box. The row stores only what the PRD allows: the account ID, the +-- consent record (date and form version), and the page the user joined +-- from. It is kept apart from billing. +-- +-- Leaving or following an email's unsubscribe link sets unsubscribed_at. +-- The row and the account stay, so rejoining is an update that clears +-- unsubscribed_at and records a new consent. The unsubscribe token is a +-- bearer credential for the public alpha-unsubscribe Edge Function, so +-- users can neither read nor change it; only the service role (the future +-- email sender and that function) sees it. + +create table public.alpha_waitlist ( + user_id uuid primary key references auth.users (id) on delete cascade, + consented_at timestamptz not null, + form_version text not null check (char_length(form_version) <= 64), + -- A site path such as /roadmap. Bounded because users write it directly. + source_page text check (char_length(source_page) <= 256), + unsubscribed_at timestamptz, + unsubscribe_token uuid not null default gen_random_uuid() unique, + created_at timestamptz not null default now() +); + +alter table public.alpha_waitlist enable row level security; + +create policy "Users can view own alpha waitlist row" + on public.alpha_waitlist for select + to authenticated + using (user_id = auth.uid()); + +create policy "Users can join the alpha waitlist" + on public.alpha_waitlist for insert + to authenticated + with check (user_id = auth.uid()); + +create policy "Users can update own alpha waitlist row" + on public.alpha_waitlist for update + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +-- 008_public_grants.sql gives every new public table full DML to anon and +-- authenticated. Replace that with column grants: users never touch +-- unsubscribe_token, and consented_at and created_at are set by the +-- trigger below, not by the browser. No delete: leaving sets +-- unsubscribed_at, and the row goes when the account is deleted. +revoke all on table public.alpha_waitlist from anon, authenticated; + +grant select (user_id, consented_at, form_version, source_page, unsubscribed_at, created_at) + on public.alpha_waitlist to authenticated; +grant insert (user_id, form_version, source_page, unsubscribed_at) + on public.alpha_waitlist to authenticated; +grant update (user_id, form_version, source_page, unsubscribed_at) + on public.alpha_waitlist to authenticated; + +-- Consent and unsubscribe times come from the database clock: +-- * a new row, or a row that goes from unsubscribed to subscribed (a +-- rejoin) or changes form_version, gets consented_at = now(); +-- * unsubscribing stamps unsubscribed_at = now() once, and repeating it +-- keeps the first time, so the unsubscribe link is idempotent. +create function public.alpha_waitlist_stamp() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if tg_op = 'INSERT' then + new.consented_at := now(); + new.created_at := now(); + if new.unsubscribed_at is not null then + new.unsubscribed_at := now(); + end if; + return new; + end if; + + if new.unsubscribed_at is null + and (old.unsubscribed_at is not null or new.form_version is distinct from old.form_version) then + new.consented_at := now(); + end if; + + if new.unsubscribed_at is not null then + new.unsubscribed_at := coalesce(old.unsubscribed_at, now()); + end if; + + return new; +end; +$$; + +-- Only the trigger calls this. +revoke all on function public.alpha_waitlist_stamp() from public, anon, authenticated; + +create trigger alpha_waitlist_stamp + before insert or update on public.alpha_waitlist + for each row execute function public.alpha_waitlist_stamp(); diff --git a/supabase/tests/database/007_alpha_waitlist.test.sql b/supabase/tests/database/007_alpha_waitlist.test.sql new file mode 100644 index 000000000..3cd629abf --- /dev/null +++ b/supabase/tests/database/007_alpha_waitlist.test.sql @@ -0,0 +1,163 @@ +-- 023_alpha_waitlist.sql: a signed-in user reads, joins, leaves, and rejoins +-- only their own waitlist row. They never see or change the unsubscribe +-- token, and the consent and unsubscribe times come from the database +-- clock, not the browser. +begin; +select plan(25); + +insert into auth.users (id, email) values + ('00000000-0000-0000-0000-0000000000a1', 'alpha-a@example.com'), + ('00000000-0000-0000-0000-0000000000b1', 'alpha-b@example.com'), + ('00000000-0000-0000-0000-0000000000c1', 'alpha-c@example.com'); + +-- User B is already on the waitlist, inserted as postgres. +insert into public.alpha_waitlist (user_id, form_version, source_page, unsubscribe_token) values + ('00000000-0000-0000-0000-0000000000b1', 'v-test', '/roadmap', '40000000-0000-0000-0000-0000000000b1'); + +-- ── Table setup and privileges ──────────────────────────────────────────── +select ok((select relrowsecurity from pg_class where oid = 'public.alpha_waitlist'::regclass), + 'RLS enabled on public.alpha_waitlist'); +select table_privs_are('public', 'alpha_waitlist', 'anon', array[]::text[], + 'anon has no privileges on alpha_waitlist'); +select table_privs_are('public', 'alpha_waitlist', 'authenticated', array[]::text[], + 'authenticated has no table-wide privileges on alpha_waitlist (column grants only)'); +select ok(not has_column_privilege('anon', 'public.alpha_waitlist', 'user_id', 'SELECT'), + 'anon cannot read alpha_waitlist'); +select ok(not has_column_privilege('authenticated', 'public.alpha_waitlist', 'unsubscribe_token', 'SELECT, INSERT, UPDATE'), + 'authenticated has no privileges on unsubscribe_token'); +select ok(not has_column_privilege('authenticated', 'public.alpha_waitlist', 'consented_at', 'INSERT, UPDATE'), + 'authenticated cannot write consented_at directly'); +select ok(not has_table_privilege('authenticated', 'public.alpha_waitlist', 'DELETE'), + 'authenticated cannot delete waitlist rows (leaving sets unsubscribed_at)'); +select ok(has_table_privilege('service_role', 'public.alpha_waitlist', 'SELECT, INSERT, UPDATE'), + 'service_role keeps access for the unsubscribe function and metrics'); + +-- ── As user A ───────────────────────────────────────────────────────────── +set local role authenticated; +select set_config('request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-0000000000a1","role":"authenticated"}', true); + +select is_empty( + $$select user_id from public.alpha_waitlist$$, + 'user A sees no rows before joining, not even user B''s'); + +select throws_ok( + $$insert into public.alpha_waitlist (user_id, form_version, source_page) + values ('00000000-0000-0000-0000-0000000000b1', 'v-test', '/x') + on conflict (user_id) do update set source_page = excluded.source_page$$, + '42501', null, + 'user A cannot insert or upsert a row for user B'); + +select throws_ok( + $$insert into public.alpha_waitlist (user_id, form_version, unsubscribe_token) + values ('00000000-0000-0000-0000-0000000000a1', 'v-test', gen_random_uuid())$$, + '42501', null, + 'user A cannot choose their own unsubscribe token'); + +select lives_ok( + $$insert into public.alpha_waitlist (user_id, form_version, source_page, unsubscribed_at) + values ('00000000-0000-0000-0000-0000000000a1', '2026-10-alpha-v1', '/dashboard', null) + on conflict (user_id) do update set + form_version = excluded.form_version, + source_page = excluded.source_page, + unsubscribed_at = excluded.unsubscribed_at$$, + 'user A can join (upsert) their own row'); + +select results_eq( + $$select user_id, form_version, source_page, unsubscribed_at is null, consented_at = now() + from public.alpha_waitlist$$, + $$values ('00000000-0000-0000-0000-0000000000a1'::uuid, '2026-10-alpha-v1'::text, '/dashboard'::text, true, true)$$, + 'user A reads exactly their own row, with consent stamped by the database'); + +select throws_ok( + $$select unsubscribe_token from public.alpha_waitlist$$, + '42501', null, + 'user A cannot read their unsubscribe token'); + +select throws_ok( + $$update public.alpha_waitlist set unsubscribe_token = gen_random_uuid() + where user_id = '00000000-0000-0000-0000-0000000000a1'$$, + '42501', null, + 'user A cannot change their unsubscribe token'); + +select throws_ok( + $$update public.alpha_waitlist set consented_at = '2000-01-01' + where user_id = '00000000-0000-0000-0000-0000000000a1'$$, + '42501', null, + 'user A cannot rewrite their consent date'); + +select is_empty( + $$update public.alpha_waitlist set unsubscribed_at = now() + where user_id = '00000000-0000-0000-0000-0000000000b1' returning user_id$$, + 'user A cannot unsubscribe user B'); + +select throws_ok( + $$update public.alpha_waitlist set user_id = '00000000-0000-0000-0000-0000000000b1' + where user_id = '00000000-0000-0000-0000-0000000000a1'$$, + '42501', null, + 'user A cannot move their row onto user B'); + +-- Leave: the browser's timestamp is replaced with the database clock. +update public.alpha_waitlist set unsubscribed_at = '2000-01-01' + where user_id = '00000000-0000-0000-0000-0000000000a1'; + +select is( + (select unsubscribed_at from public.alpha_waitlist), + now(), + 'leaving stamps unsubscribed_at with the database clock'); + +-- ── Rejoin records a new consent ────────────────────────────────────────── +-- Backdate A's consent as postgres so the new stamp is distinguishable +-- inside this single transaction (now() is fixed per transaction). +reset role; +update public.alpha_waitlist set consented_at = '2020-01-01' + where user_id = '00000000-0000-0000-0000-0000000000a1'; + +set local role authenticated; +select set_config('request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-0000000000a1","role":"authenticated"}', true); + +update public.alpha_waitlist set source_page = '/roadmap' + where user_id = '00000000-0000-0000-0000-0000000000a1'; + +select is( + (select consented_at from public.alpha_waitlist), + '2020-01-01'::timestamptz, + 'an update that does not rejoin keeps the original consent date'); + +update public.alpha_waitlist set unsubscribed_at = null, form_version = '2026-10-alpha-v1' + where user_id = '00000000-0000-0000-0000-0000000000a1'; + +select results_eq( + $$select unsubscribed_at is null, consented_at = now() from public.alpha_waitlist$$, + $$values (true, true)$$, + 'rejoining clears unsubscribed_at and records a new consent date'); + +-- ── As postgres: the token survives every user write ────────────────────── +reset role; + +select isnt( + (select unsubscribe_token from public.alpha_waitlist where user_id = '00000000-0000-0000-0000-0000000000a1'), + null, + 'user A''s row got a generated unsubscribe token'); + +select results_eq( + $$select unsubscribed_at, unsubscribe_token from public.alpha_waitlist + where user_id = '00000000-0000-0000-0000-0000000000b1'$$, + $$values (null::timestamptz, '40000000-0000-0000-0000-0000000000b1'::uuid)$$, + 'user B''s row is untouched by user A'); + +select throws_ok( + $$insert into public.alpha_waitlist (user_id, form_version, unsubscribe_token) + values ('00000000-0000-0000-0000-0000000000c1', 'v-test', '40000000-0000-0000-0000-0000000000b1')$$, + '23505', null, + 'unsubscribe tokens are unique'); + +select throws_ok( + $$insert into public.alpha_waitlist (user_id, form_version, source_page) + values ('00000000-0000-0000-0000-0000000000c1', 'v-test', repeat('/a', 200))$$, + '23514', null, + 'source_page length is bounded'); + +select * from finish(); +rollback; diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index f97b46142..32773bcec 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -48,7 +48,7 @@ interface QueueStatus { bisect_mode: boolean; } -type Tab = "subscription" | "integrations"; +type Tab = "subscription" | "integrations" | "alpha"; function defaultConfig(repoId: number, defaultBranch: string): MergeQueueConfig { return { @@ -469,6 +469,167 @@ function IntegrationsTab() { ); } +// ── Alpha tab ──────────────────────────────────────────────────────────────── + +// Stored with every consent. Change it whenever the consent wording below +// changes, so each consent record names the text the user agreed to. +const ALPHA_FORM_VERSION = "2026-10-alpha-v1"; + +interface AlphaMembership { + consented_at: string; + unsubscribed_at: string | null; +} + +// The page that sent the user here (`?from=/roadmap`), else this page. Only +// a plain site path is kept, so nothing else can end up in the row. +function alphaSourcePage(): string { + const from = new URLSearchParams(window.location.search).get("from"); + return from && /^\/[\w\-/]{0,200}$/.test(from) + ? from + : window.location.pathname; +} + +function AlphaTab({ user }: { user: User }) { + const [membership, setMembership] = useState(null); + const [loading, setLoading] = useState(true); + const [consent, setConsent] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // RLS returns only the signed-in user's row. + const load = useCallback(async () => { + const { data, error: loadError } = await supabase + .from("alpha_waitlist") + .select("consented_at, unsubscribed_at") + .maybeSingle(); + if (loadError) setError(loadError.message); + setMembership((data as AlphaMembership | null) ?? null); + setLoading(false); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const save = async ( + write: () => PromiseLike<{ error: { message: string } | null }> + ) => { + setSaving(true); + setError(null); + const { error: saveError } = await write(); + if (saveError) { + setError(saveError.message); + } else { + setConsent(false); + await load(); + } + setSaving(false); + }; + + // The database stamps consented_at, and a rejoin clears unsubscribed_at + // and records a new consent (023_alpha_waitlist.sql). + const join = () => + save(() => + supabase.from("alpha_waitlist").upsert( + { + user_id: user.id, + form_version: ALPHA_FORM_VERSION, + source_page: alphaSourcePage(), + unsubscribed_at: null, + }, + { onConflict: "user_id" } + ) + ); + + const leave = () => + save(() => + supabase + .from("alpha_waitlist") + .update({ unsubscribed_at: new Date().toISOString() }) + .eq("user_id", user.id) + ); + + return ( +
+

Private alpha

+
+
+ Cloud workspaces and SSH Remote Development +
+

+ The private alpha covers two features that are still in + development. Managed cloud workspaces run your Treq workspaces on a + machine that Treq hosts. SSH Remote Development opens a workspace on + your own server over SSH. Treq invites people from the waitlist in + batches. +

+ + {loading ? ( +
Loading…
+ ) : membership && !membership.unsubscribed_at ? ( +
+
+ On the waitlist + + Joined {new Date(membership.consented_at).toLocaleDateString()} + +
+

+ Treq will email {user.email} with alpha invitations and updates. + Every email has an unsubscribe link. +

+ +
+ ) : ( +
+ {membership?.unsubscribed_at && ( +

+ You left the waitlist on{" "} + {new Date(membership.unsubscribed_at).toLocaleDateString()}. + Treq will not send you alpha emails unless you join again. +

+ )} +

+ Joining lets Treq email {user.email} about this alpha: + invitations to try it, and updates on its progress. It does not + change your plan or billing. +

+ + +
+ )} + + {error &&
{error}
} +
+
+ ); +} + // ── Dashboard shell ────────────────────────────────────────────────────────── function DashboardContent() { @@ -493,7 +654,9 @@ function DashboardContent() { useEffect(() => { supabase.auth.getSession().then(({ data: { session: s } }) => { if (!s) { - window.location.href = "/sign-in"; + // Come back to this tab after sign-in, e.g. /dashboard?tab=alpha. + const here = window.location.pathname + window.location.search; + window.location.href = `/sign-in?redirect=${encodeURIComponent(here)}`; return; } setSession(s); @@ -584,6 +747,15 @@ function DashboardContent() { > Integrations + + + ); +} + +function SignedInJoin({user}: {user: User}): ReactNode { + const [membership, setMembership] = useState(null); + const [loading, setLoading] = useState(true); + const [consent, setConsent] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Row-level security returns only the signed-in user's row. + const load = useCallback(async () => { + const {data, error: loadError} = await supabase + .from('alpha_waitlist') + .select('consented_at, unsubscribed_at') + .maybeSingle(); + if (loadError) setError(loadError.message); + setMembership((data as Membership | null) ?? null); + setLoading(false); + }, []); + + const save = useCallback( + async (write: JoinWrite) => { + setSaving(true); + setError(null); + const {error: saveError} = await write(); + if (saveError) { + setError(saveError.message); + } else { + setConsent(false); + await load(); + } + setSaving(false); + }, + [load], + ); + + useEffect(() => { + // A visitor who ticked the consent box before signing in comes back + // here. Finish that join instead of asking them to tick it again. + const pending = takePendingJoin(sessionStore()); + if (pending) { + save(joinWaitlist(user, pending.sourcePage)); + } else { + load(); + } + }, [load, save, user]); + + const leave = () => + save(() => + supabase + .from('alpha_waitlist') + .update({unsubscribed_at: new Date().toISOString()}) + .eq('user_id', user.id), + ); + + if (loading) { + return
Loading…
; + } + + if (membership && !membership.unsubscribed_at) { + return ( +
+ You're on the waitlist +

+ Joined + + {new Date(membership.consented_at).toLocaleDateString()} + +

+

+ Treq will email {user.email} with alpha invitations and updates. + Every email has an unsubscribe link. +

+ + {error &&
{error}
} +
+ ); + } + + return ( +
+ Join the waitlist + {membership?.unsubscribed_at && ( +

+ You left the waitlist on{' '} + {new Date(membership.unsubscribed_at).toLocaleDateString()}. Treq + will not send you alpha emails unless you join again. +

+ )} +

+ Joining lets Treq email {user.email} about this alpha: invitations to + try it, and updates on its progress. It does not change your plan or + billing. +

+ + + {error &&
{error}
} +
+ ); +} + +function JoinPanel(): ReactNode { + const [user, setUser] = useState(undefined); + + useEffect(() => { + supabase.auth.getSession().then(({data: {session}}) => { + setUser(session?.user ?? null); + }); + }, []); + + if (user === undefined) { + return
Loading…
; + } + return user ? : ; +} + +export default function AlphaPage(): ReactNode { + return ( + +
+ + Private alpha + +

+ Two Treq features are in a private alpha. Treq invites people from + the waitlist in batches and emails each invitation. +

+
    +
  • + Managed cloud workspaces +

    + Your workspaces and agents run on a machine that Treq hosts + instead of your laptop. +

    +
  • +
  • + SSH Remote Development +

    + Treq opens a repository on your own server over SSH, and agents + run there. +

    +
  • +
+ Loading…}> + {() => } + +
+
+ ); +} diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 32773bcec..f97b46142 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -48,7 +48,7 @@ interface QueueStatus { bisect_mode: boolean; } -type Tab = "subscription" | "integrations" | "alpha"; +type Tab = "subscription" | "integrations"; function defaultConfig(repoId: number, defaultBranch: string): MergeQueueConfig { return { @@ -469,167 +469,6 @@ function IntegrationsTab() { ); } -// ── Alpha tab ──────────────────────────────────────────────────────────────── - -// Stored with every consent. Change it whenever the consent wording below -// changes, so each consent record names the text the user agreed to. -const ALPHA_FORM_VERSION = "2026-10-alpha-v1"; - -interface AlphaMembership { - consented_at: string; - unsubscribed_at: string | null; -} - -// The page that sent the user here (`?from=/roadmap`), else this page. Only -// a plain site path is kept, so nothing else can end up in the row. -function alphaSourcePage(): string { - const from = new URLSearchParams(window.location.search).get("from"); - return from && /^\/[\w\-/]{0,200}$/.test(from) - ? from - : window.location.pathname; -} - -function AlphaTab({ user }: { user: User }) { - const [membership, setMembership] = useState(null); - const [loading, setLoading] = useState(true); - const [consent, setConsent] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - // RLS returns only the signed-in user's row. - const load = useCallback(async () => { - const { data, error: loadError } = await supabase - .from("alpha_waitlist") - .select("consented_at, unsubscribed_at") - .maybeSingle(); - if (loadError) setError(loadError.message); - setMembership((data as AlphaMembership | null) ?? null); - setLoading(false); - }, []); - - useEffect(() => { - load(); - }, [load]); - - const save = async ( - write: () => PromiseLike<{ error: { message: string } | null }> - ) => { - setSaving(true); - setError(null); - const { error: saveError } = await write(); - if (saveError) { - setError(saveError.message); - } else { - setConsent(false); - await load(); - } - setSaving(false); - }; - - // The database stamps consented_at, and a rejoin clears unsubscribed_at - // and records a new consent (023_alpha_waitlist.sql). - const join = () => - save(() => - supabase.from("alpha_waitlist").upsert( - { - user_id: user.id, - form_version: ALPHA_FORM_VERSION, - source_page: alphaSourcePage(), - unsubscribed_at: null, - }, - { onConflict: "user_id" } - ) - ); - - const leave = () => - save(() => - supabase - .from("alpha_waitlist") - .update({ unsubscribed_at: new Date().toISOString() }) - .eq("user_id", user.id) - ); - - return ( -
-

Private alpha

-
-
- Cloud workspaces and SSH Remote Development -
-

- The private alpha covers two features that are still in - development. Managed cloud workspaces run your Treq workspaces on a - machine that Treq hosts. SSH Remote Development opens a workspace on - your own server over SSH. Treq invites people from the waitlist in - batches. -

- - {loading ? ( -
Loading…
- ) : membership && !membership.unsubscribed_at ? ( -
-
- On the waitlist - - Joined {new Date(membership.consented_at).toLocaleDateString()} - -
-

- Treq will email {user.email} with alpha invitations and updates. - Every email has an unsubscribe link. -

- -
- ) : ( -
- {membership?.unsubscribed_at && ( -

- You left the waitlist on{" "} - {new Date(membership.unsubscribed_at).toLocaleDateString()}. - Treq will not send you alpha emails unless you join again. -

- )} -

- Joining lets Treq email {user.email} about this alpha: - invitations to try it, and updates on its progress. It does not - change your plan or billing. -

- - -
- )} - - {error &&
{error}
} -
-
- ); -} - // ── Dashboard shell ────────────────────────────────────────────────────────── function DashboardContent() { @@ -654,9 +493,7 @@ function DashboardContent() { useEffect(() => { supabase.auth.getSession().then(({ data: { session: s } }) => { if (!s) { - // Come back to this tab after sign-in, e.g. /dashboard?tab=alpha. - const here = window.location.pathname + window.location.search; - window.location.href = `/sign-in?redirect=${encodeURIComponent(here)}`; + window.location.href = "/sign-in"; return; } setSession(s); @@ -747,15 +584,6 @@ function DashboardContent() { > Integrations -