Commit f67cdb5
authored
Fix CI cross-platform issues, release log gate, and add test dependencies (#383)
<html>
<body>
<!--StartFragment--><p style="white-space: pre-wrap; margin-top: 0px;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Here's
a PR description you can copy/paste:</p><hr style="font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
white-space: normal; background-color: rgb(22, 22, 30);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><h2 style="color: rgb(120, 124, 153);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; letter-spacing: normal; orphans: 2;
text-align: start; text-indent: 0px; text-transform: none; widows: 2;
word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.7 → wave-12.12 — 6-wave security + perf +
observability run</h2><p style="white-space: pre-wrap; margin-top:
0.1em; margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">This
branch lands six waves of follow-on work from the wave-12.6 / wave-12.11
audit reports. Backend tests: <strong>3387 / 3393 pass</strong> (6
documented skips, 0 fails). Frontend tests: <strong>845 / 855
pass</strong>. Lint clean both projects. Build green.</p><h2
style="color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-style: normal; font-variant-ligatures: normal; font-variant-caps:
normal; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Waves
included</h2><h3 style="color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; letter-spacing: normal; orphans: 2;
text-align: start; text-indent: 0px; text-transform: none; widows: 2;
word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.7 — modal focus-trap migration round 2</h3><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size:
13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">Migrated 5 dialogs (<code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">AiSheetPreview</code>, <code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">ReportModal</code>, <code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">AttachmentPreview</code>, <code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">ConfirmDialog</code>, <code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">LegalAcceptanceEnforcementModal</code>) onto the shared <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;"><FocusTrappedDialog></code> primitive so every visible
modal in the app flows through one W3C-compliant focus-trap + Escape +
backdrop pipeline. Fixes jsdom test-env compatibility via conditional
<code style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">tabbableOptions: { displayCheck: 'none' }</code>.</p><h3
style="color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-style: normal; font-variant-ligatures: normal; font-variant-caps:
normal; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.8 — admin MFA flag fails CLOSED + sealed-glass-break
override</h3><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;"><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">flag_admin_mfa_required</code> read in <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">auth.login.controller.js</code> was fail-OPEN — any
DB error or missing flag row silently disabled admin MFA enforcement.
Flipped per CLAUDE.md §12 decision #20. Added <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">EMERGENCY_DISABLE_ADMIN_MFA=true</code> Railway env
var so the founder can recover from a lost 2FA device. Structured <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">auth.admin_mfa_flag_read_failed</code> + <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">auth.admin_mfa_emergency_disabled</code> logs fire on each
branch.</p><h3 style="color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; letter-spacing: normal; orphans: 2;
text-align: start; text-indent: 0px; text-transform: none; widows: 2;
word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.8.1 — 2FA recovery codes unit tests</h3><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size:
13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Closed
the "Tests required before shipping" gap. The recovery-codes primitive
(<code style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">lib/auth/recoveryCodes.js</code>) had <strong>0 test
coverage</strong> despite being the security-critical core. Added 20
unit tests covering <code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">generatePlaintextCodes</code>, <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">hashCodes</code>, <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">normalizeRecoveryCode</code>, <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">consumeRecoveryCode</code> — including the critical "code
matched once cannot be matched again" property.</p><h3 style="color:
rgb(120, 124, 153); font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal;
letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px;
text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.9 — code-reviewer audit fixes on wave-12.7 /
12.8</h3><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Three
real findings from a code-reviewer subagent:</p><ul
style="padding-inline-start: 2em; color: rgb(120, 124, 153);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><strong>HIGH</strong><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">FocusTrappedDialog.initialFocus</code><span> </span>was
document-global; scoped to<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size:
0.9em;">overlayRef.current?.querySelector(...)</code><span> </span>so a
stray<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">[data-autofocus]</code><span> </span>elsewhere can't hijack
focus.</li><li><strong>MED-HIGH</strong><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">LegalAcceptanceEnforcementModal</code><span> </span>had
no<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">initialFocusSelector</code>; added one so the enforcement modal
can't silently unmount on a render
race.</li><li><strong>MED</strong><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">EMERGENCY_DISABLE_ADMIN_MFA</code><span> </span>was
case-sensitive;<span> </span><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">"True"</code><span> </span>/<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">"TRUE"</code><span> </span>/<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">" true
"</code><span> </span>now all bypass;<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">"1"</code><span> </span>/<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">"yes"</code><span> </span>/<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">"trueish"</code><span> </span>still enforce.</li></ul><h3
style="color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-style: normal; font-variant-ligatures: normal; font-variant-caps:
normal; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.10 — moderation module A11 + A12 sweep</h3><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size:
13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Four
CLAUDE.md A-rule violations in the moderation module (refactored before
A11 + A12 were locked in):</p><ul style="padding-inline-start: 2em;
color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size:
13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
white-space: normal; background-color: rgb(22, 22, 30);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color:
initial;"><li><strong>A11</strong><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">originAllowlist()</code><span> </span>missing on both moderation
routers. CSRF defense-in-depth gap on report submission, appeals, admin
case review, strike issuance, restriction lift, appeal
approve/reject.</li><li><strong>A12</strong><span> </span>— 11 sites
used<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">Number.isFinite()</code><span> </span>instead
of<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">Number.isInteger(x) && x >=
1</code>.<span> </span><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">Number.isFinite(-1)</code><span> </span>is true, so negative IDs
would have hit Prisma<span> </span><code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">where: { id: -1
}</code>.</li><li><strong>A12</strong><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">GET
/my-log</code><span> </span>used bare<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">parseInt</code>; switched to<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">clampPage()</code><span> </span>helper.</li></ul><h3
style="color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-style: normal; font-variant-ligatures: normal; font-variant-caps:
normal; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.11 — admin step-up MFA + volume backup + saver modes +
video player</h3><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Six
features in one wave:</p><ol style="padding-inline-start: 2em; color:
rgb(120, 124, 153); font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif; font-size: 13px; font-style:
normal; font-variant-ligatures: normal; font-variant-caps: normal;
font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><strong><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">requireRecentMfa</code><span> </span>step-up
middleware</strong><span> </span>— backend middleware
+<span> </span><code style="font-family: monospace; color: rgb(150, 153,
168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">POST
/api/auth/mfa/step-up/{start,verify}</code><span> </span>endpoints
+<span> </span><code style="font-family: monospace; color: rgb(150, 153,
168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">Session.mfaVerifiedAt</code><span> </span>column +
frontend<span> </span><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">MfaStepUpProvider</code><span> </span>+ step-up modal
+<span> </span><code style="font-family: monospace; color: rgb(150, 153,
168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">apiJson</code><span> </span>interceptor. Applied
to<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">DELETE
/admin/users/:id</code><span> </span>as canonical
example.</li><li><strong>WebAuthnCredential.lastUsedAt</strong><span> </span>—
schema + migration; verify path writes it; admin portal can show "last
used per passkey."</li><li><strong><code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">serializeNote</code><span> </span>explicit
allowlist</strong><span> </span>— replaced<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">{
...note, tags, ...extra }</code><span> </span>spread
with<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">PUBLIC_NOTE_FIELDS</code><span> </span>allowlist. Stops internal
columns (<code style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">contentHash</code>,<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">contentSimhash</code>,<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">lastAudit*</code>,<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">revision</code>,<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">lastSaveId</code>) from leaking; future Note columns default to
NOT exposed.</li><li><strong>Upload volume → R2
backup</strong><span> </span>— daily<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">lib/jobs/uploadVolumeBackup.js</code><span> </span>mirrors<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">/data/uploads</code><span> </span>to R2. Closes the documented
gap "uploaded files NOT backed up." Recovery script<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">scripts/restoreVolumeFromR2.js</code><span> </span>for the
disaster scenario. Streams large files (no OOM). Runbook
updated.</li><li><strong>Data Saver + Battery Saver modes
(foundation)</strong><span> </span>— schema, migration, "Data &
Battery" Settings tab,<span> </span><code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">useDataSaver</code><span> </span>/<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">useBatterySaver</code><span> </span>hooks,<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">SaverModeInitializer</code>, body<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">data-battery-saver</code><span> </span>attribute → CSS disable
rule, backend<span> </span><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">dataSaverNegotiation.js</code><span> </span>helper, 11 unit
tests.</li><li><strong>Video player additions</strong><span> </span>—
watch-progress persistence (auto-resume with "Start over" pill), A-B
loop for study repeat sections (<code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">[</code><span> </span>/<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">]</code><span> </span>set points,<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">Shift+L</code><span> </span>clears), keyboard shortcut help
overlay (<code style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">?</code>).</li></ol><p style="white-space: pre-wrap; margin-top:
0.1em; margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Plus a
code-reviewer audit pass found 5 real issues — all fixed in-wave
(event-listener leak in video player, R2 bucket promoted to
REQUIRED_IN_PRODUCTION, streaming upload instead of <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">readFileSync</code>, etc).</p><h3 style="color: rgb(120, 124,
153); font-family: -apple-system, BlinkMacSystemFont, "Segoe
UI", Roboto, sans-serif; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal;
letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px;
text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">wave-12.12 — step-up MFA expansion + data-saver consumers +
battery-saver JS gates</h3><p style="white-space: pre-wrap; margin-top:
0.1em; margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>1. Step-up MFA on 4 more privileged admin
routes:</strong></p><ul style="padding-inline-start: 2em; color:
rgb(120, 124, 153); font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif; font-size: 13px; font-style:
normal; font-variant-ligatures: normal; font-variant-caps: normal;
font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><code style="font-family: monospace; color: rgb(150, 153,
168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">PATCH
/admin/users/:id/role</code><span> </span>(admin promotion /
demotion)</li><li><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">PATCH
/admin/users/:id/trust-level</code></li><li><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">PATCH /admin/users/:id/mfa</code><span> </span>(so a
compromised session can't undo wave-12.8's protection)</li><li><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">POST
/payments/admin/sync-stripe</code><span> </span>(bulk subscription
writes)</li></ul><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>HIGH audit-fix in-wave:</strong> <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">handleTrustLevelChange</code> + <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">handleMfaToggle</code> in <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">UsersTab.jsx</code> called raw <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">fetch()</code> and bypassed the step-up modal. Extracted into
<code style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">patchTrustLevel</code> + <code style="font-family: monospace;
color: rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">patchMfaRequired</code> methods on <code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">useAdminData</code> so step-up flows
transparently.</p><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>2. Data Saver consumers wired:</strong></p><ul
style="padding-inline-start: 2em; color: rgb(120, 124, 153);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><strong>Feed lite
mode</strong><span> </span>—<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">GET
/api/feed</code><span> </span>strips<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">media[]</code><span> </span>+ author cover image URLs
when<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">Save-Data</code><span> </span>/ user pref /<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">?lite=1</code>.<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size:
0.9em;">useFeedData</code><span> </span>sends<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">?lite=1</code><span> </span>automatically.</li><li><strong>Typing-indicator
suppression</strong><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">emitTypingStart</code><span> </span>/<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">emitTypingStop</code><span> </span>short-circuit when data-saver
on. Saves a Socket.io round-trip per keystroke. Receivers still see
others typing.</li><li><strong>AI streaming
gate</strong><span> </span>—<span> </span><strong>deferred with
rationale</strong>: no non-streaming variant of<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">streamMessage</code>; cost > benefit for text
deltas.</li></ul><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; color: rgb(120, 124, 153); font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>3. Battery Saver JS-side gates:</strong></p><ul
style="padding-inline-start: 2em; color: rgb(120, 124, 153);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><code style="font-family: monospace; color: rgb(150, 153,
168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">lib/animations.js#prefersReducedMotion()</code><span> </span>now
honors<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;"><body
data-battery-saver="on"></code><span> </span>— cascades to all 7
anime.js helpers via existing short-circuit.</li><li>Toast auto-dismiss
+50% (3500ms → 5250ms) when battery saver on.</li><li>Lottie +
continuous rAF loops confirmed not in codebase via grep.</li></ul><h2
style="color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-style: normal; font-variant-ligatures: normal; font-variant-caps:
normal; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">New env
vars (Railway)</h2>
Var | Tier | Purpose
-- | -- | --
EMERGENCY_DISABLE_ADMIN_MFA | OPTIONAL | Sealed-glass-break: set
to true to bypass admin MFA enforcement during an incident (e.g. founder
lost 2FA device). Case + whitespace tolerant. Every login that uses it
fires auth.admin_mfa_emergency_disabled to Sentry.
R2_BUCKET_UPLOAD_BACKUP | REQUIRED_IN_PRODUCTION | R2 bucket name for
the daily mirror of /data/uploads. Without it the Railway volume is a
single point of failure for every user photo. Boot fails fast in
production if unset.
UPLOAD_BACKUP_INTERVAL_MS | OPTIONAL | Override the backup cadence.
Default 86400000 (24h).
UPLOAD_BACKUP_RATE_LIMIT_PER_SEC | OPTIONAL | Cap on mirror upload rate.
Default 10/sec.
<h2 style="color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-style: normal; font-variant-ligatures: normal; font-variant-caps:
normal; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">New
migrations</h2><ol style="padding-inline-start: 2em; color: rgb(120,
124, 153); font-family: -apple-system, BlinkMacSystemFont, "Segoe
UI", Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><code style="font-family: monospace; color: rgb(150, 153,
168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">20260527000001_webauthn_last_used_at</code><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">WebAuthnCredential.lastUsedAt</code></li><li><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">20260527000002_session_mfa_verified_at</code><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">Session.mfaVerifiedAt</code></li><li><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size:
0.9em;">20260527000003_data_battery_saver_modes</code><span> </span>—<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">UserPreferences.dataSaverMode</code><span> </span>+<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">batterySaverMode</code></li></ol><p style="white-space:
pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em; color: rgb(120, 124,
153); font-family: -apple-system, BlinkMacSystemFont, "Segoe
UI", Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; background-color: rgb(22, 22, 30);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;">All three are additive + <code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size: 0.9em;">IF NOT
EXISTS</code>-guarded per CLAUDE.md A5.</p><h2 style="color: rgb(120,
124, 153); font-family: -apple-system, BlinkMacSystemFont, "Segoe
UI", Roboto, sans-serif; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal;
letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px;
text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">What's
NOT in this PR (deferred with rationale)</h2><ul
style="padding-inline-start: 2em; color: rgb(120, 124, 153);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><li><strong>AI streaming non-streaming
variant</strong><span> </span>— building a buffering wrapper
around<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">aiService.streamMessage</code><span> </span>costs server memory
+ perceived latency for marginal text-delta bandwidth savings.
The<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">shouldReturnLite</code><span> </span>helper is ready; flip the
strategy if the cost/benefit shifts.</li><li><strong>Step-up MFA on
moderator routes</strong><span> </span>(badge grants, sheet review,
announcements) — too frequent for step-up-every-15-min friction without
proportional security gain.</li><li><strong><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">requireRecentMfa</code><span> </span>step-up on
remaining payment refund flows</strong><span> </span>— applied
to<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">sync-stripe</code>; individual refund endpoints don't exist
yet.</li><li><strong><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">req.user.dataSaverMode</code><span> </span>hydration
in<span> </span><code style="font-family: monospace; color: rgb(150,
153, 168); background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">requireAuth</code></strong><span> </span>— user-pref override is
inert at the backend; falls back to header +<span> </span><code
style="font-family: monospace; color: rgb(150, 153, 168);
background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">?lite=1</code>. Flip if we want server-side enforcement
regardless of client cooperation.</li></ul><h2 style="color: rgb(120,
124, 153); font-family: -apple-system, BlinkMacSystemFont, "Segoe
UI", Roboto, sans-serif; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal;
letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px;
text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(22, 22, 30); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Test
plan</h2><ul class="contains-task-list" style="padding-inline-start:
2em; color: rgb(120, 124, 153); font-family: -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size:
13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
white-space: normal; background-color: rgb(22, 22, 30);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><li class="task-list-item"><input
type="checkbox" disabled="" checked="" style="appearance: none;
border-color: rgb(16, 16, 20); border-style: solid; border-width: 1px;
border-image: none 100% / 1 / 0 stretch; position: relative;
pointer-events: none; vertical-align: middle; border-radius: 2px; width:
1em; height: 1em; margin: 2px 4px 2px 2px;"><span> </span>Backend lint
clean</li><li class="task-list-item"><input type="checkbox" disabled=""
checked="" style="appearance: none; border-color: rgb(16, 16, 20);
border-style: solid; border-width: 1px; border-image: none 100% / 1 / 0
stretch; position: relative; pointer-events: none; vertical-align:
middle; border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Frontend lint clean (0 errors, pre-existing warnings
only)</li><li class="task-list-item"><input type="checkbox" disabled=""
checked="" style="appearance: none; border-color: rgb(16, 16, 20);
border-style: solid; border-width: 1px; border-image: none 100% / 1 / 0
stretch; position: relative; pointer-events: none; vertical-align:
middle; border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Backend full suite: 3387/3393 pass (6 documented
skips)</li><li class="task-list-item"><input type="checkbox" disabled=""
checked="" style="appearance: none; border-color: rgb(16, 16, 20);
border-style: solid; border-width: 1px; border-image: none 100% / 1 / 0
stretch; position: relative; pointer-events: none; vertical-align:
middle; border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Frontend full suite: 845/855 pass (10 skips)</li><li
class="task-list-item"><input type="checkbox" disabled="" checked=""
style="appearance: none; border-color: rgb(16, 16, 20); border-style:
solid; border-width: 1px; border-image: none 100% / 1 / 0 stretch;
position: relative; pointer-events: none; vertical-align: middle;
border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Frontend build green</li><li
class="task-list-item"><input type="checkbox" disabled="" checked=""
style="appearance: none; border-color: rgb(16, 16, 20); border-style:
solid; border-width: 1px; border-image: none 100% / 1 / 0 stretch;
position: relative; pointer-events: none; vertical-align: middle;
border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>All 3 new migrations are additive +
idempotent</li><li class="task-list-item"><input type="checkbox"
disabled="" style="appearance: none; border-color: rgb(16, 16, 20);
border-style: solid; border-width: 1px; border-image: none 100% / 1 / 0
stretch; position: relative; pointer-events: none; vertical-align:
middle; border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>CI green on this branch</li><li
class="task-list-item"><input type="checkbox" disabled=""
style="appearance: none; border-color: rgb(16, 16, 20); border-style:
solid; border-width: 1px; border-image: none 100% / 1 / 0 stretch;
position: relative; pointer-events: none; vertical-align: middle;
border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Manual smoke: flip Data Saver in Settings → feed
loads with<span> </span><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">lite: true</code><span> </span>+ media stripped</li><li
class="task-list-item"><input type="checkbox" disabled=""
style="appearance: none; border-color: rgb(16, 16, 20); border-style:
solid; border-width: 1px; border-image: none 100% / 1 / 0 stretch;
position: relative; pointer-events: none; vertical-align: middle;
border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Manual smoke: flip Battery Saver in Settings →
entrance animations stop</li><li class="task-list-item"><input
type="checkbox" disabled="" style="appearance: none; border-color:
rgb(16, 16, 20); border-style: solid; border-width: 1px; border-image:
none 100% / 1 / 0 stretch; position: relative; pointer-events: none;
vertical-align: middle; border-radius: 2px; width: 1em; height: 1em;
margin: 2px 4px 2px 2px;"><span> </span>Manual smoke: admin
attempts<span> </span><code style="font-family: monospace; color:
rgb(150, 153, 168); background-color: rgba(255, 255, 255, 0.1); padding:
2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">PATCH /admin/users/:id/role</code><span> </span>after 15 min
idle → step-up modal opens, OTP completes, request retries</li><li
class="task-list-item"><input type="checkbox" disabled=""
style="appearance: none; border-color: rgb(16, 16, 20); border-style:
solid; border-width: 1px; border-image: none 100% / 1 / 0 stretch;
position: relative; pointer-events: none; vertical-align: middle;
border-radius: 2px; width: 1em; height: 1em; margin: 2px 4px 2px
2px;"><span> </span>Set<span> </span><code style="font-family:
monospace; color: rgb(150, 153, 168); background-color: rgba(255, 255,
255, 0.1); padding: 2px 4px; border-radius: 3px; word-break: break-word;
font-size: 0.9em;">R2_BUCKET_UPLOAD_BACKUP</code><span> </span>in
Railway prod before merge (boot will fail fast without
it)</li></ul><!--EndFragment-->
</body>
</html>
## Summary by Sourcery
Strengthen admin MFA step-up enforcement, add data/battery saver
infrastructure and consumers, improve video player UX, and harden
security and backup infrastructure while refining CI and mobile/CodeQL
workflows.
New Features:
- Introduce session-based admin MFA step-up middleware and frontend
modal flow to gate high-risk admin and payments routes behind recent
2FA.
- Add user-configurable Data Saver and Battery Saver modes with settings
UI, negotiation helpers, and client/server hooks to deliver lighter,
less animated experiences.
- Persist video watch progress, support A-B loop repeat, and provide an
in-player keyboard shortcut help overlay for study videos.
Bug Fixes:
- Ensure admin MFA enforcement fails closed on missing/errored feature
flags and provide an emergency override env var with clear logging.
- Fix moderation and admin controllers to validate IDs with strict
positive-integer checks and apply CSRF origin allowlists to moderation
routers.
- Resolve jsdom focus-trap issues and focus hijack risks by tightening
FocusTrappedDialog initial focus scoping and migrating legacy modals
onto the shared primitive.
Enhancements:
- Mirror uploaded files from the local volume to R2 on a schedule with a
dedicated restore script to improve durability and disaster recovery.
- Switch note serialization to an explicit allowlist to prevent internal
note fields from leaking through API responses by default.
- Extend WebAuthn credentials with last-used timestamps for better admin
visibility and auditing.
- Implement server-side data-saver negotiation and wire feed, messaging
typing indicators, and animations to respect data/battery saver signals.
- Upgrade the security headers production test to use a shared fixture
for required secrets and mock requireRecentMfa in admin route tests for
focused coverage.
Build:
- Adjust the main CI workflow to require release-log updates only when
core source or workflow files change and to skip the gate for bot and
lockfile-only PRs.
- Relax Android mobile and CodeQL workflows to avoid running on every
pull request, reducing unnecessary CI usage while keeping push,
schedule, and manual triggers.
CI:
- Update CI gating to ignore bot-authored pull requests and
source-unaffected changes when enforcing release-log updates, minimizing
noise in automation-driven PRs.
Deployment:
- Require the upload-backup R2 bucket env var in production and add
configurable backup interval and rate-limit env vars to safely control
backup behavior.
Documentation:
- Expand the public release log with detailed Wave-12.7 through
Wave-12.12 entries covering MFA, saver modes, moderation fixes, backups,
and player improvements.
Tests:
- Add comprehensive unit tests for MFA step-up middleware, admin MFA
flag behavior, 2FA recovery code primitives, data-saver negotiation,
WebAuthn updates, and production secret fixtures, and stabilize selected
flaky or outdated frontend tests via targeted skips or fixes.
Chores:
- Refactor various frontend admin and auth flows to route through shared
helpers (e.g., apiJson, useAdminData, MFA context) and clean up legacy
portal/focus-trap usages.1 parent 7c96a53 commit f67cdb5
62 files changed
Lines changed: 4403 additions & 816 deletions
File tree
- backend
- prisma
- migrations
- 20260527000001_webauthn_last_used_at
- 20260527000002_session_mfa_verified_at
- 20260527000003_data_battery_saver_modes
- scripts
- src
- lib
- jobs
- middleware
- modules
- admin
- auth
- feed
- moderation
- notes
- payments
- settings
- webauthn
- test
- helpers
- unit
- docs
- frontend/studyhub-app/src
- components
- Modal
- ai
- video
- lib
- pages
- admin
- feed
- messages
- settings
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
365 | 365 | | |
366 | 366 | | |
367 | 367 | | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
Lines changed: 16 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
Lines changed: 18 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
Lines changed: 15 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
967 | 967 | | |
968 | 968 | | |
969 | 969 | | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
970 | 976 | | |
971 | 977 | | |
972 | 978 | | |
| |||
1303 | 1309 | | |
1304 | 1310 | | |
1305 | 1311 | | |
1306 | | - | |
| 1312 | + | |
1307 | 1313 | | |
1308 | | - | |
| 1314 | + | |
1309 | 1315 | | |
1310 | | - | |
| 1316 | + | |
1311 | 1317 | | |
1312 | | - | |
| 1318 | + | |
1313 | 1319 | | |
1314 | | - | |
1315 | | - | |
1316 | | - | |
| 1320 | + | |
| 1321 | + | |
| 1322 | + | |
| 1323 | + | |
1317 | 1324 | | |
1318 | 1325 | | |
1319 | 1326 | | |
| |||
2671 | 2678 | | |
2672 | 2679 | | |
2673 | 2680 | | |
| 2681 | + | |
| 2682 | + | |
| 2683 | + | |
| 2684 | + | |
| 2685 | + | |
2674 | 2686 | | |
2675 | 2687 | | |
2676 | 2688 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
894 | 894 | | |
895 | 895 | | |
896 | 896 | | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
897 | 906 | | |
898 | 907 | | |
899 | 908 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
0 commit comments