Skip to content

Commit f67cdb5

Browse files
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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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;">&lt;FocusTrappedDialog&gt;</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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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) &amp;&amp; x &gt;= 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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 &amp; 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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 &gt; 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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;">&lt;body data-battery-saver="on"&gt;</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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/.env.example

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,27 @@ ENABLE_QUERY_LOGGING=
365365
# Older code paths read EMAIL_PROVIDER; new code reads EMAIL_TRANSPORT.
366366
# Set whichever your deployment scripts use; the other is ignored.
367367
EMAIL_PROVIDER=
368+
369+
# ── ADMIN MFA SEALED-GLASS-BREAK (do NOT enable in normal operations) ──
370+
# When set to the literal string "true", disables admin MFA enforcement
371+
# at login regardless of the flag_admin_mfa_required feature flag.
372+
# Intended ONLY for incident response when the founder is locked out
373+
# (e.g. lost 2FA device). Every login that bypasses MFA via this
374+
# override emits the structured log event
375+
# `auth.admin_mfa_emergency_disabled` which is captured by Sentry.
376+
# Unset as soon as the incident is resolved.
377+
EMERGENCY_DISABLE_ADMIN_MFA=
378+
379+
# ── UPLOAD VOLUME BACKUP (R2 mirror, wave-12.11) ──────────────────
380+
# Daily job that mirrors /data/uploads (avatars, covers, attachments,
381+
# group-media, etc.) to a dedicated R2 bucket. REQUIRED IN PRODUCTION —
382+
# without it the Railway volume is a single point of failure and the
383+
# backup job silently no-ops, meaning every user-uploaded photo is
384+
# permanently lost if the disk corrupts or the instance is rebuilt.
385+
# Boot fails fast in NODE_ENV=production if this is unset.
386+
# Recovery procedure: scripts/restoreVolumeFromR2.js. Documented in
387+
# docs/internal/security/RUNBOOK_DB_RESTORE.md "Upload Volume Recovery".
388+
R2_BUCKET_UPLOAD_BACKUP=
389+
# Optional overrides — defaults to 24h cadence + 10 uploads/sec.
390+
UPLOAD_BACKUP_INTERVAL_MS=
391+
UPLOAD_BACKUP_RATE_LIMIT_PER_SEC=
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Wave-12.11 — WebAuthnCredential.lastUsedAt
2+
--
3+
-- Adds a nullable timestamp column to WebAuthnCredential so the admin
4+
-- portal can show "last used" per passkey, and so we have an audit
5+
-- signal for unused passkeys that should be retired. Written every
6+
-- time the credential verifies successfully in webauthn.routes.js.
7+
--
8+
-- Originally part of the L2.14 deferred plan for admin MFA. Implemented
9+
-- standalone in wave-12.11 because the column is useful on its own (no
10+
-- behavioural change to the verify path beyond the additional write).
11+
--
12+
-- Idempotency: ADD COLUMN IF NOT EXISTS so re-running the migration
13+
-- after a partial deploy is safe (CLAUDE.md A5).
14+
15+
ALTER TABLE "WebAuthnCredential"
16+
ADD COLUMN IF NOT EXISTS "lastUsedAt" TIMESTAMP(3);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Wave-12.11 — Session.mfaVerifiedAt
2+
--
3+
-- Adds a nullable timestamp column to Session. Set when the user
4+
-- completes the email-OTP challenge (loginChallenge.consume) OR
5+
-- redeems a recovery code OR hits POST /api/auth/mfa/step-up.
6+
-- requireRecentMfa middleware compares this to NOW() and 403s if the
7+
-- gap exceeds the per-route window (default 15 min).
8+
--
9+
-- Closes the L2.14 deferred admin-MFA step-up plan. The column is
10+
-- nullable so existing sessions don't need to be re-issued — the
11+
-- middleware treats NULL as "never verified" which 403s on the
12+
-- protected route, sending the admin through the step-up flow.
13+
--
14+
-- Idempotency: ADD COLUMN IF NOT EXISTS so re-running the migration
15+
-- after a partial deploy is safe (CLAUDE.md A5).
16+
17+
ALTER TABLE "Session"
18+
ADD COLUMN IF NOT EXISTS "mfaVerifiedAt" TIMESTAMP(3);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Wave-12.11 — UserPreferences.dataSaverMode + batterySaverMode
2+
--
3+
-- One combined migration covering both founder-priority modes from
4+
-- docs/internal/plans/data-saver-mode.md and battery-saver-mode.md.
5+
-- Both use the same "on" | "off" | "auto" tri-state. `auto` means
6+
-- honor the platform signal:
7+
-- - data-saver: Save-Data header + navigator.connection.saveData
8+
-- - battery-saver: prefers-reduced-motion: reduce
9+
--
10+
-- Idempotent guards per CLAUDE.md A5 — safe to re-run if a prior
11+
-- partial deploy already applied one of the columns.
12+
13+
ALTER TABLE "UserPreferences"
14+
ADD COLUMN IF NOT EXISTS "dataSaverMode" TEXT NOT NULL DEFAULT 'auto',
15+
ADD COLUMN IF NOT EXISTS "batterySaverMode" TEXT NOT NULL DEFAULT 'auto';

backend/prisma/schema.prisma

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,12 @@ model UserPreferences {
967967
// "All schools" inline per-picker. Default true matches the founder's
968968
// intent that most users see their own school first.
969969
scopeBySchool Boolean @default(true)
970+
// Wave-12.11 — Data Saver + Battery Saver. Both follow the same
971+
// "on" / "off" / "auto" pattern. `auto` means honor the platform
972+
// signal: Save-Data request header / navigator.connection.saveData
973+
// for data-saver; prefers-reduced-motion: reduce for battery-saver.
974+
dataSaverMode String @default("auto")
975+
batterySaverMode String @default("auto")
970976
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
971977
}
972978

@@ -1303,17 +1309,18 @@ model UserSensitive {
13031309

13041310
// ── WebAuthn passkey credentials ────────────────────────────
13051311
model WebAuthnCredential {
1306-
id Int @id @default(autoincrement())
1312+
id Int @id @default(autoincrement())
13071313
userId Int
1308-
credentialId String @unique
1314+
credentialId String @unique
13091315
publicKey Bytes
1310-
counter Int @default(0)
1316+
counter Int @default(0)
13111317
deviceType String?
1312-
backedUp Boolean @default(false)
1318+
backedUp Boolean @default(false)
13131319
transports String?
1314-
name String @default("Passkey")
1315-
createdAt DateTime @default(now())
1316-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1320+
name String @default("Passkey")
1321+
createdAt DateTime @default(now())
1322+
lastUsedAt DateTime?
1323+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
13171324
13181325
@@index([userId])
13191326
}
@@ -2671,6 +2678,11 @@ model Session {
26712678
riskScore Int?
26722679
trustedDeviceId String?
26732680
lastActiveAt DateTime @default(now())
2681+
// Timestamp of the last MFA verification on this session. Set when
2682+
// the user completes the email-OTP challenge, redeems a recovery
2683+
// code, or hits POST /api/auth/mfa/step-up. requireRecentMfa
2684+
// middleware enforces a max-age window against this column.
2685+
mfaVerifiedAt DateTime?
26742686
expiresAt DateTime
26752687
revokedAt DateTime?
26762688
createdAt DateTime @default(now())
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* restoreVolumeFromR2.js — disaster-recovery for /data/uploads.
3+
*
4+
* Wave-12.11. Pairs with backend/src/lib/jobs/uploadVolumeBackup.js.
5+
*
6+
* When to run: the Railway volume is gone, corrupted, or replaced. The
7+
* DB rows still point at /uploads/... paths; without restoring the
8+
* files every <img> 404s. Run this on the new instance BEFORE flipping
9+
* traffic back on.
10+
*
11+
* What it does:
12+
* 1. Lists every object under the `upload-volume-backup/` prefix in
13+
* R2_BUCKET_UPLOAD_BACKUP.
14+
* 2. For each one, writes the file back into UPLOADS_DIR preserving
15+
* the original relative path (`avatars/123.jpg` etc.).
16+
* 3. Refuses to overwrite existing files by default. Pass --force
17+
* to overwrite (use ONLY when the volume is intentionally being
18+
* wiped + repopulated; otherwise you risk replacing live newer
19+
* content with stale backups).
20+
* 4. Skips R2 listing pagination boundaries cleanly — handles the
21+
* ContinuationToken pattern S3 returns for buckets > 1000 keys.
22+
*
23+
* Usage:
24+
* # Inspect what would happen (no writes):
25+
* node backend/scripts/restoreVolumeFromR2.js --dry-run
26+
*
27+
* # Actual restore (writes new files only, skip-if-exists):
28+
* node backend/scripts/restoreVolumeFromR2.js
29+
*
30+
* # Wipe + repopulate (use after volume reformat):
31+
* node backend/scripts/restoreVolumeFromR2.js --force
32+
*
33+
* Required env: R2_BUCKET_UPLOAD_BACKUP + R2_ACCOUNT_ID +
34+
* R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY.
35+
*
36+
* Exit codes: 0 success, 1 missing config / R2 unavailable, 2 partial
37+
* failure (some objects could not be restored — report shows which).
38+
*/
39+
const fs = require('node:fs')
40+
const path = require('node:path')
41+
const { ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3')
42+
43+
require('dotenv').config({ path: path.resolve(__dirname, '../.env') })
44+
45+
const { KEY_PREFIX } = require('../src/lib/jobs/uploadVolumeBackup')
46+
47+
async function main() {
48+
const args = process.argv.slice(2)
49+
const dryRun = args.includes('--dry-run')
50+
const force = args.includes('--force')
51+
52+
const bucket = process.env.R2_BUCKET_UPLOAD_BACKUP
53+
if (!bucket) {
54+
console.error('FATAL: R2_BUCKET_UPLOAD_BACKUP is not set.')
55+
process.exit(1)
56+
}
57+
58+
// Lazy-require the R2 client so we share the same singleton + config
59+
// checks as the runtime app.
60+
const { isR2Configured } = require('../src/lib/r2Storage')
61+
if (!isR2Configured()) {
62+
console.error('FATAL: R2 credentials are not configured.')
63+
process.exit(1)
64+
}
65+
66+
// Build a dedicated S3 client for this script rather than reaching
67+
// into r2Storage internals. Same credentials, same endpoint — keeps
68+
// r2Storage's getClient() private and avoids tying script lifetimes
69+
// to the runtime singleton's connection pool.
70+
const { S3Client } = require('@aws-sdk/client-s3')
71+
const client = new S3Client({
72+
region: 'auto',
73+
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
74+
credentials: {
75+
accessKeyId: process.env.R2_ACCESS_KEY_ID,
76+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
77+
},
78+
})
79+
80+
const { UPLOADS_DIR } = require('../src/lib/storage')
81+
console.log(`Restoring from R2 bucket "${bucket}" → ${UPLOADS_DIR}`)
82+
console.log(
83+
`Mode: ${dryRun ? 'DRY RUN' : force ? 'FORCE (overwrite existing)' : 'SAFE (skip-if-exists)'}`,
84+
)
85+
86+
let restored = 0
87+
let skipped = 0
88+
let failed = 0
89+
let scanned = 0
90+
let continuationToken
91+
92+
do {
93+
const list = await client.send(
94+
new ListObjectsV2Command({
95+
Bucket: bucket,
96+
Prefix: KEY_PREFIX,
97+
ContinuationToken: continuationToken,
98+
}),
99+
)
100+
101+
for (const obj of list.Contents || []) {
102+
scanned += 1
103+
const relativePath = obj.Key.slice(KEY_PREFIX.length)
104+
const destination = path.join(UPLOADS_DIR, relativePath)
105+
106+
if (fs.existsSync(destination) && !force) {
107+
skipped += 1
108+
continue
109+
}
110+
if (dryRun) {
111+
console.log(`[dry-run] would restore ${obj.Key}${destination}`)
112+
restored += 1
113+
continue
114+
}
115+
116+
try {
117+
fs.mkdirSync(path.dirname(destination), { recursive: true })
118+
const get = await client.send(new GetObjectCommand({ Bucket: bucket, Key: obj.Key }))
119+
// The Body is a stream — collect to a buffer for the write.
120+
// For multi-GB videos a streaming pipe would be better; we
121+
// don't expect that volume here (avatars + small attachments).
122+
const chunks = []
123+
for await (const chunk of get.Body) chunks.push(chunk)
124+
fs.writeFileSync(destination, Buffer.concat(chunks))
125+
restored += 1
126+
if (restored % 100 === 0) {
127+
console.log(`Restored ${restored} files (skipped ${skipped}, failed ${failed})...`)
128+
}
129+
} catch (err) {
130+
console.error(`FAILED ${obj.Key}: ${err?.message || err}`)
131+
failed += 1
132+
}
133+
}
134+
135+
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined
136+
} while (continuationToken)
137+
138+
console.log('')
139+
console.log('=== Restore summary ===')
140+
console.log(`Scanned: ${scanned}`)
141+
console.log(`Restored: ${restored}`)
142+
console.log(`Skipped: ${skipped} (already on disk; use --force to overwrite)`)
143+
console.log(`Failed: ${failed}`)
144+
process.exit(failed > 0 ? 2 : 0)
145+
}
146+
147+
main().catch((err) => {
148+
console.error('FATAL:', err)
149+
process.exit(1)
150+
})

backend/src/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,15 @@ async function startServer() {
894894
).unref()
895895
}, msUntilNextFour).unref()
896896

897+
// Wave-12.11 — daily mirror of /data/uploads to R2 so user-
898+
// uploaded photos survive a Railway volume crash. No-ops
899+
// gracefully when R2_BUCKET_UPLOAD_BACKUP is unset. Recovery
900+
// procedure documented in docs/internal/security/
901+
// RUNBOOK_DB_RESTORE.md "Upload Volume Recovery" section.
902+
const { startUploadVolumeBackup } = require('./lib/jobs/uploadVolumeBackup')
903+
const { UPLOADS_DIR } = require('./lib/storage')
904+
startUploadVolumeBackup({ uploadsDir: UPLOADS_DIR })
905+
897906
log.info({ port: PORT }, `Server running on http://localhost:${PORT}`)
898907
})
899908

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* dataSaverNegotiation.js — server-side data-saver detection.
3+
*
4+
* Wave-12.11. Pairs with the frontend `useDataSaver` hook.
5+
*
6+
* Returns true when the current request should receive a lighter
7+
* response (smaller image variants, no thumbnail array, no SSE
8+
* streaming, etc.). Three signals, OR'd:
9+
*
10+
* 1. `Save-Data: on` request header (RFC 8478). Sent by Chrome on
11+
* cellular when the user has Data Saver enabled, and by some
12+
* mobile browsers on metered connections.
13+
* 2. `req.user.dataSaverMode === 'on'` — the user explicitly turned
14+
* Data Saver on in Settings. Honored even if no header is set.
15+
* 3. `req.user.dataSaverMode === 'auto'` AND the header is on. The
16+
* `auto` default means we follow the platform signal when the
17+
* user hasn't expressed a preference either way.
18+
*
19+
* Explicit `dataSaverMode === 'off'` ALWAYS wins — the user said no.
20+
* This is the "I have wifi, give me the full experience" override even
21+
* if their browser is sending Save-Data for unrelated reasons.
22+
*
23+
* The helper is a synchronous read; no DB roundtrip. Routes that want
24+
* to honor data-saver should pull the user pref into req.user (or a
25+
* scoped lookup) BEFORE calling this — the existing requireAuth
26+
* middleware does not currently load dataSaverMode, so callers either:
27+
* a) accept "header-only" detection (anonymous requests, public
28+
* endpoints), or
29+
* b) hydrate req.user.dataSaverMode themselves.
30+
*
31+
* Why no middleware sets it globally yet: the per-request Prisma read
32+
* cost adds up across every endpoint. Routes that benefit from
33+
* data-saver opt in explicitly. Settings tab is the canonical write.
34+
*/
35+
36+
/**
37+
* @param {import('express').Request} req
38+
* @returns {boolean}
39+
*/
40+
function isDataSaverRequest(req) {
41+
const userPref = req?.user?.dataSaverMode
42+
43+
// Explicit "off" wins — never down-rev the response when the user
44+
// said they want the full version.
45+
if (userPref === 'off') return false
46+
47+
// Explicit "on" wins — return the lighter response regardless of
48+
// header presence.
49+
if (userPref === 'on') return true
50+
51+
// `auto` (or anonymous / unloaded pref) → honor the request header.
52+
const header = String(req?.headers?.['save-data'] || '')
53+
.trim()
54+
.toLowerCase()
55+
return header === 'on'
56+
}
57+
58+
/**
59+
* Frontend may also pass an explicit `?lite=1` query param when its
60+
* hook detected client-side that data-saver is on (covers Safari,
61+
* which doesn't send Save-Data). Honored as a third signal.
62+
*/
63+
function isLiteQueryRequest(req) {
64+
return req?.query?.lite === '1' || req?.query?.lite === 'true'
65+
}
66+
67+
/**
68+
* Convenience: returns true if ANY of the three signals fire.
69+
*/
70+
function shouldReturnLite(req) {
71+
return isDataSaverRequest(req) || isLiteQueryRequest(req)
72+
}
73+
74+
module.exports = {
75+
isDataSaverRequest,
76+
isLiteQueryRequest,
77+
shouldReturnLite,
78+
}

0 commit comments

Comments
 (0)