Devafusion.net

Engineering log

Building in public.

A chronological record of the changes, decisions and milestones shaping Devafusion.

  1. Project inception and architecture

    Established Devafusion as a public technical laboratory and chose a code-first, auditable path for its web application, Azure infrastructure, delivery process, and future documentation.

  2. Separate web and infrastructure deployments

    Separated web delivery from Terraform application delivery so a web-only change does not depend on an infrastructure plan or approval stage.

  3. SEO and accessible navigation

    Improved the site's search visibility and made the shared navigation work better on small screens and with keyboards or assistive technology.

  4. Key Vault-backed Google Search verification

    Added Google Search Console domain verification for devafusion.com and devafusion.net without ever committing the verification strings to code, using a new Key Vault module and a centrally managed Azure DevOps Library variable group.

  5. Codifying engineering standards, then proving them with secret scanning

    Wrote down the engineering standards this project had been following informally, then delivered automated secret scanning as the first feature built under them, so the rules are demonstrated rather than just declared.

  6. GA4 analytics behind a consent-first cookie banner

    Added Google Analytics 4 with Consent Mode v2, defaulting to denied storage until a visitor accepts, moved Key Vault secret consumption for this GA4 ID and the existing Google verification strings from Terraform-managed resources to Terraform data sources, made an explicit App Service restart mandatory at the end of every CD pipeline that changes code or app settings, and removed several repeated literals across the Terraform configuration in favour of single-sourced locals and resource references.

  7. A resource-to-data-source rename tried to purge two live secrets

    Converting two Key Vault secrets from Terraform-managed resources to data sources deleted them from Azure instead, because Terraform only saw the resource address disappear from config. A missing Purge permission on the access policy is the only thing that stopped the deletion from becoming permanent. Recovered both secrets, cleaned up state, and closed the underlying gap in the provider configuration.

  8. Enable Azure App Service Health check for the dev web app

    Added a dedicated health endpoint and wired Azure App Service Health check to it, so a persistently unhealthy instance is detected and eventually replaced even on the current single-instance B1 plan.

  9. Split AGENTS.md into a Hub-and-Spoke model and locked down file layout

    Split the monolithic root AGENTS.md into a universal Hub plus stack-specific Spokes, added a reusable spoke template for future modules, introduced Tailwind v4 dark theme tokens, and made component/feature file layout a build-breaking ESLint gate instead of an unenforced convention.

  10. Standardized pipeline naming to devafusion-<domain>-<ci|cd>

    The web and infrastructure pipeline pairs had drifted onto two different naming schemes - fixed the half of each pair that didn't match its sibling so every pipeline name, YAML file path, and cross-pipeline resource reference now follows the same devafusion-<domain>-<ci|cd> pattern.

  11. Three-profile accessibility theme engine without losing static generation

    Added Obsidian/Editorial/Tactical colour-and-scale profiles switchable from a footer control, backed by a strictly-necessary cookie pair and a pre-hydration inline script - not a server-side cookies() read, which would have forced every page off static generation.

  12. Establish the Vitest and Playwright testing framework

    Added a 3-tier testing gate (Vitest unit/component, Playwright E2E, Docker-based visual regression) to the web application with native Azure DevOps dashboard reporting, while enforcing strict budget, speed, and environment-isolation constraints.

  13. Onboarded devafusion.co.uk and flipped the canonical domain to .net

    Brought a third App Service Domain, devafusion.co.uk, under Terraform management alongside devafusion.com and devafusion.net - matching DNS, tags, auto-renew, privacy, and a managed certificate to the existing pattern - and corrected the canonical domain to devafusion.net, redirecting both devafusion.com and the new devafusion.co.uk to it. A local terraform apply was run in violation of the pipeline-only apply rule; no live infrastructure changes actually took effect, but the process led to two new hard rules in AGENTS.md.

  14. Split the engineering log into one file per entry

    engineering-log.ts held every entry in a single shared array, so two branches in flight at once - this one and the concurrently-merged Vitest/Playwright PR - both appended to the same array tail and produced a merge conflict. Split it into one file per entry under features/log/entries/, so future concurrent branches each add a new file instead of colliding on the same seam.

  15. Nominet .co.uk domains reject the privacy setting

    The infrastructure CD pipeline's terraform apply for devafusion.co.uk failed with a 400 ("The parameter privacy has an invalid value.") after creating the DNS zone, hostname binding, and managed certificate successfully. Nominet, the UK domain registry, does not offer WHOIS privacy protection for .uk/.co.uk domains, unlike the gTLDs (.com, .net) Azure's registrar supports it for - so the azapi_resource domain resource's privacy = true was rejected outright by the registration API for this one TLD.

  16. devafusion.com email authentication (SPF, DKIM, DMARC)

    Added SPF, DKIM, and DMARC DNS records for devafusion.com so mail sent from the Microsoft 365 tenant is trusted by Google and other mail providers instead of being flagged as unauthenticated.

  17. Fix devafusion.com SPF TXT recordset conflict

    terraform apply failed to create the new SPF TXT record because Azure DNS treats every TXT value at the same name as one recordset, and a second azurerm_dns_txt_record resource at "@" collided with the existing Google site verification record already managing that recordset.

  18. Devafusion brand mark, favicon and web manifest

    Designed the Fortress Crossroads badge mark, shipped the full Next.js App Router icon/manifest/social-image file convention set, and surfaced the badge, wordmark and tagline in the site header and homepage hero so the brand identity is visible on every page, not just in browser chrome.

  19. Sync brand mark stroke-width with updated icon.svg

    The horizontal white crossroads line in app/icon.svg was tuned to a slightly thinner stroke-width (2.5 to 2.0). Propagated that same value to every other place the badge geometry is duplicated so all rendered instances stay visually identical.

  20. Devafusion digital presence: About, Contact and Organization schema

    Rewrote the About page with the full Deva Victrix origin story and engineering-principles copy, built out the Contact page stub, published GitHub/X/LinkedIn as rel="me" identity-verified links, and added Organization JSON-LD plus a sitemap fix so the site is genuinely discoverable and verifiable by Google Search Console.

  21. About page tone, footer decluttering, and JSON-LD console warning fix

    Reworked the About page copy to read as an engineering exploration site rather than a company - removed first-person/company voice, expanded the 'Dev a fusion' pun to its literal 'Develop a Fusion' meaning, cited the Deva Victrix Wikipedia entry as a real historical source, and removed the duplicate 'Get in Touch' block in favour of a single link to the Contact page. Decluttered the site footer (dropped the tagline, fixed a literal '&amp;' rendering bug, routed the email affordance to /contact instead of a mailto: link, collapsed the always-open theme/scale controls behind a single 'Theme' disclosure, and replaced plain-text social labels with real platform logos). Also swapped the raw JSON-LD <script> tag for next/script to eliminate a React-DOM dev console warning.

  22. Provision Azure Database for PostgreSQL Flexible Server

    Added a reusable postgresql Terraform module and wired it into the dev environment as the project's first relational database, following ADR-0010's selection of Flexible Server on the Burstable B_Standard_B1ms tier. Also backfilled ADR-0001 through ADR-0009 for architectural decisions already made in this repository but never formally recorded.

  23. PostgreSQL's first rollout hit two first-use-only Azure gaps

    terraform apply for the new postgresql module failed twice in a row, each time on a completely different problem: first an AuthorizationFailed on Microsoft.DBforPostgreSQL/flexibleServers/write because the pipeline's custom role had never included that action, then (once fixed) a MissingSubscriptionRegistration because the subscription itself had never activated the Microsoft.DBforPostgreSQL resource provider. Both were first-use-only gaps - nothing wrong with the module - fixed manually and out of band, and the third apply succeeded end to end (15 added, 4 changed, 0 destroyed), creating the server and all 14 web-app-outbound-* firewall rules cleanly. A ClientIPAddress_* rule was also observed on the server between the second (failed) and third (successful) apply. It was mistakenly deleted on an unverified assumption that it had been manually added; the successful apply's own log never created or referenced that name, so its origin was never actually confirmed. It has since been restored (same name, same IP address) once the deletion was recognized as a mistake, since a resource this project did not create is not this project's decision to remove.

  24. Cost circuit breaker for the PostgreSQL Flexible Server

    Closed out ADR-0010's deferred 'cost circuit breaker' consequence: a Consumption Budget on the app resource group now warns at 80% actual spend and, at 100%, triggers an Action Group wired to an Automation Runbook that stops the PostgreSQL Flexible Server. Everything added stays inside Azure's free tier - no new billable resource.

  25. Cost circuit breaker's first apply hit two more first-use gaps

    terraform apply for the cost-circuit-breaker module failed on two independent problems in the same run: a MissingSubscriptionRegistration for Microsoft.Automation (never registered on the subscription, same class of gap as the earlier PostgreSQL provider registration), and an availability-zone conflict on the PostgreSQL server itself, unrelated to the new module - Azure had auto-assigned a zone at creation time that the postgresql module never declared, so every subsequent plan tried to reconcile a diff the API rejects outright.

  26. Automation webhook rejected a far-future expiry time

    The cost-circuit-breaker module's apply got past the earlier provider-registration and zone blockers, created the Automation Account, Runbook, and role assignment cleanly, then failed on the budget-trigger webhook: Azure's Automation webhook API returned a plain 'Invalid expiry time' 400 for an expiry_time set decades out (2099), with no documented ceiling given anywhere in the REST reference. Replaced the hardcoded far-future literal with a bounded, variable expiry 10 years out, and documented rotating it as a deliberate operational task rather than guessing at the undocumented real limit.

  27. The 10-year webhook expiry fix was still over the real ceiling

    The previous fix's 10-years-out default (2036) hit the exact same 'Invalid expiry time' 400 it was meant to solve - the assumption that 10 years was safely inside Azure's undocumented ceiling was wrong. Rather than guess a third time, tested the real boundary directly against the Automation webhook REST API: 2027, 2028, 2031, 2033, and 2035 (up to roughly 9 years out) all succeeded; 2036 (about 10 years and 3 months out) and 2099 both failed identically. The real ceiling sits somewhere in that narrow window, so the module now defaults to 9 years out - comfortably inside it, not balanced on the edge.

  28. Database tooling: Drizzle ORM behind a repository pattern

    Set up Drizzle ORM, drizzle-kit migrations, drizzle-zod validation, and spec-only OpenAPI generation, all proven against a local in-memory PGlite Postgres rather than a real network database. No production data was touched - the engineering log stays on static files for now; this slice proves the tooling and the SOLID repository pattern that will carry it, and any future OAuth/contact-form data, into PostgreSQL.

  29. Local Docker Postgres workflow for testing drizzle-kit migrations

    Documented and proved, end-to-end against a real container, the local workflow for testing a drizzle-kit migration before it ever runs unattended in CD - and fixed a real drizzle-kit/server-only incompatibility found while doing it. No table was migrated to the live Azure Postgres server - that decision (and the identity/auth model it depends on) stays deliberately open.

  30. Identity foundation: Better Auth + self-hosted, encrypted TOTP MFA

    Built the identity layer's foundation - Better Auth for session/credential/OAuth core, its CLI-generated Drizzle schema against Postgres 16, citext for case-insensitive email, and a self-built TOTP MFA module with application-layer envelope encryption - after a live discussion surfaced that Auth.js has merged into Better Auth and that Better Auth's own twoFactor plugin stores its TOTP secret as plain text with no read-side decrypt hook. No registration/login UI or the role-gated visibility feature this was originally motivated by are built yet - this slice is the identity foundation only.

  31. CD pipeline runs the first real database migration, gated by manual approval

    Wired pipelines/cd/web.yml to actually apply drizzle-kit migrations to the live psql-devafusion-dev-uks server - a temporary named firewall rule for the CD agent, manual approval on a dedicated Azure DevOps environment, and the current migration SQL printed in CI before that approval gate is ever reached. Went through three design passes in one sitting: split detection/preview into a separate stage using a cross-stage output-variable condition whose exact syntax couldn't be verified, then discovered and removed an unsound git-diff-based gate in favour of running drizzle-kit migrate unconditionally, then diagnosed and fixed a real first-production-run failure (firewall-rule propagation delay) using a live connection test to rule out a competing hypothesis (an SSL deprecation warning) before committing to a fix - and, along the way, caused and immediately rotated a real credential exposure.

  32. Registration and login UI, wired to the existing Better Auth backend

    Built email/password sign-up, log-in, and log-out UI on top of the identity backend from the previous slice - the first user-facing consumer of Better Auth. A review before opening the PR correctly flagged that registration triggers real UK GDPR obligations this slice hadn't addressed, so the scope grew to include self-service account deletion (Article 17) and data export (Article 15) rather than shipping registration ahead of them. Deliberately still excludes MFA enrollment/challenge UI - no account has MFA enabled and that is a separate future slice. Caught and fixed a real rendering regression before it shipped: an initial server-side session read in the header would have forced every page on the site into dynamic rendering.

  33. Bluesky added as a verified social identity

    Published Bluesky (@devafusion.net) alongside GitHub, X and LinkedIn as a rel="me" identity-verified link, and provisioned the AT Protocol domain-handle verification DNS record so bsky.app can confirm ownership of devafusion.net.

  34. Client-level auth hardening: rate limiting, Turnstile, GDPR transparency (no WAF/Front Door)

    Hardened the registration/login/account-recovery surface against automated abuse without a Web Application Firewall or CDN in front of the App Service - a deliberate cost decision, not an oversight. Enabled Better Auth's own database-backed rate limiter, added a self-built limiter for routes outside its router, added Cloudflare Turnstile bot protection, a stateless timing heuristic as an independent second signal, and a new /forget-password + /reset-password flow (email delivery itself is stubbed, since this project has no email infrastructure yet). Also closed a real UK GDPR transparency gap - /sign-up and /log-in collected data with no in-context privacy notice or link to /legal.

  35. Fixed two live bugs from the auth-hardening deploy: Turnstile build-time key, CSP blocking GA4

    The previous slice's Turnstile widget and CSP header both shipped broken on the live site, confirmed directly from the browser console: sign-up/log-in were unusable because the widget never rendered, and GA4 analytics were silently blocked. Both root causes were confirmed empirically by inspecting real compiled build output and real console errors, not assumed from documentation.

  36. GA4 measurement ID has the same build-time NEXT_PUBLIC_* inlining bug as the Turnstile sitekey

    A live '@next/third-parties: GA has not been initialized' console warning traced back to the exact same root cause as the previously-fixed Turnstile sitekey bug: NEXT_PUBLIC_GA_ID is inlined into the compiled bundle at next build time, not read at App Service runtime, and google-analytics.tsx having no 'use client' directive does not exempt it from that. CI's Build stage never fetched this value from Key Vault before running next build, so the live artifact had it baked in as empty.

  37. Consistent hand cursor across every native button, matching links

    Native <button> elements across the app (Log out, Download my data, Log in, Create account, and every other button-styled control) showed the browser's default cursor rather than a hand cursor, while visually identical <Link>/<a> elements right next to them (e.g. Account, Log in) already got a hand cursor for free from the browser's default anchor styling. Tailwind v4 removed the old preflight rule that set cursor: pointer on buttons, so every native button in this app had silently inherited an inconsistent default. Added an explicit cursor-pointer class to every native button so its affordance matches links.

  38. First real-use feedback: Turnstile UX, error styling, password strength, passwordrules

    A round of genuine first-time use on the live sign-up/log-in forms (not a code review) surfaced four real defects: appearance:'interaction-only' left the submit button silently disabled for several seconds with no visible feedback on a low-risk visitor, a genuine reproducible 'Captcha verification failed' bug caused by never calling turnstile.reset() on a spent/stale token, error messages with no color/icon signal at all, and a new password complexity rule (Better Auth exposes only a length option) that Google Password Manager's own generated passwords didn't reliably satisfy until a passwordrules attribute was added. A fifth observation (a Chrome DevTools CSP-blocks-eval warning against Cloudflare's own script) was confirmed benign and documented, not fixed.

  39. Automated Lighthouse CI and axe-core accessibility audits

    Added two independent automated audit gates that previously only existed as manual review: Lighthouse CI (accessibility/best-practices/SEO/performance category scores against /, /projects, /log) and an axe-core Playwright spec (WCAG 2.1 AA violation checks against those three plus /sign-up and /log-in). Both immediately found real, pre-existing defects rather than passing cleanly on the first run - two color-contrast violations locally, and a site-wide rel=canonical inheritance bug surfaced by cross-checking against a live production Lighthouse run - proving their worth before being merged.

  40. Lighthouse HTML reports as pipeline run tabs

    LighthouseCI's per-page reports were previously only reachable by downloading the lighthouse-reports pipeline artifact and opening the HTML files locally. Added the org-installed blakyaks.azure-pipeline-html-reports extension's PublishHtmlReport@1 task to render each page's report as its own tab under the run's Reports section, with a small pre-step that copies lhci's representative-run output into readable, page-named files first.

  41. Triaging the first real Lighthouse HTML report findings

    Now that LighthouseCI's reports are visible as pipeline tabs (see the preceding lighthouse-html-report-tabs entry), triaged the actual findings across all 3 audited pages. Fixed a real Lighthouse CI environment gap causing every page's errors-in-console audit to fail, and a legacy-javascript/unused-javascript regression from Next.js's default browserslist target. Confirmed the color-contrast finding is an already-handled false positive.

  42. Extensible self-hosted MFA matrix: sequential factors, encrypted-column customType, session-withholding login flow

    Extended ADR-0012's single-optional-TOTP-factor MFA into a dynamically sequenced matrix (required_factors: text[]) supporting multiple ordered factors, backup codes, a PECR-compliant trusted-device bypass, and UK GDPR SAR export/deletion coverage. Corrected several inaccuracies in an initial design drafted without access to this codebase: better-auth@^1.7.5 has neither a dontCreateSession flag nor a public createSession surface (confirmed against its compiled source), and Turnstile's captcha plugin never fires for a direct auth.api.signInEmail() call. Also swapped the email-OTP provider mid-slice after live investigation found Resend's nearest-to-UK region stores content in the US regardless - replaced with Azure Communication Services Email, whose data_location is a genuine resource-level UK residency guarantee and which needs zero manual Key Vault steps at all.

  43. Closing the MFA matrix gaps: custom domain, real QR images, accessible codes, and the actual frontend

    A prior slice (self-hosted-mfa-matrix) shipped only backend routes for the MFA matrix with no frontend wiring, no e2e coverage, an Azure-managed sender domain, and a bare otpauth:// URI instead of a scannable image - none of it was reachable through the actual website. This addendum closes every gap: a verified custom domain (donotreply@devafusion.net with real DKIM/SPF/DMARC), a real PNG QR code with an accessible text fallback, screen-reader-friendly spaced OTP digits, the missing login/enrolment UI, a /legal disclosure section, and a genuine bug fix (login-step1 was silently missing the form-timing-token field required by auth.ts's before-hook, which runs for direct auth.api calls too).

  44. MFA email sender display name, accessible-digit comment correction, i18n-ready copy

    Follow-up correction after being asked directly about four points: a Microsoft 365 mailbox on devafusion.com does not conflict with the MFA sender domain devafusion.net (confirmed and documented); the OTP digit separator was already a space, not a comma, but the reasoning is now made explicit with a dedicated test; email subject/body copy is now centralised rather than inlined so a future i18n adoption is a one-file change; and a prior claim that Azure had no Terraform resource for a custom sender display name was wrong - it does, and it is now provisioned.

  45. Closing the MFA matrix's test-coverage gap - every backend route, every crypto/cache module

    None of the five new MFA backend routes or the security-critical crypto/cache modules (encrypted-column-cipher, backup-code-hash, mfa-session-cache, mfa-export-handler, three Drizzle repositories) had any dedicated test coverage - a real gap surfaced only after being asked directly what was still missing. Closed with 90 new tests across 12 files, and one genuine production bug found and fixed in the process: backup codes could never have actually advanced or completed the MFA matrix.

  46. Actually running the MFA e2e spec found a real bug; hardened the ACS DNS name assumption away

    tests-e2e/mfa-flow.spec.ts had been written but never executed. Running it for real against a local Postgres container and a real browser found a genuine bug: TOTP enrolment's confirmation step was a nested <form>, which HTML forbids - a real click submitted the outer settings form instead of calling the confirm API, so enrolment could never actually complete through the UI. Fixed, and the full sign-up-to-authenticated-session flow now passes end-to-end. Separately, the previously-flagged 'must verify against a live Azure subscription' DNS record-name-format caveat has been resolved without needing that live check, using a documented Terraform function guarantee.

  47. Wired a real Postgres sandbox into the E2ETests CI job; closed the last MFA test-coverage gap

    TEST_DB_ACTIONS and TEST_MFA_FLOWS previously only ran when a human manually set them locally - pipelines/ci/web.yml's E2ETests job passed the toggles through as env vars with no PostgreSQL sandbox for the connection they gate to reach. Wired in the same postgres:16 service container and citext+migrate steps the pre-existing LighthouseCI job already uses, and verified the exact same sequence locally before trusting the YAML. Also closed mfa-deletion-handler.ts's test-coverage gap, the last untested security-critical module from the earlier coverage-closing pass.

  48. The real Azure DevOps CI run caught a terraform fmt gap every local check had missed

    Opening the MFA matrix PR and letting devafusion-infrastructure-ci actually run against it (rather than trusting only local checks) surfaced a real formatting failure on two files every local terraform fmt -check run that session had reported as clean. Root cause: local checks were run from infrastructure/app; CI's terraformWorkingDirectory is infrastructure/app/environments/dev, and terraform fmt without -recursive only checks its own directory - a different, non-representative file set.

  49. Corrected accessibility.spec.ts's actual WCAG version scope from 2.1 to 2.2

    accessibility.spec.ts's AxeBuilder call and its own test names/comments scoped the automated gate to WCAG 2.1 AA tags (wcag2a/wcag2aa/wcag21a/wcag21aa) while every other reference in this codebase (src/web/AGENTS.md's checklist, this branch's own touch-target work) states WCAG 2.2 as the actual target. Fixed by adding the wcag22aa tag (WCAG 2.2 introduced no new Level A criteria, so there is no wcag22a tag in the installed axe-core) and force-enabling axe-core's disabled-by-default target-size rule - the only wcag22aa-tagged rule, and the one rule that maps directly onto this project's own 24px AA touch-target floor.

  50. Dev-mode CSP was missing 'unsafe-eval', logging a console warning on every page load

    npm run dev logged 'eval() is not supported in this environment' on every page load - script-src had no 'unsafe-eval', but Next.js/React's dev-mode Fast Refresh and debugging tooling genuinely calls eval(). Fixed by branching script-src on NODE_ENV: 'unsafe-eval' is granted only in development, never in production, verified directly against both a real npm run dev response header and a real production standalone build's response header.

  51. ACS DNS recordset collision and a self-healing sender-username fix

    The CD pipeline's terraform apply failed on the ACS Domain/SPF verification records for devafusion.net: Azure's verification_records name is the bare zone apex, and a second azurerm_dns_txt_record at "@" collides with the recordset devafusion_net_google_verification already owns there - the same class of bug as the earlier devafusion-com-spf-txt-recordset-conflict incident. Fixed by merging the ACS values into that existing resource instead. Also switched the domain's auto-provisioned DoNotReply sender username from a plain resource to azapi_update_resource, since Azure creates that resource itself the moment the domain exists - Terraform can only ever patch it, never create or delete it. Also corrected devafusion.net's _dmarc record, which pointed its aggregate reports at a domain with no real mailbox.

  52. npm run dev now starts and migrates local Postgres automatically

    Getting a working local dev environment required three manual steps before npm run dev would even boot without every page 500ing: start a Docker Postgres container, enable citext, and run npm run db:migrate. Added a predev script that does all three automatically, idempotently, every time npm run dev is run.

  53. Trusted devices, TOTP issuer casing, and a self-service re-enrolment path

    Saving '30 days' in the security settings dashboard updated the mfa_frequency policy column but never actually trusted the current device - trusted_devices stayed empty until the next full login challenge, so the setting had no visible effect until then. Fixed by trusting the current device immediately on save. Also fixed a TOTP issuer casing typo ('DevaFusion' instead of the real brand casing, 'Devafusion') and added a self-service path to reset a lost/reset authenticator app, gated behind re-confirming the account password.

  54. Enforced email verification, backed by real ACS delivery

    Sign-up previously accepted any email address with no proof it was reachable. With Azure Communication Services' custom domain now verified end-to-end, requireEmailVerification is enabled: a fresh sign-up no longer gets an immediate session, and both the verification and password-reset links dispatch through the same ACS infrastructure the MFA email-OTP factor already uses. The sign-up and log-in forms, and the gated E2E specs that exercise them, were updated to match the new flow rather than assuming an immediate session.

  55. Added aria-invalid to every form error state, alongside aria-describedby

    Checked as requested whether form errors were screen-reader compliant: every auth/account form's error state (SignUpForm, LogInForm, ForgetPasswordForm, ResetPasswordForm, MfaChallengeForm, TotpEnrolment, MfaSettingsDashboard, DeleteAccountForm) already wired aria-describedby correctly, but none set aria-invalid on the affected input(s). aria-describedby alone links a field to its error text but does not itself announce 'invalid entry' when the field receives focus - aria-invalid is what does that. Fixed across every form, plus the shared PasswordField component.

  56. Added a real keyboard-only navigation E2E spec (Tab/Enter, not just axe-core)

    Checked as requested whether a user could navigate a form, select/enter, and submit using only Tab and Enter. axe-core (accessibility.spec.ts) only checks static/structural WCAG rules - an element is reachable, has a visible focus indicator, isn't a negative-tabindex trap - it never actually drives a keyboard through a real form. Confirmed no test anywhere in the codebase called page.keyboard.press at all. Added keyboard-navigation.spec.ts, which does: real Tab/type/Enter against sign-up, log-in, and reset-password, run against the real built app.

  57. Touch targets/contrast audit, then AA-by-default with an AAA showcase opt-up and a force override

    Checked as requested whether every button/interactive touchpoint hits 4.5:1 contrast and a 44x44 CSS px minimum touch target. Independently recomputed every color token pair in globals.css against the real WCAG relative-luminance formula - all passed 4.5:1. The touch-target audit found real gaps (PasswordField's reveal toggle, footer social icons/links, desktop nav links, several checkbox/radio labels) - fixed all of them via a new --touch-target-size CSS custom property, and added a real ThemeSelector toggle for it. That toggle initially defaulted to 44px (AAA) with an opt-down to 24px (AA); on request, reversed to the correct posture - WCAG AA is the widely accepted legal/regulatory baseline, so 24px is now the default and 44px (AAA) is an explicit opt-UP, a showcase choice, never the reverse. Also added a per-element data-touch-target-force="aaa" escape hatch so a specific critical control (this codebase's example: the irreversible account-deletion button) can be pinned to the stricter bar regardless of a visitor's own site-wide preference.

  58. Documented exactly which WCAG checks Lighthouse/axe-core do and don't run

    Asked directly whether stating 'must be UK GDPR and WCAG 2.2 AA/AAA compliant' should have been enough to already catch this session's aria-invalid and touch-target gaps, and whether Lighthouse should find them. Answer, verified against primary sources rather than assumed: no. Confirmed by reading the installed axe-core@4.13.0 source directly that its target-size rule is disabled by default, scoped to WCAG 2.2 tags this project's accessibility.spec.ts never requests, and even when active only checks a 24px floor (WCAG 2.2 AA SC 2.5.8), not the 44px bar (SC 2.5.5 AAA) this project actually requires - axe-core has no rule for 44px at all. Lighthouse's own published accessibility scoring weights confirm no target-size entry exists in its category either. Color contrast is the one item Lighthouse/axe-core genuinely do check automatically.

  59. Live MFA_ENCRYPTION_KEY was 24 bytes, not 32 - rotated after a real 500 on TOTP enrolment

    A real account hit a 500 clicking 'Set up authenticator app' in production. Traced to the live App Service's MFA_ENCRYPTION_KEY decoding to 24 bytes instead of the required 32 (AES-256-GCM) - encrypted-column-cipher.ts's loadEncryptedColumnKey() throws hard on any length mismatch, caught by the enrol route's try/catch and surfaced only as a generic 500, invisible server-side since App Service fileSystem logging is Off and Application Insights does not yet support the Node 24 LTS runtime this app is pinned to.

  60. Forced MFA setup on first login, real otpauth:// deep link, and App Service logging enabled

    Closed a real lockout: a fresh account with no user_security row was silently defaulted to require TOTP at login, but had no forced enrolment step first - first login prompted for an authenticator code the user had never set up, a genuine dead end with no way out. Fixed with real, request-level enforcement (proxy.ts) achieving the same parity requireEmailVerification already has for email: an authenticated account whose second-factor choice is not genuinely complete is redirected to a new mandatory /mfa-setup page on every route, not shown a dashboard banner it could navigate past. Also added a tappable otpauth:// deep link for enrolling on a phone with no second camera-equipped device to scan with, a 'start over' control for a botched QR scan, and enabled App Service filesystem logging so a future 500 (like the MFA_ENCRYPTION_KEY-length incident this session) is diagnosable without a manual local reproduction.

  61. Fixed a real production lockout: the header's Log out button stuck forever after re-login

    A user reported being unable to log out at all after setting their second factor to 'None' and signing back in: the header's Log out button stayed permanently disabled, reading 'Logging out…'. Root cause was in AccountNav, not MFA or the new otpauth:// deep link initially suspected: it is rendered from the root layout, so it is never unmounted across a client-side log-out -> /log-in -> log-back-in round trip (Better Auth's own client and this project's login-step1 route both navigate via router.push, not a full page reload). Its isLoggingOut state was set to true on sign-out and never reset back to false anywhere, so it stayed stuck on the very next login on that same tab, permanently disabling the button. Reproduced live with a real headed (non-headless) Playwright run before writing the fix, and again to confirm the fix actually resolves it.

  62. WCAG 2.2 AAA audit, a new /accessibility explainer page, and three real UX fixes

    Audited this site's accessibility controls (colour theme, text size, touch-target size) against the full WCAG 2.2 spec, publishing an honest /accessibility page mapping each control to its success criterion and stating plainly where the site does and does not meet the strictest (AAA) level - including two genuinely un-met criteria (3.3.9, 1.4.8) and two documented security-driven exceptions (2.2.3/2.2.6, 2.2.5). Alongside the audit, fixed three real defects the review surfaced: ThemeSelector's disclosure control never changed its own label between open/closed states, three password fields across the account/MFA settings had no show/hide toggle unlike every other password field on the site, and the footer's two reference links (Privacy & cookies, and the new Accessibility page) needed a consistent, explicitly-labelled group rather than an ad hoc mix of underlined/non-underlined styling.

  63. Four real MFA lockout/UX bugs fixed together: unconfirmed-TOTP lockout, lost verification email, split trust-device logic, stale Account page state

    One ticket covering four related reports, all traced to the same root cause: user_security state being written and read inconsistently across the enrol/challenge/settings/account-display code paths. (1) Real, reported account lockout: /api/user/security/settings let a user save 'totp' as a required sign-in factor without ever actually confirming a real code - login-step1 then demanded a factor with no working secret behind it at all, an unrecoverable lockout with no error shown at save time. Root cause confirmed live: reproduced the exact reported sequence (enrol Email OTP, log out/in, then select Authenticator App on the Account page without ever scanning/confirming it) against real Postgres, confirmed the save previously succeeded silently, then confirmed the fix blocks it both client- and server-side while leaving the account fully usable. (2) Sign-up's 'check your inbox' screen and log-in's verification-required error had no way to request a fresh link if the original was lost - Better Auth's own duplicate-signup path never resent anything either. (3) Two disconnected 'trust this device' write paths existed: the MFA-challenge checkbox created a real trusted_devices row but never set the column login-step1 actually checks, so the device was silently re-challenged every time regardless. (4) The Account page always rendered the settings dashboard with hardcoded defaults instead of the account's real saved choices, making every visit look unconfigured. A fifth reported symptom (an old/stale TOTP code from a second authenticator-app install being silently accepted) was investigated with two separate live repros against real Postgres and could NOT be reproduced as described - left open, tracked honestly as unconfirmed rather than guessed at.

  64. A public Tech Stack page - every real tool, one line each, then the real reasoning

    Added /tech-stack: a single page listing every tool and Azure resource actually running in this project, each traced back to a real, dated ADR and engineering-log entry rather than asserted from memory. Deliberately dropped an initial referral-link/affiliate-tracking version of this page early in scoping - that would have required PECR-compliant opt-in consent gating identical to the existing GA4 consent-mode wiring, and there was no real referral programme to justify the added compliance surface, so the page ships with zero tracking and zero external monetisation links instead. Structured as a skimmable 'at a glance' verdict strip (one line per entry, anchor-linked) followed by the full ADR-style breakdown, after an early draft's dense wall of prose read as something a reviewer would bounce off rather than dig into.

  65. Fixed the footer Accessibility panel overflowing off-screen on mobile viewports

    The footer's 'Accessibility' disclosure panel (ThemeSelector) used a plain w-max width with no viewport cap, anchored absolute right-0 against its trigger button. On a narrow mobile viewport (reported on a Pixel 9, 412px wide) the panel grew wider than the screen and overflowed off both edges, with button labels clipped and no way to scroll it back into view - the control was effectively unusable on mobile. Capped the panel's width to the viewport, added a max-height with vertical scroll as a safety net, and let each control row wrap instead of forcing one unbreakable line.

  66. Fixed the header overflowing/reflowing incorrectly on a narrow mobile viewport at Accessible XL text size

    SiteHeader's brand wordmark + Menu + Log in row had no flex-wrap. Reported live: switching to the Accessible XL text-size opt-up (globals.css's html[data-a11y-scale="accessible-xl"], 125% root font-size) on a narrow mobile viewport grew the row's combined width past the screen, forcing horizontal scroll and squashing the Log in button's own text onto two lines ("Log" / "in") rather than reflowing the row itself - a real WCAG 2.2 SC 1.4.10 (Reflow, AA) violation, not just a cosmetic one. Added flex-wrap to the header row and whitespace-nowrap to every header control's own label so a control's text is never the thing that breaks - the row wraps onto a second line instead.

This site uses Google Analytics to understand traffic. See the privacy and cookies page for details.