From e96d689465d8d6e35f538faf6c4e5ae4cb30a0f9 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 19:43:48 +0800 Subject: [PATCH] Add T20 (email verification) + T21 (anonymous identity binding) --- auth.test.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/auth.test.js b/auth.test.js index f73482f..48f9d80 100644 --- a/auth.test.js +++ b/auth.test.js @@ -169,3 +169,89 @@ test("a user_session cookie doesn't unlock /api/admin/* (the two session systems const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }); assert.equal(res.status, 401); }); + +// ---- T20: email verification ---- + +test("verify: no vendor configured yet, so register's dev response carries a usable verify link that actually flips emailVerified", async () => { + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "erin@example.com", password: "correct-horse-battery" }), + }); + const registered = await registerRes.json(); + assert.ok(registered.devVerifyUrl, "dev/test environments must be able to complete verification without a real inbox"); + + const cookie = sessionCookie(registerRes); + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(meBefore.emailVerified, false); + + const verifyRes = await fetch(`${BASE}${registered.devVerifyUrl}`); + assert.equal(verifyRes.status, 200); + + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(meAfter.emailVerified, true); +}); + +test("verify: an unknown/garbage token is a clear error, not a silent success", async () => { + const res = await fetch(`${BASE}/api/auth/verify?token=not-a-real-token`); + assert.equal(res.status, 400); +}); + +test("verify: a token can't be reused once it's already verified the account", async () => { + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "frank@example.com", password: "correct-horse-battery" }), + }); + const { devVerifyUrl } = await registerRes.json(); + + const firstRes = await fetch(`${BASE}${devVerifyUrl}`); + assert.equal(firstRes.status, 200); + const secondRes = await fetch(`${BASE}${devVerifyUrl}`); + assert.equal(secondRes.status, 400, "a used verify token must not silently succeed again"); +}); + +// ---- T21: binding an existing anonymous viewer_uid/viewer_nickname ---- + +test("register: an existing viewer_uid/viewer_nickname cookie gets bound to the new account as legacyUid/nickname", async () => { + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: "viewer_uid=old-anon-uid-123; viewer_nickname=%E8%80%81%E8%A7%82%E4%BC%97", // "老观众" + }, + body: JSON.stringify({ email: "grace@example.com", password: "correct-horse-battery" }), + }); + const registered = await registerRes.json(); + assert.equal(registered.nickname, "老观众", "nickname should migrate from the cookie, not require re-entering it"); + + const cookie = sessionCookie(registerRes); + const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(me.nickname, "老观众"); +}); + +test("login: an account with a bound legacyUid pushes it back onto the browser, even with no/fresh viewer_uid cookie", async () => { + await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: "viewer_uid=henry-bound-uid; viewer_nickname=Henry" }, + body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }), + }); + + // Log in from what looks like a clean browser — no viewer_uid cookie at + // all this time (simulating cleared cookies / a different device). + const loginRes = await fetch(`${BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }), + }); + assert.equal(loginRes.status, 200); + const setCookies = loginRes.headers.getSetCookie(); + const viewerUidCookie = setCookies.find((c) => c.startsWith("viewer_uid=")); + const viewerNicknameCookie = setCookies.find((c) => c.startsWith("viewer_nickname=")); + assert.ok(viewerUidCookie, "login must re-issue the account's bound viewer_uid, not leave the browser without one"); + assert.match(viewerUidCookie, /^viewer_uid=henry-bound-uid;/); + assert.match(viewerNicknameCookie, /^viewer_nickname=Henry;/); + // Not HttpOnly — live.html/dance.html's own client-side JS reads these + // via document.cookie, same as the identity cookies they already set. + assert.ok(!viewerUidCookie.includes("HttpOnly")); +}); diff --git a/server.js b/server.js index 7bb7427..df36cc4 100644 --- a/server.js +++ b/server.js @@ -153,6 +153,27 @@ function isAdminAuthed(req) { // the two must never be confused for one another. const USER_SESSION_COOKIE = "user_session"; const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, MVCP M.10 +const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours, MVCP M.10 + +// T20: no email-delivery vendor has been picked yet (dev-plan-3.md §1's +// open question — SMTP vs. an HTTP-API provider like Resend/SendGrid). +// This is the one seam a real integration plugs into later; nothing else +// needs to change. For now it logs, and outside production also returns +// the link directly so registering can be tested end to end without a +// real inbox. +function sendVerificationEmail(email, token) { + const verifyUrl = `/api/auth/verify?token=${token}`; + console.log(`[email_verify] would send to ${email}: ${verifyUrl}`); + return verifyUrl; +} + +// T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, +// samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are +// the same viewer_uid/viewer_nickname cookies those pages already read +// with plain client-side JS, not a new cookie scheme. +function viewerIdentityCookie(name, value) { + return `${name}=${encodeURIComponent(value)}; Max-Age=${365 * 86400}; Path=/; SameSite=Lax`; +} function hashPassword(password) { const salt = crypto.randomBytes(16).toString("hex"); @@ -1019,9 +1040,20 @@ http res.end(JSON.stringify({ error: "password must be at least 8 characters" })); return; } + // T21: an existing anonymous viewer_uid/viewer_nickname cookie + // (set client-side by live.html/dance.html, plaintext, never + // trusted for anything sensitive) gets carried onto the new + // account — history isn't rewritten, but future votes/plays from + // this browser stay attributed to the same identity. + const existingCookies = parseCookies(req); let user; try { - user = userStore.create({ email, passwordHash: hashPassword(password) }); + user = userStore.create({ + email, + passwordHash: hashPassword(password), + nickname: existingCookies.viewer_nickname || null, + legacyUid: existingCookies.viewer_uid || null, + }); } catch (err) { if (err.code === "EMAIL_TAKEN") { res.writeHead(409, { "Content-Type": "application/json" }); @@ -1031,11 +1063,17 @@ http throw err; } const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); + // T20: fire off the verification email. sendVerificationEmail is + // the entire integration seam — see its own comment. + const verifySession = userSessions.create({ userId: user.id, kind: "email_verify", expiresAt: Date.now() + EMAIL_VERIFY_TTL_MS }); + const devVerifyUrl = sendVerificationEmail(user.email, verifySession.token); + const responseBody = { id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }; + if (process.env.NODE_ENV !== "production") responseBody.devVerifyUrl = devVerifyUrl; res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`, }); - res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); + res.end(JSON.stringify(responseBody)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); @@ -1059,10 +1097,16 @@ http return; } const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); - res.writeHead(200, { - "Content-Type": "application/json", - "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`, - }); + const setCookies = [`${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`]; + // T21: push the account's bound identity back onto this browser, + // even if it showed up with no viewer_uid cookie at all or a + // fresh one from a different device — otherwise logging in from + // a clean browser would silently start a new anonymous identity, + // which for anything vote-limited defeats the point of binding + // it to an account in the first place. + if (user.legacyUid) setCookies.push(viewerIdentityCookie("viewer_uid", user.legacyUid)); + if (user.nickname) setCookies.push(viewerIdentityCookie("viewer_nickname", user.nickname)); + res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": setCookies }); res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); }) .catch(() => { @@ -1072,6 +1116,25 @@ http return; } + // T20: single-use — the email_verify session is revoked on success, so + // a second visit to the same link (or an expired one) hits the same + // "invalid or expired" error rather than silently re-succeeding. + if (pathname === "/api/auth/verify" && req.method === "GET") { + const params = new URLSearchParams(req.url.split("?")[1] || ""); + const token = params.get("token") || ""; + const verifySession = userSessions.findByToken(token); + if (!verifySession || verifySession.kind !== "email_verify") { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid or expired verification link" })); + return; + } + userStore.update(verifySession.userId, { emailVerified: true }); + userSessions.revoke(token); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (pathname === "/api/auth/logout" && req.method === "POST") { const token = parseCookies(req)[USER_SESSION_COOKIE]; if (token) userSessions.revoke(token); @@ -1473,6 +1536,10 @@ http partition: "manual", likes: 0, createdAt: Date.now(), + // channel: "dance" puts this clip in dance.html's pool + // (pickNextClip filters on it, architecture.md 2.1); anything + // else stays out of it and is live.html's/text-channel pool. + channel: body.channel === "dance" ? "dance" : undefined, }; const lib = getLibrary(); lib.push(clip); @@ -1501,6 +1568,7 @@ http for (const field of ["prompt", "promptEn", "fullPrompt", "videoUrl"]) { if (typeof body[field] === "string" && body[field].trim()) clip[field] = body[field].trim(); } + if (body.channel === "dance" || body.channel === "video") clip.channel = body.channel === "dance" ? "dance" : undefined; // Storing a videoUrl (whether this clip started as h3console or // manual) means /lib/video/:id should serve that directly instead // of proxying the H3 console — see the handler below. -- libgit2 0.21.4