Commit e96d689465d8d6e35f538faf6c4e5ae4cb30a0f9
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("a user_session cookie doesn't unlock /api/admin/* (the two session systems | @@ -169,3 +169,89 @@ test("a user_session cookie doesn't unlock /api/admin/* (the two session systems | ||
| 169 | const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }); | 169 | const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }); |
| 170 | assert.equal(res.status, 401); | 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,6 +153,27 @@ function isAdminAuthed(req) { | ||
| 153 | // the two must never be confused for one another. | 153 | // the two must never be confused for one another. |
| 154 | const USER_SESSION_COOKIE = "user_session"; | 154 | const USER_SESSION_COOKIE = "user_session"; |
| 155 | const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, MVCP M.10 | 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 | function hashPassword(password) { | 178 | function hashPassword(password) { |
| 158 | const salt = crypto.randomBytes(16).toString("hex"); | 179 | const salt = crypto.randomBytes(16).toString("hex"); |
| @@ -1019,9 +1040,20 @@ http | @@ -1019,9 +1040,20 @@ http | ||
| 1019 | res.end(JSON.stringify({ error: "password must be at least 8 characters" })); | 1040 | res.end(JSON.stringify({ error: "password must be at least 8 characters" })); |
| 1020 | return; | 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 | let user; | 1049 | let user; |
| 1023 | try { | 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 | } catch (err) { | 1057 | } catch (err) { |
| 1026 | if (err.code === "EMAIL_TAKEN") { | 1058 | if (err.code === "EMAIL_TAKEN") { |
| 1027 | res.writeHead(409, { "Content-Type": "application/json" }); | 1059 | res.writeHead(409, { "Content-Type": "application/json" }); |
| @@ -1031,11 +1063,17 @@ http | @@ -1031,11 +1063,17 @@ http | ||
| 1031 | throw err; | 1063 | throw err; |
| 1032 | } | 1064 | } |
| 1033 | const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); | 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 | res.writeHead(200, { | 1072 | res.writeHead(200, { |
| 1035 | "Content-Type": "application/json", | 1073 | "Content-Type": "application/json", |
| 1036 | "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`, | 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 | .catch(() => { | 1078 | .catch(() => { |
| 1041 | res.writeHead(400, { "Content-Type": "application/json" }); | 1079 | res.writeHead(400, { "Content-Type": "application/json" }); |
| @@ -1059,10 +1097,16 @@ http | @@ -1059,10 +1097,16 @@ http | ||
| 1059 | return; | 1097 | return; |
| 1060 | } | 1098 | } |
| 1061 | const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); | 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 | res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); | 1110 | res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); |
| 1067 | }) | 1111 | }) |
| 1068 | .catch(() => { | 1112 | .catch(() => { |
| @@ -1072,6 +1116,25 @@ http | @@ -1072,6 +1116,25 @@ http | ||
| 1072 | return; | 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 | if (pathname === "/api/auth/logout" && req.method === "POST") { | 1138 | if (pathname === "/api/auth/logout" && req.method === "POST") { |
| 1076 | const token = parseCookies(req)[USER_SESSION_COOKIE]; | 1139 | const token = parseCookies(req)[USER_SESSION_COOKIE]; |
| 1077 | if (token) userSessions.revoke(token); | 1140 | if (token) userSessions.revoke(token); |
| @@ -1473,6 +1536,10 @@ http | @@ -1473,6 +1536,10 @@ http | ||
| 1473 | partition: "manual", | 1536 | partition: "manual", |
| 1474 | likes: 0, | 1537 | likes: 0, |
| 1475 | createdAt: Date.now(), | 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 | const lib = getLibrary(); | 1544 | const lib = getLibrary(); |
| 1478 | lib.push(clip); | 1545 | lib.push(clip); |
| @@ -1501,6 +1568,7 @@ http | @@ -1501,6 +1568,7 @@ http | ||
| 1501 | for (const field of ["prompt", "promptEn", "fullPrompt", "videoUrl"]) { | 1568 | for (const field of ["prompt", "promptEn", "fullPrompt", "videoUrl"]) { |
| 1502 | if (typeof body[field] === "string" && body[field].trim()) clip[field] = body[field].trim(); | 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 | // Storing a videoUrl (whether this clip started as h3console or | 1572 | // Storing a videoUrl (whether this clip started as h3console or |
| 1505 | // manual) means /lib/video/:id should serve that directly instead | 1573 | // manual) means /lib/video/:id should serve that directly instead |
| 1506 | // of proxying the H3 console — see the handler below. | 1574 | // of proxying the H3 console — see the handler below. |
-
mentioned in commit 79a2a1e90d67cf178d44c735548a95bac8d0ce24