diff --git a/CLAUDE.md b/CLAUDE.md index cce44d8..f1d60dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,12 +54,20 @@ them ships something the user will reject: 3. **No Webflow.** Off-limits in the public version. 4. **Don't add signup/login/email/dashboard.** The device-code flow is the entire UX. Adding auth surfaces breaks the product thesis. - *Documented exception:* GitHub OAuth lives inside the device-code - verify step (May 2026) — it replaces Turnstile, doesn't add a new - surface. There is still no email, no password, no dashboard, no - account page. Sign-in happens once at `/verify` and the agent flow - is unchanged from its side. Do not extend this exception to add a - user-facing account UI. + *Documented exceptions:* + - GitHub OAuth lives inside the device-code verify step (May 2026) + — it replaces Turnstile, doesn't add a new surface. Sign-in + happens once at `/verify` and the agent flow is unchanged from + its side. + - We collect the user's **verified primary email** from GitHub + (May 2026) via the `user:email` scope. Stored on the user row as + contact metadata only — used for occasional operator-to-user + outreach (incident notice, "are you the StressLessAgency that's + publishing X?"). There is **still no password, no dashboard, no + account page, no user-facing email surface, no marketing list, + no transactional product email.** If you find yourself adding any + of those, stop and ask first. + Do not extend these exceptions to add a user-facing account UI. 5. **Don't introduce a new keyword (formerly "HTMD").** The product is called htmlbin; the artifact is "a drop"; "drop" is just casual English, not a coined term we own. We do not have authority to define @@ -123,9 +131,11 @@ Modeled on OAuth device-code (think `gh auth login`): 1. `POST /api/auth/start` → `{code, verification_url, poll_token}` 2. Agent prints code + URL to human -3. Human opens URL, signs in with **GitHub** — we ask for `read:user` - only (public username + numeric id). The Worker upserts a user row - by `github_user_id` (UNIQUE) and mints a token in the same callback. +3. Human opens URL, signs in with **GitHub** — we ask for + `read:user user:email`. `read:user` gives public username + numeric + id; `user:email` lets us hit `/user/emails` for the primary+verified + address. The Worker upserts a user row by `github_user_id` (UNIQUE) + and mints a token in the same callback. 4. `GET /api/auth/poll?token=…` → `{api_token}` revealed exactly once 5. `Authorization: Bearer hb_…` thereafter @@ -141,6 +151,22 @@ device. The callback finds the existing user by `github_user_id` and mints a new token attached to the same `user_id`. The old "paste an existing hb_… token" UX was deleted in the same change. +**Email column (`users.email`, May 2026):** verified primary email +from `/user/emails`. **Optional** — sign-in never blocks on it +(scope denied / no verified email / lookup failed → NULL). **No +UNIQUE constraint** (two distinct GitHub accounts can legitimately +share a personal address). **Refreshed on every sign-in** via +`updateUserEmail()` so pre-`user:email` rows backfill the first time +the user signs in again. **Backfill caveat:** users who never sign in +again will keep `email IS NULL` forever — there is no scheduled +backfill job and no human-facing "please update your email" prompt +(would violate rule #4). Disclosure is the GitHub OAuth consent +screen only. **No marketing list, no transactional product email, +no unsubscribe surface** — current use is operator-to-user reach-out +only, surfaced as a `mailto:` link in `npm run dashboard`. If you +ever add automated send, that's a real product change with a privacy +notice + unsubscribe story — talk to the user first. + **Routes:** `/auth/github/start` and `/auth/github/callback` live in `src/github-oauth.ts`. State binding to the verification row uses the verify code as the OAuth `state` param — it's already a short-lived, diff --git a/migrations/0003-add-user-email.sql b/migrations/0003-add-user-email.sql new file mode 100644 index 0000000..9f72869 --- /dev/null +++ b/migrations/0003-add-user-email.sql @@ -0,0 +1,15 @@ +-- Collect the user's verified primary email from GitHub. +-- +-- OAuth scope expands from `read:user` to `read:user user:email`. On every +-- sign-in the callback fetches /user/emails, picks the primary+verified +-- entry, and stores it. Optional — sign-in still succeeds with email NULL +-- (user denied scope, has no verified email, or the lookup failed). +-- +-- No UNIQUE constraint: two distinct GitHub accounts can legitimately +-- share an email (work + personal sharing user@gmail.com). github_user_id +-- remains the identity; email is contact metadata only. +-- +-- Apply locally: npm run db:migrate:local +-- Apply to prod: npm run db:migrate:remote + +ALTER TABLE users ADD COLUMN email TEXT; diff --git a/schema.sql b/schema.sql index c7729ab..eca4bb1 100644 --- a/schema.sql +++ b/schema.sql @@ -9,7 +9,11 @@ CREATE TABLE IF NOT EXISTS users ( -- GitHub identity (required for accounts minted after the OAuth migration; -- NULL for legacy pre-OAuth accounts that still carry working tokens). github_user_id INTEGER, - github_login TEXT + github_login TEXT, + -- Verified primary email from GitHub (user:email scope). Optional — + -- NULL when the user denied scope, has no verified email on GitHub, + -- or the /user/emails lookup failed. Refreshed on every sign-in. + email TEXT ); -- Enforce one user per GitHub identity, but only for rows that have one diff --git a/scripts/dashboard/app.js b/scripts/dashboard/app.js index 4120ac8..d9afb72 100644 --- a/scripts/dashboard/app.js +++ b/scripts/dashboard/app.js @@ -384,6 +384,12 @@ async function viewUser(userId) { ), gh?.bio ? el("p", { class: "user-bio" }, gh.bio) : null, facts.length > 0 ? el("div", { class: "user-facts" }, facts.join(" · ")) : null, + u.email + ? el("div", { class: "user-ext-links" }, + "✉ ", + el("a", { href: `mailto:${u.email}`, class: "link" }, u.email), + ) + : null, extLinks.length > 0 ? el("div", { class: "user-ext-links" }, ...extLinks.flatMap((l, i) => i === 0 ? [l] : [" · ", l])) : null, diff --git a/scripts/dashboard/server.mjs b/scripts/dashboard/server.mjs index cd3c724..ca54e94 100644 --- a/scripts/dashboard/server.mjs +++ b/scripts/dashboard/server.mjs @@ -245,7 +245,7 @@ function userDetail(userId) { if (!RE_USER.test(userId)) throw new Error("invalid user id"); const user = runQuery(` - SELECT id, display_name, github_login, github_user_id, created_at + SELECT id, display_name, github_login, github_user_id, email, created_at FROM users WHERE id = '${userId}' `)[0]; if (!user) return null; diff --git a/src/db.ts b/src/db.ts index 2bf8f55..36df489 100644 --- a/src/db.ts +++ b/src/db.ts @@ -7,7 +7,7 @@ export async function getUserByTokenHash( const row = await db .prepare( `SELECT u.id, u.display_name, u.created_at, - u.github_user_id, u.github_login + u.github_user_id, u.github_login, u.email FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.revoked_at IS NULL` @@ -23,7 +23,7 @@ export async function getUserByGitHubId( ): Promise { const row = await db .prepare( - `SELECT id, display_name, created_at, github_user_id, github_login + `SELECT id, display_name, created_at, github_user_id, github_login, email FROM users WHERE github_user_id = ?` ) .bind(githubUserId) @@ -31,6 +31,20 @@ export async function getUserByGitHubId( return row ?? null; } +// Refresh the stored email for an existing user on re-auth. Only writes +// when the value actually changes (avoids a no-op UPDATE on every +// sign-in). Pass null to clear; pass undefined to leave the column alone. +export async function updateUserEmail( + db: D1Database, + userId: string, + email: string | null +): Promise { + await db + .prepare(`UPDATE users SET email = ? WHERE id = ? AND (email IS NOT ?)`) + .bind(email, userId, email) + .run(); +} + export async function touchToken( db: D1Database, tokenHash: string @@ -45,19 +59,20 @@ export async function createUser( db: D1Database, id: string, displayName: string | null, - github: { id: number; login: string } | null = null + github: { id: number; login: string; email?: string | null } | null = null ): Promise { await db .prepare( - `INSERT INTO users (id, display_name, created_at, github_user_id, github_login) - VALUES (?, ?, ?, ?, ?)` + `INSERT INTO users (id, display_name, created_at, github_user_id, github_login, email) + VALUES (?, ?, ?, ?, ?, ?)` ) .bind( id, displayName, Date.now(), github?.id ?? null, - github?.login ?? null + github?.login ?? null, + github?.email ?? null ) .run(); } diff --git a/src/github-oauth.ts b/src/github-oauth.ts index c3aa372..b89c362 100644 --- a/src/github-oauth.ts +++ b/src/github-oauth.ts @@ -34,7 +34,13 @@ import { newApiToken, newUserId, } from "./crypto"; -import { createUser, getUserByGitHubId, insertToken, rateLimit } from "./db"; +import { + createUser, + getUserByGitHubId, + insertToken, + rateLimit, + updateUserEmail, +} from "./db"; import { verifyPage } from "./views/verify"; const DEV_MOCK = "dev-mock"; @@ -105,7 +111,10 @@ githubOAuthRoutes.get("/auth/github/start", async (c) => { const authorize = new URL("https://github.com/login/oauth/authorize"); authorize.searchParams.set("client_id", c.env.GITHUB_CLIENT_ID); authorize.searchParams.set("redirect_uri", redirectUri); - authorize.searchParams.set("scope", "read:user"); + // user:email lets us read /user/emails for the primary+verified address. + // Stored on the user row as contact metadata; sign-in still succeeds + // when it's null (denied scope / no verified email / lookup failed). + authorize.searchParams.set("scope", "read:user user:email"); authorize.searchParams.set("state", code); authorize.searchParams.set("allow_signup", "true"); return c.redirect(authorize.toString(), 302); @@ -204,12 +213,17 @@ githubOAuthRoutes.get("/auth/github/callback", async (c) => { let githubUserId: number; let githubLogin: string; + let githubEmail: string | null = null; try { if (c.env.GITHUB_CLIENT_ID === DEV_MOCK) { githubLogin = (c.req.query("mock_login") ?? "dev-user").slice(0, 40); // Deterministic but per-login id, so two different mock_logins // create two different accounts. 32-bit space is plenty for tests. githubUserId = await stableMockId(githubLogin); + // .test is a reserved TLD, so the synthetic value can't collide + // with a real address. Lets the e2e + dashboard exercise the + // happy path without ever sending mail. + githubEmail = `${githubLogin}@example.test`; } else { const exchanged = await exchangeCode( ghCode, @@ -220,6 +234,9 @@ githubOAuthRoutes.get("/auth/github/callback", async (c) => { const ghUser = await fetchGitHubUser(exchanged.access_token); githubUserId = ghUser.id; githubLogin = ghUser.login; + // Optional: if /user/emails fails or returns nothing usable, we + // log and continue. Sign-in never blocks on email. + githubEmail = await fetchGitHubPrimaryEmail(exchanged.access_token); } } catch (e) { console.error("github_oauth_failed", String(e)); @@ -240,11 +257,18 @@ githubOAuthRoutes.get("/auth/github/callback", async (c) => { // "same human on a new device" path that used to require pasting // an existing token. linked = true; + // Refresh contact email — user may have changed their primary on + // GitHub since last sign-in, and pre-user:email accounts get their + // first email backfilled on next sign-in. + if (githubEmail) { + await updateUserEmail(c.env.DB, user.id, githubEmail); + } } else { const newId = newUserId(); await createUser(c.env.DB, newId, githubLogin, { id: githubUserId, login: githubLogin, + email: githubEmail, }); user = { id: newId, @@ -252,6 +276,7 @@ githubOAuthRoutes.get("/auth/github/callback", async (c) => { created_at: Date.now(), github_user_id: githubUserId, github_login: githubLogin, + email: githubEmail, }; } @@ -332,6 +357,39 @@ async function fetchGitHubUser( return { id: json.id, login: json.login }; } +// Pull the user's primary+verified email from /user/emails. Returns null +// (and only logs) on any failure — the caller treats email as optional. +async function fetchGitHubPrimaryEmail( + accessToken: string +): Promise { + try { + const res = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "htmlbin-oauth", + }, + }); + if (!res.ok) { + console.warn("github_emails_failed", res.status); + return null; + } + const list = (await res.json()) as Array<{ + email?: string; + primary?: boolean; + verified?: boolean; + }>; + if (!Array.isArray(list)) return null; + const primary = list.find( + (e) => e.primary === true && e.verified === true && typeof e.email === "string" + ); + return primary?.email ?? null; + } catch (e) { + console.warn("github_emails_threw", String(e)); + return null; + } +} + // Dev-mock id: SHA-256(login) → first 4 bytes → uint32. Same login always // resolves to the same id, different logins almost certainly don't collide. async function stableMockId(login: string): Promise { diff --git a/src/types.ts b/src/types.ts index 7777ef8..7580333 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,7 @@ export type User = { created_at: number; github_user_id: number | null; github_login: string | null; + email: string | null; }; export type Drop = {