Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions app/Http/Errors/ErrorPages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Http\Errors;

use App\Http\Localization\Locale;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
use Inertia\ExceptionResponse;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

/**
* Renders HTTP errors as the Inertia page Errors/ErrorPage, translated and
* in the request's locale. Registered with `$exceptions->respond()` in
* bootstrap/app.php.
*
* The page receives only the status code, never the exception, so no
* message, trace or path reaches the browser (NFR-SEC-07). With APP_DEBUG
* on, a 500 keeps Laravel's debug page for developers.
*/
final class ErrorPages
{
public const STATUSES = [403, 404, 419, 500, 503];

public function __invoke(Response $response, Throwable $exception, Request $request): Response
{
$status = $response->getStatusCode();

if (! in_array($status, self::STATUSES, true)
|| $response instanceof JsonResponse
|| ($status === 500 && config('app.debug'))) {
return $response;
}

// A 404 with no matching route never ran the `locale:` middleware;
// the URL prefix is authoritative, so take the locale from it.
if (Locale::current() === Locale::fallback()) {
app()->setLocale(Locale::fromPath($request->path())->value);
}

return (new ExceptionResponse($exception, $request, $response, app(Router::class), app(Kernel::class)))
->render('Errors/ErrorPage', ['status' => $status])
->usingMiddleware(HandleInertiaRequests::class)
->withSharedData()
->toResponse($request);
}
}
14 changes: 14 additions & 0 deletions app/Http/Localization/Locale.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ public static function current(): self
return self::tryFrom(app()->getLocale()) ?? self::fallback();
}

/** The locale whose prefix starts `$path` (e.g. "ar/products"), else the fallback. */
public static function fromPath(string $path): self
{
$segment = explode('/', trim($path, '/'), 2)[0];

foreach (self::cases() as $locale) {
if ($locale->prefix() !== '' && $locale->prefix() === $segment) {
return $locale;
}
}

return self::fallback();
}

public function direction(): string
{
return $this === self::Arabic ? 'rtl' : 'ltr';
Expand Down
18 changes: 12 additions & 6 deletions app/Http/Localization/LocalizedRoutes.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
*/
final class LocalizedRoutes
{
public static function group(Closure $routes): void
/**
* @param bool $redirectEnglishPrefix false for a second group (routes/local.php):
* the /en redirect is registered once, by routes/web.php.
*/
public static function group(Closure $routes, bool $redirectEnglishPrefix = true): void
{
// /en/... is never canonical: send it to the unprefixed URL. Registered
// first so a later catch-all storefront route cannot shadow it.
Expand All @@ -22,12 +26,14 @@ public static function group(Closure $routes): void
// the redirect is always absolute and same-host. Without that,
// /en//evil.com or /en/%2Fevil.com became "//evil.com", a
// protocol-relative open redirect.
Route::get('en/{path?}', function (Request $request, ?string $path = null) {
$target = url('/'.ltrim($path ?? '', '/'));
$query = $request->getQueryString();
if ($redirectEnglishPrefix) {
Route::get('en/{path?}', function (Request $request, ?string $path = null) {
$target = url('/'.ltrim($path ?? '', '/'));
$query = $request->getQueryString();

return redirect($target.($query === null ? '' : '?'.$query), 301);
})->where('path', '.*');
return redirect($target.($query === null ? '' : '?'.$query), 301);
})->where('path', '.*');
}

foreach (Locale::cases() as $locale) {
Route::middleware('locale:'.$locale->value)
Expand Down
5 changes: 5 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
];
}
}
95 changes: 95 additions & 0 deletions app/Http/Middleware/SecurityHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;

/**
* Security headers on every response, error pages included (NFR-SEC-08).
*
* Registered as global middleware so it also covers responses that never
* reach a route (404s, maintenance mode, /up). A fresh nonce per request is
* given to Laravel's Vite helper, which adds it to every tag it renders
* (@vite, @fonts); app.blade.php exposes it to Vite and Inertia for the
* styles they inject at runtime.
*/
final class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$nonce = Vite::useCspNonce();

$response = $next($request);

$response->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 <style> tags while developing, so inline styles are
// allowed there (and only there): browsers ignore 'unsafe-inline'
// once a nonce is listed, so the nonce is dropped from style-src.
if (($devServer = $this->viteDevServer()) !== null) {
$script[] = $devServer;
$style = ["'self'", "'unsafe-inline'", $devServer];
$connect[] = $devServer;
$connect[] = preg_replace('/^http/', 'ws', $devServer);
$assets[] = $devServer;
}

return implode('; ', [
"default-src 'self'",
'script-src '.implode(' ', $script),
'style-src '.implode(' ', $style),
'img-src '.implode(' ', [...$assets, 'data:']),
'font-src '.implode(' ', $assets),
'connect-src '.implode(' ', $connect),
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
]);
}

/** Origin of the running Vite dev server, from public/hot, or null. */
private function viteDevServer(): ?string
{
if (! Vite::isRunningHot()) {
return null;
}

$url = trim((string) file_get_contents(Vite::hotFile()));
$parts = parse_url($url);

if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
return null;
}

return $parts['scheme'].'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
}
}
9 changes: 9 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?php

use App\Http\Errors\ErrorPages;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
Expand All @@ -22,6 +24,10 @@
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias(['locale' => SetLocale::class]);

// Global and first, so 404s, maintenance (503) responses and /up get
// the headers too, and the CSP nonce exists before any page renders.
$middleware->prepend(SecurityHeaders::class);

$middleware->web(append: [
HandleInertiaRequests::class,
]);
Expand All @@ -30,4 +36,7 @@
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);

// 403, 404, 419, 500 and 503 render as translated Inertia pages.
$exceptions->respond(new ErrorPages);
})->create();
30 changes: 29 additions & 1 deletion docs/engineering/code-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ composer analyse
`LocalizedRoutes::group()` in `routes/web.php` and registered for `/`,
`/ms` and `/ar`, named `{locale}.{name}` (e.g. `ar.storefront.home`).
`/en/...` redirects (301) to the unprefixed URL. Every localised page
emits `hreflang` alternates plus `x-default`.
emits `hreflang` alternates plus `x-default`. They are the shared
`alternates` prop: Blade renders them for the first response and
Inertia's `serverHead` (`resources/js/i18n/alternates.ts`) replaces them
on every client-side visit. The language switcher reads the same prop,
never the DOM.
- **Strings:** `resources/js/i18n/messages/{locale}/{namespace}.json`; the
key is `{namespace}.{path}` (e.g. `storefront.home.title`). Use
`const { t } = useI18n()`; parameters are `{name}`, and plurals are objects
Expand Down Expand Up @@ -122,6 +126,30 @@ composer analyse
`resources/js/test/setup.ts` stubs `showModal`/`close`; real browser
behaviour is checked manually and in Phase 9 browser tests.

## Layouts

- **Storefront** (`resources/js/storefront/layouts/StorefrontLayout.vue`):
skip link, header (brand, language switcher, cart count), `<main
id="main">`, toasts. **Checkout** (`CheckoutLayout.vue`): the same frame
with a quiet header and no cart or browsing links. **Vendor**
(`resources/js/vendor/layouts/VendorLayout.vue`): static menu, no auth
until ADR-0011; sections without a page are text, never dead links.
- A page picks its layout with `defineOptions({ layout: StorefrontLayout })`.
Inertia keeps it mounted across visits.
- Shared frame pieces (`SkipLink`, `LocaleSwitcher`) live in
`resources/js/shared/layout/` so both surfaces can use them (F1).
- Local previews: `/dev/layouts/checkout` and `/dev/layouts/vendor`, in
every locale (`/ms/...`, `/ar/...`).
- Which screen uses which layout, and what each one waits for:
[`screen-inventory.md`](../product/screen-inventory.md).

## Error pages

`App\Http\Errors\ErrorPages` (registered in `bootstrap/app.php`) renders
403, 404, 419, 500 and 503 as the Inertia page `Errors/ErrorPage`, in the
URL's locale. It receives only the status code (NFR-SEC-07). JSON clients
keep JSON errors; with `APP_DEBUG=true` a 500 shows Laravel's debug page.

## Before every pull request

```sh
Expand Down
5 changes: 5 additions & 0 deletions docs/engineering/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,8 @@ If a value might be sensitive, log an identifier instead of the value.
Return a helpful, non-leaking message. Validation errors are specific; internal
errors are generic. Never surface a stack trace, SQL, or an internal path to a
user.

HTTP errors 403, 404, 419, 500 and 503 render as the translated Inertia page
`Errors/ErrorPage` (`App\Http\Errors\ErrorPages`), which receives only the
status code. `tests/Feature/ErrorPagesTest.php` proves the 500 page leaks
nothing with `APP_DEBUG=false` (NFR-SEC-07).
24 changes: 24 additions & 0 deletions docs/engineering/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ See [`error-handling.md`](error-handling.md). In short: never log passwords,
tokens, API keys, full card numbers, or full request bodies for auth/payment
endpoints.

## Security headers

`App\Http\Middleware\SecurityHeaders` runs first in the global stack, so
every response gets them, error and maintenance pages included
(NFR-SEC-08, tested in `tests/Feature/SecurityHeadersTest.php`):

- **CSP** with a fresh nonce per request and no `unsafe-inline` or
`unsafe-eval`: `default-src 'self'`, scripts and styles from `'self'`
plus the nonce, `object-src 'none'`, `base-uri 'self'`,
`form-action 'self'`, `frame-ancestors 'none'`. Laravel's Vite helper
adds the nonce to `@vite` and `@fonts` tags; the `csp-nonce` meta in
`app.blade.php` passes it to Vite and Inertia for styles they inject.
While `npm run dev` runs, the Vite origin, its WebSocket and inline
styles are allowed (development only).
- `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy: strict-origin-when-cross-origin`,
`Cross-Origin-Opener-Policy: same-origin`, and a `Permissions-Policy`
denying camera, microphone and geolocation.
- **HSTS** (`max-age=31536000`) on HTTPS requests outside local
development. Behind a TLS-terminating proxy the demo host must be a
trusted proxy, or requests will not count as HTTPS.
- Adding a third-party origin (payments, analytics, images) means adding
it to the policy in the same PR, with a test.

## Dependency auditing

- `composer audit` runs in CI and in `composer check`. A new advisory fails the
Expand Down
1 change: 1 addition & 0 deletions docs/product/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Implementation
| [`functional-requirements.md`](functional-requirements.md) | FR-* requirements with acceptance criteria |
| [`non-functional-requirements.md`](non-functional-requirements.md) | NFR-*: correctness, security, performance, a11y, i18n, quality gates |
| [`user-journeys.md`](user-journeys.md) | Customer, vendor and platform-staff workflows with failure branches |
| [`screen-inventory.md`](screen-inventory.md) | Every customer and vendor screen, its layout, status and what it waits for |

## Superseded (history only)

Expand Down
33 changes: 33 additions & 0 deletions docs/product/screen-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Screen Inventory

- **Status:** Living document (Phase 3, M5). Update it in the PR that adds or removes a screen.
- **Date:** 2026-11-09

Every screen the customer and vendor journeys in [`user-journeys.md`](user-journeys.md) need, the layout it uses, and what it waits for. Platform staff screens (PJ-1…PJ-11) are Filament resources (ADR-0020) and are not listed. Every screen ships in en, ms and ar (NFR-I18N-01).

| Screen | Journeys | Layout | Status | Waits for |
|---|---|---|---|---|
| Home | CJ-1 | Storefront | Built (placeholder content) | Catalogue for featured content |
| Category listing with facets | CJ-1 | Storefront | Not started | Catalogue, ADR-0019 (search) |
| Search results | CJ-1 | Storefront | Not started | ADR-0019 |
| Product | CJ-1, CJ-5 | Storefront | Not started | Catalogue, ADR-0013 (money) |
| Store page (vendor storefront, follow) | CJ-1, CJ-6 | Storefront | Not started | Catalogue, ADR-0011 for follow |
| Cart (grouped by vendor) | CJ-1 | Storefront | Not started (header shows a placeholder count) | ADR-0013, ADR-0014 |
| Sign in, register, verify email (inline in checkout too) | CJ-1 4a–4d | Storefront, Checkout | Not started | ADR-0011 (M6) |
| Checkout: address, shipping, coupon, points, summary | CJ-1 5 | Checkout | Frame only (`/dev/layouts/checkout`, local) | ADR-0011, ADR-0015 |
| Payment (reservation timer, 3-D Secure) | CJ-1 6 | Checkout | Not started | ADR-0014, ADR-0015 |
| Confirming payment, then order confirmation | CJ-1 7 | Checkout | Not started | ADR-0012, ADR-0015 |
| Account: orders and shipment timelines | CJ-2, CJ-3 | Storefront | Not started | ADR-0011, ADR-0012 |
| Return request | CJ-4 | Storefront | Not started | ADR-0012, uploads (NFR-SEC-05) |
| Review form | CJ-5 | Storefront | Not started | ADR-0011 |
| Wishlist, followed stores, referrals | CJ-6 | Storefront | Not started | ADR-0011 |
| Sell on Nexo application | VJ-1 | Storefront | Not started | ADR-0011 |
| Vendor dashboard and readiness checklist | VJ-1 | Vendor | Frame only (`/dev/layouts/vendor`, local) | ADR-0011 (vendor context) |
| Store profile and shipping rates | VJ-1 | Vendor | Not started | ADR-0011 |
| Products, product editor with variant matrix, CSV import | VJ-2 | Vendor | Not started | ADR-0011, catalogue |
| Orders and VendorOrder detail | VJ-3 | Vendor | Not started | ADR-0011, ADR-0012 |
| Stock | VJ-4 | Vendor | Not started | ADR-0011, ADR-0014 |
| Returns | VJ-5 | Vendor | Not started | ADR-0011, ADR-0012 |
| Earnings, statement, payouts | VJ-6 | Vendor | Not started | ADR-0011, ADR-0016 |
| Team | VJ-7 | Vendor | Not started | ADR-0011 |
| Error pages: 403, 404, 419, 500, 503 | All | Own minimal frame (shared by both surfaces) | Built | — |
8 changes: 8 additions & 0 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import { createInertiaApp } from '@inertiajs/vue3';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { activateLocale } from './i18n';
import { hreflangTags } from './i18n/alternates';

// Read from the element property: browsers blank the nonce attribute.
const cspNonce = document.querySelector<HTMLMetaElement>('meta[property="csp-nonce"]')?.nonce;

const pages = import.meta.glob<DefineComponent>('./pages/**/*.vue');

void createInertiaApp({
title: (title) => (title ? `${title} · Nexo` : 'Nexo'),
// CSP nonce for the <style> tags Inertia injects (progress bar, error dialog).
...(cspNonce ? { nonce: cspNonce } : {}),
// 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`];

Expand Down
Loading
Loading