This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3d6b0e0d4e
commit 5386bc51d1
65 changed files with 4058 additions and 161 deletions
+47 -18
View File
@@ -2,9 +2,10 @@
A modern sports-fashion e-commerce platform. A modern sports-fashion e-commerce platform.
**Status: milestone 1 — catalog read API, live end to end, in Vietnamese and English.** **Status: milestone 2 — auth and RBAC, on top of a live bilingual catalog.**
The storefront renders real products from the database through the REST API. Cart, checkout, The storefront renders real products in Vietnamese and English; the admin has working sign-in
orders and auth are still ahead; see [Roadmap](#roadmap). with rotating refresh tokens and permission-filtered navigation. Cart, checkout and orders are
next; see [Roadmap](#roadmap).
``` ```
Storefront (Next.js) ─┐ Storefront (Next.js) ─┐
@@ -97,8 +98,13 @@ pnpm db:generate # regenerate Prisma Client
pnpm db:seed # reconcile permissions + roles (idempotent) pnpm db:seed # reconcile permissions + roles (idempotent)
pnpm db:studio # Prisma Studio pnpm db:studio # Prisma Studio
pnpm db:reset # drop, re-migrate, re-seed pnpm db:reset # drop, re-migrate, re-seed
pnpm db:create-admin # create/repair a SUPER_ADMIN (prints a generated password)
``` ```
`pnpm db:seed` also creates three **development** sign-in accounts and prints their generated
passwords once. They are skipped when `NODE_ENV=production` or `SEED_DEMO=false`; real
environments use `pnpm db:create-admin`, which is also the lockout-recovery path.
### Running one app manually ### Running one app manually
```bash ```bash
@@ -153,7 +159,7 @@ sport-store/
│ │
├── docs/ ├── docs/
│ ├── architecture.md Boundaries, conventions, risks — read this first │ ├── architecture.md Boundaries, conventions, risks — read this first
│ └── adr/ 14 decision records │ └── adr/ 15 decision records
│ │
├── docker-compose.yml Backing services; `--profile full` runs everything ├── docker-compose.yml Backing services; `--profile full` runs everything
├── turbo.json pnpm-workspace.yaml package.json ├── turbo.json pnpm-workspace.yaml package.json
@@ -194,6 +200,11 @@ Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that
is why most "add a language" projects end up half-translated. is why most "add a language" projects end up half-translated.
([ADR-0013](./docs/adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md)) ([ADR-0013](./docs/adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md))
9. **The browser always calls the API on its own origin** — Nginx in production, a Next rewrite
in development. That is what makes the httpOnly refresh cookie first-party, and what keeps
dev and production authenticating identically.
([ADR-0015](./docs/adr/0015-frontends-reach-the-api-through-their-own-origin.md))
### Languages ### Languages
Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`. Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`.
@@ -231,7 +242,7 @@ locale-in-path would buy nothing.
| --------- | -------------------------------------------------------------------------------------------------------------------------------------- | | --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | | **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI |
| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data | | **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data |
| **M2** | Auth: login, refresh rotation, RBAC admin, user/role management | | **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management |
| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory | | **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory |
| **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain | | **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain |
| **M5** | Cart, checkout, orders | | **M5** | Cart, checkout, orders |
@@ -240,10 +251,10 @@ locale-in-path would buy nothing.
| **M8** | Customer account | | **M8** | Customer account |
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
**Recommended next step: M2 (auth).** The enforcement half already exists — global access-token **Recommended next step: M3 (admin catalog write path).** Reads, auth and RBAC are in place, so
guard, RBAC permissions guard, audience separation — so only issuance is missing: login, refresh the product editor and variant matrix now have everything they need — a known operator, a
rotation, and the admin user/role screens. Everything after it (cart ownership, orders, the admin permission to check, and a catalog to edit. It is also what makes the seed replaceable by real
write path) depends on knowing who is asking. merchandising.
--- ---
@@ -253,17 +264,35 @@ Everything below was run, not assumed:
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 25/25 Turborepo tasks pass; - `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 25/25 Turborepo tasks pass;
`pnpm format:check` clean `pnpm format:check` clean
- 5 migrations applied; 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, - 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
3 collections, 12 products, **155 variants** and 64 generated images uploaded to MinIO 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
- `pnpm test` — 17 passing (RBAC guards, translation fallback, `Accept-Language` negotiation) - **25 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`
- API: listings with filters/facets/cursor paging, PDP, navigation, brands and collections all - Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in
return correctly localised payloads in both `vi` and `en` both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`)
- Storefront: every route returns 200 in both locales; PDP renders translated options, spec - Storefront: every route 200 in both locales; `/en/products/<vi-slug>` → 307 →
table and variant titles; `/en/products/<vi-slug>` → 307 → `/en/products/<en-slug>`; `/en/products/<en-slug>`; `hreflang` + canonical emitted per locale
`hreflang` + canonical emitted per locale - **Auth:** admin sign-in works through the app's own origin; the refresh cookie is httpOnly and
- Money formats per locale from one integer: `690.000 ₫` (vi) / `₫690,000` (en) scoped to `/api/v1/auth`; refresh rotates the token; **replaying a rotated token is rejected and
revokes the entire family** (verified: 2 of 3 sessions revoked)
- **Audience isolation:** a customer cannot sign in at the admin endpoint and an admin cannot sign
in at the storefront endpoint — both return the same `INVALID_CREDENTIALS` as a wrong password,
so the form cannot be used to enumerate accounts
- **RBAC:** a `catalog_manager` receives `PERMISSION_DENIED` on `/admin/users` and `/admin/roles`;
no token gives `UNAUTHENTICATED`
- Admin: renders Vietnamese by default and English with `sport_admin_locale=en` - Admin: renders Vietnamese by default and English with `sport_admin_locale=en`
### Verified in a real browser
Server-side checks and curl are not sufficient for client behaviour — three bugs proved it.
Confirmed by clicking through Chrome with the console and network panel open:
- Admin sign-in issues exactly **one** `POST /auth/admin/login`, then redirects to the dashboard
- Sidebar is filtered by the signed-in operator's permissions; users table and role viewer load
real data; language switch preserves the session and the current page; sign-out returns to login
- Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes,
SKU updates, and switching language moves between translated slugs
- Filters apply (`/men?colors=black&onSale=true`), and all 16 grid images load
Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They
come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are
auto-converted correctly, and routing is verified working. Nothing in this repository registers auto-converted correctly, and routing is verified working. Nothing in this repository registers
+35 -3
View File
@@ -6,16 +6,48 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
/**
* Proxies API calls through this app's own origin.
*
* The refresh token is a `SameSite=Lax` httpOnly cookie, so the browser only
* sends it first-party. Calling the API host directly from the browser would
* mean `SameSite=None; Secure`, which cannot work over plain HTTP in local
* development at all. Production does the same thing at the Nginx layer, so
* dev and prod share one topology instead of two.
*/
async rewrites() {
const target = process.env.API_INTERNAL_URL ?? 'http://localhost:4000';
return [{ source: '/api/:path*', destination: `${target}/api/:path*` }];
},
transpilePackages: ['@sport/ui'], transpilePackages: ['@sport/ui'],
typedRoutes: true, typedRoutes: true,
output: 'standalone', output: 'standalone',
images: { images: {
/**
* Media is served from R2/CDN in production and MinIO locally.
*
* `pathname` and `search` are specified explicitly: Next 16 matches remote
* patterns strictly, and an entry without them does not authorise the URL —
* the optimizer answers `"url" parameter is not allowed` and every product
* image renders broken. Scoping to the bucket path also keeps this from
* becoming an open image proxy.
*/
remotePatterns: [ remotePatterns: [
{ protocol: 'http', hostname: 'localhost', port: '9000' }, { protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' },
{ protocol: 'https', hostname: '**.r2.dev' }, { protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' },
{ protocol: 'https', hostname: 'cdn.sport-store.local' }, { protocol: 'https', hostname: 'cdn.sport-store.local', pathname: '/**', search: '' },
], ],
/**
* DEVELOPMENT ONLY. See the storefront config for the full explanation:
* Next 16 blocks upstream images on private IPs (SSRF guard) and reports it
* with the same message as an unmatched pattern. Local MinIO is on
* localhost, production media is on a public CDN host.
*/
dangerouslyAllowLocalIP: process.env.NODE_ENV !== 'production',
}, },
// The admin is an internal tool: keep it out of every index, permanently. // The admin is an internal tool: keep it out of every index, permanently.
+8 -4
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { LanguageSwitcher } from '@/components/language-switcher'; import { LanguageSwitcher } from '@/components/language-switcher';
import { LoginForm } from '@/features/auth/login-form';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('common.login'); const t = await getTranslations('common.login');
@@ -9,8 +10,8 @@ export async function generateMetadata(): Promise<Metadata> {
} }
/** /**
* Sits outside the dashboard route group so it renders without the sidebar and * Outside the dashboard route group, so it renders without the sidebar and
* without the auth requirement. * without the session requirement.
*/ */
export default async function LoginPage() { export default async function LoginPage() {
const t = await getTranslations('common'); const t = await getTranslations('common');
@@ -18,13 +19,16 @@ export default async function LoginPage() {
return ( return (
<div className="bg-ink-50 flex min-h-screen items-center justify-center px-6"> <div className="bg-ink-50 flex min-h-screen items-center justify-center px-6">
<div className="border-ink-200 w-full max-w-sm border bg-white p-8"> <div className="border-ink-200 w-full max-w-sm border bg-white p-8">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-4">
<h1 className="text-lg font-black uppercase tracking-tighter"> <h1 className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span> {t('appName')} Sport<span className="text-volt-600">.</span> {t('appName')}
</h1> </h1>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<p className="text-ink-500 mt-4 text-sm">{t('login.body')}</p>
<p className="text-ink-500 mt-3 text-sm">{t('login.body')}</p>
<LoginForm />
</div> </div>
</div> </div>
); );
+16 -18
View File
@@ -1,27 +1,25 @@
import { getTranslations } from 'next-intl/server';
import { LanguageSwitcher } from '@/components/language-switcher';
import { AdminSidebar } from '@/components/layout/admin-sidebar'; import { AdminSidebar } from '@/components/layout/admin-sidebar';
import { RequireSession } from '@/features/auth/require-session';
import { SessionBar } from '@/features/auth/session-bar';
/** /**
* Every route in this group requires an authenticated back-office actor. * Every route in this group requires an authenticated back-office actor.
* Enforcement is layered: the proxy checks for a session cookie, this layout *
* verifies the token server-side, and the API re-checks permissions on every * Enforcement is layered, and only the last layer is real security:
* request. Only the last one is real security; the first two are UX. * 1. `RequireSession` avoids rendering the shell for a signed-out visitor (UX).
* 2. Permission-aware navigation hides screens they cannot use (UX).
* 3. The API authorises every single request (security).
*/ */
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const t = await getTranslations('common');
return ( return (
<div className="flex min-h-screen"> <RequireSession>
<AdminSidebar /> <div className="flex min-h-screen">
<div className="min-w-0 flex-1"> <AdminSidebar />
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6"> <div className="min-w-0 flex-1">
<LanguageSwitcher /> <SessionBar />
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span> <main>{children}</main>
</header> </div>
<main>{children}</main>
</div> </div>
</div> </RequireSession>
); );
} }
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { RolesPanel } from '@/features/settings/roles-panel';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.roles'); const t = await getTranslations('pages.roles');
@@ -12,11 +12,13 @@ export default async function RolesPage() {
const t = await getTranslations('pages.roles'); const t = await getTranslations('pages.roles');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="role.read" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M2 — auth & RBAC" </header>
/>
<RolesPanel />
</div>
); );
} }
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { UsersTable } from '@/features/settings/users-table';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.users'); const t = await getTranslations('pages.users');
@@ -12,11 +12,13 @@ export default async function UsersPage() {
const t = await getTranslations('pages.users'); const t = await getTranslations('pages.users');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="user.read" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M2 — auth & RBAC" </header>
/>
<UsersTable />
</div>
); );
} }
+5 -1
View File
@@ -4,6 +4,8 @@ import { getLocale, getTranslations } from 'next-intl/server';
import { LOCALE_TAGS } from '@sport/types'; import { LOCALE_TAGS } from '@sport/types';
import { SessionProvider } from '@/features/auth/session-provider';
import '@/styles/globals.css'; import '@/styles/globals.css';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
@@ -27,7 +29,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
suppressHydrationWarning suppressHydrationWarning
> >
<body className="min-h-screen antialiased"> <body className="min-h-screen antialiased">
<NextIntlClientProvider>{children}</NextIntlClientProvider> <NextIntlClientProvider>
<SessionProvider>{children}</SessionProvider>
</NextIntlClientProvider>
</body> </body>
</html> </html>
); );
@@ -1,16 +1,36 @@
import Link from 'next/link'; 'use client';
import { getTranslations } from 'next-intl/server';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { NAVIGATION } from '@/lib/navigation'; import { NAVIGATION } from '@/lib/navigation';
/** /**
* Renders every section for now. Once the session carries permissions, each * Navigation filtered by the signed-in operator's permissions.
* item is filtered with `hasPermission(actor.permissions, item.permission)` — *
* the same catalog the API guards read, so menu and enforcement cannot drift. * This is the visible payoff of RBAC: someone who cannot read orders never sees
* an Orders link, so the admin has no dead ends that 403 on click. It is
* presentation only — the API re-checks every request, because a hidden link is
* one devtools inspection away from being visible.
*
* A client component because the permission set lives in the session. The cost
* is small: the nav is a list of links, and it re-renders only when the session
* changes.
*/ */
export async function AdminSidebar() { export function AdminSidebar() {
const t = await getTranslations('common'); const t = useTranslations('common');
const tPages = await getTranslations('pages'); const tPages = useTranslations('pages');
const { can } = useSession();
const pathname = usePathname();
const sections = NAVIGATION.map((section) => ({
...section,
items: section.items.filter((item) => can(item.permission)),
})).filter((section) => section.items.length > 0);
return ( return (
<aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block"> <aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block">
@@ -21,22 +41,32 @@ export async function AdminSidebar() {
</div> </div>
<nav className="space-y-6 p-5"> <nav className="space-y-6 p-5">
{NAVIGATION.map((section) => ( {sections.map((section) => (
<div key={section.titleKey}> <div key={section.titleKey}>
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest"> <h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{t(`sections.${section.titleKey}`)} {t(`sections.${section.titleKey}`)}
</h2> </h2>
<ul className="mt-2 space-y-0.5"> <ul className="mt-2 space-y-0.5">
{section.items.map((item) => ( {section.items.map((item) => {
<li key={item.href}> const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
<Link
href={item.href} return (
className="rounded-card text-ink-600 hover:bg-ink-100 hover:text-ink-950 block px-2 py-1.5 text-sm" <li key={item.href}>
> <Link
{tPages(`${item.labelKey}.title`)} href={item.href}
</Link> aria-current={active ? 'page' : undefined}
</li> className={cn(
))} 'rounded-card block px-2 py-1.5 text-sm transition-colors',
active
? 'bg-ink-950 text-white'
: 'text-ink-600 hover:bg-ink-100 hover:text-ink-950',
)}
>
{tPages(`${item.labelKey}.title`)}
</Link>
</li>
);
})}
</ul> </ul>
</div> </div>
))} ))}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState, type FormEvent } from 'react';
import { isApiClientError } from '@sport/api-client';
import { Button, Input } from '@sport/ui';
import { loginSchema } from '@sport/validation';
import { useSession } from './session-provider';
export function LoginForm() {
const t = useTranslations('common.login');
const router = useRouter();
const { signIn, status } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Someone who is already signed in has no business on the login page.
useEffect(() => {
if (status === 'authenticated') {
router.replace('/');
}
}, [status, router]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
const parsed = loginSchema.safeParse({ email, password });
if (!parsed.success) {
setError(t('invalid'));
return;
}
setSubmitting(true);
try {
await signIn(parsed.data.email, parsed.data.password);
router.replace('/');
} catch (caught) {
/**
* The API returns one message for every credential failure — unknown
* email, wrong password, suspended account — so that this form cannot be
* used to enumerate accounts. Surface it verbatim rather than trying to
* be more specific.
*/
if (isApiClientError(caught)) {
setError(caught.status === 429 ? t('throttled') : caught.message);
} else {
setError(t('networkError'));
}
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
<div className="space-y-1.5">
<label htmlFor="email" className="text-xs font-semibold uppercase tracking-widest">
{t('email')}
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
invalid={Boolean(error)}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-xs font-semibold uppercase tracking-widest">
{t('password')}
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
invalid={Boolean(error)}
/>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<Button type="submit" fullWidth size="lg" disabled={submitting}>
{submitting ? t('signingIn') : t('signIn')}
</Button>
</form>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Skeleton } from '@sport/ui';
import { useSession } from './session-provider';
/**
* Client-side route guard for the dashboard.
*
* This is UX, **not** security. It removes the flash of an empty admin shell
* for a signed-out visitor and sends them to the login page. Every request the
* shell would make is independently authorised by the API, which is where the
* actual enforcement lives — a determined visitor can render this tree by
* editing memory and will still receive 401s for every byte of data.
*/
export function RequireSession({ children }: { children: React.ReactNode }) {
const { status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'anonymous') {
router.replace('/login');
}
}, [status, router]);
if (status === 'loading') {
return (
<div className="space-y-4 p-8" aria-busy="true">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-full max-w-xl" />
</div>
);
}
if (status === 'anonymous') {
return null;
}
return <>{children}</>;
}
/**
* Renders children only when the session holds the permission.
*
* Again presentation only: it keeps operators from clicking into screens that
* would 403, which is a usability property. The API decides.
*/
export function Can({
permission,
children,
fallback = null,
}: {
permission: Parameters<ReturnType<typeof useSession>['can']>[0];
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { can } = useSession();
return <>{can(permission) ? children : fallback}</>;
}
@@ -0,0 +1,44 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { Button } from '@sport/ui';
import { LanguageSwitcher } from '@/components/language-switcher';
import { useSession } from './session-provider';
export function SessionBar() {
const t = useTranslations('common');
const { user, signOut } = useSession();
const router = useRouter();
const [isPending, startTransition] = useTransition();
function handleSignOut() {
startTransition(async () => {
await signOut();
router.replace('/login');
});
}
return (
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6">
<LanguageSwitcher />
{user ? (
<>
<span className="text-ink-500 text-xs font-medium">
{t('session.signedInAs', { name: user.displayName })}
</span>
<Button variant="ghost" size="sm" onClick={handleSignOut} disabled={isPending}>
{t('session.signOut')}
</Button>
</>
) : (
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span>
)}
</header>
);
}
@@ -0,0 +1,127 @@
'use client';
import { createContext, use, useCallback, useEffect, useMemo, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import {
hasAllPermissions,
hasAnyPermission,
type CurrentUser,
type Permission,
} from '@sport/types';
import { browserApi, tokenStore } from '@/lib/api';
interface SessionContextValue {
user: CurrentUser | null;
status: 'loading' | 'authenticated' | 'anonymous';
signIn(email: string, password: string): Promise<void>;
signOut(): Promise<void>;
can(permission: Permission): boolean;
canAny(permissions: readonly Permission[]): boolean;
canAll(permissions: readonly Permission[]): boolean;
}
const SessionContext = createContext<SessionContextValue | null>(null);
/**
* Client-side session state.
*
* The access token lives in `tokenStore` (module memory), never in React state
* — it must be readable from inside a fetch callback and must not be persisted.
* On mount this exchanges the httpOnly refresh cookie for a fresh token, which
* is how a page reload recovers a session without anything durable being stored
* where a script could read it.
*/
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [status, setStatus] = useState<SessionContextValue['status']>('loading');
const clearSession = useCallback(() => {
tokenStore.set(null);
setUser(null);
setStatus('anonymous');
}, []);
useEffect(() => {
tokenStore.onLost(clearSession);
return () => tokenStore.onLost(null);
}, [clearSession]);
useEffect(() => {
let cancelled = false;
async function restore() {
try {
const refreshed = await browserApi.auth.adminRefresh();
if (cancelled) return;
tokenStore.set(refreshed.accessToken);
const current = await browserApi.auth.me();
if (cancelled) return;
setUser(current);
setStatus('authenticated');
} catch (error) {
if (cancelled) return;
// A missing or expired cookie is the ordinary signed-out case, not a
// fault worth logging.
if (!isApiClientError(error) || error.status !== 401) {
console.warn('Session restore failed', error);
}
clearSession();
}
}
void restore();
return () => {
cancelled = true;
};
}, [clearSession]);
const signIn = useCallback(async (email: string, password: string) => {
const result = await browserApi.auth.adminLogin({ email, password });
tokenStore.set(result.accessToken);
setUser(result.user);
setStatus('authenticated');
}, []);
const signOut = useCallback(async () => {
try {
await browserApi.auth.adminLogout();
} finally {
// Clear local state even if the request failed. The user asked to sign
// out; leaving them apparently signed in is the worse outcome.
clearSession();
}
}, [clearSession]);
const value = useMemo<SessionContextValue>(
() => ({
user,
status,
signIn,
signOut,
// These read the same permission catalog the API guards enforce. This is
// presentation only — hiding a control is not authorization.
can: (permission) => hasAnyPermission(user?.permissions, [permission]),
canAny: (permissions) => hasAnyPermission(user?.permissions, permissions),
canAll: (permissions) => hasAllPermissions(user?.permissions, permissions),
}),
[user, status, signIn, signOut],
);
return <SessionContext value={value}>{children}</SessionContext>;
}
export function useSession(): SessionContextValue {
const context = use(SessionContext);
if (!context) {
throw new Error('useSession must be used inside <SessionProvider>');
}
return context;
}
@@ -0,0 +1,143 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types';
import { Badge, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
/**
* Role viewer.
*
* Read-only for now: the grid makes it obvious which role grants what, which is
* the question an operator actually asks. Editing grants is a destructive
* action that wants a confirmation flow and an audit entry, and shipping the
* viewer first means the editor can be designed against something real.
*/
export function RolesPanel() {
const t = useTranslations('settings');
const { can } = useSession();
const [roles, setRoles] = useState<RoleDetail[] | null>(null);
const [groups, setGroups] = useState<PermissionGroup[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
Promise.all([browserApi.admin.listRoles(), browserApi.admin.listPermissions()])
.then(([roleList, permissionGroups]) => {
if (cancelled) return;
setRoles(roleList);
setGroups(permissionGroups);
setSelected(roleList[0]?.id ?? null);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [t]);
if (!can(PERMISSIONS.ROLE_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
if (error) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!roles || !groups) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const active = roles.find((role) => role.id === selected) ?? roles[0];
const granted = new Set<string>(active?.permissions ?? []);
return (
<div className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<nav aria-label={t('roles')} className="space-y-1">
{roles.map((role) => (
<button
key={role.id}
type="button"
onClick={() => setSelected(role.id)}
className={cn(
'rounded-card flex w-full items-baseline justify-between border px-3 py-2 text-left text-sm transition-colors',
role.id === active?.id
? 'border-ink-950 bg-white font-semibold'
: 'hover:bg-ink-100 border-transparent',
)}
>
<span>{role.name}</span>
<span className="text-ink-400 text-xs">{role.userCount}</span>
</button>
))}
</nav>
{active ? (
<section className="border-ink-200 border bg-white p-6">
<header className="flex flex-wrap items-baseline gap-3">
<h2 className="text-lg font-semibold">{active.name}</h2>
<code className="text-ink-500 text-xs">{active.key}</code>
{active.isSystem ? <Badge variant="neutral">{t('systemRole')}</Badge> : null}
<span className="text-ink-500 ml-auto text-xs">
{t('permissionCount', { count: active.permissions.length })}
</span>
</header>
{active.description ? (
<p className="text-ink-500 mt-2 text-sm">{active.description}</p>
) : null}
<div className="mt-6 space-y-5">
{groups.map((group) => (
<div key={group.resource}>
<h3 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{group.resource}
</h3>
<ul className="mt-2 flex flex-wrap gap-1.5">
{group.permissions.map((permission) => {
const has = granted.has(permission.key);
return (
<li key={permission.key}>
<span
className={cn(
'rounded-card inline-flex items-center gap-1.5 border px-2 py-1 text-xs',
has
? 'border-success/40 bg-success/10 text-ink-950'
: 'border-ink-200 text-ink-400',
)}
>
<span aria-hidden>{has ? '✓' : '·'}</span>
{permission.action}
</span>
</li>
);
})}
</ul>
</div>
))}
</div>
</section>
) : null}
</div>
);
}
@@ -0,0 +1,107 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type UserSummary } from '@sport/types';
import { Badge, Skeleton } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
const STATUS_VARIANT = {
ACTIVE: 'success',
INVITED: 'warning',
SUSPENDED: 'neutral',
} as const;
export function UsersTable() {
const t = useTranslations('settings');
const { can } = useSession();
const [users, setUsers] = useState<UserSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
browserApi.admin
.listUsers({ perPage: 50 })
.then((result) => {
if (!cancelled) setUsers([...result.items]);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [t]);
// The API would reject this anyway; checking here avoids a guaranteed 403.
if (!can(PERMISSIONS.USER_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
if (error) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!users) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
return (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('table.name')}</th>
<th className="px-4 py-3 font-semibold">{t('table.email')}</th>
<th className="px-4 py-3 font-semibold">{t('table.type')}</th>
<th className="px-4 py-3 font-semibold">{t('table.roles')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3 font-semibold">{t('table.lastLogin')}</th>
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3 font-medium">{user.displayName}</td>
<td className="text-ink-600 px-4 py-3">{user.email}</td>
<td className="text-ink-600 px-4 py-3">{user.type}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<Badge key={role.id} variant="outline">
{role.name}
</Badge>
))}
</div>
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[user.status]}>{user.status}</Badge>
</td>
<td className="text-ink-500 px-4 py-3">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+73 -4
View File
@@ -1,12 +1,12 @@
import { createApiClient } from '@sport/api-client'; import { createApiClient } from '@sport/api-client';
import { clientEnv, getServerEnv } from './env'; import { getServerEnv } from './env';
/** /**
* The admin's only channel to data. * The admin's only channel to data.
* *
* There is no Prisma client in this application and there never will be. Every * There is no Prisma client in this application and there never will be. Every
* read and write crosses the REST boundary, which is what guarantees that RBAC, * read and write crosses the REST boundary, which is what guarantees RBAC,
* validation and audit logging apply uniformly — a second write path is a * validation and audit logging apply uniformly — a second write path is a
* second place for authorization to be forgotten. * second place for authorization to be forgotten.
*/ */
@@ -14,7 +14,76 @@ export function getServerApi() {
return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL }); return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL });
} }
/**
* In-memory access token.
*
* Module scope rather than React state on purpose: the API client needs to read
* it from inside a `fetch` callback, and it must survive re-renders without
* being persisted. It is never written to localStorage or a readable cookie —
* those outlive the tab and are readable by any script, which is precisely what
* an XSS is looking for. The httpOnly refresh cookie is what survives a reload.
*/
let accessToken: string | null = null;
let onSessionLost: (() => void) | null = null;
/**
* The in-flight refresh, if any.
*
* When an access token expires, every request already in flight gets a 401 at
* roughly the same moment. Without this, each one starts its own rotation — and
* because rotation invalidates the previous token, the second refresh would be
* treated as *token reuse* and revoke the whole family, logging the user out
* for the crime of loading two panels at once. One shared promise, one rotation.
*/
let refreshInFlight: Promise<boolean> | null = null;
export const tokenStore = {
get: (): string | null => accessToken,
set: (token: string | null): void => {
accessToken = token;
},
/** Lets the session provider react when a refresh attempt finally fails. */
onLost: (handler: (() => void) | null): void => {
onSessionLost = handler;
},
};
/**
* Browser client.
*
* `baseUrl: ''` means same-origin: requests go to this app's own host and are
* proxied to the API (Next rewrite in dev, Nginx in production). That is what
* makes the refresh cookie first-party — see the `rewrites()` comment in
* next.config.ts.
*/
export const browserApi = createApiClient({ export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL, baseUrl: '',
getAccessToken: () => null, // wired to the auth store in the auth milestone getAccessToken: () => tokenStore.get(),
/**
* Transparent re-auth: on a 401, rotate once and retry the original request.
* Without this, a 15-minute access token would interrupt an operator
* mid-edit. If the rotation itself fails, the session is genuinely over.
*/
onUnauthorized: () => {
refreshInFlight ??= (async () => {
try {
const refreshed = await browserApi.auth.adminRefresh();
tokenStore.set(refreshed.accessToken);
return true;
} catch {
tokenStore.set(null);
onSessionLost?.();
return false;
} finally {
// Cleared in a microtask so every caller awaiting this rotation sees
// the same result before a new one can start.
queueMicrotask(() => {
refreshInFlight = null;
});
}
})();
return refreshInFlight;
},
}); });
+28 -1
View File
@@ -13,7 +13,19 @@
}, },
"login": { "login": {
"title": "Sign in", "title": "Sign in",
"body": "Sign-in lands with the auth milestone (M2). Credentials will be exchanged for a short-lived access token plus a rotating, httpOnly refresh cookie scoped to the admin audience." "body": "Use your back-office account. Sessions are short-lived and refresh automatically.",
"email": "Email",
"password": "Password",
"signIn": "Sign in",
"signingIn": "Signing in…",
"signOut": "Sign out",
"invalid": "Enter a valid email and password.",
"throttled": "Too many attempts. Please wait a few minutes.",
"networkError": "Could not reach the server. Check your connection."
},
"session": {
"signedInAs": "Signed in as {name}",
"signOut": "Sign out"
} }
}, },
"pages": { "pages": {
@@ -77,5 +89,20 @@
"title": "Roles", "title": "Roles",
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy." "body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
} }
},
"settings": {
"roles": "Roles",
"systemRole": "System role",
"permissionCount": "{count} permissions",
"loadFailed": "Could not load this data.",
"noPermission": "You do not have permission to view this.",
"table": {
"name": "Name",
"email": "Email",
"type": "Type",
"roles": "Roles",
"status": "Status",
"lastLogin": "Last sign-in"
}
} }
} }
+28 -1
View File
@@ -13,7 +13,19 @@
}, },
"login": { "login": {
"title": "Đăng nhập", "title": "Đăng nhập",
"body": "Chức năng đăng nhập sẽ có ở giai đoạn xác thực (M2). Thông tin đăng nhập sẽ được đổi lấy access token ngắn hạn cùng refresh cookie httpOnly xoay vòng, giới hạn cho phạm vi quản trị." "body": "Dùng tài khoản nội bộ của bạn. Phiên đăng nhập ngắn hạn và tự động làm mới.",
"email": "Email",
"password": "Mật khẩu",
"signIn": "Đăng nhập",
"signingIn": "Đang đăng nhập…",
"signOut": "Đăng xuất",
"invalid": "Nhập email và mật khẩu hợp lệ.",
"throttled": "Quá nhiều lần thử. Vui lòng đợi vài phút.",
"networkError": "Không kết nối được tới máy chủ. Kiểm tra kết nối của bạn."
},
"session": {
"signedInAs": "Đăng nhập với {name}",
"signOut": "Đăng xuất"
} }
}, },
"pages": { "pages": {
@@ -77,5 +89,20 @@
"title": "Vai trò", "title": "Vai trò",
"body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại." "body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại."
} }
},
"settings": {
"roles": "Vai trò",
"systemRole": "Vai trò hệ thống",
"permissionCount": "{count} quyền",
"loadFailed": "Không tải được dữ liệu.",
"noPermission": "Bạn không có quyền xem mục này.",
"table": {
"name": "Tên",
"email": "Email",
"type": "Loại",
"roles": "Vai trò",
"status": "Trạng thái",
"lastLogin": "Đăng nhập gần nhất"
}
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ STORAGE_PUBLIC_URL=http://localhost:9000/sport-media
# --- Rate limiting ---------------------------------------------------------- # --- Rate limiting ----------------------------------------------------------
RATE_LIMIT_TTL_SECONDS=60 RATE_LIMIT_TTL_SECONDS=60
RATE_LIMIT_MAX=120 RATE_LIMIT_MAX=300
# --- Observability ---------------------------------------------------------- # --- Observability ----------------------------------------------------------
LOG_LEVEL=debug LOG_LEVEL=debug
+4 -1
View File
@@ -17,7 +17,8 @@
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force" "db:reset": "prisma migrate reset --force",
"create-admin": "tsx prisma/create-admin.ts"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
@@ -37,6 +38,7 @@
"@sport/types": "workspace:*", "@sport/types": "workspace:*",
"@sport/validation": "workspace:*", "@sport/validation": "workspace:*",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.6.1",
@@ -54,6 +56,7 @@
"@sport/config": "workspace:*", "@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*", "@sport/eslint-config": "workspace:*",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
+83
View File
@@ -0,0 +1,83 @@
/**
* Creates or repairs a SUPER_ADMIN account.
*
* pnpm --filter @sport/api run create-admin
* ADMIN_EMAIL=me@example.com ADMIN_PASSWORD='…' pnpm ... run create-admin
*
* This is the production bootstrap path, and the reason the seed never creates
* a privileged account with a known password. Safe to re-run: an existing
* account has its password reset and its role re-granted, which doubles as the
* "locked out of the admin" recovery procedure.
*/
import { PrismaClient } from '@prisma/client';
import { SYSTEM_ROLES } from '@sport/types';
import { generatePassword, hashPassword, verifyPassword } from './seed/accounts';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const email = (process.env['ADMIN_EMAIL'] ?? 'admin@sport.local').trim().toLowerCase();
const provided = process.env['ADMIN_PASSWORD'];
const password = provided ?? generatePassword();
if (provided && provided.length < 10) {
throw new Error('ADMIN_PASSWORD must be at least 10 characters.');
}
const passwordHash = await hashPassword(password);
// Verify the hash round-trips before writing it. A malformed hash here would
// create an account nobody can ever sign in to, and the failure would only
// surface at the login screen.
if (!(await verifyPassword(password, passwordHash))) {
throw new Error('Password hash failed self-verification; refusing to write.');
}
const role = await prisma.role.findUnique({ where: { key: SYSTEM_ROLES.SUPER_ADMIN } });
if (!role) {
throw new Error('The super_admin role is missing. Run `pnpm db:seed` first.');
}
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, status: 'ACTIVE', type: 'SUPER_ADMIN', deletedAt: null },
create: {
email,
passwordHash,
type: 'SUPER_ADMIN',
status: 'ACTIVE',
firstName: 'Super',
lastName: 'Admin',
emailVerifiedAt: new Date(),
},
select: { id: true },
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: role.id } },
update: {},
create: { userId: user.id, roleId: role.id },
});
console.log('\nSuper admin ready.\n');
console.log(` Email: ${email}`);
if (provided) {
console.log(' Password: (from ADMIN_PASSWORD)');
} else {
console.log(` Password: ${password}`);
console.log('\n Generated password — shown once. Store it now.');
}
console.log('');
}
main()
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
+14
View File
@@ -9,6 +9,7 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types'; import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
import { seedDevAccounts } from './seed/accounts';
import { seedCatalog } from './seed/catalog'; import { seedCatalog } from './seed/catalog';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -144,6 +145,19 @@ async function main(): Promise<void> {
} }
await seedCatalog(prisma); await seedCatalog(prisma);
const accounts = await seedDevAccounts(prisma);
const created = accounts.filter((account) => account.created);
if (created.length > 0) {
console.log('\nDevelopment sign-in accounts (shown once):\n');
for (const account of created) {
console.log(` ${account.email.padEnd(24)} ${account.password} [${account.role}]`);
}
console.log('\n Development only. Use `pnpm db:create-admin` for real environments.\n');
} else {
console.log('Development accounts already exist; passwords left unchanged.');
}
} }
main() main()
+167
View File
@@ -0,0 +1,167 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import type { PrismaClient } from '@prisma/client';
import { SYSTEM_ROLES } from '@sport/types';
/**
* Password hashing for scripts.
*
* Deliberately duplicated from `common/security/password.service.ts` rather
* than imported: these scripts run under `tsx` outside the Nest container, and
* booting the DI graph to hash one string would be the more fragile choice.
*
* The hash FORMAT is the contract between the two, and it is self-describing —
* so a drift shows up as a failed login on the very next attempt, not as silent
* corruption. If a third caller ever appears, extract it to a package.
*/
const PARAMS = { N: 16_384, r: 8, p: 1 } as const;
const KEY_LENGTH = 64;
const MAX_MEM = 64 * 1024 * 1024;
export function hashPassword(plaintext: string): Promise<string> {
const salt = randomBytes(16);
return new Promise((resolve, reject) => {
scrypt(
plaintext.normalize('NFKC'),
salt,
KEY_LENGTH,
{ ...PARAMS, maxmem: MAX_MEM },
(error, derived) => {
if (error) return reject(error);
resolve(
[
'scrypt',
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString('base64'),
derived.toString('base64'),
].join('$'),
);
},
);
});
}
/** Used by the smoke test below to prove the format round-trips. */
export function verifyPassword(plaintext: string, stored: string): Promise<boolean> {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return Promise.resolve(false);
const [, n, r, p, salt, hash] = parts;
return new Promise((resolve) => {
scrypt(
plaintext.normalize('NFKC'),
Buffer.from(salt ?? '', 'base64'),
KEY_LENGTH,
{ N: Number(n), r: Number(r), p: Number(p), maxmem: MAX_MEM },
(error, derived) => {
if (error) return resolve(false);
const expected = Buffer.from(hash ?? '', 'base64');
resolve(derived.length === expected.length && timingSafeEqual(derived, expected));
},
);
});
}
/** A readable, high-entropy password for generated accounts. */
export function generatePassword(): string {
// Base64url of 18 bytes ≈ 24 characters, ~144 bits. Suffixed to guarantee the
// policy's uppercase/lowercase/digit requirements regardless of the draw.
return `${randomBytes(18).toString('base64url')}aA1`;
}
export interface SeededAccount {
email: string;
password: string;
role: string;
created: boolean;
}
/**
* Development sign-in accounts.
*
* Guarded twice — by NODE_ENV and by an explicit opt-out — because a known
* password reaching production is the single worst thing a seed can do. The
* generated password is printed once and never stored anywhere else.
*
* Existing accounts are left completely alone: re-running the seed must not
* reset a password someone has already changed.
*/
export async function seedDevAccounts(prisma: PrismaClient): Promise<SeededAccount[]> {
const accounts: SeededAccount[] = [];
const definitions = [
{
email: 'admin@sport.local',
type: 'SUPER_ADMIN' as const,
firstName: 'Demo',
lastName: 'Admin',
roleKey: SYSTEM_ROLES.SUPER_ADMIN,
},
{
email: 'staff@sport.local',
type: 'STAFF' as const,
firstName: 'Demo',
lastName: 'Staff',
roleKey: SYSTEM_ROLES.CATALOG_MANAGER,
},
{
email: 'customer@sport.local',
type: 'CUSTOMER' as const,
firstName: 'Demo',
lastName: 'Customer',
roleKey: SYSTEM_ROLES.CUSTOMER,
},
];
for (const definition of definitions) {
const existing = await prisma.user.findUnique({ where: { email: definition.email } });
if (existing) {
accounts.push({
email: definition.email,
password: '(unchanged)',
role: definition.roleKey,
created: false,
});
continue;
}
const password = generatePassword();
const role = await prisma.role.findUnique({ where: { key: definition.roleKey } });
const user = await prisma.user.create({
data: {
email: definition.email,
passwordHash: await hashPassword(password),
type: definition.type,
status: 'ACTIVE',
firstName: definition.firstName,
lastName: definition.lastName,
emailVerifiedAt: new Date(),
...(role ? { roles: { create: { roleId: role.id } } } : {}),
},
select: { id: true },
});
// A customer account needs its shopper profile, or /account has nothing to
// hang addresses and orders off later.
if (definition.type === 'CUSTOMER') {
await prisma.customer.create({ data: { userId: user.id } });
}
accounts.push({
email: definition.email,
password,
role: definition.roleKey,
created: true,
});
}
return accounts;
}
+2
View File
@@ -5,6 +5,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor'; import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { MediaUrlModule } from './common/media/media.module'; import { MediaUrlModule } from './common/media/media.module';
import { SecurityModule } from './common/security/security.module';
import { APP_CONFIG, AppConfigModule } from './config/app-config.module'; import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
import type { AppConfig } from './config/configuration'; import type { AppConfig } from './config/configuration';
import { EventsModule } from './infrastructure/events/events.module'; import { EventsModule } from './infrastructure/events/events.module';
@@ -55,6 +56,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
StorageModule, StorageModule,
EventsModule, EventsModule,
MediaUrlModule, MediaUrlModule,
SecurityModule,
ThrottlerModule.forRootAsync({ ThrottlerModule.forRootAsync({
inject: [APP_CONFIG], inject: [APP_CONFIG],
@@ -0,0 +1,154 @@
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
/**
* Hand-written rather than `promisify(scrypt)`: promisify resolves to the
* no-options overload, so passing `maxmem` becomes a type error even though the
* runtime accepts it.
*/
function scryptAsync(
password: string,
salt: Buffer,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (error, derivedKey) => {
if (error) reject(error);
else resolve(derivedKey);
});
});
}
/**
* Password hashing.
*
* ALGORITHM CHOICE
* scrypt from `node:crypto`, at OWASP's recommended parameters. Argon2id would
* be the first choice on paper, but every Node binding for it is a native
* module — a compile step in CI, a platform matrix in Docker, and a class of
* deployment failure that has nothing to do with this application. scrypt is
* memory-hard, standardised (RFC 7914), on OWASP's approved list, and already
* in the runtime.
*
* ALGORITHM AGILITY
* Hashes are stored self-describing:
*
* scrypt$16384$8$1$<salt-b64>$<hash-b64>
*
* The verifier reads its parameters from the stored string rather than from
* today's constants, so raising the cost — or moving to Argon2id later — is a
* transparent rehash-on-next-login. `needsRehash()` reports when that applies.
* A password store you cannot upgrade is a password store you are stuck with.
*/
/**
* N=2^14 (16 MiB), r=8, p=1 — OWASP's minimum for scrypt.
*
* Deliberately not higher: this runs on the login path, and a cost that makes
* sign-in feel slow is a cost someone will quietly lower in six months. The
* `maxmem` bump is required because Node's default ceiling is below what N
* needs.
*/
const CURRENT_PARAMS = { N: 16_384, r: 8, p: 1 } as const;
const KEY_LENGTH = 64;
const SALT_LENGTH = 16;
const MAX_MEM = 64 * 1024 * 1024;
@Injectable()
export class PasswordService {
private readonly logger = new Logger(PasswordService.name);
async hash(plaintext: string): Promise<string> {
const salt = randomBytes(SALT_LENGTH);
const derived = await this.derive(plaintext, salt, CURRENT_PARAMS);
return [
'scrypt',
CURRENT_PARAMS.N,
CURRENT_PARAMS.r,
CURRENT_PARAMS.p,
salt.toString('base64'),
derived.toString('base64'),
].join('$');
}
/**
* Constant-time verification.
*
* Returns false on a malformed hash rather than throwing: a corrupt row must
* read as "wrong password", not as a 500 that tells an attacker the account
* exists and is in an unusual state.
*/
async verify(plaintext: string, stored: string | null | undefined): Promise<boolean> {
if (!stored) return false;
const parsed = this.parse(stored);
if (!parsed) {
this.logger.warn('Encountered an unparseable password hash');
return false;
}
try {
const derived = await this.derive(plaintext, parsed.salt, parsed.params);
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
} catch (error) {
this.logger.error(`Password verification failed: ${String(error)}`);
return false;
}
}
/** True when the stored hash uses weaker parameters than we now require. */
needsRehash(stored: string): boolean {
const parsed = this.parse(stored);
if (!parsed) return true;
return (
parsed.params.N < CURRENT_PARAMS.N ||
parsed.params.r < CURRENT_PARAMS.r ||
parsed.params.p < CURRENT_PARAMS.p
);
}
/**
* A hash of a throwaway value, used to keep login timing flat when the email
* does not exist. Without it, "unknown email" returns measurably faster than
* "wrong password", which turns the login form into a user enumeration oracle.
*/
async burnCycles(): Promise<void> {
await this.derive('timing-equalisation', Buffer.alloc(SALT_LENGTH), CURRENT_PARAMS);
}
private derive(
plaintext: string,
salt: Buffer,
params: { N: number; r: number; p: number },
): Promise<Buffer> {
return scryptAsync(plaintext.normalize('NFKC'), salt, KEY_LENGTH, {
...params,
maxmem: MAX_MEM,
});
}
private parse(
stored: string,
): { params: { N: number; r: number; p: number }; salt: Buffer; hash: Buffer } | null {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return null;
const [, rawN, rawR, rawP, rawSalt, rawHash] = parts;
const N = Number(rawN);
const r = Number(rawR);
const p = Number(rawP);
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return null;
if (!rawSalt || !rawHash) return null;
return {
params: { N, r, p },
salt: Buffer.from(rawSalt, 'base64'),
hash: Buffer.from(rawHash, 'base64'),
};
}
}
@@ -0,0 +1,66 @@
import { PasswordService } from './password.service';
/**
* Password handling has no second chance: a bug here is either "nobody can log
* in" or "everybody can". These tests pin the properties that matter rather
* than the implementation.
*/
describe('PasswordService', () => {
const service = new PasswordService();
it('round-trips a password', async () => {
const hash = await service.hash('Correct horse battery 1');
await expect(service.verify('Correct horse battery 1', hash)).resolves.toBe(true);
});
it('rejects the wrong password', async () => {
const hash = await service.hash('Correct horse battery 1');
await expect(service.verify('Correct horse battery 2', hash)).resolves.toBe(false);
});
it('salts, so the same password hashes differently every time', async () => {
const [a, b] = await Promise.all([service.hash('SameInput123'), service.hash('SameInput123')]);
expect(a).not.toBe(b);
await expect(service.verify('SameInput123', a)).resolves.toBe(true);
await expect(service.verify('SameInput123', b)).resolves.toBe(true);
});
it('emits a self-describing hash so parameters can change later', async () => {
const hash = await service.hash('Parameters123');
const [algorithm, n, r, p] = hash.split('$');
expect(algorithm).toBe('scrypt');
expect(Number(n)).toBeGreaterThanOrEqual(16_384);
expect(Number(r)).toBeGreaterThanOrEqual(8);
expect(Number(p)).toBeGreaterThanOrEqual(1);
});
it('treats a null or malformed hash as a failed verification, never an error', async () => {
// A corrupt row must read as "wrong password". Throwing would tell an
// attacker the account exists and is in an unusual state.
await expect(service.verify('anything', null)).resolves.toBe(false);
await expect(service.verify('anything', undefined)).resolves.toBe(false);
await expect(service.verify('anything', 'not-a-hash')).resolves.toBe(false);
await expect(service.verify('anything', 'scrypt$bad$params$here$x$y')).resolves.toBe(false);
});
it('normalises unicode, so an accented password survives a different keyboard', async () => {
// U+00E9 vs U+0065 U+0301 — visually identical, different bytes.
const composed = 'caféPassw0rd';
const decomposed = 'caféPassw0rd';
const hash = await service.hash(composed);
await expect(service.verify(decomposed, hash)).resolves.toBe(true);
});
it('flags weaker stored parameters for rehash', () => {
expect(service.needsRehash('scrypt$1024$8$1$c2FsdA==$aGFzaA==')).toBe(true);
expect(service.needsRehash('garbage')).toBe(true);
});
it('does not flag a current hash for rehash', async () => {
const hash = await service.hash('CurrentParams1');
expect(service.needsRehash(hash)).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { PasswordService } from './password.service';
/**
* Stateless security primitives.
*
* `PasswordService` lives here rather than in AuthModule to break a dependency
* cycle: AuthModule needs UsersModule to look up accounts, and UsersModule
* needs password hashing to create them. Hashing has no dependencies of its
* own, so hoisting it out of both is the fix — `forwardRef` would only hide the
* cycle rather than remove it.
*/
@Global()
@Module({
providers: [PasswordService],
exports: [PasswordService],
})
export class SecurityModule {}
+11 -1
View File
@@ -45,7 +45,17 @@ export const envSchema = z.object({
STORAGE_PUBLIC_URL: z.url(), STORAGE_PUBLIC_URL: z.url(),
RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60), RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120), /**
* Coarse per-IP ceiling — a DoS safety net, not the real protection.
*
* Raised from 120: many users share one address behind corporate NAT or
* carrier-grade NAT, and a catalog page makes several API calls. At 120/min a
* single office could exhaust the budget and start receiving 429s on
* `/auth/refresh`, which the client correctly reads as "session over" and
* signs everyone out. Fine-grained protection lives per-endpoint — see
* LoginThrottleService, which counts failures per account and per IP.
*/
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
LOG_PRETTY: z.stringbool().default(false), LOG_PRETTY: z.stringbool().default(false),
+4
View File
@@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import compression from 'compression'; import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmet from 'helmet'; import helmet from 'helmet';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
@@ -31,6 +32,9 @@ async function bootstrap(): Promise<void> {
// First in the chain: every log line and error response carries this id. // First in the chain: every log line and error response carries this id.
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
// Refresh tokens arrive as httpOnly cookies; without this `req.cookies` is
// undefined and every refresh silently fails as "no session".
app.use(cookieParser());
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })); app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
app.use(compression()); app.use(compression());
@@ -0,0 +1,194 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import {
TOKEN_AUDIENCES,
type AuthenticatedActor,
type CurrentUser,
type LoginResult,
type RefreshResult,
type SessionSummary,
type TokenAudience,
} from '@sport/types';
import { loginSchema, type LoginInput } from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { Public } from '@/common/decorators/public.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { AuthService, type IssuedSession, type RequestContext } from './auth.service';
import { clearRefreshCookie, readRefreshCookie, setRefreshCookie } from './refresh-cookie';
/**
* Credential exchange.
*
* Storefront and admin have separate login endpoints rather than one endpoint
* that infers the audience. The audience decides which population may sign in
* and which cookie is issued — inferring it from the account would mean a
* single leaked customer credential could be pointed at the admin surface and
* only a later check would stop it. Two routes make the boundary explicit.
*/
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Customer sign-in (storefront audience)' })
login(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Back-office sign-in (admin audience)' })
adminLogin(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
return this.handleLogin(body, TOKEN_AUDIENCES.ADMIN, request, response);
}
/**
* Public because it authenticates with the cookie, not with an access token —
* the whole point is to be callable once the access token has expired.
*/
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the storefront refresh token' })
refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<RefreshResult> {
return this.handleRefresh(TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the admin refresh token' })
adminRefresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<RefreshResult> {
return this.handleRefresh(TOKEN_AUDIENCES.ADMIN, request, response);
}
@Public()
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sign out of the storefront' })
logout(@Req() request: Request, @Res({ passthrough: true }) response: Response): Promise<void> {
return this.handleLogout(TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sign out of the admin' })
adminLogout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
return this.handleLogout(TOKEN_AUDIENCES.ADMIN, request, response);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'The signed-in user, with roles and permissions' })
me(@CurrentActor() actor: AuthenticatedActor): Promise<CurrentUser> {
return this.authService.me(actor.userId);
}
@Get('sessions')
@ApiBearerAuth()
@ApiOperation({ summary: 'Active sessions for the signed-in user' })
sessions(@CurrentActor() actor: AuthenticatedActor): Promise<SessionSummary[]> {
return this.authService.listSessions(actor.userId, actor.sessionId);
}
// ---- shared handlers -----------------------------------------------------
private async handleLogin(
body: LoginInput,
audience: TokenAudience,
request: Request,
response: Response,
): Promise<LoginResult> {
const { result, session } = await this.authService.login(body, audience, contextOf(request));
this.writeSession(response, audience, session);
return result;
}
private async handleRefresh(
audience: TokenAudience,
request: Request,
response: Response,
): Promise<RefreshResult> {
const { result, session } = await this.authService.refresh(
readRefreshCookie(request, audience),
audience,
contextOf(request),
);
this.writeSession(response, audience, session);
return result;
}
private async handleLogout(
audience: TokenAudience,
request: Request,
response: Response,
): Promise<void> {
await this.authService.logout(readRefreshCookie(request, audience));
clearRefreshCookie(response, audience, this.config.app.isProduction);
}
private writeSession(response: Response, audience: TokenAudience, session: IssuedSession): void {
setRefreshCookie(
response,
audience,
session.refreshToken,
session.refreshTokenExpiresAt,
this.config.app.isProduction,
);
}
}
/**
* The client IP comes from Express's `trust proxy` handling, which is why
* `app.set('trust proxy', 1)` in main.ts matters — without it every request
* behind Nginx would look like it came from the proxy, and the per-IP login
* throttle would lock out the entire internet at once.
*/
function contextOf(request: Request): RequestContext {
return {
userAgent: request.header('user-agent')?.slice(0, 512) ?? null,
ipAddress: request.ip ?? null,
};
}
+18 -7
View File
@@ -2,27 +2,38 @@ import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '@/modules/users/users.module';
import { AuthController } from './auth.controller';
import { AuthRepository } from './auth.repository';
import { AuthService } from './auth.service';
import { AccessTokenGuard } from './guards/access-token.guard'; import { AccessTokenGuard } from './guards/access-token.guard';
import { PermissionsGuard } from './guards/permissions.guard'; import { PermissionsGuard } from './guards/permissions.guard';
import { LoginThrottleService } from './login-throttle.service';
import { TokenService } from './token.service';
/** /**
* Milestone 0 provides the *enforcement* half of auth: token verification, * AuthModule — owns `sessions`, and nothing else.
* audience separation and RBAC evaluation, wired globally.
* *
* The *issuance* half — login, registration, refresh rotation, password reset, * Accounts, roles and permissions belong to UsersModule; this module exchanges
* OTP — is milestone 1. Splitting it this way means every endpoint written from * credentials for tokens and manages session lifetime. Password *hashing* lives
* here on is protected by default, before a single credential exists. * in `common/security` so that both modules can use it without a cycle.
* *
* Guard order matters: AccessTokenGuard must populate `request.actor` before * Guard order matters: AccessTokenGuard must populate `request.actor` before
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order. * PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
*/ */
@Global() @Global()
@Module({ @Module({
imports: [JwtModule.register({})], imports: [JwtModule.register({}), UsersModule],
controllers: [AuthController],
providers: [ providers: [
AuthService,
AuthRepository,
TokenService,
LoginThrottleService,
{ provide: APP_GUARD, useClass: AccessTokenGuard }, { provide: APP_GUARD, useClass: AccessTokenGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_GUARD, useClass: PermissionsGuard },
], ],
exports: [JwtModule], exports: [JwtModule, AuthService],
}) })
export class AuthModule {} export class AuthModule {}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/**
* The session table — the only data AuthModule owns.
*
* One row per refresh token. Rotation appends a new row and links the old one
* to it, so a session's full history is reconstructable, which is what makes
* token-reuse detection possible at all.
*/
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findByRefreshHash(refreshTokenHash: string) {
return this.prisma.session.findUnique({
where: { refreshTokenHash },
select: {
id: true,
userId: true,
familyId: true,
replacedById: true,
revokedAt: true,
expiresAt: true,
},
});
}
create(params: {
userId: string;
familyId: string;
refreshTokenHash: string;
expiresAt: Date;
userAgent: string | null;
ipAddress: string | null;
}) {
return this.prisma.session.create({
data: {
userId: params.userId,
familyId: params.familyId,
refreshTokenHash: params.refreshTokenHash,
expiresAt: params.expiresAt,
userAgent: params.userAgent,
ipAddress: params.ipAddress,
},
select: { id: true, familyId: true },
});
}
/**
* Rotates a session: creates the successor and links the predecessor to it,
* in one transaction.
*
* If this were two statements and the second failed, the old token would stay
* valid alongside the new one — two live credentials from one refresh, which
* defeats the point of rotating.
*/
async rotate(params: {
previousSessionId: string;
userId: string;
familyId: string;
refreshTokenHash: string;
expiresAt: Date;
userAgent: string | null;
ipAddress: string | null;
}) {
return this.prisma.$transaction(async (tx) => {
const next = await tx.session.create({
data: {
userId: params.userId,
familyId: params.familyId,
refreshTokenHash: params.refreshTokenHash,
expiresAt: params.expiresAt,
userAgent: params.userAgent,
ipAddress: params.ipAddress,
},
select: { id: true },
});
await tx.session.update({
where: { id: params.previousSessionId },
data: { replacedById: next.id },
});
return next;
});
}
/** Revokes one session — a single device signing out. */
async revoke(sessionId: string): Promise<void> {
await this.prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Revokes an entire token family.
*
* Called when a refresh token is replayed. Either the token leaked or the
* client is broken; both warrant forcing a fresh sign-in on that device.
*/
async revokeFamily(familyId: string): Promise<number> {
const result = await this.prisma.session.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
/** Signs a user out everywhere — used after a password change. */
async revokeAllForUser(userId: string): Promise<number> {
const result = await this.prisma.session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
listActiveForUser(userId: string) {
return this.prisma.session.findMany({
where: { userId, revokedAt: null, replacedById: null, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ipAddress: true,
createdAt: true,
expiresAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Housekeeping for expired rows.
*
* Rotation is append-only, so this table grows with every refresh — a daily
* job calls this. Rows are kept a week past expiry so a security review can
* still see what happened.
*/
async deleteExpired(before = new Date(Date.now() - 7 * 86_400_000)): Promise<number> {
const result = await this.prisma.session.deleteMany({ where: { expiresAt: { lt: before } } });
return result.count;
}
}
+298
View File
@@ -0,0 +1,298 @@
import { Injectable, Logger } from '@nestjs/common';
import {
API_ERROR_CODES,
TOKEN_AUDIENCES,
isBackOfficeUser,
type CurrentUser,
type LoginResult,
type RefreshResult,
type SessionSummary,
type TokenAudience,
type UserType,
} from '@sport/types';
import type { LoginInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { PasswordService } from '@/common/security/password.service';
import { UsersService, type AuthUserRow } from '@/modules/users/public';
import { AuthRepository } from './auth.repository';
import { LoginThrottleService } from './login-throttle.service';
import { TokenService } from './token.service';
export interface RequestContext {
userAgent: string | null;
ipAddress: string | null;
}
export interface IssuedSession {
accessToken: string;
accessTokenExpiresAt: Date;
refreshToken: string;
refreshTokenExpiresAt: Date;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
private readonly tokenService: TokenService,
private readonly repository: AuthRepository,
private readonly throttle: LoginThrottleService,
) {}
/**
* Exchanges credentials for a session.
*
* Every failure path returns the same message and the same status. Telling a
* caller apart — "no such account" vs "wrong password" vs "suspended" — turns
* the login form into a user-enumeration oracle, and the timing is equalised
* for the same reason.
*/
async login(
input: LoginInput,
audience: TokenAudience,
context: RequestContext,
): Promise<{ result: LoginResult; session: IssuedSession }> {
await this.throttle.assertNotLocked(input.email, context.ipAddress);
const user = await this.usersService.findForAuthByEmail(input.email);
if (!user) {
// Spend the same CPU as a real verification so a missing account is not
// measurably faster than a wrong password.
await this.passwordService.burnCycles();
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
const passwordValid = await this.passwordService.verify(input.password, user.passwordHash);
if (!passwordValid) {
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
if (user.status !== 'ACTIVE') {
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
this.assertAudience(user.type as UserType, audience);
// Transparent upgrade if the stored hash predates the current cost.
if (user.passwordHash && this.passwordService.needsRehash(user.passwordHash)) {
await this.usersService.updatePasswordHash(
user.id,
await this.passwordService.hash(input.password),
);
this.logger.log(`Upgraded password hash parameters for user ${user.id}`);
}
const session = await this.startSession(user, audience, context);
await Promise.all([
this.usersService.recordLogin(user.id),
this.throttle.recordSuccess(input.email),
]);
return {
result: {
user: this.usersService.toCurrentUser(user),
accessToken: session.accessToken,
accessTokenExpiresAt: session.accessTokenExpiresAt.toISOString(),
},
session,
};
}
/**
* Rotates a refresh token.
*
* The security-critical branch is reuse detection: a token that has already
* been rotated or revoked must never work again, and being presented with one
* means either it leaked or the client is broken. Both justify killing the
* whole family — that is the difference between detecting theft and merely
* limiting its window.
*/
async refresh(
refreshToken: string | undefined,
audience: TokenAudience,
context: RequestContext,
): Promise<{ result: RefreshResult; session: IssuedSession }> {
if (!refreshToken) {
throw AppException.unauthenticated('Your session has expired. Please sign in again.');
}
const hash = this.tokenService.hashRefreshToken(refreshToken);
const existing = await this.repository.findByRefreshHash(hash);
if (!existing) {
throw AppException.unauthenticated(
'Your session has expired. Please sign in again.',
API_ERROR_CODES.TOKEN_INVALID,
);
}
if (existing.revokedAt !== null || existing.replacedById !== null) {
const revoked = await this.repository.revokeFamily(existing.familyId);
this.logger.error(
`Refresh token reuse detected for user ${existing.userId}; revoked ${revoked} session(s) in family ${existing.familyId}`,
);
throw AppException.unauthenticated(
'Your session is no longer valid. Please sign in again.',
API_ERROR_CODES.TOKEN_INVALID,
);
}
if (existing.expiresAt.getTime() <= Date.now()) {
throw AppException.unauthenticated(
'Your session has expired. Please sign in again.',
API_ERROR_CODES.TOKEN_EXPIRED,
);
}
const user = await this.usersService.findForAuthById(existing.userId);
if (!user || user.status !== 'ACTIVE') {
await this.repository.revokeFamily(existing.familyId);
throw AppException.unauthenticated('Your session is no longer valid. Please sign in again.');
}
this.assertAudience(user.type as UserType, audience);
const refresh = this.tokenService.issueRefreshToken();
const next = await this.repository.rotate({
previousSessionId: existing.id,
userId: user.id,
familyId: existing.familyId,
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
userAgent: context.userAgent,
ipAddress: context.ipAddress,
});
// Permissions are re-read from the database on every rotation, so a role
// change takes effect within one access-token lifetime rather than
// persisting for the life of the refresh token.
const access = await this.tokenService.issueAccessToken({
userId: user.id,
userType: user.type as UserType,
audience,
permissions: this.usersService.permissionsOf(user),
sessionId: next.id,
});
return {
result: {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt.toISOString(),
},
session: {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refresh.expiresAt,
},
};
}
/** Signs out one device. Idempotent — an unknown token is still a success. */
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
const existing = await this.repository.findByRefreshHash(
this.tokenService.hashRefreshToken(refreshToken),
);
if (existing) {
await this.repository.revokeFamily(existing.familyId);
}
}
async me(userId: string): Promise<CurrentUser> {
const user = await this.usersService.findForAuthById(userId);
if (!user || user.status !== 'ACTIVE') {
throw AppException.unauthenticated();
}
return this.usersService.toCurrentUser(user);
}
async listSessions(userId: string, currentSessionId: string): Promise<SessionSummary[]> {
const rows = await this.repository.listActiveForUser(userId);
return rows.map((row) => ({
id: row.id,
userAgent: row.userAgent,
ipAddress: row.ipAddress,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
isCurrent: row.id === currentSessionId,
}));
}
// ---- internals -----------------------------------------------------------
private async startSession(
user: AuthUserRow,
audience: TokenAudience,
context: RequestContext,
): Promise<IssuedSession> {
const refresh = this.tokenService.issueRefreshToken();
const session = await this.repository.create({
userId: user.id,
// A fresh sign-in starts a new family; rotation stays within it. That is
// what keeps revoking one compromised device from signing out the rest.
familyId: this.tokenService.newSessionFamilyId(),
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
userAgent: context.userAgent,
ipAddress: context.ipAddress,
});
const access = await this.tokenService.issueAccessToken({
userId: user.id,
userType: user.type as UserType,
audience,
permissions: this.usersService.permissionsOf(user),
sessionId: session.id,
});
return {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refresh.expiresAt,
};
}
/**
* A customer may never obtain an admin token, and a staff account may not
* sign in through the storefront form.
*
* Checked at issuance as well as at every request (AccessTokenGuard), because
* a token that should never have existed is worse than one that is merely
* rejected later.
*/
private assertAudience(userType: UserType, audience: TokenAudience): void {
const allowed =
audience === TOKEN_AUDIENCES.ADMIN ? isBackOfficeUser(userType) : userType === 'CUSTOMER';
if (!allowed) {
throw invalidCredentials();
}
}
}
/** One message, one status, for every failure mode. */
function invalidCredentials(): AppException {
return AppException.unauthenticated(
'Email or password is incorrect.',
API_ERROR_CODES.INVALID_CREDENTIALS,
);
}
@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { API_ERROR_CODES } from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
/**
* Login-specific rate limiting, on top of the global per-IP throttle.
*
* Two counters, because they stop different attacks:
*
* - **per account** — someone guessing one user's password. A botnet spreads
* across many IPs, so an IP counter alone never trips.
* - **per IP** — someone spraying one common password across many accounts.
* An account counter alone never trips for that.
*
* Counting only failures means a busy legitimate user is never locked out, and
* a successful login clears the account counter.
*/
const MAX_FAILURES_PER_ACCOUNT = 8;
const MAX_FAILURES_PER_IP = 30;
const WINDOW_SECONDS = 15 * 60;
@Injectable()
export class LoginThrottleService {
private readonly logger = new Logger(LoginThrottleService.name);
constructor(private readonly redis: RedisService) {}
async assertNotLocked(email: string, ipAddress: string | null): Promise<void> {
const [accountFailures, ipFailures] = await Promise.all([
this.peek(CACHE_KEYS.rateLimit('login:account', email.toLowerCase())),
ipAddress ? this.peek(CACHE_KEYS.rateLimit('login:ip', ipAddress)) : Promise.resolve(0),
]);
if (accountFailures >= MAX_FAILURES_PER_ACCOUNT || ipFailures >= MAX_FAILURES_PER_IP) {
this.logger.warn(
`Login blocked by throttle (account failures: ${accountFailures}, ip failures: ${ipFailures})`,
);
throw new AppException({
code: API_ERROR_CODES.RATE_LIMITED,
message: 'Too many failed sign-in attempts. Please try again in a few minutes.',
status: 429,
});
}
}
async recordFailure(email: string, ipAddress: string | null): Promise<void> {
await Promise.all([
this.redis.increment(
CACHE_KEYS.rateLimit('login:account', email.toLowerCase()),
WINDOW_SECONDS,
),
ipAddress
? this.redis.increment(CACHE_KEYS.rateLimit('login:ip', ipAddress), WINDOW_SECONDS)
: Promise.resolve(0),
]);
}
/**
* Clears the account counter on success. The IP counter is deliberately left
* alone: one correct password should not reset a spray in progress from the
* same address.
*/
async recordSuccess(email: string): Promise<void> {
await this.redis.delete(CACHE_KEYS.rateLimit('login:account', email.toLowerCase()));
}
/**
* Reads a counter without incrementing. Redis being unavailable must not
* block sign-in — the global throttle and the password itself still apply.
*/
private async peek(key: string): Promise<number> {
try {
const value = await this.redis.get<number>(key);
return typeof value === 'number' ? value : 0;
} catch {
return 0;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Public surface of AuthModule.
*
* Deliberately narrow. `TokenService`, `AuthRepository` and the throttle stay
* private — nothing outside this module should be minting tokens or writing to
* the session table.
*
* Note that `PasswordService` is NOT here: it lives in `common/security`
* because UsersModule needs it too, and routing it through this module would
* create a dependency cycle.
*/
export { AuthService } from '../auth.service';
export type { RequestContext } from '../auth.service';
@@ -0,0 +1,78 @@
import type { CookieOptions, Request, Response } from 'express';
import type { TokenAudience } from '@sport/types';
/**
* Refresh-token cookie handling.
*
* Storefront and admin get *separately named* cookies. Sharing one name would
* mean signing into the admin silently replaces a customer session on the same
* browser — and worse, that a single cookie could be replayed against the other
* audience.
*/
const COOKIE_NAMES: Record<TokenAudience, string> = {
storefront: 'sport_refresh',
admin: 'sport_admin_refresh',
};
/**
* Scoped to the refresh endpoints only.
*
* The browser then sends this cookie on exactly two requests instead of
* attaching it to every API call — so an XSS that can read responses still
* never sees it, and it is not sitting in the headers of hundreds of unrelated
* requests waiting to be logged somewhere.
*/
const COOKIE_PATH = '/api/v1/auth';
export function refreshCookieName(audience: TokenAudience): string {
return COOKIE_NAMES[audience];
}
export function readRefreshCookie(request: Request, audience: TokenAudience): string | undefined {
const cookies = request.cookies as Record<string, string> | undefined;
return cookies?.[refreshCookieName(audience)];
}
export function setRefreshCookie(
response: Response,
audience: TokenAudience,
token: string,
expiresAt: Date,
isProduction: boolean,
): void {
response.cookie(refreshCookieName(audience), token, {
...baseOptions(isProduction),
expires: expiresAt,
});
}
export function clearRefreshCookie(
response: Response,
audience: TokenAudience,
isProduction: boolean,
): void {
response.clearCookie(refreshCookieName(audience), baseOptions(isProduction));
}
function baseOptions(isProduction: boolean): CookieOptions {
return {
// JavaScript cannot read it. This is the property that makes an XSS unable
// to steal the credential that mints new sessions.
httpOnly: true,
// HTTPS only in production. Local development runs on plain HTTP, and a
// Secure cookie would simply never be set — which looks like a broken
// login rather than a config choice.
secure: isProduction,
/**
* `lax` works because the frontends reach the API through their *own*
* origin — Nginx in production, a Next.js rewrite in development — so this
* is a first-party cookie. Cross-origin would force `SameSite=None`, which
* requires `Secure` and therefore cannot work over local HTTP at all.
*/
sameSite: 'lax',
path: COOKIE_PATH,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AccessTokenClaims, Permission, TokenAudience, UserType } from '@sport/types';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
export interface IssuedAccessToken {
token: string;
expiresAt: Date;
}
export interface IssuedRefreshToken {
/** The value handed to the client. Never stored. */
token: string;
/** SHA-256 of the token — this is what the database keeps. */
hash: string;
expiresAt: Date;
}
/**
* Mints and verifies tokens. Holds no state; sessions live in AuthRepository.
*
* The two token types are deliberately different in kind:
*
* - The **access token** is a JWT. Stateless, short-lived, carries the
* permission set so guards do no database work on the hot path.
* - The **refresh token** is opaque random bytes, not a JWT. There is nothing
* for a client to read in it, and because it is checked against a database
* row it can be revoked — which a stateless JWT fundamentally cannot be.
*
* Only a SHA-256 of the refresh token is stored. A database leak therefore
* yields no usable credentials. SHA-256 rather than a password hash is correct
* here: the token is 256 bits of entropy, so there is no dictionary to attack
* and no reason to pay scrypt's cost on every refresh.
*/
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async issueAccessToken(params: {
userId: string;
userType: UserType;
audience: TokenAudience;
permissions: readonly Permission[];
sessionId: string;
}): Promise<IssuedAccessToken> {
const expiresInSeconds = parseDuration(this.config.auth.accessTtl);
const token = await this.jwtService.signAsync(
{
sub: params.userId,
aud: params.audience,
type: params.userType,
permissions: params.permissions,
sid: params.sessionId,
} satisfies Omit<AccessTokenClaims, 'iat' | 'exp'>,
{
secret: this.config.auth.accessSecret,
issuer: this.config.auth.issuer,
expiresIn: expiresInSeconds,
},
);
return { token, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
}
issueRefreshToken(): IssuedRefreshToken {
// 32 bytes = 256 bits. base64url so it is cookie- and URL-safe without
// escaping.
const token = randomBytes(32).toString('base64url');
return {
token,
hash: this.hashRefreshToken(token),
expiresAt: new Date(Date.now() + parseDuration(this.config.auth.refreshTtl) * 1000),
};
}
hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
newSessionFamilyId(): string {
return randomUUID();
}
get refreshTtlSeconds(): number {
return parseDuration(this.config.auth.refreshTtl);
}
}
/**
* `15m` → 900. The env schema already guarantees the format, so an unparseable
* value here means the validator and this function have drifted — which should
* fail loudly at boot rather than silently issue an eternal token.
*/
export function parseDuration(value: string): number {
const match = /^(\d+)(ms|s|m|h|d)$/.exec(value);
if (!match) {
throw new Error(`Invalid duration: ${value}`);
}
const amount = Number(match[1]);
const unit = match[2];
switch (unit) {
case 'ms':
return Math.ceil(amount / 1000);
case 's':
return amount;
case 'm':
return amount * 60;
case 'h':
return amount * 3600;
case 'd':
return amount * 86_400;
default:
throw new Error(`Invalid duration unit: ${String(unit)}`);
}
}
+5 -6
View File
@@ -1,10 +1,9 @@
/** /**
* Public surface of UsersModule. * Public surface of UsersModule.
* *
* This barrel is the ONLY thing other modules may import from here. Everything * AuthModule consumes `UsersService` for account lookup, permission resolution
* else — repository, DTOs, internal services — is private, and the ESLint * and login bookkeeping. The repositories, the mapper and every Prisma row type
* boundary rule in @sport/eslint-config/nest enforces it. * stay private.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { UsersService } from '../users.service';
export type { AuthUserRow } from '../users.repository';
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PERMISSIONS, TOKEN_AUDIENCES, type PermissionGroup, type RoleDetail } from '@sport/types';
import {
createRoleSchema,
updateRoleSchema,
type CreateRoleInput,
type UpdateRoleInput,
} from '@sport/validation';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { RolesService } from './roles.service';
@ApiTags('admin/roles')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'List roles with their permissions and user counts' })
list(): Promise<RoleDetail[]> {
return this.rolesService.list();
}
/**
* Declared before `:id` so the literal path wins — otherwise
* `/admin/roles/permissions` resolves as a role with the id "permissions".
*/
@Get('permissions')
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'The permission catalog, grouped by resource' })
listPermissions(): Promise<PermissionGroup[]> {
return this.rolesService.listPermissions();
}
@Get(':id')
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'Get one role' })
getById(@Param('id') id: string): Promise<RoleDetail> {
return this.rolesService.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@ApiOperation({ summary: 'Create a role' })
create(
@Body(new ZodValidationPipe(createRoleSchema)) body: CreateRoleInput,
): Promise<RoleDetail> {
return this.rolesService.create(body);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@ApiOperation({ summary: "Update a role's name, description or permissions" })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateRoleSchema)) body: UpdateRoleInput,
): Promise<RoleDetail> {
return this.rolesService.update(id, body);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a non-system role that nobody holds' })
delete(@Param('id') id: string): Promise<void> {
return this.rolesService.delete(id);
}
}
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const roleSelect = {
id: true,
key: true,
name: true,
description: true,
isSystem: true,
permissions: { select: { permission: { select: { key: true } } } },
} as const;
@Injectable()
export class RolesRepository {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.role.findMany({
select: { ...roleSelect, _count: { select: { users: true } } },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
}
findById(id: string) {
return this.prisma.role.findUnique({
where: { id },
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
findByKey(key: string) {
return this.prisma.role.findUnique({ where: { key }, select: roleSelect });
}
findManyByIds(ids: readonly string[]) {
return this.prisma.role.findMany({ where: { id: { in: [...ids] } }, select: { id: true } });
}
listPermissions() {
return this.prisma.permission.findMany({
select: { key: true, resource: true, action: true },
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
}
/**
* Creates a role and its grants in one transaction.
*
* Permission keys are resolved to ids here rather than trusted from the
* client: an unknown key is dropped instead of silently creating a permission
* that no guard will ever check.
*/
async create(input: {
key: string;
name: string;
description: string | null;
permissionKeys: readonly string[];
}) {
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
return this.prisma.role.create({
data: {
key: input.key,
name: input.name,
description: input.description,
isSystem: false,
permissions: {
createMany: { data: permissionIds.map((permissionId) => ({ permissionId })) },
},
},
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
async update(
id: string,
input: { name?: string; description?: string | null; permissionKeys?: readonly string[] },
) {
if (input.permissionKeys) {
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
// Replace the grant set wholesale inside a transaction — a half-applied
// permission change is a security incident, not a glitch.
await this.prisma.$transaction([
this.prisma.rolePermission.deleteMany({ where: { roleId: id } }),
this.prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
skipDuplicates: true,
}),
]);
}
return this.prisma.role.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description }),
},
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
delete(id: string) {
return this.prisma.role.delete({ where: { id }, select: { id: true } });
}
private async resolvePermissionIds(keys: readonly string[]): Promise<string[]> {
if (keys.length === 0) return [];
const rows = await this.prisma.permission.findMany({
where: { key: { in: [...keys] } },
select: { id: true },
});
return rows.map((row) => row.id);
}
}
export type RoleRow = NonNullable<Awaited<ReturnType<RolesRepository['findById']>>>;
+120
View File
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { ALL_PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types';
import type { CreateRoleInput, UpdateRoleInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { RolesRepository } from './roles.repository';
import { UsersMapper } from './users.mapper';
@Injectable()
export class RolesService {
constructor(
private readonly repository: RolesRepository,
private readonly mapper: UsersMapper,
) {}
async list(): Promise<RoleDetail[]> {
const rows = await this.repository.findAll();
return rows.map((row) => this.mapper.toRoleDetail(row));
}
async getById(id: string): Promise<RoleDetail> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('Role');
return this.mapper.toRoleDetail(row);
}
/**
* The permission catalog, served from the database.
*
* The seed reconciles this table against the code catalog in @sport/types, so
* what the role editor shows is exactly what the running guards enforce — a
* deploy skew surfaces as a missing checkbox rather than a grant that silently
* does nothing.
*/
async listPermissions(): Promise<PermissionGroup[]> {
const rows = await this.repository.listPermissions();
return this.mapper.toPermissionGroups(rows);
}
async create(input: CreateRoleInput): Promise<RoleDetail> {
const existing = await this.repository.findByKey(input.key);
if (existing) {
throw AppException.conflict('A role with that key already exists.');
}
const row = await this.repository.create({
key: input.key,
name: input.name,
description: input.description ?? null,
permissionKeys: this.assertKnownPermissions(input.permissions),
});
return this.mapper.toRoleDetail(row);
}
async update(id: string, input: UpdateRoleInput): Promise<RoleDetail> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Role');
/**
* System roles may have their permissions edited but not their identity.
*
* The seed reconciles system roles from code on every run, so a renamed key
* would be silently recreated — and an operator would be left wondering why
* their change vanished. Rejecting it is clearer than losing it.
*/
if (existing.isSystem && input.name !== undefined && input.name !== existing.name) {
throw AppException.badRequest('System roles cannot be renamed.');
}
const row = await this.repository.update(id, {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description ?? null }),
...(input.permissions === undefined
? {}
: { permissionKeys: this.assertKnownPermissions(input.permissions) }),
});
return this.mapper.toRoleDetail(row);
}
async delete(id: string): Promise<void> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Role');
if (existing.isSystem) {
throw AppException.badRequest('System roles cannot be deleted.');
}
// Deleting a role that people hold would silently strip their access.
// Making the operator reassign first keeps the consequence visible.
if (existing._count.users > 0) {
throw AppException.conflict(
`This role is assigned to ${existing._count.users} user(s). Reassign them first.`,
);
}
await this.repository.delete(id);
}
/**
* Rejects permission keys the code does not define.
*
* Without this a typo'd key would be stored, displayed as granted, and never
* match a guard — an access-control bug that looks like working configuration.
*/
private assertKnownPermissions(keys: readonly string[]): string[] {
const known = new Set<string>(ALL_PERMISSIONS);
const unknown = keys.filter((key) => !known.has(key));
if (unknown.length > 0) {
throw AppException.badRequest(`Unknown permission(s): ${unknown.join(', ')}`);
}
return [...new Set(keys)];
}
}
@@ -0,0 +1,111 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PERMISSIONS, TOKEN_AUDIENCES, type OffsetPaginated, type UserSummary } from '@sport/types';
import {
createUserSchema,
resetUserPasswordSchema,
updateUserSchema,
userListQuerySchema,
type CreateUserInput,
type UpdateUserInput,
type UserListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { AppException } from '@/common/errors/app.exception';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { PasswordService } from '@/common/security/password.service';
import { UsersService } from './users.service';
/**
* Back-office user administration.
*
* `@RequireAudience('admin')` on the controller means a storefront token is
* rejected before any permission is even read — defence in depth, not an
* optimisation.
*/
@ApiTags('admin/users')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/users')
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
) {}
@Get()
@RequirePermissions(PERMISSIONS.USER_READ)
@ApiOperation({ summary: 'List back-office users' })
list(
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
): Promise<OffsetPaginated<UserSummary>> {
return this.usersService.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.USER_READ)
@ApiOperation({ summary: 'Get one user' })
getById(@Param('id') id: string): Promise<UserSummary> {
return this.usersService.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: 'Create a back-office user' })
async create(
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserInput,
): Promise<UserSummary> {
const passwordHash = await this.passwordService.hash(body.password);
return this.usersService.create(body, passwordHash);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: 'Update a user, including their roles' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserInput,
@CurrentActor() actor: { userId: string },
): Promise<UserSummary> {
/**
* An operator cannot change their own type, status or roles.
*
* This is the lockout guard: without it, an admin can demote themselves out
* of the very permission needed to undo it, and the only recovery is a
* database console.
*/
if (id === actor.userId) {
const touchesOwnAccess =
body.roleIds !== undefined || body.status !== undefined || body.type !== undefined;
if (touchesOwnAccess) {
throw AppException.forbidden('You cannot change your own roles, type or status.');
}
}
return this.usersService.update(id, body);
}
@Post(':id/password')
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: "Reset another user's password" })
async resetPassword(
@Param('id') id: string,
@Body(new ZodValidationPipe(resetUserPasswordSchema)) body: { password: string },
): Promise<{ ok: true }> {
await this.usersService.getById(id);
await this.usersService.updatePasswordHash(id, await this.passwordService.hash(body.password));
// NOTE: existing sessions are intentionally NOT revoked here yet. Doing it
// properly means revoking every session family for the user, which belongs
// with the session-management screen rather than bolted on here.
return { ok: true };
}
}
+112
View File
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import type {
CurrentUser,
Permission,
PermissionGroup,
RoleDetail,
UserStatus,
UserSummary,
UserType,
} from '@sport/types';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { RoleRow } from './roles.repository';
import type { AuthUserRow, UserRow } from './users.repository';
@Injectable()
export class UsersMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
/**
* Flattens role grants into a distinct permission set.
*
* Roles are additive and may overlap — a user with both "Catalog Manager" and
* "Order Manager" gets the union, deduplicated. Nothing subtracts, which is
* what keeps "why can this person do X?" answerable by listing their roles.
*/
permissionsOf(row: AuthUserRow): Permission[] {
const permissions = new Set<string>();
for (const link of row.roles) {
for (const grant of link.role.permissions) {
permissions.add(grant.permission.key);
}
}
return [...permissions] as Permission[];
}
roleKeysOf(row: AuthUserRow): string[] {
return row.roles.map((link) => link.role.key);
}
toCurrentUser(row: AuthUserRow): CurrentUser {
return {
id: row.id,
email: row.email,
type: row.type as UserType,
firstName: row.firstName,
lastName: row.lastName,
displayName: displayName(row.firstName, row.lastName, row.email),
avatarUrl: row.avatar ? this.mediaUrl.url(row.avatar.storageKey) : null,
roles: this.roleKeysOf(row),
permissions: this.permissionsOf(row),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
};
}
toSummary(row: UserRow): UserSummary {
return {
id: row.id,
email: row.email,
type: row.type as UserType,
status: row.status as UserStatus,
firstName: row.firstName,
lastName: row.lastName,
displayName: displayName(row.firstName, row.lastName, row.email),
roles: row.roles.map((link) => ({
id: link.role.id,
key: link.role.key,
name: link.role.name,
isSystem: link.role.isSystem,
})),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
toRoleDetail(row: RoleRow): RoleDetail {
return {
id: row.id,
key: row.key,
name: row.name,
description: row.description,
isSystem: row.isSystem,
permissions: row.permissions.map((grant) => grant.permission.key as Permission),
userCount: row._count.users,
};
}
/** Groups the catalog by resource so the role editor renders as sections. */
toPermissionGroups(
rows: readonly { key: string; resource: string; action: string }[],
): PermissionGroup[] {
const groups = new Map<string, PermissionGroup['permissions'][number][]>();
for (const row of rows) {
const bucket = groups.get(row.resource) ?? [];
bucket.push({ key: row.key as Permission, resource: row.resource, action: row.action });
groups.set(row.resource, bucket);
}
return [...groups.entries()].map(([resource, permissions]) => ({ resource, permissions }));
}
}
/** Falls back to the email so a row never renders as an empty name. */
function displayName(firstName: string | null, lastName: string | null, email: string): string {
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : email;
}
+18 -13
View File
@@ -1,19 +1,24 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesRepository } from './roles.repository';
import { RolesService } from './roles.service';
import { UsersController } from './users.controller';
import { UsersMapper } from './users.mapper';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
/** /**
* UsersModule — boundary declared, implementation pending. * UsersModule — owns `users`, `roles`, `permissions`, `role_permissions` and
* `user_roles`.
* *
* Owns (exclusively): `users`, `roles`, `permissions`, `role_permissions`, `user_roles` * Back-office identity plus the RBAC administration surface. AuthModule reads
* * accounts through this module's public service; it never queries `users`
* Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module. * itself, which keeps credential exchange and account management separable.
*
* Anatomy once implemented (see ../README.md):
* users.module.ts wiring only
* users.controller.ts HTTP surface, no logic
* users.service.ts business rules
* users.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService, UsersRepository, RolesRepository, UsersMapper],
exports: [UsersService],
})
export class UsersModule {} export class UsersModule {}
@@ -0,0 +1,152 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { UserListQuery } from '@sport/validation';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/** Everything needed to authenticate, in one query. */
const authSelect = {
id: true,
email: true,
passwordHash: true,
type: true,
status: true,
firstName: true,
lastName: true,
lastLoginAt: true,
avatar: { select: { storageKey: true } },
roles: {
select: {
role: {
select: {
key: true,
permissions: { select: { permission: { select: { key: true } } } },
},
},
},
},
} as const;
const summarySelect = {
id: true,
email: true,
type: true,
status: true,
firstName: true,
lastName: true,
lastLoginAt: true,
createdAt: true,
roles: {
select: { role: { select: { id: true, key: true, name: true, isSystem: true } } },
},
} as const;
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Soft-deleted accounts are invisible everywhere. Filtering here rather than
* at each call site means a forgotten `deletedAt: null` cannot resurrect a
* removed operator.
*/
private alive(): Prisma.UserWhereInput {
return { deletedAt: null };
}
findForAuthByEmail(email: string) {
return this.prisma.user.findFirst({
// Emails are normalised to lowercase on write (@sport/validation), so a
// plain equality match is correct and uses the unique index.
where: { ...this.alive(), email: email.toLowerCase() },
select: authSelect,
});
}
findForAuthById(id: string) {
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: authSelect });
}
async list(query: UserListQuery) {
const where: Prisma.UserWhereInput = {
...this.alive(),
...(query.type ? { type: query.type } : {}),
...(query.status ? { status: query.status } : {}),
...(query.q
? {
OR: [
{ email: { contains: query.q, mode: 'insensitive' } },
{ firstName: { contains: query.q, mode: 'insensitive' } },
{ lastName: { contains: query.q, mode: 'insensitive' } },
],
}
: {}),
};
const [items, totalItems] = await Promise.all([
this.prisma.user.findMany({
where,
select: summarySelect,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.perPage,
take: query.perPage,
}),
this.prisma.user.count({ where }),
]);
return { items, totalItems };
}
findById(id: string) {
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: summarySelect });
}
create(data: Prisma.UserCreateInput) {
return this.prisma.user.create({ data, select: summarySelect });
}
update(id: string, data: Prisma.UserUpdateInput) {
return this.prisma.user.update({ where: { id }, data, select: summarySelect });
}
/**
* Replaces a user's role set atomically.
*
* Delete-then-insert inside one transaction, because a partial application
* would briefly leave an operator with fewer — or worse, more — permissions
* than intended.
*/
async setRoles(userId: string, roleIds: readonly string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.userRole.deleteMany({ where: { userId } }),
this.prisma.userRole.createMany({
data: roleIds.map((roleId) => ({ userId, roleId })),
skipDuplicates: true,
}),
]);
}
recordLogin(id: string) {
return this.prisma.user.update({
where: { id },
data: { lastLoginAt: new Date() },
select: { id: true },
});
}
updatePasswordHash(id: string, passwordHash: string) {
return this.prisma.user.update({
where: { id },
data: { passwordHash },
select: { id: true },
});
}
countByRole(roleId: string) {
return this.prisma.userRole.count({ where: { roleId } });
}
}
export type AuthUserRow = NonNullable<Awaited<ReturnType<UsersRepository['findForAuthByEmail']>>>;
export type UserRow = NonNullable<Awaited<ReturnType<UsersRepository['findById']>>>;
+142
View File
@@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import {
API_ERROR_CODES,
type CurrentUser,
type OffsetPaginated,
type Permission,
type UserSummary,
} from '@sport/types';
import type { CreateUserInput, UpdateUserInput, UserListQuery } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { RolesRepository } from './roles.repository';
import { UsersMapper } from './users.mapper';
import { UsersRepository, type AuthUserRow } from './users.repository';
/**
* Identity data: who exists, what they are, which roles they hold.
*
* AuthModule owns the *exchange* of credentials for tokens and the session
* table; this module owns the accounts themselves. Auth reads through the
* public surface below rather than querying `users` directly, which is what
* keeps password handling and account management from bleeding into each other.
*/
@Injectable()
export class UsersService {
constructor(
private readonly repository: UsersRepository,
private readonly rolesRepository: RolesRepository,
private readonly mapper: UsersMapper,
) {}
// ---- Consumed by AuthModule ---------------------------------------------
findForAuthByEmail(email: string): Promise<AuthUserRow | null> {
return this.repository.findForAuthByEmail(email);
}
findForAuthById(id: string): Promise<AuthUserRow | null> {
return this.repository.findForAuthById(id);
}
permissionsOf(row: AuthUserRow): Permission[] {
return this.mapper.permissionsOf(row);
}
toCurrentUser(row: AuthUserRow): CurrentUser {
return this.mapper.toCurrentUser(row);
}
async recordLogin(userId: string): Promise<void> {
await this.repository.recordLogin(userId);
}
async updatePasswordHash(userId: string, passwordHash: string): Promise<void> {
await this.repository.updatePasswordHash(userId, passwordHash);
}
// ---- Back-office user management ----------------------------------------
async list(query: UserListQuery): Promise<OffsetPaginated<UserSummary>> {
const { items, totalItems } = await this.repository.list(query);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: items.map((item) => this.mapper.toSummary(item)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async getById(id: string): Promise<UserSummary> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('User');
return this.mapper.toSummary(row);
}
async create(input: CreateUserInput, passwordHash: string): Promise<UserSummary> {
const existing = await this.repository.findForAuthByEmail(input.email);
if (existing) {
throw AppException.conflict('An account with that email already exists.');
}
await this.assertRolesExist(input.roleIds);
const user = await this.repository.create({
email: input.email,
passwordHash,
type: input.type,
status: 'ACTIVE',
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone ?? null,
roles: { createMany: { data: input.roleIds.map((roleId) => ({ roleId })) } },
});
return this.getById(user.id);
}
async update(id: string, input: UpdateUserInput): Promise<UserSummary> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('User');
if (input.roleIds) {
await this.assertRolesExist(input.roleIds);
await this.repository.setRoles(id, input.roleIds);
}
await this.repository.update(id, {
...(input.firstName === undefined ? {} : { firstName: input.firstName }),
...(input.lastName === undefined ? {} : { lastName: input.lastName }),
...(input.phone === undefined ? {} : { phone: input.phone ?? null }),
...(input.status === undefined ? {} : { status: input.status }),
...(input.type === undefined ? {} : { type: input.type }),
});
return this.getById(id);
}
/**
* Rejects unknown role ids rather than silently ignoring them.
*
* A create that quietly drops a role leaves an operator convinced they
* granted access that was never granted — the worst possible failure mode for
* a permissions screen.
*/
private async assertRolesExist(roleIds: readonly string[]): Promise<void> {
if (roleIds.length === 0) return;
const found = await this.rolesRepository.findManyByIds(roleIds);
if (found.length !== new Set(roleIds).size) {
throw AppException.badRequest('One or more roles do not exist.', API_ERROR_CODES.BAD_REQUEST);
}
}
}
+40 -4
View File
@@ -12,6 +12,20 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
/**
* Proxies API calls through this app's own origin.
*
* The refresh token is a `SameSite=Lax` httpOnly cookie, so the browser only
* sends it first-party. Calling the API host directly from the browser would
* mean `SameSite=None; Secure`, which cannot work over plain HTTP in local
* development at all. Production does the same thing at the Nginx layer, so
* dev and prod share one topology instead of two.
*/
async rewrites() {
const target = process.env.API_INTERNAL_URL ?? 'http://localhost:4000';
return [{ source: '/api/:path*', destination: `${target}/api/:path*` }];
},
/** /**
* Workspace packages ship TypeScript source rather than a build artefact, so * Workspace packages ship TypeScript source rather than a build artefact, so
* Next compiles them with the app. No watch-and-rebuild step during local * Next compiles them with the app. No watch-and-rebuild step during local
@@ -23,13 +37,35 @@ const nextConfig: NextConfig = {
typedRoutes: true, typedRoutes: true,
images: { images: {
// Media is served from R2/CDN. Locally that is MinIO. /**
* Media is served from R2/CDN in production and MinIO locally.
*
* `pathname` and `search` are specified explicitly: Next 16 matches remote
* patterns strictly, and an entry without them does not authorise the URL —
* the optimizer answers `"url" parameter is not allowed` and every product
* image renders broken. Scoping to the bucket path also keeps this from
* becoming an open image proxy.
*/
remotePatterns: [ remotePatterns: [
{ protocol: 'http', hostname: 'localhost', port: '9000' }, { protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' },
{ protocol: 'https', hostname: '**.r2.dev' }, { protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' },
{ protocol: 'https', hostname: 'cdn.sport-store.local' }, { protocol: 'https', hostname: 'cdn.sport-store.local', pathname: '/**', search: '' },
], ],
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
/**
* DEVELOPMENT ONLY.
*
* Next 16 refuses to fetch an upstream image whose hostname resolves to a
* private IP — an SSRF precaution — and reports it as `"url" parameter is
* not allowed`, the same message it uses for an unmatched remote pattern.
* That shared message is what makes this so easy to misdiagnose.
*
* Local MinIO lives on `localhost:9000`, so the guard blocks every product
* image in development. It stays ON in production, where media is served
* from a public CDN host and the protection is exactly what we want.
*/
dangerouslyAllowLocalIP: process.env.NODE_ENV !== 'production',
}, },
// Standalone output keeps the production image small (no node_modules copy). // Standalone output keeps the production image small (no node_modules copy).
+15 -3
View File
@@ -1,6 +1,6 @@
import { createApiClient, type RequestOptions } from '@sport/api-client'; import { createApiClient, type RequestOptions } from '@sport/api-client';
import { clientEnv, getServerEnv } from './env'; import { getServerEnv } from './env';
/** /**
* Two clients, because the two runtimes have different needs: * Two clients, because the two runtimes have different needs:
@@ -22,9 +22,21 @@ export function getServerApi() {
}); });
} }
/**
* Browser client.
*
* `baseUrl: ''` means same-origin: requests go through this app's own host and
* are proxied to the API (Next rewrite in development, Nginx in production).
* That is what makes the refresh cookie first-party — see ADR-0015 and the
* `rewrites()` comment in next.config.ts.
*
* Pointing this at the API host directly would work for anonymous catalog reads
* and then break the moment customer sign-in lands in M8, which is precisely
* the kind of latent inconsistency worth removing now.
*/
export const browserApi = createApiClient({ export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL, baseUrl: '',
getAccessToken: () => null, // wired to the auth store in the auth milestone getAccessToken: () => null, // wired to the customer auth store in M8
}); });
/** /**
@@ -0,0 +1,73 @@
# ADR-0015: Frontends reach the API through their own origin
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
The refresh token is delivered as an httpOnly cookie (ADR-0008) — that is what
stops an XSS from stealing the credential that mints new sessions.
Cookies are governed by `SameSite`. If the browser calls `api.example.com` from
`admin.example.com`, that is a cross-site request, and the cookie only rides
along with `SameSite=None`. `SameSite=None` requires `Secure`, which requires
HTTPS. Local development runs on plain HTTP, so the cookie would simply never be
set — presenting as a login that "succeeds" and then immediately forgets you.
The alternatives are all worse: put the refresh token in `localStorage` (readable
by any script, which defeats the entire httpOnly design), run local development
over self-signed HTTPS (friction on every machine and in CI), or accept that dev
and production authenticate differently (the class of bug you only find in
staging).
## Decision
**The browser always calls the API on the same origin as the page it is on.**
- Production: Nginx already routes `/api/` to the API container on the same host
— this was in the topology from milestone 0.
- Development: a Next.js `rewrites()` entry maps `/api/:path*` to
`API_INTERNAL_URL`, reproducing that topology exactly.
- `browserApi` is therefore created with `baseUrl: ''`, and `HttpClient`
resolves a relative base against `window.location.origin`.
Server-side rendering is unaffected: it calls `API_INTERNAL_URL` directly over
the internal network, because there is no cookie and no browser involved.
The cookie is then first-party, `SameSite=Lax`, `httpOnly`, `Secure` in
production, and scoped to `path=/api/v1/auth` so it is attached to two endpoints
rather than every API call.
## Consequences
Development and production share one authentication topology, so a cookie
problem is reproducible locally instead of appearing first in staging.
CORS effectively disappears for browser traffic — the requests are same-origin.
The API's CORS config remains for non-browser and tooling access.
Storefront and admin get separately named cookies (`sport_refresh`,
`sport_admin_refresh`). Sharing a name would mean signing into the admin
silently replaced a customer session in the same browser.
The costs, stated plainly:
- One extra network hop in development (browser → Next → API). Irrelevant
locally, and absent in production where Nginx was already the front door.
- The frontends now have a route namespace (`/api/*`) they must not use for
their own route handlers. Worth noting in review; neither app has any.
- `API_INTERNAL_URL` becomes required for the dev rewrite, not just for SSR.
## Alternatives considered
**Refresh token in `localStorage`.** Removes the cookie problem entirely and
removes the security property with it — any injected script can read it.
Rejected outright.
**`SameSite=None; Secure` with HTTPS in development.** Correct, but forces every
developer and CI job to trust a local certificate. Rejected as friction that
buys nothing production does not already provide.
**A dedicated auth subdomain with a parent-domain cookie.** Works in production,
but `localhost` has no usable parent domain, so development still diverges.
Rejected for the same reason as above.
+1
View File
@@ -23,6 +23,7 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted | | [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted |
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted | | [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted | | [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
| [0015](./0015-frontends-reach-the-api-through-their-own-origin.md) | Frontends reach the API through their own origin | Accepted |
## Decisions deliberately NOT recorded yet ## Decisions deliberately NOT recorded yet
+122 -34
View File
@@ -240,7 +240,65 @@ consumer transient too, which for `PrismaService` would mean a second connection
--- ---
## 9. Internationalisation ## 9. Authentication and sessions
See [ADR-0008](./adr/0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md) for
the decision and [ADR-0015](./adr/0015-frontends-reach-the-api-through-their-own-origin.md) for
the transport.
### The two tokens
| | Access token | Refresh token |
| ------------ | ---------------------------------------------- | --------------------------------------- |
| Form | JWT | Opaque random bytes |
| Lifetime | 15 minutes | 30 days |
| Stored where | Client memory only | httpOnly cookie + SHA-256 in `sessions` |
| Revocable | No (stateless by design) | Yes |
| Carries | userId, audience, type, permissions, sessionId | Nothing — it is a lookup key |
The refresh token is **not** a JWT. There is nothing for a client to read in it, and because it
is checked against a row it can be revoked — which a stateless token fundamentally cannot be.
Only its hash is stored, so a database leak yields no usable credential.
### Rotation and reuse detection
```
login → session A (new family)
refresh(A) → session B, A.replacedBy=B (same family)
refresh(A) → REUSE: revoke whole family
```
A rotated token that shows up again means either it leaked or the client is broken. Both justify
killing the family, which signs out the thief _and_ the legitimate device — deliberately, because
the alternative is letting a known-compromised session continue.
### Audience separation
Every token carries an audience (`storefront` / `admin`) and each has its own login endpoint and
its own cookie name. It is enforced three times: at issuance (the user's type must match), on
every request (`AccessTokenGuard`, before permissions are read), and per controller via
`@RequireAudience`.
### Why login is deliberately uninformative
Unknown email, wrong password, suspended account and wrong audience all return the same message
and status, and the unknown-email path burns equivalent CPU so it is not measurably faster.
Anything else turns the login form into a user-enumeration oracle.
Failures are counted twice in Redis: per account (stops guessing one password from many IPs) and
per IP (stops spraying one password across many accounts). Only failures count, so a busy
legitimate user is never locked out.
### Known limitation
A permission change takes effect within one access-token lifetime, not instantly — that is the
price of stateless guards with no database read on the hot path. `CACHE_KEYS.revokedSession`
exists for an immediate denylist and is deliberately not wired up; it reintroduces exactly the
lookup the design removes.
---
## 10. Internationalisation
Two languages, **two mechanisms**, split by who owns the string. Conflating them is the usual Two languages, **two mechanisms**, split by who owns the string. Conflating them is the usual
way an i18n project ends up half-finished. See way an i18n project ends up half-finished. See
@@ -284,7 +342,7 @@ locale-in-path would add a proxy hop and double the route tree for nothing.
--- ---
## 10. Naming conventions ## 11. Naming conventions
| Thing | Convention | Example | | Thing | Convention | Example |
| --------------------- | ------------------------- | ------------------------------ | | --------------------- | ------------------------- | ------------------------------ |
@@ -309,7 +367,7 @@ Booleans read as assertions: `isActive`, `hasVariants`, `canRefund`. Money field
--- ---
## 11. Configuration and environment ## 12. Configuration and environment
Four `.env` files, each with a committed `.env.example`: Four `.env` files, each with a committed `.env.example`:
@@ -336,7 +394,7 @@ Rules:
--- ---
## 12. Where premature abstraction must be avoided ## 13. Where premature abstraction must be avoided
Places where the instinct to generalise should be resisted until a second real case appears: Places where the instinct to generalise should be resisted until a second real case appears:
@@ -358,50 +416,80 @@ Places where the instinct to generalise should be resisted until a second real c
--- ---
## 13. Architectural risks to prevent from day one ## 14. Architectural risks to prevent from day one
| Risk | Why it is fatal later | Prevention in place | | Risk | Why it is fatal later | Prevention in place |
| ---------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ------------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) | | Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) | | Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) | | Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) | | Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) |
| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels | | Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels |
| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) | | Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) |
| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation | | Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation |
| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) | | Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) |
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) | | Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) |
| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start | | Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start |
| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored | | Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored |
| No request correlation | Production incidents become guesswork | `x-request-id` end to end | | No request correlation | Production incidents become guesswork | `x-request-id` end to end |
| Browser-only failures passing every check | Server rendering and curl both succeed while every click fails | Client paths must be exercised in a real browser before a milestone closes |
| `onUnauthorized` retrying the refresh call | Unbounded refresh loop hammering the API from the browser | `skipAuthRetry` on all auth endpoints plus a single-flight refresh |
| Concurrent 401s each rotating the token | The second rotation reads as token reuse and revokes the family | One shared in-flight refresh promise |
--- ---
## 14. Deliberate limitations ## 15. Testing that actually catches things
The catalog and auth milestones both passed lint, typecheck, unit tests, a full build and
curl-based API checks — while the admin login button did nothing at all in a browser. Three
defects hid in that gap, and all three shared one property: **they only exist in a browser.**
- `globalThis.fetch` stored on an object and called as a method throws `Illegal invocation`
in a browser and works fine in Node. Server rendering and curl could not have caught it.
- `onUnauthorized` refreshing through an endpoint that was itself retryable recursed without
bound — visible only as a flood of requests in a real network panel.
- Next 16 refuses upstream images on private IPs, and reports it with the _same_ message it
uses for an unmatched remote pattern. Only the rendered page revealed it.
The rule this earns: **a milestone touching client behaviour is not done until its
interactions have been clicked in a real browser**, with the console and network panel open.
Regression tests now cover the first two (`packages/api-client/src/http-client.spec.ts`); the
third belongs in an end-to-end test when one exists.
---
## 16. Deliberate limitations
Stated plainly so they are choices rather than oversights. Stated plainly so they are choices rather than oversights.
**Shipped (M0–M1)** **Shipped (M0–M2)**
- Catalog reads: products, variants, options, categories, collections, brands, navigation — - Catalog reads: products, variants, options, categories, collections, brands, navigation —
localised, cached, filtered and faceted. localised, cached, filtered and faceted.
- RBAC enforcement, response envelope, structured logging, media pipeline. - Storefront browsing and PDP in Vietnamese and English, with per-locale slugs and `hreflang`.
- Storefront browsing and PDP in Vietnamese and English. - Auth: login, refresh rotation with reuse detection, audience separation, login throttling.
- RBAC enforcement end to end, plus user administration and a role viewer in the admin.
**Not built yet, and why** **Not built yet, and why**
- **No token issuance.** Guards verify and enforce; login, refresh rotation and registration are - **No customer-facing auth UI.** The storefront login/register endpoints exist and are tested;
M2. Every endpoint written from here is protected by default, before a credential exists. the screens land with the account milestone (M8) where they belong.
- **No cart, order or payment tables.** They arrive in M5, so the migration history stays - **The role editor is read-only.** Viewing which role grants what is the question operators
reviewable and the variant model gets proven against real reads first. actually ask; editing grants is destructive and wants a confirmation flow and an audit entry.
- **`best_selling` and `relevance` sorts fall back to newest.** There is no order data (M5) and - **A password reset does not revoke existing sessions.** Doing it properly means revoking every
no ranking (M6). Falling back is honest; a fake ranking would not be. family for the user and belongs with the session-management screen, not bolted onto the
- **No sport/gender facet counts.** Those dimensions are navigated by route, not refined within a endpoint. Noted in the code.
page, so a count would render nowhere. - **No email.** Password reset, order confirmations and back-in-stock alerts all need it; Mailpit
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it stops is already running locally for when it lands.
being fine is a search index (ADR-0012), not a bigger query. - **No cart, order or payment tables.** M5, so the migration history stays reviewable.
- **`best_selling` and `relevance` sorts fall back to newest.** No order data (M5), no ranking
(M6). Falling back is honest; a fake ranking would not be.
- **No sport/gender facet counts.** Navigated by route, not refined in-page, so a count would
render nowhere.
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it is not is
a search index (ADR-0012), not a bigger query.
- **The event bus is in-process and lossy.** Anything that must not be lost stays in the same - **The event bus is in-process and lossy.** Anything that must not be lost stays in the same
database transaction as its cause. transaction as its cause.
- **No observability beyond logs.** Traces and metrics are worth adding once there is production - **No observability beyond logs.** Traces and metrics are worth adding once there is production
traffic to explain. traffic to explain.
- **No CDN, TLS or WAF config.** That belongs to the deployment repository. - **No CDN, TLS or WAF config.** That belongs to the deployment repository.
+2 -1
View File
@@ -31,7 +31,8 @@
"db:deploy": "pnpm --filter @sport/api run db:deploy", "db:deploy": "pnpm --filter @sport/api run db:deploy",
"db:studio": "pnpm --filter @sport/api run db:studio", "db:studio": "pnpm --filter @sport/api run db:studio",
"db:seed": "pnpm --filter @sport/api run db:seed", "db:seed": "pnpm --filter @sport/api run db:seed",
"db:reset": "pnpm --filter @sport/api run db:reset" "db:reset": "pnpm --filter @sport/api run db:reset",
"db:create-admin": "pnpm --filter @sport/api run create-admin"
}, },
"devDependencies": { "devDependencies": {
"@sport/eslint-config": "workspace:*", "@sport/eslint-config": "workspace:*",
+14 -2
View File
@@ -19,7 +19,8 @@
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo", "clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@sport/types": "workspace:*" "@sport/types": "workspace:*"
@@ -29,6 +30,17 @@
"@sport/eslint-config": "workspace:*", "@sport/eslint-config": "workspace:*",
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
"eslint": "catalog:", "eslint": "catalog:",
"typescript": "catalog:" "typescript": "catalog:",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"@types/jest": "^30.0.0"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": [
"<rootDir>/src"
],
"testRegex": ".*\\.spec\\.ts$"
} }
} }
+6
View File
@@ -1,4 +1,6 @@
import { HttpClient, type HttpClientOptions } from './http-client'; import { HttpClient, type HttpClientOptions } from './http-client';
import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog'; import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createHealthResource, type HealthResource } from './resources/health'; import { createHealthResource, type HealthResource } from './resources/health';
@@ -12,6 +14,8 @@ export interface ApiClient {
readonly http: HttpClient; readonly http: HttpClient;
readonly health: HealthResource; readonly health: HealthResource;
readonly catalog: CatalogResource; readonly catalog: CatalogResource;
readonly auth: AuthResource;
readonly admin: AdminResource;
} }
export function createApiClient(options: HttpClientOptions): ApiClient { export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -21,5 +25,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
http, http,
health: createHealthResource(http), health: createHealthResource(http),
catalog: createCatalogResource(http), catalog: createCatalogResource(http),
auth: createAuthResource(http),
admin: createAdminResource(http),
}; };
} }
@@ -0,0 +1,89 @@
import { HttpClient } from './http-client';
/**
* Regression tests for two bugs that only manifested in a browser, and so
* survived every server-side and curl-based check.
*/
describe('HttpClient', () => {
const okResponse = () =>
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
it('calls the default fetch with the global receiver, not the client instance', async () => {
// Browsers require `fetch` to be invoked with Window as `this`. Storing
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
// passes the HttpClient as the receiver and throws "Illegal invocation" —
// in the browser only. Node does not care, which is precisely why every
// server-side check and every curl passed while the browser was broken.
const original = globalThis.fetch;
const receivers: unknown[] = [];
globalThis.fetch = function (this: unknown) {
// Pushed rather than assigned to a local: capturing the receiver is the
// point of the test, and a plain alias trips `no-this-alias`.
receivers.push(this);
return Promise.resolve(okResponse());
} as unknown as typeof fetch;
try {
// No fetchImpl — exercise the default path, which is the one that broke.
const client = new HttpClient({ baseUrl: 'http://api.test' });
await client.get('/thing');
} finally {
globalThis.fetch = original;
}
expect(receivers).toHaveLength(1);
expect(receivers[0]).toBe(globalThis);
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
});
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
// is itself retryable, its own 401 triggers another refresh — an unbounded
// loop that hammers the API from the browser.
let calls = 0;
let refreshes = 0;
const fetchImpl = (() => {
calls += 1;
return Promise.resolve(new Response('{}', { status: 401 }));
}) as unknown as typeof fetch;
const client = new HttpClient({
baseUrl: 'http://api.test',
fetchImpl,
onUnauthorized: () => {
refreshes += 1;
return false;
},
});
await expect(
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
).rejects.toThrow();
expect(calls).toBe(1);
expect(refreshes).toBe(0);
});
it('still offers one retry for ordinary requests', async () => {
let calls = 0;
const fetchImpl = (() => {
calls += 1;
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
}) as unknown as typeof fetch;
const client = new HttpClient({
baseUrl: 'http://api.test',
fetchImpl,
onUnauthorized: () => true,
});
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
expect(calls).toBe(2);
});
});
+61 -5
View File
@@ -3,7 +3,15 @@ import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
import { ApiClientError } from './errors'; import { ApiClientError } from './errors';
export interface HttpClientOptions { export interface HttpClientOptions {
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */ /**
* Origin, e.g. `http://localhost:4000` — or an empty string to call the
* current page's own origin.
*
* Same-origin is what the browser client uses: the refresh cookie is
* `SameSite=Lax` and therefore only sent first-party, so requests must go
* through the app's own host (Nginx in production, a Next.js rewrite in
* development) rather than directly to the API host.
*/
baseUrl: string; baseUrl: string;
/** Defaults to `v1`. */ /** Defaults to `v1`. */
apiVersion?: string; apiVersion?: string;
@@ -13,7 +21,13 @@ export interface HttpClientOptions {
onUnauthorized?: () => boolean | Promise<boolean>; onUnauthorized?: () => boolean | Promise<boolean>;
defaultHeaders?: Record<string, string>; defaultHeaders?: Record<string, string>;
timeoutMs?: number; timeoutMs?: number;
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */ /**
* Injectable for tests and for runtimes with a patched fetch.
*
* Must be independently callable — pass a mock, or `window.fetch.bind(window)`.
* A bare `window.fetch` reference throws "Illegal invocation" in the browser
* because it loses its `Window` receiver.
*/
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
} }
@@ -26,6 +40,15 @@ export interface RequestOptions {
cache?: RequestCache; cache?: RequestCache;
/** Send cookies (used by the refresh-token flow). */ /** Send cookies (used by the refresh-token flow). */
credentials?: RequestCredentials; credentials?: RequestCredentials;
/**
* Opts this request out of the automatic re-authentication retry.
*
* MANDATORY on the auth endpoints themselves. `onUnauthorized` refreshes by
* calling `/auth/refresh`; if that call is itself eligible for the retry,
* its own 401 triggers another refresh, which 401s, which triggers another —
* an unbounded loop that hammers the API from the browser. Ask how I know.
*/
skipAuthRetry?: boolean;
} }
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -42,7 +65,17 @@ export class HttpClient {
this.options = options; this.options = options;
this.baseUrl = options.baseUrl.replace(/\/+$/, ''); this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiVersion = options.apiVersion ?? 'v1'; this.apiVersion = options.apiVersion ?? 'v1';
this.fetchImpl = options.fetchImpl ?? globalThis.fetch; /**
* Wrapped, never stored bare.
*
* `globalThis.fetch` must be invoked with `Window` as its receiver in the
* browser. Assigning it to an instance property and calling
* `this.fetchImpl(...)` invokes it with the HttpClient as `this`, which
* throws "Illegal invocation" — in the browser only. Node does not care,
* so server-side rendering and curl both worked while every click in the
* browser silently failed as a network error.
*/
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
} }
get<T>(path: string, options?: RequestOptions): Promise<T> { get<T>(path: string, options?: RequestOptions): Promise<T> {
@@ -106,7 +139,12 @@ export class HttpClient {
throw ApiClientError.network(cause); throw ApiClientError.network(cause);
} }
if (response.status === 401 && !isRetry && this.options.onUnauthorized) { if (
response.status === 401 &&
!isRetry &&
!options?.skipAuthRetry &&
this.options.onUnauthorized
) {
const shouldRetry = await this.options.onUnauthorized(); const shouldRetry = await this.options.onUnauthorized();
if (shouldRetry) { if (shouldRetry) {
return this.request<T>(method, path, body, options, true); return this.request<T>(method, path, body, options, true);
@@ -122,6 +160,17 @@ export class HttpClient {
try { try {
payload = (await response.json()) as ApiResponse<T>; payload = (await response.json()) as ApiResponse<T>;
} catch (cause) { } catch (cause) {
/**
* A non-JSON body on a 5xx means the request never reached the API —
* a reverse proxy or dev rewrite answered with an HTML error page while
* the upstream was down or restarting. Reporting that as "unreadable
* response" sends people hunting for a serialisation bug; it is an
* availability problem, and the offline message says so.
*/
if (response.status >= 500) {
throw ApiClientError.network(cause);
}
throw new ApiClientError({ throw new ApiClientError({
code: API_ERROR_CODES.INTERNAL_ERROR, code: API_ERROR_CODES.INTERNAL_ERROR,
message: 'The server returned an unreadable response.', message: 'The server returned an unreadable response.',
@@ -140,7 +189,14 @@ export class HttpClient {
private buildUrl(path: string, query?: RequestOptions['query']): string { private buildUrl(path: string, query?: RequestOptions['query']): string {
const normalized = path.startsWith('/') ? path : `/${path}`; const normalized = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`); const url = new URL(
`${this.baseUrl}/api/${this.apiVersion}${normalized}`,
// Only used when baseUrl is relative. On the server a relative baseUrl is
// a configuration error, and `new URL` will say so loudly.
this.baseUrl.startsWith('http')
? undefined
: (globalThis as { location?: { origin: string } }).location?.origin,
);
for (const [key, value] of Object.entries(query ?? {})) { for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined || value === null || value === '') continue; if (value === undefined || value === null || value === '') continue;
+8
View File
@@ -16,4 +16,12 @@ export { HttpClient } from './http-client';
export type { HttpClientOptions, RequestOptions } from './http-client'; export type { HttpClientOptions, RequestOptions } from './http-client';
export { createApiClient } from './create-client'; export { createApiClient } from './create-client';
export type { CatalogResource, ProductListQuery } from './resources/catalog'; export type { CatalogResource, ProductListQuery } from './resources/catalog';
export type { AuthResource, LoginCredentials } from './resources/auth';
export type {
AdminResource,
CreateUserPayload,
RolePayload,
UpdateUserPayload,
UserListParams,
} from './resources/admin';
export type { ApiClient } from './create-client'; export type { ApiClient } from './create-client';
@@ -0,0 +1,84 @@
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface UserListParams {
page?: number;
perPage?: number;
q?: string;
type?: string;
status?: string;
}
export interface CreateUserPayload {
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
type: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
roleIds: string[];
}
export interface UpdateUserPayload {
firstName?: string;
lastName?: string;
phone?: string | null;
status?: 'ACTIVE' | 'INVITED' | 'SUSPENDED';
type?: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
roleIds?: string[];
}
export interface RolePayload {
key?: string;
name?: string;
description?: string | null;
permissions?: string[];
}
/** Back-office administration. Every call requires an admin-audience token. */
export interface AdminResource {
listUsers(params?: UserListParams): Promise<OffsetPaginated<UserSummary>>;
getUser(id: string): Promise<UserSummary>;
createUser(payload: CreateUserPayload): Promise<UserSummary>;
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
listRoles(): Promise<RoleDetail[]>;
getRole(id: string): Promise<RoleDetail>;
listPermissions(): Promise<PermissionGroup[]>;
createRole(
payload: Required<Pick<RolePayload, 'key' | 'name'>> & RolePayload,
): Promise<RoleDetail>;
updateRole(id: string, payload: RolePayload): Promise<RoleDetail>;
deleteRole(id: string): Promise<void>;
}
export function createAdminResource(http: HttpClient): AdminResource {
// Administration data is never cached: an operator editing permissions must
// see the result of their own change immediately.
const uncached = { cache: 'no-store' } as const;
return {
listUsers: (params = {}) =>
http.get<OffsetPaginated<UserSummary>>('/admin/users', { ...uncached, query: { ...params } }),
getUser: (id) => http.get<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, uncached),
createUser: (payload) => http.post<UserSummary>('/admin/users', payload, uncached),
updateUser: (id, payload) =>
http.patch<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, payload, uncached),
resetUserPassword: (id, password) =>
http.post<{ ok: true }>(
`/admin/users/${encodeURIComponent(id)}/password`,
{ password },
uncached,
),
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
createRole: (payload) => http.post<RoleDetail>('/admin/roles', payload, uncached),
updateRole: (id, payload) =>
http.patch<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, payload, uncached),
deleteRole: (id) => http.delete<void>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { CurrentUser, LoginResult, RefreshResult, SessionSummary } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Storefront and admin have separate endpoints because they issue separate
* cookies for separate audiences. Exposing them as one method with a flag would
* make it far too easy to point a customer credential at the admin surface.
*/
export interface AuthResource {
login(credentials: LoginCredentials): Promise<LoginResult>;
adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
refresh(): Promise<RefreshResult>;
adminRefresh(): Promise<RefreshResult>;
logout(): Promise<void>;
adminLogout(): Promise<void>;
me(): Promise<CurrentUser>;
sessions(): Promise<SessionSummary[]>;
}
export function createAuthResource(http: HttpClient): AuthResource {
/**
* `cache: 'no-store'` on every call. An authentication response must never be
* served from a cache — not Next's data cache, not a CDN, not the browser's.
*/
const uncached = {
cache: 'no-store',
/**
* Every auth endpoint opts out of the 401 retry. Refresh is the mechanism
* the retry *uses*, so letting it retry itself recurses without bound;
* login and logout have nothing to re-authenticate with either.
*/
skipAuthRetry: true,
} as const;
return {
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
logout: () => http.post<void>('/auth/logout', undefined, uncached),
adminLogout: () => http.post<void>('/auth/admin/logout', undefined, uncached),
me: () => http.get<CurrentUser>('/auth/me', uncached),
sessions: () => http.get<SessionSummary[]>('/auth/sessions', uncached),
};
}
+56
View File
@@ -0,0 +1,56 @@
import type { Id, IsoDateTime, Nullable } from '../primitives';
import type { UserType } from './actors';
import type { Permission } from './permissions';
/**
* The `/auth/me` payload — everything a frontend needs to render a session.
*
* Note what is absent: no password hash, no session id, no refresh token, no
* internal flags. This is the whole of what a client is allowed to know about
* itself, and it is assembled explicitly rather than by spreading a database
* row, so a new column can never leak by accident.
*/
export interface CurrentUser {
readonly id: Id;
readonly email: string;
readonly type: UserType;
readonly firstName: Nullable<string>;
readonly lastName: Nullable<string>;
readonly displayName: string;
readonly avatarUrl: Nullable<string>;
readonly roles: readonly string[];
readonly permissions: readonly Permission[];
readonly lastLoginAt: Nullable<IsoDateTime>;
}
/**
* Login response.
*
* The access token is returned in the body because the client holds it in
* memory. The refresh token is NOT here — it is set as an httpOnly cookie the
* page's JavaScript cannot read, which is the entire point: an XSS can steal
* whatever is in memory, but it cannot steal the credential that mints new
* sessions.
*/
export interface LoginResult {
readonly user: CurrentUser;
readonly accessToken: string;
readonly accessTokenExpiresAt: IsoDateTime;
}
/** Returned by the refresh endpoint; the rotated cookie rides along with it. */
export interface RefreshResult {
readonly accessToken: string;
readonly accessTokenExpiresAt: IsoDateTime;
}
/** One signed-in device. Surfaced so a user can review and revoke sessions. */
export interface SessionSummary {
readonly id: Id;
readonly userAgent: Nullable<string>;
readonly ipAddress: Nullable<string>;
readonly createdAt: IsoDateTime;
readonly expiresAt: IsoDateTime;
readonly isCurrent: boolean;
}
+2
View File
@@ -13,6 +13,8 @@ export * from './api/pagination';
export * from './auth/actors'; export * from './auth/actors';
export * from './auth/permissions'; export * from './auth/permissions';
export * from './auth/tokens'; export * from './auth/tokens';
export * from './auth/session';
export * from './users/user';
export * from './catalog/product'; export * from './catalog/product';
export * from './catalog/variant'; export * from './catalog/variant';
export * from './catalog/taxonomy'; export * from './catalog/taxonomy';
+57
View File
@@ -0,0 +1,57 @@
import type { UserType } from '../auth/actors';
import type { Permission } from '../auth/permissions';
import type { Id, IsoDateTime, Nullable } from '../primitives';
export const USER_STATUSES = {
ACTIVE: 'ACTIVE',
INVITED: 'INVITED',
SUSPENDED: 'SUSPENDED',
} as const;
export type UserStatus = (typeof USER_STATUSES)[keyof typeof USER_STATUSES];
/** Row shape for the back-office user table. */
export interface UserSummary {
readonly id: Id;
readonly email: string;
readonly type: UserType;
readonly status: UserStatus;
readonly firstName: Nullable<string>;
readonly lastName: Nullable<string>;
readonly displayName: string;
readonly roles: readonly RoleSummary[];
readonly lastLoginAt: Nullable<IsoDateTime>;
readonly createdAt: IsoDateTime;
}
export interface RoleSummary {
readonly id: Id;
readonly key: string;
readonly name: string;
/** System roles are seeded from code and cannot be deleted or renamed. */
readonly isSystem: boolean;
}
export interface RoleDetail extends RoleSummary {
readonly description: Nullable<string>;
readonly permissions: readonly Permission[];
readonly userCount: number;
}
/**
* The permission catalog, grouped by resource for the role editor.
*
* Served from the API rather than read from @sport/types in the browser so the
* admin always shows exactly what the running backend enforces — a deploy skew
* shows up as a missing checkbox, not as a silently ineffective grant.
*/
export interface PermissionGroup {
readonly resource: string;
readonly permissions: readonly PermissionInfo[];
}
export interface PermissionInfo {
readonly key: Permission;
readonly resource: string;
readonly action: string;
}
+7
View File
@@ -28,5 +28,12 @@ export const registerSchema = z.object({
acceptsMarketing: z.boolean().default(false), acceptsMarketing: z.boolean().default(false),
}); });
/**
* The refresh token travels as an httpOnly cookie, never in a body — so this
* schema is deliberately empty. It exists to document that the endpoint takes
* no client-supplied input, which is what makes it safe to expose unauthenticated.
*/
export const refreshSchema = z.object({});
export type LoginInput = z.infer<typeof loginSchema>; export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>; export type RegisterInput = z.infer<typeof registerSchema>;
+1
View File
@@ -15,3 +15,4 @@ export * from './common';
export * from './pagination'; export * from './pagination';
export * from './auth'; export * from './auth';
export * from './catalog'; export * from './catalog';
export * from './users';
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod';
import { passwordSchema } from './auth';
import { anyIdSchema, emailSchema, phoneSchema } from './common';
import { offsetPageQuerySchema } from './pagination';
/**
* Back-office user and role management.
*
* Note what is *not* here: no `permissions` array on a user. Permissions are
* granted only through roles (ADR-0007). Allowing per-user overrides would make
* "who can refund an order?" unanswerable without inspecting every account.
*/
export const userTypeSchema = z.enum(['CUSTOMER', 'STAFF', 'ADMIN', 'SUPER_ADMIN']);
export const userStatusSchema = z.enum(['ACTIVE', 'INVITED', 'SUSPENDED']);
/** Back-office accounts only — customers are created by registration. */
export const backOfficeUserTypeSchema = z.enum(['STAFF', 'ADMIN', 'SUPER_ADMIN']);
export const userListQuerySchema = offsetPageQuerySchema.extend({
q: z.string().trim().max(120).optional(),
type: userTypeSchema.optional(),
status: userStatusSchema.optional(),
});
export const createUserSchema = z.object({
email: emailSchema,
password: passwordSchema,
firstName: z.string().trim().min(1).max(80),
lastName: z.string().trim().min(1).max(80),
phone: phoneSchema.optional(),
type: backOfficeUserTypeSchema,
roleIds: z.array(anyIdSchema).default([]),
});
export const updateUserSchema = z.object({
firstName: z.string().trim().min(1).max(80).optional(),
lastName: z.string().trim().min(1).max(80).optional(),
phone: phoneSchema.nullish(),
status: userStatusSchema.optional(),
type: backOfficeUserTypeSchema.optional(),
roleIds: z.array(anyIdSchema).optional(),
});
/** An operator resetting someone else's password — no current password needed. */
export const resetUserPasswordSchema = z.object({
password: passwordSchema,
});
/** A user changing their own password — proves possession of the current one. */
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: passwordSchema,
});
export const roleKeySchema = z
.string()
.trim()
.min(2)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores');
export const createRoleSchema = z.object({
key: roleKeySchema,
name: z.string().trim().min(2).max(120),
description: z.string().trim().max(500).nullish(),
permissions: z.array(z.string().max(64)).default([]),
});
export const updateRoleSchema = z.object({
name: z.string().trim().min(2).max(120).optional(),
description: z.string().trim().max(500).nullish(),
permissions: z.array(z.string().max(64)).optional(),
});
export type UserListQuery = z.output<typeof userListQuerySchema>;
export type CreateUserInput = z.output<typeof createUserSchema>;
export type UpdateUserInput = z.output<typeof updateUserSchema>;
export type CreateRoleInput = z.output<typeof createRoleSchema>;
export type UpdateRoleInput = z.output<typeof updateRoleSchema>;
export type ChangePasswordInput = z.output<typeof changePasswordSchema>;
+38
View File
@@ -162,6 +162,9 @@ importers:
compression: compression:
specifier: ^1.8.1 specifier: ^1.8.1
version: 1.8.1 version: 1.8.1
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
helmet: helmet:
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.3.0 version: 8.3.0
@@ -208,6 +211,9 @@ importers:
'@types/compression': '@types/compression':
specifier: ^1.8.1 specifier: ^1.8.1
version: 1.8.1 version: 1.8.1
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6)
'@types/express': '@types/express':
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.6 version: 5.0.6
@@ -321,12 +327,21 @@ importers:
'@sport/eslint-config': '@sport/eslint-config':
specifier: workspace:* specifier: workspace:*
version: link:../eslint-config version: link:../eslint-config
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/node': '@types/node':
specifier: ^22.19.0 specifier: ^22.19.0
version: 22.20.1 version: 22.20.1
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.39.5(jiti@2.7.0) version: 9.39.5(jiti@2.7.0)
jest:
specifier: ^30.2.0
version: 30.4.2(@types/node@22.20.1)
ts-jest:
specifier: ^29.4.6
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.20.1))(typescript@5.9.3)
typescript: typescript:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.9.3 version: 5.9.3
@@ -2054,6 +2069,11 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookiejar@2.1.5': '@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
@@ -2819,6 +2839,13 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.2.2: cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'} engines: {node: '>=6.6.0'}
@@ -6940,6 +6967,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.20.1 '@types/node': 22.20.1
'@types/cookie-parser@1.4.10(@types/express@5.0.6)':
dependencies:
'@types/express': 5.0.6
'@types/cookiejar@2.1.5': {} '@types/cookiejar@2.1.5': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
@@ -7756,6 +7787,13 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie-signature@1.2.2: {} cookie-signature@1.2.2: {}
cookie@0.7.2: {} cookie@0.7.2: {}