Repository navigation
feat(ui): M5 layouts, error pages and security headers (nonce CSP, NFR-SEC-07/08) + M3 locale fixes - #14
Merged
Conversation
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
commented
Nov 9, 2026
AdilAzhariOmsan
left a comment
Collaborator
Author
There was a problem hiding this comment.
All Good
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
M5 layouts, error pages and hardening:
hreflang);No new dependencies.
Summary
Seven commits on
main(a2cde2e):fix(i18n): ignore superseded locale loads (M3 review: locale race)fix(i18n): keephreflangalternates current after client-side visits (M3 review: stalehreflang)feat(security): security headers and a nonce-based CSPfeat(errors): render 403, 404, 419, 500 and 503 as Inertia pagesfeat(ui): storefront, checkout and vendor layoutsfeat(dev): preview the checkout and vendor layouts locallydocs: screen inventory and M5 documentationRelated Issues
N/A
Requirement
M5 (layouts, error pages and hardening), per the Phase 3 milestone plan:
APP_DEBUG=false);X-Content-Type-Options,Referrer-Policy,frame-ancestors 'none'(header tests);hreflangand language switcher (F-8);Type of Change
What Changed
Security headers and CSP (NFR-SEC-08)
App\Http\Middleware\SecurityHeadersis prepended to the global stack, so 404s with no route, maintenance-mode 503s and/upget the headers too, and the nonce exists before any page renders.Content-Security-Policydefault-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-OptionsDENY(legacy twin offrame-ancestors 'none')X-Content-Type-OptionsnosniffReferrer-Policystrict-origin-when-cross-originCross-Origin-Opener-Policysame-originPermissions-Policycamera=(), microphone=(), geolocation=()Strict-Transport-Securitymax-age=31536000, HTTPS requests only, never inlocalVite::useCspNonce(). Laravel's Vite helper adds it to every@viteand@fontstag (the font@font-faceblock is an inline<style>).<meta property="csp-nonce">passes the nonce to Vite and tocreateInertiaApp({ nonce }), which covers the styles Inertia injects (progress bar, error dialog).unsafe-inlineorunsafe-evalin production. Fonts are self-hosted by the Vite fonts plugin, so everything is'self'. Vue:stylebindings go through the CSSOM and are not affected.npm run dev: whilepublic/hotexists, 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 fromstyle-srcin that mode only.Error pages (NFR-SEC-07)
App\Http\Errors\ErrorPages, registered with$exceptions->respond()inbootstrap/app.php, renders 403, 404, 419, 500 and 503 as the Inertia pageErrors/ErrorPage. It reuses Inertia 3'sExceptionResponsefor the shared props and asset version.locale:middleware, so the locale comes from the URL prefix (owner decision: the prefix is authoritative)./ar/missingis Arabic and RTL;/arabicis English.APP_DEBUG=true, a 500 keeps Laravel's debug page for developers.Error 404eyebrow, a plain-language title and description, and a link to the locale's home, except on 503 (home is down too).Locale race fix (M3 review)
activateLocale()used to set the active locale after anawaitwith no check that it was still the latest call. A slow first-time Arabic catalogue load could therefore finish after a quicker/msvisit and show the Malay page in Arabic.<html lang dir>).locale-race.test.tsholds the Arabic catalogue back with a mocked slow import. It failed withexpected 'ar' to be 'ms'before the fix.Stale
hreflangfix (M3 review)hreflanglinks were rendered once by Blade, so Inertia visits kept the first page's links.alternates:data-inertia="hreflang-{code}"key;resources/js/i18n/alternates.tsbuilds the same markup for Inertia 3'sserverHead, so the head manager replaces them on every visit.Layouts
StorefrontLayout: skip link, header (brand → locale home, language switcher, cart count),<main id="main" tabindex="-1">, toast region.Storefront/Homeuses 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 hasaria-current="page".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,العربية) withlangandhreflang; the current one hasaria-current="page". Hidden on pages that are not localised.storefront/components/CartIndicator.vue): shows0, with the spoken label "Cart, 0 items" and Arabic plural forms. It is not a link until the cart exists.vendornamespace.Local previews
/dev/layouts/checkoutand/dev/layouts/vendor:routes/local.php,APP_ENV=localonly, in every locale (/ms/…,/ar/…), linked from the dev bar.LocalizedRoutes::group()gainsredirectEnglishPrefix: false, so the/enredirect is still registered exactly once (tested).Docs
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.code-quality.md: layouts, error pages, howhreflangstays 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)
alternates, alongsidelocale,directionandflash(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.includeSubDomainsorpreload.form-action 'self'stays until payment integration needs the provider's 3-D Secure origin.Other design notes
serverHeadandnonceoptions;ExceptionResponsefor the error pages;defineOptions.@inertiapage data is a<script type="application/json">, which CSP does not block, since it is not executed.Testing
hreflangTags, error page in every status × locale, layouts and switcher in en/ms/ar with axe)Test Evidence
Local, Node 24.22.2 / npm 11.19.1, PHP 8.5.8, from a clean
npm ci/npm run build, on headd4022d4:npm cinpm run buildcomposer checkcomposer.jsonvalid · 0 advisories · Pint passed · PHPStan 0 errors · Pest 100 passed (590 assertions), was 45npm run typechecknpm testnpm audit --omit=devnpm audit(start-of-milestone reassessment)security.md, nothing newAPP_DEBUG=falseErrorPagesTest"never exposes the exception with APP_DEBUG=false": a route throwsRuntimeException('DB_PASSWORD=hunter2 in /var/www/nexo/app/Secret.php'). For a full page load and an Inertia visit, the 500 body contains none ofhunter2,DB_PASSWORD,Secret.php,RuntimeException, the base path (raw or JSON-escaped),Stack traceor#n /…frames. Mutation check: passing$exception->getMessage()to the page makes both cases fail; revertedAPP_DEBUG=true, the 500 keeps Laravel's debug page (shows the message). JSON clients get{"message": …}, not the pageSecurityHeadersTest: all six headers on/,/ar, a 404 with no route, and/up; CSP directives asserted exactly with nounsafe-inline/unsafe-eval; every executable<script>/<style>tag carries the response's nonce, as does thecsp-noncemeta; 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 whilepublic/hotexistsErrorPagesTest"shows the maintenance page": maintenance mode → 503,Errors/ErrorPage, CSP present (this caught the middleware order;SecurityHeadersnow runs first)Errors/ErrorPagewithstatus,locale,direction;/ms/missingand/ar/missingrender 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 Arabiclocale-race.test.ts(red before the fix, green after)hreflangafter client-side visitsalternatesprop on full loads and Inertia visits. Vitest:hreflangTagsmarkup matches Blade's, escaping, non-string values ignored. Chrome: an Inertia visit from/ms/dev/layouts/checkoutto/msreplaced all four<link hreflang>tags and the switcher links (checkout → home URLs)#mainis 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 sectionlang/hreflang;aria-current="page"on the current one; follows a changedalternatesprop; hidden without alternates; labelled landmark; axe cleanui.layout,ui.errors,storefront.header/checkoutandvendor.*keys; Arabic cart plurals cover all six categoriessurface-boundaries.test.tsandlogical-direction.test.tspass over the new storefront, vendor and shared files/dev/layouts/{checkout,vendor}(and/ar/…,/ms/…) 404 in production, staging, demo and testing; render in local in all three locales with alternates; the/enredirect is registered oncesecuritypolicyviolationlistener and console) on/,/ar,/ms/dev/layouts/checkout,/dev/components(which uses dynamic:style); Inertia's injected<style>tags carry the nonce; switching/→/arclient-side setslang="ar" dir="rtl", the Arabic title and 4hreflanglinks;/ar/dev/layouts/vendorand/ar/no-such-pagerender RTLdist65.78 +app3.13 +Home1.12 +SkipLink+ToastRegion+ the English catalogue. CSS 7.74 kB gzip. The storefront graph (app→Home) contains no vendor chunk;VendorLayoutships only withVendor/LayoutPreview(≈ 71.9 kB for that page)Security / Data Isolation
local)alternatesholds only this app's own route URLspublic/hot), never in a built deployment. The test asserts both modes.$request->isSecure(). A demo host that terminates TLS at a proxy must trust that proxy, or HSTS is not sent. Noted insecurity.md; it belongs to Phase 10 hosting.DB_PASSWORD=hunter2is deliberately fake: it is what the leak test looks for.Database / Migration
Screenshots / Recording
Run locally (
APP_ENV=local) and use the dev bar: Home (en / ms / ar), Checkout layout, Vendor layout; try/ar/no-such-pagefor an error page. Screenshots can be added on request.Documentation
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
SecurityHeaders: directive set, prepend order, dev-server branch;ErrorPages: which responses it leaves alone, the locale-from-path rule;activateLocale();hreflangTags()markup staying identical (the Inertia head keys);VendorLayouton narrow screens.serverHeadreplacement, 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./dev/layouts/*in local (screens wait on ADR-0011, M6).msandarstrings (layouts, errors, vendor), like M3 and M4.origin-*,object-*/bg-*positions, arbitrary properties).Node 24 / Vue 3a required check.types/shared.ts(now includingalternates) once ADR-0018 is decided.form-actionorigins are added with the payment integration, with tests (decision 5).Definition of Done
composer checkpasses locally (validate, audit, Pint, PHPStan, Pest)