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 | 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. | ... | ... |
-
mentioned in commit 79a2a1e90d67cf178d44c735548a95bac8d0ce24