Skip to content

feat(ui): M5 layouts, error pages and security headers (nonce CSP, NFR-SEC-07/08) + M3 locale fixes - #14

Merged
AdilAzhariOmsan merged 7 commits into
mainfrom
feat/layouts-hardening
Nov 9, 2026
Merged

AdilAzhariOmsan merged 7 commits into
mainfrom
feat/layouts-hardening

Conversation

@AdilAzhariOmsan

Copy link
Copy Markdown
Collaborator

M5 layouts, error pages and hardening:

  • storefront, checkout and vendor layouts, with a language switcher and a cart-count placeholder;
  • Inertia error pages for 403, 404, 419, 500 and 503 that never expose exception details;
  • security headers on every response, with a nonce-based CSP;
  • the two client-side locale bugs left open from the M3 review (locale race, stale hreflang);
  • the screen inventory (journeys → screens).

No new dependencies.

Merge condition: this PR merges only when both PHP 8.5 / Laravel 13 and Node 24 / Vue 3 are green on the final head commit (follow-up F-1 from #10).

Summary

Seven commits on main (a2cde2e):

  1. fix(i18n): ignore superseded locale loads (M3 review: locale race)
  2. fix(i18n): keep hreflang alternates current after client-side visits (M3 review: stale hreflang)
  3. feat(security): security headers and a nonce-based CSP
  4. feat(errors): render 403, 404, 419, 500 and 503 as Inertia pages
  5. feat(ui): storefront, checkout and vendor layouts
  6. feat(dev): preview the checkout and vendor layouts locally
  7. docs: screen inventory and M5 documentation

Related Issues

N/A

Requirement

M5 (layouts, error pages and hardening), per the Phase 3 milestone plan:

  • NFR-SEC-07: production error pages never expose exception messages or stack traces (test with APP_DEBUG=false);
  • NFR-SEC-08: CSP (nonce-based for Vite), HSTS on demo, X-Content-Type-Options, Referrer-Policy, frame-ancestors 'none' (header tests);
  • NFR-A11Y-01/02/04 for the layouts: landmarks, skip link, keyboard order, automated axe;
  • NFR-I18N-01/02/04: every new string in en/ms/ar, logical direction, hreflang and language switcher (F-8);
  • NFR-PERF-05: storefront initial JS ≤ 200 kB gzip, vendor code split from storefront;
  • dependency rule F1 (storefront and vendor code stay apart).

Type of Change

  • Feature
  • Bug fix (two M3 review findings)
  • Security
  • Tests
  • Documentation

What Changed

Security headers and CSP (NFR-SEC-08)

App\Http\Middleware\SecurityHeaders is prepended to the global stack, so 404s with no route, maintenance-mode 503s and /up get the headers too, and the nonce exists before any page renders.

Header Value
Content-Security-Policy default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
X-Frame-Options DENY (legacy twin of frame-ancestors 'none')
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Cross-Origin-Opener-Policy same-origin
Permissions-Policy camera=(), microphone=(), geolocation=()
Strict-Transport-Security max-age=31536000, HTTPS requests only, never in local
  • Nonce: a new one per request through Vite::useCspNonce(). Laravel's Vite helper adds it to every @vite and @fonts tag (the font @font-face block is an inline <style>).
  • Runtime styles: a <meta property="csp-nonce"> passes the nonce to Vite and to createInertiaApp({ nonce }), which covers the styles Inertia injects (progress bar, error dialog).
  • No unsafe-inline or unsafe-eval in production. Fonts are self-hosted by the Vite fonts plugin, so everything is 'self'. Vue :style bindings go through the CSSOM and are not affected.
  • npm run dev: while public/hot exists, the Vite origin, its WebSocket and inline styles are allowed (Vite injects <style> tags while developing). Browsers ignore 'unsafe-inline' when a nonce is listed, so the nonce is dropped from style-src in that mode only.

Error pages (NFR-SEC-07)

  • App\Http\Errors\ErrorPages, registered with $exceptions->respond() in bootstrap/app.php, renders 403, 404, 419, 500 and 503 as the Inertia page Errors/ErrorPage. It reuses Inertia 3's ExceptionResponse for the shared props and asset version.
  • The page receives only the status code. No message, class, trace or path is ever passed to it.
  • Locale: a 404 with no route never ran the locale: middleware, so the locale comes from the URL prefix (owner decision: the prefix is authoritative). /ar/missing is Arabic and RTL; /arabic is English.
  • Left alone: JSON clients keep JSON errors; any other status (e.g. 418) stays with the framework; with APP_DEBUG=true, a 500 keeps Laravel's debug page for developers.
  • The page: a minimal frame of its own, because it serves both surfaces and F1 forbids shared code importing either layout. It is translated, has an Error 404 eyebrow, a plain-language title and description, and a link to the locale's home, except on 503 (home is down too).
  • 419 keeps the dedicated page (owner decision below).

Locale race fix (M3 review)

  • activateLocale() used to set the active locale after an await with no check that it was still the latest call. A slow first-time Arabic catalogue load could therefore finish after a quicker /ms visit and show the Malay page in Arabic.
  • A request counter now lets only the latest call commit (the locale, and <html lang dir>).
  • Test written first: locale-race.test.ts holds the Arabic catalogue back with a mocked slow import. It failed with expected 'ar' to be 'ms' before the fix.

Stale hreflang fix (M3 review)

  • hreflang links were rendered once by Blade, so Inertia visits kept the first page's links.
  • They are now the shared prop alternates:
    • Blade renders them on the first response (crawlers need no JavaScript), each with a data-inertia="hreflang-{code}" key;
    • resources/js/i18n/alternates.ts builds the same markup for Inertia 3's serverHead, so the head manager replaces them on every visit.
  • The language switcher reads the same prop, never the DOM (F-8).

Layouts

  • StorefrontLayout: skip link, header (brand → locale home, language switcher, cart count), <main id="main" tabindex="-1">, toast region. Storefront/Home uses it as a persistent layout (defineOptions({ layout })).
  • CheckoutLayout: the same frame with a deliberately quiet header (brand, "Secure checkout", language), with no cart or browsing links.
  • VendorLayout: static, no auth. Sidebar menu on wide screens, a scrolling row on narrow ones. The seven sections (VJ-1…VJ-7) are text marked "coming soon" until their pages exist, never links that 404. The current section has aria-current="page".
  • Shared pieces (resources/js/shared/layout/):
    • SkipLink;
    • LocaleSwitcher: plain links rather than a <select>, so it works without JavaScript and is crawlable. Each link names its language in that language (English, Bahasa Melayu, العربية) with lang and hreflang; the current one has aria-current="page". Hidden on pages that are not localised.
  • Cart count (storefront/components/CartIndicator.vue): shows 0, with the spoken label "Cart, 0 items" and Arabic plural forms. It is not a link until the cart exists.
  • Strings: en/ms/ar, including a new vendor namespace.

Local previews

  • /dev/layouts/checkout and /dev/layouts/vendor: routes/local.php, APP_ENV=local only, in every locale (/ms/…, /ar/…), linked from the dev bar.
  • LocalizedRoutes::group() gains redirectEnglishPrefix: false, so the /en redirect is still registered exactly once (tested).

Docs

  • New: docs/product/screen-inventory.md, one table listing every customer and vendor screen with its journeys, layout, status and the ADR it waits for. This is the only new doc.
  • Updated:
    • code-quality.md: layouts, error pages, how hreflang stays current;
    • security.md: headers and CSP, and what to change when a third-party origin is added;
    • error-handling.md: the error pages and the NFR-SEC-07 test.

Design / Architecture

Decisions recorded (owner-approved)

  1. A fourth shared prop, alternates, alongside locale, direction and flash (approved). It is derived from the route on the server, so it adds no hand-typed domain data (the risk the three-key limit was about), and both the head and the switcher need it on every visit.
  2. F-4 closed: no second Vite entry for vendor. Pages are already separate lazy chunks; the build shows no vendor code in the storefront's import graph (see bundle evidence).
  3. 419 keeps the dedicated error page, rather than redirect-back with a toast.
  4. HSTS without includeSubDomains or preload.
  5. CSP form-action 'self' stays until payment integration needs the provider's 3-D Secure origin.

Other design notes

  • Native Inertia 3 features, no new code paths:
    • serverHead and nonce options;
    • ExceptionResponse for the error pages;
    • persistent layouts via defineOptions.
  • Inertia's @inertia page data is a <script type="application/json">, which CSP does not block, since it is not executed.

Testing

  • Unit tests (Vitest: locale race, hreflangTags, error page in every status × locale, layouts and switcher in en/ms/ar with axe)
  • Feature/integration tests (Pest: security headers, CSP, nonce, HSTS, error pages, debug-off leak test, maintenance mode, alternates prop, local previews)
  • Architecture tests (existing suite, F1, F4, translation coverage)
  • Manual verification (Chrome)

Test Evidence

Local, Node 24.22.2 / npm 11.19.1, PHP 8.5.8, from a clean npm ci / npm run build, on head d4022d4:

Step Result
npm ci ok
npm run build ok
composer check composer.json valid · 0 advisories · Pint passed · PHPStan 0 errors · Pest 100 passed (590 assertions), was 45
npm run typecheck ok (exit 0)
npm test 229 passed (22 files), was 179
npm audit --omit=dev 0 vulnerabilities
npm audit (start-of-milestone reassessment) only the two dev-only advisories already recorded in security.md, nothing new
AC Evidence
NFR-SEC-07: no stack trace leaks with APP_DEBUG=false ErrorPagesTest "never exposes the exception with APP_DEBUG=false": a route throws RuntimeException('DB_PASSWORD=hunter2 in /var/www/nexo/app/Secret.php'). For a full page load and an Inertia visit, the 500 body contains none of hunter2, DB_PASSWORD, Secret.php, RuntimeException, the base path (raw or JSON-escaped), Stack trace or #n /… frames. Mutation check: passing $exception->getMessage() to the page makes both cases fail; reverted
NFR-SEC-07 complement With APP_DEBUG=true, the 500 keeps Laravel's debug page (shows the message). JSON clients get {"message": …}, not the page
NFR-SEC-08: header tests SecurityHeadersTest: all six headers on /, /ar, a 404 with no route, and /up; CSP directives asserted exactly with no unsafe-inline / unsafe-eval; every executable <script>/<style> tag carries the response's nonce, as does the csp-nonce meta; the nonce changes per response; HSTS on HTTPS for demo, staging and production, absent over HTTP and in local; the Vite dev origin is allowed only while public/hot exists
Headers on maintenance pages ErrorPagesTest "shows the maintenance page": maintenance mode → 503, Errors/ErrorPage, CSP present (this caught the middleware order; SecurityHeaders now runs first)
Error pages 403, 404, 419, 500, 503 Pest: each status renders Errors/ErrorPage with status, locale, direction; /ms/missing and /ar/missing render in Malay and RTL Arabic; Inertia visits get the page object with the status code; 418 is untouched. Vitest: every status in plain words, home link per locale, none on 503, axe clean for all five in Arabic
Locale race locale-race.test.ts (red before the fix, green after)
hreflang after client-side visits Pest: alternates prop on full loads and Inertia visits. Vitest: hreflangTags markup matches Blade's, escaping, non-string values ignored. Chrome: an Inertia visit from /ms/dev/layouts/checkout to /ms replaced all four <link hreflang> tags and the switcher links (checkout → home URLs)
Layouts: a11y and keyboard Vitest, for storefront, checkout and vendor layouts in en, ms and ar: the skip link to #main is the first focusable element, <main id="main" tabindex="-1">, header present, toast region mounted, axe clean (9 runs). The vendor menu has no links to pages that don't exist and marks the current section
Language switcher (F-8) Vitest: links in each language with lang/hreflang; aria-current="page" on the current one; follows a changed alternates prop; hidden without alternates; labelled landmark; axe clean
Translations (NFR-I18N-01) Coverage test passes with the new ui.layout, ui.errors, storefront.header/checkout and vendor.* keys; Arabic cart plurals cover all six categories
F1 / F4 surface-boundaries.test.ts and logical-direction.test.ts pass over the new storefront, vendor and shared files
Local-only previews Pest: /dev/layouts/{checkout,vendor} (and /ar/…, /ms/…) 404 in production, staging, demo and testing; render in local in all three locales with alternates; the /en redirect is registered once
Chrome (real browser, built assets) No CSP violations (securitypolicyviolation listener and console) on /, /ar, /ms/dev/layouts/checkout, /dev/components (which uses dynamic :style); Inertia's injected <style> tags carry the nonce; switching / → /ar client-side sets lang="ar" dir="rtl", the Arabic title and 4 hreflang links; /ar/dev/layouts/vendor and /ar/no-such-page render RTL
Bundle budget (NFR-PERF-05) Initial storefront JS ≈ 71.0 kB gzip (≤ 200 kB): dist 65.78 + app 3.13 + Home 1.12 + SkipLink + ToastRegion + the English catalogue. CSS 7.74 kB gzip. The storefront graph (app → Home) contains no vendor chunk; VendorLayout ships only with Vendor/LayoutPreview (≈ 71.9 kB for that page)

Security / Data Isolation

  • No cross-vendor data leakage introduced (no vendor or auth code; the vendor layout is static and served only in local)
  • Sensitive data handling reviewed: the error page gets only a status code; alternates holds only this app's own route URLs
  • CSP in development is relaxed only while the Vite dev server runs (public/hot), never in a built deployment. The test asserts both modes.
  • HSTS behind a proxy: the header depends on $request->isSecure(). A demo host that terminates TLS at a proxy must trust that proxy, or HSTS is not sent. Noted in security.md; it belongs to Phase 10 hosting.
  • The test fixture string DB_PASSWORD=hunter2 is deliberately fake: it is what the leak test looks for.

Database / Migration

  • No database changes

Screenshots / Recording

Run locally (APP_ENV=local) and use the dev bar: Home (en / ms / ar), Checkout layout, Vendor layout; try /ar/no-such-page for an error page. Screenshots can be added on request.

Documentation

  • Documentation updated (docs/product/screen-inventory.md (new), docs/product/README.md, docs/engineering/code-quality.md, docs/engineering/security.md, docs/engineering/error-handling.md)

Reviewer Notes

  • Focus:
    • SecurityHeaders: directive set, prepend order, dev-server branch;
    • ErrorPages: which responses it leaves alone, the locale-from-path rule;
    • the request counter in activateLocale();
    • the Blade and hreflangTags() markup staying identical (the Inertia head keys);
    • VendorLayout on narrow screens.
  • Known limitations:
    • No automated browser tests in CI. CSP enforcement, serverHead replacement, focus and RTL rendering in a real browser were checked manually in Chrome. Playwright would be a new dependency; this belongs to the Phase 9 browser tests (NFR-A11Y-04), like M4's dialog stub.
    • Server-to-client toast round trip still untested. No server route sends a toast yet; 419 uses the dedicated page (decision 3), not a toast.
    • The vendor catalogue (~0.3 kB gzip per locale) loads on storefront pages too, because catalogues are loaded per locale, not per surface. Its effect on the budget is negligible; it could be split by surface if it grows.
    • The cart count is a placeholder (always 0, not a link) until the cart exists.
    • The checkout and vendor layouts have no real pages yet. They are previewed only at /dev/layouts/* in local (screens wait on ADR-0011, M6).
    • Native-speaker review of the new ms and ar strings (layouts, errors, vendor), like M3 and M4.
  • Still open from the M3 review (not M5 scope): F4 regex gaps (gradients, origin-*, object-* / bg-* positions, arbitrary properties).
  • Follow-ups:
    • F-1: make Node 24 / Vue 3 a required check.
    • F-3: generated types replace types/shared.ts (now including alternates) once ADR-0018 is decided.
    • F-9: F1 and F4 move to ESLint if approved.
    • CSP origins: the payment provider's script, frame and 3-D Secure form-action origins are added with the payment integration, with tests (decision 5).
    • F-4: closed by this PR (decision 2).

Definition of Done

  • Acceptance criteria are satisfied
  • composer check passes locally (validate, audit, Pint, PHPStan, Pest)
  • No unrelated changes
  • No secrets committed
  • Self-review completed
  • Reviewer feedback addressed

activateLocale() set the active locale after an await with no check that
the call was still the latest. A slow first-time Arabic catalogue load
could finish after a quicker visit to /ms and show the Malay page in
Arabic (M3 review). Only the most recent call now commits; the test holds
the Arabic catalogue back to reproduce the race.
The alternates were rendered once by Blade, so Inertia visits left the
first page's links in the head (M3 review). They are now a shared
`alternates` prop: Blade still renders them for crawlers, keyed with
data-inertia, and Inertia's serverHead replaces them on every visit. The
locale switcher reads the same prop (F-8).
Global SecurityHeaders middleware (NFR-SEC-08), so 404s, maintenance and
/up are covered too:
- Content-Security-Policy with a per-request nonce, no unsafe-inline or
  unsafe-eval. The nonce is handed to Laravel's Vite helper (@Vite,
  @fonts) and exposed in a csp-nonce meta for the styles Vite and Inertia
  inject at runtime. While `npm run dev` runs, the Vite origin and its
  HMR socket are allowed, with inline styles for dev only.
- frame-ancestors 'none' plus X-Frame-Options DENY, nosniff,
  strict-origin-when-cross-origin, COOP same-origin and a
  Permissions-Policy denying camera, microphone and geolocation.
- HSTS (one year) over HTTPS outside local development.
App\Http\Errors\ErrorPages, registered with $exceptions->respond(),
renders Errors/ErrorPage with the shared props and only the status code,
so no exception message, trace or path reaches the browser (NFR-SEC-07).
- The page is translated (en, ms, ar), uses its own minimal frame since it
  serves both surfaces (F1), and links to the locale's home except during
  maintenance.
- A 404 with no route takes its locale from the URL prefix, so /ar/...
  is Arabic and RTL.
- JSON clients keep JSON errors; with APP_DEBUG on, a 500 keeps the
  developer debug page.
- SecurityHeaders now runs first, so maintenance 503s get the headers.

Tests prove the leak case with APP_DEBUG=false for full page loads and
Inertia visits, plus maintenance mode.
- StorefrontLayout: skip link, header with brand, language switcher and
  a cart-count placeholder (not a link until the cart exists), <main
  id="main">, toast region. Home now uses it as a persistent layout.
- CheckoutLayout: a quiet header (brand, "Secure checkout", language)
  with no cart or browsing links.
- VendorLayout: static, no auth. Sidebar menu on wide screens, a
  scrolling row on narrow ones; sections without a page yet are shown as
  "coming soon" text, never links that 404.
- shared/layout: SkipLink and LocaleSwitcher. The switcher reads the
  server's alternates prop (F-8), links each language in its own lang and
  hreflang, marks the current one, and hides on unlocalised pages.

Strings in en, ms and ar, including a new vendor namespace; Arabic plural
forms for the cart count. Axe, keyboard-order and translation tests for
every layout in all three locales.
/dev/layouts/checkout and /dev/layouts/vendor, registered only when
APP_ENV=local and in every locale (/ms/..., /ar/...), so the language
switcher and RTL can be checked before the real pages exist (ADR-0011,
M6). LocalizedRoutes::group() gains redirectEnglishPrefix so the /en
redirect is still registered once. Linked from the dev bar.
- docs/product/screen-inventory.md: one table mapping the customer and
  vendor journeys to screens, with each screen's layout, status and the
  ADR it waits for.
- code-quality.md: layouts, error pages, and how hreflang alternates stay
  current.
- security.md: the security headers and CSP, and what changes when a
  third-party origin is added.
- error-handling.md: the Inertia error pages and the NFR-SEC-07 test.

@AdilAzhariOmsan AdilAzhariOmsan left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All Good

@AdilAzhariOmsan
AdilAzhariOmsan merged commit 9af1282 into main Nov 9, 2026
2 checks passed
@AdilAzhariOmsan
AdilAzhariOmsan deleted the feat/layouts-hardening branch November 9, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant