From ccfe8a136481c4a1142acdfe57c6c014ddfee600 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Mon, 9 Nov 2026 08:05:56 +0800 Subject: [PATCH 1/7] fix(i18n): ignore superseded locale loads 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. --- resources/js/i18n/index.ts | 14 +++++++- resources/js/i18n/locale-race.test.ts | 50 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 resources/js/i18n/locale-race.test.ts diff --git a/resources/js/i18n/index.ts b/resources/js/i18n/index.ts index f3100b6..8dc38b7 100644 --- a/resources/js/i18n/index.ts +++ b/resources/js/i18n/index.ts @@ -46,11 +46,23 @@ interface ActiveState { const active = shallowRef({ locale: FALLBACK_LOCALE, catalogue: {}, fallback: {} }); -/** Loads `locale` (falling back to English for unknown values) and makes it active. */ +// Counts activateLocale() calls. Only the latest call may commit, so a slow +// first load (e.g. Arabic) cannot overwrite a newer visit that finished first. +let latestRequest = 0; + +/** + * Loads `locale` (falling back to English for unknown values) and makes it + * active, unless a newer call has been made meanwhile. + */ export async function activateLocale(requested: unknown): Promise { + const request = ++latestRequest; const locale = isLocale(requested) ? requested : FALLBACK_LOCALE; const [catalogue, fallback] = await Promise.all([loadCatalogue(locale), loadCatalogue(FALLBACK_LOCALE)]); + if (request !== latestRequest) { + return locale; + } + active.value = { locale, catalogue, fallback }; if (typeof document !== 'undefined') { diff --git a/resources/js/i18n/locale-race.test.ts b/resources/js/i18n/locale-race.test.ts new file mode 100644 index 0000000..adf26bf --- /dev/null +++ b/resources/js/i18n/locale-race.test.ts @@ -0,0 +1,50 @@ +// M3 review: a slow catalogue load must not overwrite a newer visit's locale. +// Arabic's catalogue is held back until the test releases it, so Malay +// finishes first; without the guard, Arabic would win when it arrives late. +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const gate = vi.hoisted(() => { + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + + return { held, release: () => release() }; +}); + +vi.mock('./messages/ar/ui.json', async () => { + await gate.held; + + return { default: { common: { loading: 'جارٍ التحميل' } } }; +}); + +afterEach(() => { + gate.release(); +}); + +describe('activateLocale ordering', () => { + it('keeps the most recent locale when an earlier load finishes last', async () => { + const { activateLocale, useI18n } = await import('./index'); + const { locale } = useI18n(); + + const arabic = activateLocale('ar'); + await activateLocale('ms'); + + expect(locale.value).toBe('ms'); + + gate.release(); + await arabic; + + expect(locale.value).toBe('ms'); + expect(document.documentElement.lang).toBe('ms'); + expect(document.documentElement.dir).toBe('ltr'); + }); + + it('still resolves the superseded call with the locale it was asked for', async () => { + const { activateLocale } = await import('./index'); + + gate.release(); + + await expect(activateLocale('ar')).resolves.toBe('ar'); + }); +}); From 5708d4a534dce1c1993895059a97fea9d5eaefb5 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Mon, 9 Nov 2026 08:05:57 +0800 Subject: [PATCH 2/7] fix(i18n): keep hreflang alternates current after client-side visits 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). --- app/Http/Middleware/HandleInertiaRequests.php | 5 +++ resources/js/app.ts | 3 ++ resources/js/i18n/alternates.test.ts | 24 ++++++++++++++ resources/js/i18n/alternates.ts | 32 +++++++++++++++++++ resources/js/types/shared.ts | 5 ++- resources/views/app.blade.php | 5 +-- tests/Feature/LocaleRoutingTest.php | 22 ++++++++++++- 7 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 resources/js/i18n/alternates.test.ts create mode 100644 resources/js/i18n/alternates.ts diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 1ff4bbc..eb1ea7d 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Http\Localization\Locale; +use App\Http\Localization\LocalizedRoutes; use Illuminate\Http\Request; use Inertia\Middleware; @@ -31,6 +32,10 @@ public function share(Request $request): array ...parent::share($request), 'locale' => fn (): string => Locale::current()->value, 'direction' => fn (): string => Locale::current()->direction(), + // hreflang alternates for the head and the locale switcher. Sent + // on every visit so client-side navigation never keeps the + // previous page's links (M3 review). + 'alternates' => fn (): array => LocalizedRoutes::alternates($request), ]; } } diff --git a/resources/js/app.ts b/resources/js/app.ts index 36aa20e..9764b9f 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -2,11 +2,14 @@ import { createInertiaApp } from '@inertiajs/vue3'; import type { DefineComponent } from 'vue'; import { createApp, h } from 'vue'; import { activateLocale } from './i18n'; +import { hreflangTags } from './i18n/alternates'; const pages = import.meta.glob('./pages/**/*.vue'); void createInertiaApp({ title: (title) => (title ? `${title} · Nexo` : 'Nexo'), + // Replaces the server-rendered hreflang links on every visit. + serverHead: (page) => hreflangTags(page.props.alternates), resolve: async (name, page) => { const component = pages[`./pages/${name}.vue`]; diff --git a/resources/js/i18n/alternates.test.ts b/resources/js/i18n/alternates.test.ts new file mode 100644 index 0000000..c192187 --- /dev/null +++ b/resources/js/i18n/alternates.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { hreflangTags } from './alternates'; + +describe('hreflangTags', () => { + it('renders one keyed link per alternate, in the server markup', () => { + expect(hreflangTags({ en: 'https://nexo.test', ar: 'https://nexo.test/ar', 'x-default': 'https://nexo.test' })).toEqual([ + '', + '', + '', + ]); + }); + + it('renders nothing for pages without alternates (PHP sends [])', () => { + expect(hreflangTags(undefined)).toEqual([]); + expect(hreflangTags([])).toEqual([]); + expect(hreflangTags({ en: 42 })).toEqual([]); + }); + + it('escapes attribute values', () => { + expect(hreflangTags({ en: 'https://nexo.test/?a=1&b="x"' })[0]).toContain( + 'href="https://nexo.test/?a=1&b="x""', + ); + }); +}); diff --git a/resources/js/i18n/alternates.ts b/resources/js/i18n/alternates.ts new file mode 100644 index 0000000..09df77a --- /dev/null +++ b/resources/js/i18n/alternates.ts @@ -0,0 +1,32 @@ +// hreflang alternates (NFR-I18N-04). The server computes them per page and +// shares them as the `alternates` prop (LocalizedRoutes::alternates()). +// +// app.blade.php renders them on the first response so crawlers see them +// without JavaScript. Each tag carries a data-inertia key, and app.ts hands +// hreflangTags() to Inertia's `serverHead`, so the head manager replaces them +// on every visit instead of leaving the first page's links in the DOM. +import type { Locale } from './locales'; + +export type Hreflang = Locale | 'x-default'; + +/** Current page's URL per locale. PHP sends an empty array when there are none. */ +export type Alternates = Partial>; + +const escapeAttribute = (value: string) => + value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + +/** + * Link tags for Inertia's `serverHead`. Takes the raw prop (untyped there) + * and ignores anything that is not a string URL. Must match the markup in + * resources/views/app.blade.php. + */ +export function hreflangTags(alternates: unknown): string[] { + if (typeof alternates !== 'object' || alternates === null) return []; + + return Object.entries(alternates) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .map( + ([hreflang, href]) => + ``, + ); +} diff --git a/resources/js/types/shared.ts b/resources/js/types/shared.ts index 9206366..93f57d8 100644 --- a/resources/js/types/shared.ts +++ b/resources/js/types/shared.ts @@ -1,13 +1,16 @@ // TEMPORARY until ADR-0018: hand-written mirror of the props shared by // App\Http\Middleware\HandleInertiaRequests::share(). Replace with // generated types once ADR-0018 is decided (follow-up F-3). +import type { Alternates } from '@/i18n/alternates'; import type { Direction, Locale } from '@/i18n/locales'; -export type { Direction, Locale }; +export type { Alternates, Direction, Locale }; export interface SharedProps { locale: Locale; direction: Direction; + /** hreflang alternates of the current page; empty when it is not localised. */ + alternates: Alternates; } /** diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index b703575..73f20b2 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -5,8 +5,9 @@ {{ config('app.name', 'Nexo') }} -@foreach (\App\Http\Localization\LocalizedRoutes::alternates() as $hreflang => $href) - +{{-- Same markup and data-inertia keys as hreflangTags() in resources/js/i18n/alternates.ts, so Inertia's head manager updates these on every visit. --}} +@foreach ($page['props']['alternates'] ?? [] as $hreflang => $href) + @endforeach @fonts diff --git a/tests/Feature/LocaleRoutingTest.php b/tests/Feature/LocaleRoutingTest.php index 3f4d991..99d8f71 100644 --- a/tests/Feature/LocaleRoutingTest.php +++ b/tests/Feature/LocaleRoutingTest.php @@ -1,5 +1,6 @@ '/', 'ms' => '/ms', 'ar' => '/ar', 'x-default' => '/'] as $hreflang => $path) { $response->assertSee( - '', + '', escape: false, ); } })->with(['/', '/ms', '/ar']); +it('shares the alternates as a prop, so client-side visits replace them', function (string $url) { + $this->get($url)->assertInertia(fn (Assert $page) => $page->where('alternates', [ + 'en' => url('/'), + 'ms' => url('/ms'), + 'ar' => url('/ar'), + 'x-default' => url('/'), + ])); +})->with(['/', '/ms', '/ar']); + +it('sends the alternates on Inertia (client-side) visits too', function () { + $version = app(HandleInertiaRequests::class)->version(request()); + + $this->get('/ms', ['X-Inertia' => 'true', 'X-Inertia-Version' => (string) $version]) + ->assertOk() + ->assertJsonPath('component', 'Storefront/Home') + ->assertJsonPath('props.alternates.ar', url('/ar')) + ->assertJsonPath('props.alternates.x-default', url('/')); +}); + // One request per test: Laravel's Vite service remembers which assets it // has preloaded and skips them on later requests in the same application. it('preloads the Arabic font on Arabic pages', function () { From 20500a732103828e5a79e99534af6af39b9eac64 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Mon, 9 Nov 2026 08:09:17 +0800 Subject: [PATCH 3/7] feat(security): add security headers and a nonce-based CSP 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/Middleware/SecurityHeaders.php | 95 +++++++++++++++++++++ bootstrap/app.php | 4 + resources/js/app.ts | 5 ++ resources/views/app.blade.php | 2 + tests/Feature/SecurityHeadersTest.php | 107 ++++++++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 app/Http/Middleware/SecurityHeaders.php create mode 100644 tests/Feature/SecurityHeadersTest.php diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..93f2fe9 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,95 @@ +headers->add([ + 'Content-Security-Policy' => $this->contentSecurityPolicy($nonce), + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + // Legacy twin of frame-ancestors 'none' for older browsers. + 'X-Frame-Options' => 'DENY', + 'Cross-Origin-Opener-Policy' => 'same-origin', + 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()', + ]); + + // HTTPS only, and never locally: a pinned HSTS entry for a *.test + // host would break plain-HTTP development. + if ($request->isSecure() && ! app()->isLocal()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000'); + } + + return $response; + } + + private function contentSecurityPolicy(string $nonce): string + { + $script = ["'self'", "'nonce-{$nonce}'"]; + $style = ["'self'", "'nonce-{$nonce}'"]; + $connect = ["'self'"]; + $assets = ["'self'"]; + + // `npm run dev`: scripts, styles and HMR come from the Vite server. + // Vite injects