Commit e96d689465d8d6e35f538faf6c4e5ae4cb30a0f9

Authored by yibo
1 parent 90777eda

Add T20 (email verification) + T21 (anonymous identity binding)

T20: register now also creates a kind='email_verify' session (24h TTL)
and calls sendVerificationEmail() — the one pluggable seam for a real
provider once one's chosen (dev-plan-3.md §1 flagged no vendor exists
yet). Outside production the register response carries a devVerifyUrl
so the whole loop is testable without a real inbox. GET
/api/auth/verify?token= flips emailVerified and revokes the token so
it can't be replayed.

T21: register reads any existing viewer_uid/viewer_nickname cookie
(live.html/dance.html's plain client-set identity, unrelated to this
session system) and carries it onto the new account as legacyUid/
nickname. Login pushes that legacyUid back onto the browser via
Set-Cookie — even from a clean browser with no viewer_uid cookie at
all — so logging into an account doesn't let someone dodge
per-viewer_uid vote limits by starting a fresh anonymous identity each
time. No history is rewritten, per dev-plan-3.md's explicit scope cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Deddn3sM3oQnmvqWSMb4y
Showing 2 changed files with 160 additions and 6 deletions
auth.test.js
... ... @@ -169,3 +169,89 @@ test(&quot;a user_session cookie doesn&#39;t unlock /api/admin/* (the two session systems
169 169 const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } });
170 170 assert.equal(res.status, 401);
171 171 });
  172 +
  173 +// ---- T20: email verification ----
  174 +
  175 +test("verify: no vendor configured yet, so register's dev response carries a usable verify link that actually flips emailVerified", async () => {
  176 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  177 + method: "POST",
  178 + headers: { "Content-Type": "application/json" },
  179 + body: JSON.stringify({ email: "erin@example.com", password: "correct-horse-battery" }),
  180 + });
  181 + const registered = await registerRes.json();
  182 + assert.ok(registered.devVerifyUrl, "dev/test environments must be able to complete verification without a real inbox");
  183 +
  184 + const cookie = sessionCookie(registerRes);
  185 + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  186 + assert.equal(meBefore.emailVerified, false);
  187 +
  188 + const verifyRes = await fetch(`${BASE}${registered.devVerifyUrl}`);
  189 + assert.equal(verifyRes.status, 200);
  190 +
  191 + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  192 + assert.equal(meAfter.emailVerified, true);
  193 +});
  194 +
  195 +test("verify: an unknown/garbage token is a clear error, not a silent success", async () => {
  196 + const res = await fetch(`${BASE}/api/auth/verify?token=not-a-real-token`);
  197 + assert.equal(res.status, 400);
  198 +});
  199 +
  200 +test("verify: a token can't be reused once it's already verified the account", async () => {
  201 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  202 + method: "POST",
  203 + headers: { "Content-Type": "application/json" },
  204 + body: JSON.stringify({ email: "frank@example.com", password: "correct-horse-battery" }),
  205 + });
  206 + const { devVerifyUrl } = await registerRes.json();
  207 +
  208 + const firstRes = await fetch(`${BASE}${devVerifyUrl}`);
  209 + assert.equal(firstRes.status, 200);
  210 + const secondRes = await fetch(`${BASE}${devVerifyUrl}`);
  211 + assert.equal(secondRes.status, 400, "a used verify token must not silently succeed again");
  212 +});
  213 +
  214 +// ---- T21: binding an existing anonymous viewer_uid/viewer_nickname ----
  215 +
  216 +test("register: an existing viewer_uid/viewer_nickname cookie gets bound to the new account as legacyUid/nickname", async () => {
  217 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  218 + method: "POST",
  219 + headers: {
  220 + "Content-Type": "application/json",
  221 + Cookie: "viewer_uid=old-anon-uid-123; viewer_nickname=%E8%80%81%E8%A7%82%E4%BC%97", // "老观众"
  222 + },
  223 + body: JSON.stringify({ email: "grace@example.com", password: "correct-horse-battery" }),
  224 + });
  225 + const registered = await registerRes.json();
  226 + assert.equal(registered.nickname, "老观众", "nickname should migrate from the cookie, not require re-entering it");
  227 +
  228 + const cookie = sessionCookie(registerRes);
  229 + const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  230 + assert.equal(me.nickname, "老观众");
  231 +});
  232 +
  233 +test("login: an account with a bound legacyUid pushes it back onto the browser, even with no/fresh viewer_uid cookie", async () => {
  234 + await fetch(`${BASE}/api/auth/register`, {
  235 + method: "POST",
  236 + headers: { "Content-Type": "application/json", Cookie: "viewer_uid=henry-bound-uid; viewer_nickname=Henry" },
  237 + body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }),
  238 + });
  239 +
  240 + // Log in from what looks like a clean browser — no viewer_uid cookie at
  241 + // all this time (simulating cleared cookies / a different device).
  242 + const loginRes = await fetch(`${BASE}/api/auth/login`, {
  243 + method: "POST",
  244 + headers: { "Content-Type": "application/json" },
  245 + body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }),
  246 + });
  247 + assert.equal(loginRes.status, 200);
  248 + const setCookies = loginRes.headers.getSetCookie();
  249 + const viewerUidCookie = setCookies.find((c) => c.startsWith("viewer_uid="));
  250 + const viewerNicknameCookie = setCookies.find((c) => c.startsWith("viewer_nickname="));
  251 + assert.ok(viewerUidCookie, "login must re-issue the account's bound viewer_uid, not leave the browser without one");
  252 + assert.match(viewerUidCookie, /^viewer_uid=henry-bound-uid;/);
  253 + assert.match(viewerNicknameCookie, /^viewer_nickname=Henry;/);
  254 + // Not HttpOnly — live.html/dance.html's own client-side JS reads these
  255 + // via document.cookie, same as the identity cookies they already set.
  256 + assert.ok(!viewerUidCookie.includes("HttpOnly"));
  257 +});
... ...
server.js
... ... @@ -153,6 +153,27 @@ function isAdminAuthed(req) {
153 153 // the two must never be confused for one another.
154 154 const USER_SESSION_COOKIE = "user_session";
155 155 const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, MVCP M.10
  156 +const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours, MVCP M.10
  157 +
  158 +// T20: no email-delivery vendor has been picked yet (dev-plan-3.md §1's
  159 +// open question — SMTP vs. an HTTP-API provider like Resend/SendGrid).
  160 +// This is the one seam a real integration plugs into later; nothing else
  161 +// needs to change. For now it logs, and outside production also returns
  162 +// the link directly so registering can be tested end to end without a
  163 +// real inbox.
  164 +function sendVerificationEmail(email, token) {
  165 + const verifyUrl = `/api/auth/verify?token=${token}`;
  166 + console.log(`[email_verify] would send to ${email}: ${verifyUrl}`);
  167 + return verifyUrl;
  168 +}
  169 +
  170 +// T21: mirrors live.html/dance.html's own setCookie() exactly (path=/,
  171 +// samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are
  172 +// the same viewer_uid/viewer_nickname cookies those pages already read
  173 +// with plain client-side JS, not a new cookie scheme.
  174 +function viewerIdentityCookie(name, value) {
  175 + return `${name}=${encodeURIComponent(value)}; Max-Age=${365 * 86400}; Path=/; SameSite=Lax`;
  176 +}
156 177  
157 178 function hashPassword(password) {
158 179 const salt = crypto.randomBytes(16).toString("hex");
... ... @@ -1019,9 +1040,20 @@ http
1019 1040 res.end(JSON.stringify({ error: "password must be at least 8 characters" }));
1020 1041 return;
1021 1042 }
  1043 + // T21: an existing anonymous viewer_uid/viewer_nickname cookie
  1044 + // (set client-side by live.html/dance.html, plaintext, never
  1045 + // trusted for anything sensitive) gets carried onto the new
  1046 + // account — history isn't rewritten, but future votes/plays from
  1047 + // this browser stay attributed to the same identity.
  1048 + const existingCookies = parseCookies(req);
1022 1049 let user;
1023 1050 try {
1024   - user = userStore.create({ email, passwordHash: hashPassword(password) });
  1051 + user = userStore.create({
  1052 + email,
  1053 + passwordHash: hashPassword(password),
  1054 + nickname: existingCookies.viewer_nickname || null,
  1055 + legacyUid: existingCookies.viewer_uid || null,
  1056 + });
1025 1057 } catch (err) {
1026 1058 if (err.code === "EMAIL_TAKEN") {
1027 1059 res.writeHead(409, { "Content-Type": "application/json" });
... ... @@ -1031,11 +1063,17 @@ http
1031 1063 throw err;
1032 1064 }
1033 1065 const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS });
  1066 + // T20: fire off the verification email. sendVerificationEmail is
  1067 + // the entire integration seam — see its own comment.
  1068 + const verifySession = userSessions.create({ userId: user.id, kind: "email_verify", expiresAt: Date.now() + EMAIL_VERIFY_TTL_MS });
  1069 + const devVerifyUrl = sendVerificationEmail(user.email, verifySession.token);
  1070 + const responseBody = { id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname };
  1071 + if (process.env.NODE_ENV !== "production") responseBody.devVerifyUrl = devVerifyUrl;
1034 1072 res.writeHead(200, {
1035 1073 "Content-Type": "application/json",
1036 1074 "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`,
1037 1075 });
1038   - res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }));
  1076 + res.end(JSON.stringify(responseBody));
1039 1077 })
1040 1078 .catch(() => {
1041 1079 res.writeHead(400, { "Content-Type": "application/json" });
... ... @@ -1059,10 +1097,16 @@ http
1059 1097 return;
1060 1098 }
1061 1099 const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS });
1062   - res.writeHead(200, {
1063   - "Content-Type": "application/json",
1064   - "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`,
1065   - });
  1100 + const setCookies = [`${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`];
  1101 + // T21: push the account's bound identity back onto this browser,
  1102 + // even if it showed up with no viewer_uid cookie at all or a
  1103 + // fresh one from a different device — otherwise logging in from
  1104 + // a clean browser would silently start a new anonymous identity,
  1105 + // which for anything vote-limited defeats the point of binding
  1106 + // it to an account in the first place.
  1107 + if (user.legacyUid) setCookies.push(viewerIdentityCookie("viewer_uid", user.legacyUid));
  1108 + if (user.nickname) setCookies.push(viewerIdentityCookie("viewer_nickname", user.nickname));
  1109 + res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": setCookies });
1066 1110 res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }));
1067 1111 })
1068 1112 .catch(() => {
... ... @@ -1072,6 +1116,25 @@ http
1072 1116 return;
1073 1117 }
1074 1118  
  1119 + // T20: single-use — the email_verify session is revoked on success, so
  1120 + // a second visit to the same link (or an expired one) hits the same
  1121 + // "invalid or expired" error rather than silently re-succeeding.
  1122 + if (pathname === "/api/auth/verify" && req.method === "GET") {
  1123 + const params = new URLSearchParams(req.url.split("?")[1] || "");
  1124 + const token = params.get("token") || "";
  1125 + const verifySession = userSessions.findByToken(token);
  1126 + if (!verifySession || verifySession.kind !== "email_verify") {
  1127 + res.writeHead(400, { "Content-Type": "application/json" });
  1128 + res.end(JSON.stringify({ error: "invalid or expired verification link" }));
  1129 + return;
  1130 + }
  1131 + userStore.update(verifySession.userId, { emailVerified: true });
  1132 + userSessions.revoke(token);
  1133 + res.writeHead(200, { "Content-Type": "application/json" });
  1134 + res.end(JSON.stringify({ ok: true }));
  1135 + return;
  1136 + }
  1137 +
1075 1138 if (pathname === "/api/auth/logout" && req.method === "POST") {
1076 1139 const token = parseCookies(req)[USER_SESSION_COOKIE];
1077 1140 if (token) userSessions.revoke(token);
... ... @@ -1473,6 +1536,10 @@ http
1473 1536 partition: "manual",
1474 1537 likes: 0,
1475 1538 createdAt: Date.now(),
  1539 + // channel: "dance" puts this clip in dance.html's pool
  1540 + // (pickNextClip filters on it, architecture.md 2.1); anything
  1541 + // else stays out of it and is live.html's/text-channel pool.
  1542 + channel: body.channel === "dance" ? "dance" : undefined,
1476 1543 };
1477 1544 const lib = getLibrary();
1478 1545 lib.push(clip);
... ... @@ -1501,6 +1568,7 @@ http
1501 1568 for (const field of ["prompt", "promptEn", "fullPrompt", "videoUrl"]) {
1502 1569 if (typeof body[field] === "string" && body[field].trim()) clip[field] = body[field].trim();
1503 1570 }
  1571 + if (body.channel === "dance" || body.channel === "video") clip.channel = body.channel === "dance" ? "dance" : undefined;
1504 1572 // Storing a videoUrl (whether this clip started as h3console or
1505 1573 // manual) means /lib/video/:id should serve that directly instead
1506 1574 // of proxying the H3 console — see the handler below.
... ...