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
44 changes: 35 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions migrations/0003-add-user-email.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 5 additions & 1 deletion schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions scripts/dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion scripts/dashboard/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 21 additions & 6 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -23,14 +23,28 @@ export async function getUserByGitHubId(
): Promise<User | null> {
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)
.first<User>();
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<void> {
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
Expand All @@ -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<void> {
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();
}
Expand Down
62 changes: 60 additions & 2 deletions src/github-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Expand All @@ -240,18 +257,26 @@ 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,
display_name: githubLogin,
created_at: Date.now(),
github_user_id: githubUserId,
github_login: githubLogin,
email: githubEmail,
};
}

Expand Down Expand Up @@ -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<string | null> {
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<number> {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading