Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions prds/marketing.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ The Team plan and the trial are approved decisions. Any further price change, di
| 1 | Organic search | Learn articles, comparison pages, free tools under `/tools/`, skills directory | biz-tools daily keyword report and weekly Draft content PRs |
| 2 | Stack comments | One comment on each stacked pull request Treq creates, linking to treq.dev | Posted by the app |
| 3 | GitHub | README, release notes, changelog, GitHub Discussions | Release notes are GitHub auto-generated |
| 4 | Alpha waitlist | Join from the dashboard after sign-in, alpha invitations, alpha update emails | Waitlist counts reported in the GTM digest |
| 4 | Alpha waitlist | The public treq.dev/alpha page, alpha invitations, alpha update emails | Waitlist counts reported in the GTM digest |
| 5 | Developer communities | One post per launch moment, written for that community's rules | None. The owner posts by hand. |
| 6 | Integration directories | GitHub Marketplace listing, Linear integration listing | None |

Expand Down Expand Up @@ -167,7 +167,7 @@ A launch waits for the feature to ship and for the site copy to match it. A road

## Alpha waitlist

The private alpha covers managed cloud workspaces and SSH Remote Development. Engineers join the waitlist from the dashboard after they sign in. Joining gives marketing an account, so every waitlist member also counts as a signup.
The private alpha covers managed cloud workspaces and SSH Remote Development. Engineers join the waitlist on the public treq.dev/alpha page, which the footer and the roadmap link to. A signed-out visitor ticks the consent box, signs in, and returns to the page, which finishes the join. Joining gives marketing an account, so every waitlist member also counts as a signup.

- The join step states the purpose, which is alpha invitations and alpha updates, and asks for explicit consent. The consent record stores the date and the form version.
- Emails go to the account email from sign-in.
Expand Down
232 changes: 232 additions & 0 deletions scripts/service-qa/specs/alpha-waitlist.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Alpha waitlist (031_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 = "/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<void> {
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<AdminRow> {
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=<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);
4 changes: 2 additions & 2 deletions scripts/service-qa/specs/gtm-metrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* biz-tools calls GET gtm-metrics?from=&to= with the x-gtm-metrics-secret
* header and gets aggregate counts only. The spec seeds accounts and GitHub
* App installations (and alpha waitlist rows when 023_alpha_waitlist.sql is
* App installations (and alpha waitlist rows when 031_alpha_waitlist.sql is
* present) and checks the counts move by exactly that much. The local
* database may hold rows from other specs, so it compares before and after.
*/
Expand Down Expand Up @@ -223,7 +223,7 @@ it("returns aggregate counts that match seeded data and nothing else", async ()
await recordOutcome("gtm-metrics-02-counts", {
expectations: [
"After seeding 2 accounts and 3 installations (2 linked to one account), accounts_created and accounts_total rise by 2, installations_total by 3, and github_app_installations_linked by 1.",
"Waitlist counts rise by 2 joined, 1 unsubscribed, 1 active when 023_alpha_waitlist.sql is applied, and are null when it is not.",
"Waitlist counts rise by 2 joined, 1 unsubscribed, 1 active when 031_alpha_waitlist.sql is applied, and are null when it is not.",
"The response has only from, to, and the count keys; it contains no email, user ID, or installation ID.",
],
details: { alphaTable: alpha, before, after },
Expand Down
6 changes: 6 additions & 0 deletions supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions supabase/functions/alpha-unsubscribe/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// One-click unsubscribe for alpha waitlist emails (prds/marketing.md, "Alpha
// waitlist"). Each email links to
//
// <functions url>/alpha-unsubscribe?token=<alpha_waitlist.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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>${title}</title>
<style>
body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5;color:#1f2937;background:#fff}
a{color:#2563eb}
@media (prefers-color-scheme:dark){body{color:#e5e7eb;background:#111827}a{color:#93c5fd}}
</style>
</head>
<body>
${body}
</body>
</html>
`;
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",
`<h1>You're unsubscribed</h1>
<p>Treq will not send you any more alpha emails. Your Treq account is unchanged.</p>
<p>Changed your mind? <a href="${webUrl}/dashboard?tab=alpha">Rejoin the alpha from your dashboard</a>.</p>`,
200,
);

const failed = () =>
page(
"Unsubscribe failed",
`<h1>Something went wrong</h1>
<p>Your unsubscribe was not saved. Open the link again in a few minutes.</p>`,
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();
});
Loading
Loading