AI Implementation feature(823): Landing Page and Login/Register Modal #11
@@ -0,0 +1,351 @@
|
|||||||
|
# Implementation Plan: Job 823 — Landing Page and Login/Register Modal
|
||||||
|
|
||||||
|
## 0. Already-Implemented Status
|
||||||
|
|
||||||
|
**Not yet implemented.** No landing page, no Login/Register modal with mode switching, no `POST /api/v1/auth/register` (player registration), and no public "published blog posts" endpoint exist today. Investigation confirms:
|
||||||
|
|
||||||
|
- `grep` across the repo finds no `Landing`/`LandingComponent`/landing-page assets.
|
||||||
|
- No public blog-post controller: `BlogPostEntity` exists at `backend/src/database/entities/blog-post.entity.ts`, but is only referenced by the migration (`1700000000000-InitSchema.ts`) and seed/docs. No `BlogController`/`BlogService` exists.
|
||||||
|
- The existing `LoginComponent` (`frontend/src/app/features/auth/login.component.ts`) is a bare full-page form, not the modal overlay described in the Job. It does not surface backoff wait-times, has no Register-mode toggle, and bypasses CSRF header entirely (manual `fetch('/api/v1/auth/csrf')`).
|
||||||
|
- Backend `AuthController.login` already implements backoff (`LoginBackoffService`) and emits `RATE_LIMITED` 429 with a wait-time message, but there is no `POST /api/v1/auth/register` for player self-registration. `AuthService.registerFirstAdmin` is bootstrap-only (admin role, refuses if any admin exists).
|
||||||
|
- CSRF skip-list (`backend/src/common/middleware/csrf.middleware.ts:11`) currently includes `/api/v1/auth/login` and `/api/v1/setup/create-admin`. Login needs to come under CSRF protection for the new flow (must add CSRF header on login); registration must NOT be skipped.
|
||||||
|
|
||||||
|
The new login flow must align with the existing `authGuard` decision (`frontend/src/app/core/guards/auth.guard.decision.ts`) which already redirects unauthenticated-but-initialized users to `/login`. With the new Job, `/login` becomes the **landing** page, and the modal is rendered inside it. See Section 3 for the routing change.
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
|
||||||
|
- **Codebase style & conventions:**
|
||||||
|
- Backend: NestJS 10 standalone modules, TypeORM with `better-sqlite3`, zod-validated DTOs via `ZodValidationPipe`, `ApiError` → `GlobalExceptionFilter` envelope, `@Public()` decorator + global `JwtAuthGuard`, CSRF double-submit via `CsrfMiddleware` + `setOrGetCsrfToken`.
|
||||||
|
- Frontend: Angular 17 standalone components, `ChangeDetectionStrategy.OnPush`, signals (`signal`/`computed`/`effect`), Reactive Forms with `FormBuilder.nonNullable`, control-flow `@if`/`@for` (per `setup-create-admin.component.html`), typed discriminated-union service results (see `CreateAdminResult`).
|
||||||
|
- Cookies: `rt` (refresh, `HttpOnly`, `sameSite=lax/strict`) and `csrf` (readable by JS for the `csrfInterceptor`).
|
||||||
|
- Session restore on hard-refresh already exists (`AuthService.restoreSession()` → `POST /api/v1/auth/refresh`).
|
||||||
|
- **Data Layer:** SQLite + TypeORM. `blog_post` table already exists (`status` index `idx_blog_status_published (status, published_at)`). `user` table supports `role='player'` and `status='enabled'`. `setting` key `registrationsEnabled` already exists and is read by `SystemService.bootstrap`.
|
||||||
|
- **Test Framework & Structure:** Jest 29 with ts-jest, two projects (`backend` — node, `frontend` — jsdom) configured in `tests/jest.config.js`. Run all tests with `npm test` from repo root. New tests MUST live under `tests/backend/*.spec.ts` or `tests/frontend/*.spec.ts`.
|
||||||
|
- **Required Tools & Dependencies:**
|
||||||
|
- **Frontend:** `marked` (small, widely used Markdown → HTML) + `dompurify` (`isomorphic-dompurify` works in jsdom tests) for sanitized Markdown rendering of welcome description + blog bodies. Both have minimal footprint, no extra peer-deps, tree-shakeable.
|
||||||
|
- No backend dependency additions required (zod, argon2, typeorm all already present).
|
||||||
|
- `package.json` workspaces will get `frontend` deps installed automatically by `npm ci`. No `setup.sh` change needed beyond ensuring `npm ci` runs.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
|
||||||
|
### To Modify
|
||||||
|
|
||||||
|
| Path | Reason |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/modules/auth/auth.controller.ts` | Add `POST /api/v1/auth/register` (player self-registration); tighten CSRF handling so login/register/logout/refresh all require the CSRF header except where still skipped. |
|
||||||
|
| `backend/src/modules/auth/auth.service.ts` | Add `registerPlayer(username, password, ip)` enforcing `registrationsEnabled` setting, password policy, password === confirmation, Argon2id hash, role=`player`/`status=enabled`, then auto-login via existing `mintSession`. |
|
||||||
|
| `backend/src/modules/auth/dto/auth.dto.ts` | Add `RegisterDtoSchema` (`username`, `password`, `passwordConfirm` with refine `password === passwordConfirm`). |
|
||||||
|
| `backend/src/common/middleware/csrf.middleware.ts` | Remove `/api/v1/auth/login` from `SKIP_PATH_PREFIXES` (Job requires CSRF on login); keep `/api/v1/setup/create-admin` and `/api/v1/auth/register-first-admin` skipped. |
|
||||||
|
| `backend/src/app.module.ts` | Register the new `BlogModule` (see Created files). |
|
||||||
|
| `frontend/src/app/app.routes.ts` | Rename route: `/login` stays but now renders the new `LandingComponent` (which embeds the Login/Register modal). Update wildcard fallback to route unauthenticated users correctly (existing `** → ''` still works because `authGuard` will redirect). |
|
||||||
|
| `frontend/src/app/core/guards/auth.guard.decision.ts` | No change required: it already redirects to `/login` when initialized && !authenticated. Add a `decideLandingGuard` for the unauthenticated-but-initialized case (pure function) used by `/login` route to bounce authenticated users back to `/`. |
|
||||||
|
| `frontend/src/app/features/auth/login.component.ts` | **Replace** with the new `LandingComponent` that owns the landing content + modal (single component, two responsibilities). |
|
||||||
|
| `frontend/src/app/core/services/auth.service.ts` | Add a `login(creds)` helper that returns a discriminated `LoginResult` (`LoginSuccess` / `LoginFailure { code, message, retryAfterMs? }`) so the modal can surface backoff messages. Keep existing `setSession`/`restoreSession` API. |
|
||||||
|
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | Reference only — copy the discriminated-union shape into a new `auth.service.ts` helper / new `auth-modal.service.ts`. No changes required here. |
|
||||||
|
| `frontend/src/main.ts` | No code change; interceptors already cover POST/PUT/PATCH/DELETE. The new login modal will rely on the existing `csrfInterceptor`. |
|
||||||
|
| `package.json` (root) | No new root deps — frontend deps are workspace-scoped. |
|
||||||
|
| `frontend/package.json` | Add `marked` and `dompurify` (with `@types/dompurify` if needed). |
|
||||||
|
| `kilo.json` / `AGENTS.md` | No edits required. |
|
||||||
|
| `docs/architecture/frontend-structure.md`, `docs/architecture/key-files.md`, `docs/architecture/backend-modules.md`, `docs/api/rest-overview.md` | Optional doc updates to list new module/components. |
|
||||||
|
|
||||||
|
### To Create
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/modules/blog/blog.module.ts` | NestJS module wiring `BlogController` + `BlogService` + `BlogPostEntity`. |
|
||||||
|
| `backend/src/modules/blog/blog.controller.ts` | `GET /api/v1/blog/posts` (public, `@Public()`, returns only published posts ordered by `published_at DESC`). |
|
||||||
|
| `backend/src/modules/blog/blog.service.ts` | `listPublished()` returning sanitized DTOs (no internal IDs leaked — id is safe to expose). |
|
||||||
|
| `backend/src/modules/blog/dto/blog.dto.ts` | Zod schema for the public response shape (`id`, `title`, `bodyHtml?`, `publishedAt`). |
|
||||||
|
| `frontend/src/app/features/landing/landing.component.ts` | Landing page + Login/Register modal. Standalone, `OnPush`, signals. Mode toggle, in-flight disable, inline field errors, server-error display, backoff wait-time. |
|
||||||
|
| `frontend/src/app/features/landing/landing.component.html` | Template for the centered landing content + modal overlay. |
|
||||||
|
| `frontend/src/app/features/landing/landing.component.css` | Scoped styles for landing layout + modal. |
|
||||||
|
| `frontend/src/app/features/landing/landing.service.ts` | Calls `POST /api/v1/blog/posts`, returns `BlogPost[]`. Also returns the sanitized HTML for `welcomeMarkdown` from the existing `BootstrapService.payload().welcomeMarkdown` (no new endpoint needed). |
|
||||||
|
| `frontend/src/app/features/landing/login-modal.service.ts` | Pure helpers: `buildLoginFailureMessage(code, message)` → user-facing copy (incl. backoff wait). Mirrors the `setup-create-admin.field-errors.ts` style. |
|
||||||
|
| `frontend/src/app/core/services/markdown.service.ts` | `renderMarkdown(md: string): SafeHtml` using `marked` + `DOMPurify.sanitize`. Used by the landing for welcome description + blog bodies. |
|
||||||
|
| `frontend/src/app/core/guards/landing.guard.ts` + `landing.guard.decision.ts` | `CanActivateFn` for `/login`: awaits bootstrap+hydration; if authenticated → redirect `/`; if not initialized → redirect `/bootstrap`; else allow. Pure decision function for unit test. |
|
||||||
|
| `tests/backend/blog-public.spec.ts` | Verifies `/api/v1/blog/posts` returns only `status='published'` rows, ordered by `published_at DESC`, public, and no challenge/scoreboard/admin data is reachable from it. |
|
||||||
|
| `tests/backend/auth-register.spec.ts` | Verifies `POST /api/v1/auth/register` happy path + `registrations disabled` + rate-limit + weak password + missing confirm. |
|
||||||
|
| `tests/backend/csrf-protected-routes.spec.ts` | Verifies login now requires CSRF header (after the skip-list change). |
|
||||||
|
| `tests/frontend/landing-markdown.spec.ts` | Pure-Jest tests for `MarkdownService.renderMarkdown` (sanitizes `<script>`, preserves headings/links/code). |
|
||||||
|
| `tests/frontend/landing-guard.spec.ts` | Pure-Jest tests for `decideLandingGuard`. |
|
||||||
|
| `tests/frontend/landing-modal.spec.ts` | Pure-Jest tests for `login-modal.service.buildLoginFailureMessage` and the discriminated `LoginResult` mapping. |
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
|
||||||
|
### 3.1 Database / Schema Migration
|
||||||
|
|
||||||
|
**No schema changes.** All required tables (`user`, `setting`, `blog_post`) and indexes (`idx_blog_status_published`) already exist. The migration to add data happens via the admin UI later.
|
||||||
|
|
||||||
|
### 3.2 Backend Logic & APIs
|
||||||
|
|
||||||
|
#### 3.2.1 New `BlogController` (`GET /api/v1/blog/posts`)
|
||||||
|
|
||||||
|
- `@Controller('api/v1/blog')` with `@Public()` on the class.
|
||||||
|
- `GET /posts` → calls `BlogService.listPublished()` which queries `BlogPostEntity` `WHERE status = 'published'` ordered by `published_at DESC`.
|
||||||
|
- Response shape: `{ posts: Array<{ id: string; title: string; publishedAt: string; bodyHtml?: string }> }`.
|
||||||
|
- Frontend will render Markdown bodies; backend returns raw `bodyMd` (`bodyMd` field on entity) — sanitization happens on the frontend per the Job spec ("Markdown (sanitized) with a preview-style appearance" — the renderer is the landing page). To keep the API small we expose the raw `bodyMd`; sanitization is documented at the FE Markdown service. **Alternative**: render to sanitized HTML on backend (via `marked` + `dompurify` server-side) so the API stays a stable contract and Markdown is rendered once. Choose backend rendering for this Job since `marked` + `isomorphic-dompurify` are already familiar dependencies and the Job's "Markdown-rendered content" requirement is API-facing.
|
||||||
|
- Decision: **render on backend** (smaller payload, simpler FE, consistent with Job acceptance criterion "rendered Markdown"). Add `marked` + `isomorphic-dompurify` to `backend/package.json` (server-side only, no DOM).
|
||||||
|
- `@Public()` ensures `JwtAuthGuard` does not block unauthenticated visitors. CSRF is only enforced on unsafe methods; `GET` is safe.
|
||||||
|
|
||||||
|
#### 3.2.2 New `POST /api/v1/auth/register` (Player Self-Registration)
|
||||||
|
|
||||||
|
- Lives on existing `AuthController` (no new module).
|
||||||
|
- `@Public()`, **CSRF enforced** (do NOT add to skip-list).
|
||||||
|
- Body validated by `RegisterDtoSchema`:
|
||||||
|
```ts
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_.-]+$/),
|
||||||
|
password: z.string().min(1).max(256),
|
||||||
|
passwordConfirm: z.string().min(1).max(256),
|
||||||
|
}).refine(d => d.password === d.passwordConfirm, { path: ['passwordConfirm'], code: 'VALIDATION_FAILED' });
|
||||||
|
```
|
||||||
|
- New service method `AuthService.registerPlayer(dto, ip)`:
|
||||||
|
1. `validatePassword(dto.password, this.config)` → `WEAK_PASSWORD` 400 if policy violated.
|
||||||
|
2. Re-check `dto.password === dto.passwordConfirm` (defense in depth beyond the refine).
|
||||||
|
3. Read `registrationsEnabled` from `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')`. If `!== 'true'` → throw `ApiError(ERROR_CODES.FORBIDDEN, 'Registrations are disabled', 403)` with `code: REGISTRATIONS_DISABLED` (add new code).
|
||||||
|
4. `this.registrationRateLimit.isAllowed(ip)` → if false, throw `RATE_LIMITED` 429 with message `'Too many registration attempts; try again later.'`.
|
||||||
|
5. Inside `dataSource.transaction`:
|
||||||
|
- `manager.findOne(UserEntity, { where: { username } })` → if found, throw `ApiError.conflict('USERNAME_TAKEN', 'Username already taken')` 409.
|
||||||
|
- Argon2id hash (same options as `registerFirstAdmin`).
|
||||||
|
- Insert `UserEntity({ role: 'player', status: 'enabled' })`.
|
||||||
|
- `registrationRateLimit.record(ip)`.
|
||||||
|
- `mintSession(manager, user)` — returns `{ accessToken, refreshToken, expiresIn, user }`.
|
||||||
|
6. Controller sets `rt` cookie via existing `setRefreshCookie(res, session.refreshToken, this.config)` and returns `{ accessToken, expiresIn, user }` (same shape as login).
|
||||||
|
- The CSRF middleware already has the post-login cookie cleared and ready; CSRF was already required for `/api/v1/auth/register` previously? **No** — login is in the skip-list today. Remove login from skip-list (see 3.2.3).
|
||||||
|
|
||||||
|
#### 3.2.3 CSRF tightening
|
||||||
|
|
||||||
|
- Remove `'/api/v1/auth/login'` from `SKIP_PATH_PREFIXES` in `csrf.middleware.ts`. Keep:
|
||||||
|
- `/api/v1/auth/register-first-admin` (legacy bootstrap, pre-cookie)
|
||||||
|
- `/api/v1/setup/create-admin` (bootstrap)
|
||||||
|
- `/api/v1/auth/register` is NEW and is NOT added to skip-list (Job mandates CSRF on register).
|
||||||
|
- `/api/v1/auth/refresh` and `/api/v1/auth/logout` were already outside the skip-list.
|
||||||
|
- Login UX: frontend already calls `GET /api/v1/auth/csrf` once to seed the cookie, then `csrfInterceptor` attaches the header. Update the new `LandingComponent` to call `csrf` priming before the first login attempt (the existing `LoginComponent` did this manually — replicate it in the new modal service).
|
||||||
|
|
||||||
|
#### 3.2.4 Add new error code
|
||||||
|
|
||||||
|
- Add `REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED'` to `ERROR_CODES` in `backend/src/common/errors/error-codes.ts`. Used when `registrationsEnabled=false`.
|
||||||
|
|
||||||
|
#### 3.2.5 No changes needed
|
||||||
|
|
||||||
|
- `LoginBackoffService` and `RegistrationRateLimitService` already provide the exact behavior the Job describes. Login already throws `RATE_LIMITED` 429 with the remaining wait-time in the message.
|
||||||
|
- `mintSession` already produces the `{ accessToken, refreshToken, expiresIn, user }` envelope; register reuses it.
|
||||||
|
|
||||||
|
### 3.3 Frontend UI Integration
|
||||||
|
|
||||||
|
#### 3.3.1 Routing
|
||||||
|
|
||||||
|
- Keep `/login` path but repoint it to the new `LandingComponent`:
|
||||||
|
```ts
|
||||||
|
{ path: 'login', loadComponent: () => import('./features/landing/landing.component').then(m => m.LandingComponent), canActivate: [landingGuard] }
|
||||||
|
```
|
||||||
|
- Add `landingGuard` (functional `CanActivateFn`) that:
|
||||||
|
- `await bootstrap.ready()` and `await auth.waitUntilHydrated()`
|
||||||
|
- `decideLandingGuard({ initialized, isAuthenticated }, router)` returns either `true` or `createUrlTree(['/bootstrap'])` (if not initialized) / `createUrlTree(['/'])` (if authenticated).
|
||||||
|
- Keep `/bootstrap` route unchanged (fresh-install Create-admin modal still wins).
|
||||||
|
- The existing `authGuard` already handles `/` → unauthenticated users land at `/login` → LandingComponent.
|
||||||
|
|
||||||
|
#### 3.3.2 `LandingComponent` (smart component)
|
||||||
|
|
||||||
|
Layout (centered, single-column):
|
||||||
|
1. `<header>` — `<img [src]="logo()">` (empty string → hidden), `<h1>{{ pageTitle() }}</h1>`.
|
||||||
|
2. `<section class="welcome">` — Markdown render of `welcomeMarkdown()` via `MarkdownService.render()`.
|
||||||
|
3. `<button class="primary" (click)="openModal('login')">Login</button>`.
|
||||||
|
4. `<section class="blog-list">` — `@for (post of posts(); track post.id) { <article><h2>{{ post.title }}</h2><time>{{ post.publishedAt | date:'medium' }}</time><div [innerHTML]="post.bodyHtml"></div></article> }`. If `posts()` is empty, render `<p>No announcements yet.</p>`.
|
||||||
|
|
||||||
|
State (signals):
|
||||||
|
- `mode = signal<'closed' | 'login' | 'register'>('closed')`
|
||||||
|
- `loginForm = fb.nonNullable.group({ username: '', password: '' })`
|
||||||
|
- `registerForm = fb.nonNullable.group({ username: '', password: '', passwordConfirm: '' }, { validators: passwordMatchValidator })` (reuse existing `passwordMatchValidator` from `setup-create-admin.validators.ts`).
|
||||||
|
- `submitting = signal(false)`
|
||||||
|
- `error = signal<string | null>(null)`
|
||||||
|
- `errorCode = signal<string | null>(null)` — drives the "wait N seconds" message.
|
||||||
|
|
||||||
|
On submit (Login):
|
||||||
|
1. `if (this.submitting()) return;`
|
||||||
|
2. `this.submitting.set(true); this.error.set(null); this.errorCode.set(null);`
|
||||||
|
3. `await ensureCsrf()` (mirrors `SetupCreateAdminService.ensureCsrf`).
|
||||||
|
4. Call `auth.login({ username, password })` → returns `LoginResult`.
|
||||||
|
5. On success: `auth.setSession(...)`, `this.mode.set('closed')`, `await router.navigateByUrl('/')`.
|
||||||
|
6. On failure: surface message via `buildLoginFailureMessage({ code, message, retryAfterMs })`. For `RATE_LIMITED`, parse the wait-seconds out of the server message (backend already includes "Try again in {N}s"); display "Please wait N seconds before trying again." For `INVALID_CREDENTIALS`: "Invalid username or password."
|
||||||
|
7. `this.submitting.set(false)`; Login/Register button disabled while in flight (template binding `[disabled]="submitting()"`).
|
||||||
|
|
||||||
|
On submit (Register): same flow with `{ username, password, passwordConfirm }`. Backend rejects with `REGISTRATIONS_DISABLED` → modal shows "Registrations are currently disabled." `RATE_LIMITED` → "Too many registration attempts. Please wait."
|
||||||
|
|
||||||
|
Mode toggle: small text link at the bottom of the form (`"No account? Register here."` ↔ `"Already have an account? Login here."`). Switching modes clears the error state.
|
||||||
|
|
||||||
|
Close: a top-right `×` button inside the modal AND `keydown.escape` listener (the Job says "Close/Cancel" — providing both is standard).
|
||||||
|
|
||||||
|
#### 3.3.3 `LandingService`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LandingService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
readonly posts = signal<BlogPost[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
async refresh() { /* GET /api/v1/blog/posts */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Called from `LandingComponent.ngOnInit` (after `BootstrapService.ready()`). Reuses the `cs` and `BootstrapService` already injected.
|
||||||
|
|
||||||
|
#### 3.3.4 `MarkdownService`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MarkdownService {
|
||||||
|
render(md: string): SafeHtml {
|
||||||
|
const html = marked.parse(md ?? '', { gfm: true, breaks: true });
|
||||||
|
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Per the `angular-security` skill: bypass is **acceptable only because** we DOMPurify-sanitize first and the input is sourced from authenticated admin settings + admin-authored blog posts (not user-controlled raw input).
|
||||||
|
- Exposed as a `SafeHtml` for use with `[innerHTML]`.
|
||||||
|
|
||||||
|
#### 3.3.5 `AuthService.login` addition
|
||||||
|
|
||||||
|
Add a typed helper:
|
||||||
|
```ts
|
||||||
|
async login(creds: { username: string; password: string }): Promise<LoginResult>
|
||||||
|
```
|
||||||
|
where
|
||||||
|
```ts
|
||||||
|
type LoginResult =
|
||||||
|
| { ok: true; accessToken: string; expiresIn: number; user: CurrentUser }
|
||||||
|
| { ok: false; status: number; code: string; message: string; retryAfterMs?: number };
|
||||||
|
```
|
||||||
|
This mirrors `SetupCreateAdminService.create`'s shape. Internal: `firstValueFrom(http.post('/api/v1/auth/login', creds, { withCredentials: true, observe: 'response' }))`, map success → `{ ok: true, ... }`, map error → `{ ok: false, code, message, status }`. The modal component owns the user-facing copy (so unit tests can pin it).
|
||||||
|
|
||||||
|
#### 3.3.6 `login-modal.service.ts` (pure helper module)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function buildLoginFailureMessage(input: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}): { text: string; retryAfterSeconds?: number } { /* pure */ }
|
||||||
|
```
|
||||||
|
- Parses "Try again in N s." from the backend message when `code === 'RATE_LIMITED'`.
|
||||||
|
- Returns `{ text, retryAfterSeconds }`. Unit-tested with table-driven cases.
|
||||||
|
|
||||||
|
#### 3.3.7 `decideLandingGuard` (pure)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function decideLandingGuard(input: { initialized: boolean; isAuthenticated: boolean }, router: Pick<Router, 'createUrlTree'>): true | UrlTree {
|
||||||
|
if (!input.initialized) return router.createUrlTree(['/bootstrap']);
|
||||||
|
if (input.isAuthenticated) return router.createUrlTree(['/']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.8 CSRF on Login
|
||||||
|
|
||||||
|
- `csrfInterceptor` already adds `X-CSRF-Token` on POST. The only "first-call" issue is when no `csrf` cookie exists yet. The new modal calls `await fetch('/api/v1/auth/csrf', { credentials: 'include' })` (same pattern as `SetupCreateAdminService.ensureCsrf`) before each login attempt so a fresh visitor's first request still carries the header.
|
||||||
|
- For all subsequent requests, `csrfInterceptor` attaches the cookie automatically.
|
||||||
|
|
||||||
|
### 3.4 Compliance with Existing Conventions
|
||||||
|
|
||||||
|
- Uses `@Public()` on the new controller (matches `SystemController`, `SetupController`).
|
||||||
|
- Uses zod DTOs and `ZodValidationPipe` (matches every other controller).
|
||||||
|
- Uses `ApiError` + `ERROR_CODES` (matches `AuthService.login`).
|
||||||
|
- Frontend uses standalone `OnPush`, signals, `ReactiveFormsModule` (matches `SetupCreateAdminComponent`).
|
||||||
|
- Frontend uses `@if`/`@for` control flow (matches `setup-create-admin.component.html`).
|
||||||
|
- Discriminated-union service results (matches `SetupCreateAdminService`).
|
||||||
|
- Pure decision functions for guards + error-message helpers (matches `decideAuthRedirect`, `decideAdminGuard`, `setup-create-admin.field-errors.ts`).
|
||||||
|
- Tests use the existing pattern: `tests/backend/*.spec.ts` with `initDb(app)` and `csrfClient(app)`; `tests/frontend/*.spec.ts` with jsdom.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
|
||||||
|
### 4.1 Backend tests
|
||||||
|
|
||||||
|
**`tests/backend/blog-public.spec.ts`** (new)
|
||||||
|
- Spins up `AppModule` + `initDb`.
|
||||||
|
- Seeds two `blog_post` rows directly via TypeORM repository: one `status='published'`, one `status='draft'`, one `status='published'` with later `published_at`.
|
||||||
|
- Asserts `GET /api/v1/blog/posts`:
|
||||||
|
- returns 200 without auth header,
|
||||||
|
- returns ONLY the two published rows,
|
||||||
|
- ordered most-recent first,
|
||||||
|
- contains `bodyHtml` (sanitized — strips `<script>`),
|
||||||
|
- does NOT include any user / scoreboard / challenge fields.
|
||||||
|
- Negative: assert that calling `GET /api/v1/blog/posts/drafts` does not exist and returns 404 (no admin-style endpoint leaks).
|
||||||
|
|
||||||
|
**`tests/backend/auth-register.spec.ts`** (new)
|
||||||
|
- Spins up `AppModule`, registers the first admin via `/api/v1/auth/register-first-admin`, sets `registrationsEnabled='true'` via `SettingsService.set`.
|
||||||
|
- Happy path: `POST /api/v1/auth/register` with `{username:'alice', password:'Sup3rSecret!Pass', passwordConfirm:'Sup3rSecret!Pass'}` → 201, returns `{accessToken, user:{role:'player'}}`, `rt` cookie set `HttpOnly`.
|
||||||
|
- Validation: `passwordConfirm !== password` → 400 `VALIDATION_FAILED`.
|
||||||
|
- Weak password → 400 `WEAK_PASSWORD`.
|
||||||
|
- Username taken → 409 `USERNAME_TAKEN`.
|
||||||
|
- Registrations disabled (`registrationsEnabled='false'`) → 403 `REGISTRATIONS_DISABLED`.
|
||||||
|
- Rate limit: prime `RegistrationRateLimitService` to 10 entries; next call → 429 `RATE_LIMITED`.
|
||||||
|
- CSRF: omit `X-CSRF-Token` → 403 `CSRF_INVALID`.
|
||||||
|
|
||||||
|
**`tests/backend/csrf-protected-routes.spec.ts`** (new)
|
||||||
|
- After the skip-list change: `POST /api/v1/auth/login` without `X-CSRF-Token` → 403 `CSRF_INVALID`; with token → 201.
|
||||||
|
|
||||||
|
### 4.2 Frontend tests
|
||||||
|
|
||||||
|
**`tests/frontend/landing-markdown.spec.ts`** (new)
|
||||||
|
- `MarkdownService.render('# Hello').toString()` includes `<h1>Hello</h1>`.
|
||||||
|
- `MarkdownService.render('<script>alert(1)</script>').toString()` does NOT contain `<script>`.
|
||||||
|
- `MarkdownService.render('[x](javascript:alert(1))')` strips the `javascript:` href.
|
||||||
|
- Pure; no TestBed needed (service is a stateless class).
|
||||||
|
|
||||||
|
**`tests/frontend/landing-guard.spec.ts`** (new)
|
||||||
|
- Table-driven tests for `decideLandingGuard({ initialized, isAuthenticated }, router)`:
|
||||||
|
- `{false, _}` → `/bootstrap`
|
||||||
|
- `{true, true}` → `/`
|
||||||
|
- `{true, false}` → `true`
|
||||||
|
- Pure; mirrors the existing `auth.guard.decision.spec.ts` style.
|
||||||
|
|
||||||
|
**`tests/frontend/landing-modal.spec.ts`** (new)
|
||||||
|
- Table-driven tests for `buildLoginFailureMessage`:
|
||||||
|
- `{code:'INVALID_CREDENTIALS', message:'Invalid credentials'}` → `{ text: 'Invalid username or password.' }`
|
||||||
|
- `{code:'RATE_LIMITED', message:'Too many failed attempts. Try again in 7s.'}` → `{ text: 'Please wait 7 seconds before trying again.', retryAfterSeconds: 7 }`
|
||||||
|
- `{code:'USERNAME_TAKEN', message:'Username already exists'}` → `{ text: 'Username already exists.' }`
|
||||||
|
- `{code:'REGISTRATIONS_DISABLED', message:'...'}` → `{ text: 'Registrations are currently disabled.' }`
|
||||||
|
- Pure; no TestBed.
|
||||||
|
|
||||||
|
### 4.3 Mocking Strategy
|
||||||
|
|
||||||
|
- Backend tests use the real `AppModule` against `:memory:` SQLite (matches `auth-refresh.spec.ts`, `setup-create-admin.spec.ts`, `registration-rate-limit.spec.ts`). CSRF is registered explicitly the same way. `RegistrationRateLimitService` is fetched from the DI container (`app.get(RegistrationRateLimitService)`) to prime the per-IP window deterministically — matches the existing pattern.
|
||||||
|
- Frontend tests target **pure functions only** (`MarkdownService.render`, `decideLandingGuard`, `buildLoginFailureMessage`). No DOM rendering, no `HttpClient`, no `TestBed.createComponent`. This keeps the test suite fast and isolated.
|
||||||
|
- No Playwright/JSDOM-rendered HTML is asserted (per the global test rule).
|
||||||
|
|
||||||
|
### 4.4 What is NOT covered (and why)
|
||||||
|
|
||||||
|
- No end-to-end UI flow tests for the modal open/close/submit (would require TestBed + DOM mocks; not justified for the success path; logic lives in pure helpers).
|
||||||
|
- No visual snapshot for the landing layout (per the test rule — no visual confirmation).
|
||||||
|
- No concurrency tests for `/api/v1/auth/register` (single-user creation is idempotent-by-username via the unique index; covered by the existing `users` unique-index test).
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria Mapping
|
||||||
|
|
||||||
|
| Job acceptance criterion | Implementation |
|
||||||
|
|---|---|
|
||||||
|
| Fresh-install visit shows Create-admin modal, not landing page. | `decideLandingGuard` returns `/bootstrap` when `!initialized`. Existing `setup-create-admin` modal stays at `/bootstrap`. |
|
||||||
|
| After admin exists + logged out → landing page with logo, title, Markdown welcome, Login button, published blog posts only. | `LandingComponent` consumes `BootstrapService.payload()` for logo/title/welcomeMarkdown, `LandingService.posts()` for published-only blog list. |
|
||||||
|
| Login button opens modal in Login mode; "Register here" switches mode without closing. | Local `mode` signal + reactive forms; mode toggle is a template button that flips the signal. |
|
||||||
|
| Submitting valid credentials logs in + navigates to Challenges (default `/` route). | `auth.login(...) → setSession → router.navigateByUrl('/')`. |
|
||||||
|
| Invalid credentials → inline error + exponential backoff per IP/username + remaining-wait message. | Backend `LoginBackoffService` already enforces; `buildLoginFailureMessage` extracts `N s` from server message. |
|
||||||
|
| Registration creates account + auto-login. | `AuthService.registerPlayer` creates player + calls `mintSession`. |
|
||||||
|
| Registration rate-limit message when >10/min from same IP. | `RegistrationRateLimitService` already enforces; modal surfaces `RATE_LIMITED` via `buildLoginFailureMessage`. |
|
||||||
|
| Registration rejected when disabled in General settings. | `SettingsService.get('registrationsEnabled') === 'true'` check in `registerPlayer`. |
|
||||||
|
| Authenticated users never see landing/modal; routed to main area. | `decideLandingGuard` + `decideAuthRedirect` + `authGuard` chain. |
|
||||||
|
| No challenge/scoreboard/admin data reachable unauthenticated. | `BlogController` is the only new public endpoint; all other admin/challenge/scoreboard controllers are gated by `JwtAuthGuard` + `AdminGuard` already. |
|
||||||
|
| All state-changing requests carry CSRF; cookies HttpOnly + sameSite. | CSRF skip-list cleaned up; cookies set via existing `setRefreshCookie` which writes `HttpOnly` + `sameSite=lax/strict`. |
|
||||||
|
|
||||||
|
## 6. Implementation Order (suggested)
|
||||||
|
|
||||||
|
1. Backend: new error code + `BlogModule` + `BlogController`/`BlogService` + tests.
|
||||||
|
2. Backend: `AuthService.registerPlayer` + DTO + controller method + tests.
|
||||||
|
3. Backend: tighten CSRF skip-list + test.
|
||||||
|
4. Frontend: add `marked` + `dompurify` to `frontend/package.json`.
|
||||||
|
5. Frontend: `MarkdownService` + tests.
|
||||||
|
6. Frontend: `decideLandingGuard` + tests.
|
||||||
|
7. Frontend: `LandingService`, `buildLoginFailureMessage` + tests.
|
||||||
|
8. Frontend: `AuthService.login` discriminated helper.
|
||||||
|
9. Frontend: `LandingComponent` (template + css + ts).
|
||||||
|
10. Frontend: route wiring (`/login` → landing component, guarded).
|
||||||
|
11. Run `npm test` from repo root; ensure both backend and frontend projects pass.
|
||||||
|
12. Run `npm run build` to confirm both workspaces compile.
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Implementation Plan: Job 848 — First-Start Create Admin Modal 1.07
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
**[ALREADY_IMPLEMENTED]**
|
|
||||||
|
|
||||||
The First-Start Create Admin Modal feature is fully implemented end-to-end in
|
|
||||||
this repository. The user-facing symptom reported in the job ("HIPCTF heading +
|
|
||||||
'Frontend not built.' paragraph, no bootstrap dialog, no form controls, no
|
|
||||||
submit button") is produced by the SPA fallback in
|
|
||||||
`backend/src/frontend/spa.controller.ts:27` whenever
|
|
||||||
`frontend/dist/browser/index.html` does not exist on disk at the moment
|
|
||||||
the request is served.
|
|
||||||
|
|
||||||
That `dist/browser/index.html` is now present (rebuilt during the current
|
|
||||||
session), so navigating to `http://localhost:3000/` will serve the Angular
|
|
||||||
SPA, which redirects to `/bootstrap` and renders the create-admin modal.
|
|
||||||
|
|
||||||
No source code changes, no schema changes, and no new tests are required.
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
- **Codebase style & conventions:**
|
|
||||||
- Monorepo with npm workspaces (`backend/`, `frontend/`).
|
|
||||||
- Backend: NestJS controllers + services, TypeORM repositories,
|
|
||||||
`@nestjs/config`, argon2 for password hashing, class-validator /
|
|
||||||
`ZodValidationPipe` for input, `ApiError` + `GlobalExceptionFilter`
|
|
||||||
for the uniform `{ code, message }` error envelope.
|
|
||||||
- Frontend: Angular 17+ standalone components, `ChangeDetectionStrategy.OnPush`,
|
|
||||||
Angular Signals, reactive forms (`ReactiveFormsModule`), HTTP via
|
|
||||||
`HttpClient` with `withCredentials: true`, separate
|
|
||||||
`BootstrapService` (initialised flag) and `AuthService` (session token).
|
|
||||||
- Persistence: SQLite via TypeORM, file path comes from
|
|
||||||
`process.env.DATABASE_PATH` (default `/data/hipctf/hipctf.db`),
|
|
||||||
`FRONTEND_DIST` env var names the built SPA directory.
|
|
||||||
- **Data Layer:** SQLite (TypeORM). No schema change is needed.
|
|
||||||
- **Test Framework & Structure:**
|
|
||||||
- Jest with two projects (`backend`, `frontend`) configured in
|
|
||||||
`tests/jest.config.js`.
|
|
||||||
- Backend tests use `ts-jest` against an in-memory SQLite DB
|
|
||||||
(`process.env.DATABASE_PATH = ':memory:'`) via
|
|
||||||
`tests/backend/db-helper.ts`.
|
|
||||||
- Frontend tests use `jest-environment-jsdom` with
|
|
||||||
`tests/frontend/jest.setup.ts` providing Angular `TestBed` +
|
|
||||||
`provideHttpClientTesting`.
|
|
||||||
- All tests live under `tests/` and run via a single root command:
|
|
||||||
`npm test` (alias for `jest --config tests/jest.config.js`).
|
|
||||||
- **Required Tools & Dependencies:** No new tools. Existing `npm run build`
|
|
||||||
in `setup.sh` rebuilds `frontend/dist/browser/index.html`, which is the
|
|
||||||
actual fix for the symptom. No new packages are required.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
- **To Modify:** None.
|
|
||||||
- **To Create:** None.
|
|
||||||
|
|
||||||
### Where the feature already lives (for reference / verification)
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- `backend/src/modules/setup/setup.module.ts`
|
|
||||||
- `backend/src/modules/setup/setup.controller.ts` — `POST /api/v1/setup/create-admin`.
|
|
||||||
- `backend/src/modules/setup/setup.service.ts:39-97` — `createAdmin()` inside a
|
|
||||||
TypeORM transaction with serialized bootstrap via `runBootstrap()`
|
|
||||||
(lines 99-130) — guarantees that two concurrent callers cannot both
|
|
||||||
observe an empty admin table; the loser gets
|
|
||||||
`409 SYSTEM_INITIALIZED`.
|
|
||||||
- `backend/src/modules/setup/dto/create-admin.dto.ts`
|
|
||||||
- `backend/src/common/errors/error-codes.ts` — `SYSTEM_INITIALIZED`,
|
|
||||||
`USERNAME_TAKEN`, `WEAK_PASSWORD`, `VALIDATION_FAILED`, `RATE_LIMITED`.
|
|
||||||
- `backend/src/frontend/spa.controller.ts:11-28` — SPA fallback that emits
|
|
||||||
the placeholder HTML **only** when no built `index.html` is found.
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.ts` — the
|
|
||||||
modal component (`data-testid="setup-submit"`, `setup-retry`, etc.).
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.html`
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.css`
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.service.ts` —
|
|
||||||
`POST /api/v1/setup/create-admin` with CSRF preflight + error mapping.
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.validators.ts` —
|
|
||||||
`passwordMatchValidator`.
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.field-errors.ts` —
|
|
||||||
pure helpers `usernameError`, `passwordError`, `confirmError`.
|
|
||||||
- `frontend/src/app/core/services/bootstrap.service.ts` — exposes
|
|
||||||
`initialized()`, `markInitialized()`, and `passwordPolicy()`.
|
|
||||||
- `frontend/src/app/core/services/auth.service.ts` — `setSession()` used
|
|
||||||
on success.
|
|
||||||
- `frontend/src/app/app.routes.ts` — `/bootstrap` route, guarded by
|
|
||||||
`authGuard` which redirects to `/bootstrap` while `initialized() === false`.
|
|
||||||
|
|
||||||
Existing tests that already cover this Job:
|
|
||||||
- `tests/backend/setup-create-admin.spec.ts`
|
|
||||||
- `tests/backend/bootstrap.integration.spec.ts`
|
|
||||||
- `tests/backend/registration-rate-limit.spec.ts`
|
|
||||||
- `tests/backend/api-error.spec.ts`
|
|
||||||
- `tests/frontend/setup-create-admin.spec.ts`
|
|
||||||
- `tests/frontend/guard-bootstrap-race.spec.ts`
|
|
||||||
- `tests/frontend/auth-restore.spec.ts`
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
No code changes. The only operational fix is to keep
|
|
||||||
`frontend/dist/browser/index.html` in place.
|
|
||||||
|
|
||||||
If a fresh container runs without it, `setup.sh` already invokes
|
|
||||||
`npm --workspace frontend run build` (line 11) which produces the Angular
|
|
||||||
bundle under `frontend/dist/browser/index.html`. The
|
|
||||||
`SpaFallbackMiddleware` looks for the file in both
|
|
||||||
`frontend/dist/index.html` and `frontend/dist/browser/index.html`, so the
|
|
||||||
standard build output is resolved without any extra wiring.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
No new tests. The relevant behaviour is already covered:
|
|
||||||
|
|
||||||
- **Backend:** `tests/backend/setup-create-admin.spec.ts` exercises
|
|
||||||
`POST /api/v1/setup/create-admin` happy path + each documented error
|
|
||||||
(`SYSTEM_INITIALIZED`, `USERNAME_TAKEN`, `WEAK_PASSWORD`,
|
|
||||||
`VALIDATION_FAILED`, `RATE_LIMITED`).
|
|
||||||
- **Backend concurrency:** the `runBootstrap()` chain in
|
|
||||||
`SetupService` is what serializes concurrent first-admin requests;
|
|
||||||
`tests/backend/bootstrap.integration.spec.ts` exercises the bootstrap
|
|
||||||
endpoint end-to-end.
|
|
||||||
- **Frontend:** `tests/frontend/setup-create-admin.spec.ts` covers the
|
|
||||||
modal's reactive form, server-error states, retry button, and the
|
|
||||||
post-success navigation. `tests/frontend/guard-bootstrap-race.spec.ts`
|
|
||||||
covers the `authGuard` redirect race with `BootstrapService`.
|
|
||||||
|
|
||||||
Run the existing suite from the repo root with:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Why this is reported as "not built"
|
|
||||||
|
|
||||||
`SpaFallbackMiddleware` (`backend/src/frontend/spa.controller.ts:27`)
|
|
||||||
sends:
|
|
||||||
|
|
||||||
```
|
|
||||||
<!doctype html><html><body><h1>HIPCTF</h1><p>Frontend not built.</p></body></html>
|
|
||||||
```
|
|
||||||
|
|
||||||
when neither `frontend/dist/index.html` nor `frontend/dist/browser/index.html`
|
|
||||||
exists. The Angular CLI's `application` builder writes to
|
|
||||||
`dist/browser/index.html`, so after `npm --workspace frontend run build`
|
|
||||||
the file is present and the SPA is served. The reported symptom was a
|
|
||||||
snapshot taken before the build artefact existed; rebuilding resolves it.
|
|
||||||
@@ -11,6 +11,7 @@ import { SystemModule } from './modules/system/system.module';
|
|||||||
import { SettingsModule } from './modules/settings/settings.module';
|
import { SettingsModule } from './modules/settings/settings.module';
|
||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
|
import { BlogModule } from './modules/blog/blog.module';
|
||||||
import { FrontendModule } from './frontend/frontend.module';
|
import { FrontendModule } from './frontend/frontend.module';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
@@ -31,6 +32,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
|||||||
SystemModule,
|
SystemModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
UploadsModule,
|
UploadsModule,
|
||||||
|
BlogModule,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const ERROR_CODES = {
|
|||||||
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
||||||
TOKEN_REVOKED: 'TOKEN_REVOKED',
|
TOKEN_REVOKED: 'TOKEN_REVOKED',
|
||||||
THEME_INVALID: 'THEME_INVALID',
|
THEME_INVALID: 'THEME_INVALID',
|
||||||
|
REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED',
|
||||||
INTERNAL: 'INTERNAL',
|
INTERNAL: 'INTERNAL',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const COOKIE_NAME = 'csrf';
|
|||||||
const HEADER_NAME = 'x-csrf-token';
|
const HEADER_NAME = 'x-csrf-token';
|
||||||
const SKIP_PATH_PREFIXES = [
|
const SKIP_PATH_PREFIXES = [
|
||||||
'/api/v1/auth/register-first-admin',
|
'/api/v1/auth/register-first-admin',
|
||||||
'/api/v1/auth/login',
|
|
||||||
'/api/v1/setup/create-admin',
|
'/api/v1/setup/create-admin',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Request, Response } from 'express';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { LoginDtoSchema, RefreshDtoSchema } from './dto/auth.dto';
|
import { LoginDtoSchema, RefreshDtoSchema, RegisterDtoSchema } from './dto/auth.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
|
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
|
||||||
import { setRefreshCookie, clearRefreshCookie } from './cookie';
|
import { setRefreshCookie, clearRefreshCookie } from './cookie';
|
||||||
@@ -27,6 +27,29 @@ export class AuthController {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Public player self-registration' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Registration successful, returns login session' })
|
||||||
|
async register(
|
||||||
|
@Body(new ZodValidationPipe(RegisterDtoSchema)) body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
},
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
||||||
|
const session = await this.auth.registerPlayer(body, ip);
|
||||||
|
setRefreshCookie(res, session.refreshToken, this.config);
|
||||||
|
return {
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
expiresIn: session.expiresIn,
|
||||||
|
user: session.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with username and password' })
|
@ApiOperation({ summary: 'Login with username and password' })
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||||
|
import { SettingsModule } from '../settings/settings.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -22,6 +23,7 @@ import { AdminGuard } from '../../common/guards/admin.guard';
|
|||||||
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
|
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
SettingsModule,
|
||||||
],
|
],
|
||||||
providers: [AuthService, JwtStrategy, AdminGuard],
|
providers: [AuthService, JwtStrategy, AdminGuard],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { ApiError } from '../../common/errors/api-error';
|
|||||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||||
import { LoginBackoffService } from '../../common/services/login-backoff.service';
|
import { LoginBackoffService } from '../../common/services/login-backoff.service';
|
||||||
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
||||||
import { LoginDto, LoginResponse } from './dto/auth.dto';
|
import { LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
|
||||||
import { validatePassword } from '../../common/utils/password-policy';
|
import { validatePassword } from '../../common/utils/password-policy';
|
||||||
|
import { SettingsService } from '../settings/settings.module';
|
||||||
|
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||||
|
|
||||||
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
|
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ export class AuthService implements OnModuleInit {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly backoff: LoginBackoffService,
|
private readonly backoff: LoginBackoffService,
|
||||||
private readonly registrationRateLimit: RegistrationRateLimitService,
|
private readonly registrationRateLimit: RegistrationRateLimitService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
@@ -130,6 +133,52 @@ export class AuthService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerPlayer(dto: RegisterDto, ip: string): Promise<LoginResponse> {
|
||||||
|
validatePassword(dto.password, this.config);
|
||||||
|
|
||||||
|
if (!this.registrationRateLimit.isAllowed(ip)) {
|
||||||
|
throw new ApiError(
|
||||||
|
ERROR_CODES.RATE_LIMITED,
|
||||||
|
'Too many registration attempts; please wait a minute before trying again.',
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = (await this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true';
|
||||||
|
if (!enabled) {
|
||||||
|
throw new ApiError(
|
||||||
|
ERROR_CODES.REGISTRATIONS_DISABLED,
|
||||||
|
'Registrations are currently disabled',
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const existing = await manager.findOne(UserEntity, { where: { username: dto.username } });
|
||||||
|
if (existing) {
|
||||||
|
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(dto.password, {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||||
|
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||||
|
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||||
|
});
|
||||||
|
const user = manager.create(UserEntity, {
|
||||||
|
id: uuid(),
|
||||||
|
username: dto.username,
|
||||||
|
passwordHash,
|
||||||
|
role: 'player',
|
||||||
|
status: 'enabled',
|
||||||
|
});
|
||||||
|
await manager.save(user);
|
||||||
|
|
||||||
|
this.registrationRateLimit.record(ip);
|
||||||
|
return this.mintSession(manager, user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mint a fresh access JWT and a new refresh token row inside the supplied
|
* Mint a fresh access JWT and a new refresh token row inside the supplied
|
||||||
* transaction. The refresh token (raw) is returned alongside the access
|
* transaction. The refresh token (raw) is returned alongside the access
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ export const CsrfResponseDtoSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CsrfResponseDto = z.infer<typeof CsrfResponseDtoSchema>;
|
export type CsrfResponseDto = z.infer<typeof CsrfResponseDtoSchema>;
|
||||||
|
|
||||||
|
export const RegisterDtoSchema = z
|
||||||
|
.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(32)
|
||||||
|
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'),
|
||||||
|
password: z.string().min(1).max(256),
|
||||||
|
passwordConfirm: z.string().min(1).max(256),
|
||||||
|
})
|
||||||
|
.refine((d) => d.password === d.passwordConfirm, {
|
||||||
|
path: ['passwordConfirm'],
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
});
|
||||||
|
export type RegisterDto = z.infer<typeof RegisterDtoSchema>;
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { BlogService } from './blog.service';
|
||||||
|
import { PublicBlogList } from './dto/blog.dto';
|
||||||
|
|
||||||
|
@ApiTags('blog')
|
||||||
|
@Controller('api/v1/blog')
|
||||||
|
export class BlogController {
|
||||||
|
constructor(private readonly blog: BlogService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('posts')
|
||||||
|
@ApiOperation({ summary: 'Public list of published blog posts' })
|
||||||
|
async listPosts(): Promise<PublicBlogList> {
|
||||||
|
const posts = await this.blog.listPublished();
|
||||||
|
return { posts };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
|
||||||
|
import { BlogController } from './blog.controller';
|
||||||
|
import { BlogService } from './blog.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([BlogPostEntity])],
|
||||||
|
controllers: [BlogController],
|
||||||
|
providers: [BlogService],
|
||||||
|
})
|
||||||
|
export class BlogModule {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
|
||||||
|
import { PublicBlogPost } from './dto/blog.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BlogService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BlogPostEntity)
|
||||||
|
private readonly posts: Repository<BlogPostEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listPublished(): Promise<PublicBlogPost[]> {
|
||||||
|
const rows = await this.posts.find({
|
||||||
|
where: { status: 'published' },
|
||||||
|
order: { publishedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
publishedAt: row.publishedAt ?? row.createdAt,
|
||||||
|
bodyMd: row.bodyMd ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const PublicBlogPostSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
publishedAt: z.string(),
|
||||||
|
bodyMd: z.string(),
|
||||||
|
});
|
||||||
|
export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
|
||||||
|
|
||||||
|
export const PublicBlogListSchema = z.object({
|
||||||
|
posts: z.array(PublicBlogPostSchema),
|
||||||
|
});
|
||||||
|
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Admin Endpoints
|
||||||
|
description: User management endpoints mounted under /api/v1/admin (admin role required).
|
||||||
|
tags: [api, admin, users]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|---------|-----------------------|---------|-----------------------------------------------------|
|
||||||
|
| `GET` | `/api/v1/admin/users` | Admin | `backend/src/modules/admin/admin.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/admin/users` | Admin | Same. |
|
||||||
|
| `PATCH` | `/api/v1/admin/users/:id` | Admin | Same. |
|
||||||
|
| `DELETE`| `/api/v1/admin/users/:id` | Admin | Same. |
|
||||||
|
|
||||||
|
# Guard chain
|
||||||
|
|
||||||
|
1. `JwtAuthGuard` (global) validates the access token unless the handler
|
||||||
|
is `@Public()`. Admin handlers are not.
|
||||||
|
2. `AdminGuard` (`backend/src/common/guards/admin.guard.ts`) requires
|
||||||
|
`req.user.role === 'admin'`.
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
* `AdminService` enforces the **last-admin invariant** (`LAST_ADMIN` /
|
||||||
|
`409`) — the final admin row cannot be demoted or deleted.
|
||||||
|
* `POST /api/v1/admin/users` creates a new user; `PATCH /api/v1/admin/users/:id`
|
||||||
|
changes role / status; `DELETE /api/v1/admin/users/:id` removes the row.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
|
- [Admin Shell & User Management](/guides/admin-shell.md)
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Auth Endpoints
|
||||||
|
description: Login, refresh, logout, public self-registration, CSRF, and first-admin registration.
|
||||||
|
tags: [api, auth, login, register, refresh, csrf]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | CSRF | Rate limited | Source |
|
||||||
|
|--------|-----------------------------------|--------|-----------------------------------------------|--------------|--------|
|
||||||
|
| `POST` | `/api/v1/auth/register` | `@Public()` | Enforced — caller must first call `GET /api/v1/auth/csrf` (the SPA's `AuthService.ensureCsrf()` does this automatically). | Yes — `RegistrationRateLimitService` per IP | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/login` | `@Public()` | Enforced — same pattern as `register`. CSRF skip was removed in this change. | Yes — `LoginBackoffService` per IP + username | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/refresh` | Public route, requires a valid refresh cookie. | Skipped (the request is a `POST` carrying an HttpOnly cookie, not a state-changing user action). | No | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/logout` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `GET` | `/api/v1/auth/csrf` | `@Public()` | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/register-first-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`). | Yes | `backend/src/modules/users/users.controller.ts` |
|
||||||
|
|
||||||
|
# Public player self-registration — `POST /api/v1/auth/register`
|
||||||
|
|
||||||
|
New endpoint, gated by the `registrationsEnabled` setting
|
||||||
|
(`backend/src/config/env.schema.ts` → `SETTINGS_KEYS.REGISTRATIONS_ENABLED`,
|
||||||
|
default `"false"`).
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|-------------------|--------|------------------------------------------------------------------------------|
|
||||||
|
| `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` |
|
||||||
|
| `password` | string | 1–256 chars; also passes `validatePassword` (Argon2 policy). |
|
||||||
|
| `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED` on `passwordConfirm`). |
|
||||||
|
|
||||||
|
Validated by `backend/src/modules/auth/dto/auth.dto.ts` → `RegisterDtoSchema`.
|
||||||
|
|
||||||
|
## Successful response (201)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "<jwt>",
|
||||||
|
"expiresIn": 900,
|
||||||
|
"user": { "id": "<uuid>", "username": "player1", "role": "player" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, session.refreshToken, config)`. The new `user` row has `role='player'` and `status='enabled'`.
|
||||||
|
|
||||||
|
## Error envelope
|
||||||
|
|
||||||
|
| HTTP | `code` | When |
|
||||||
|
|------|-------------------------|---------------------------------------------------------------------------------|
|
||||||
|
| 400 | `VALIDATION_FAILED` | zod body validation failed. |
|
||||||
|
| 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password. |
|
||||||
|
| 403 | `REGISTRATIONS_DISABLED`| `setting.registrationsEnabled !== 'true'`. |
|
||||||
|
| 409 | `USERNAME_TAKEN` | A user with that username already exists. |
|
||||||
|
| 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. |
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
* `AuthService.registerPlayer(dto, ip)` runs inside a single
|
||||||
|
`DataSource.transaction`:
|
||||||
|
1. `RegistrationRateLimitService.isAllowed(ip)` check.
|
||||||
|
2. `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')` check.
|
||||||
|
3. Username uniqueness lookup → `USERNAME_TAKEN` on collision.
|
||||||
|
4. Argon2id hash + insert `UserEntity({ role: 'player', status: 'enabled' })`.
|
||||||
|
5. `RegistrationRateLimitService.record(ip)`.
|
||||||
|
6. `mintSession(manager, user)` → access JWT + refresh row.
|
||||||
|
|
||||||
|
# Frontend consumer
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/auth.service.ts` exposes `login()` and
|
||||||
|
`register()`. Both:
|
||||||
|
|
||||||
|
1. Call `ensureCsrf()` → `GET /api/v1/auth/csrf` to materialise the `csrf`
|
||||||
|
cookie if it isn't set yet (failures are swallowed because the
|
||||||
|
`CsrfMiddleware` will mint the cookie on the next response anyway).
|
||||||
|
2. `POST` with `{ withCredentials: true, observe: 'response' }`.
|
||||||
|
3. Return a discriminated union:
|
||||||
|
* `{ ok: true, accessToken, expiresIn, user }` on success.
|
||||||
|
* `{ ok: false, status, code, message }` on failure, built by the
|
||||||
|
private `mapAuthError()` helper from the standard
|
||||||
|
[error envelope](/api/rest-overview.md).
|
||||||
|
|
||||||
|
The `LandingComponent` (`frontend/src/app/features/landing/landing.component.ts`)
|
||||||
|
consumes both methods and maps the `code` to user-facing copy via
|
||||||
|
`buildLoginFailureMessage` (see [Landing Page](/guides/landing-page.md)).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
|
- [Setup Endpoint](/api/setup.md)
|
||||||
|
- [Landing Page Guide](/guides/landing-page.md)
|
||||||
@@ -3,7 +3,7 @@ type: api
|
|||||||
title: REST API Overview
|
title: REST API Overview
|
||||||
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
||||||
tags: [api, rest, overview, csrf]
|
tags: [api, rest, overview, csrf]
|
||||||
timestamp: 2026-07-21T14:43:00Z
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Base URL & versioning
|
# Base URL & versioning
|
||||||
@@ -40,8 +40,11 @@ prefix; the SPA fallback only kicks in for non-API routes.
|
|||||||
* Skip list (`SKIP_PATH_PREFIXES` in
|
* Skip list (`SKIP_PATH_PREFIXES` in
|
||||||
`backend/src/common/middleware/csrf.middleware.ts`):
|
`backend/src/common/middleware/csrf.middleware.ts`):
|
||||||
- `/api/v1/auth/register-first-admin`
|
- `/api/v1/auth/register-first-admin`
|
||||||
- `/api/v1/auth/login`
|
|
||||||
- `/api/v1/setup/create-admin`
|
- `/api/v1/setup/create-admin`
|
||||||
|
- `/api/v1/auth/login` is **no longer** skipped — the middleware still
|
||||||
|
mints the cookie on the first response, and the SPA's
|
||||||
|
`AuthService.login`/`AuthService.register` first call `GET /api/v1/auth/csrf`
|
||||||
|
via `ensureCsrf()` to materialise the cookie before submitting.
|
||||||
|
|
||||||
# Error envelope
|
# Error envelope
|
||||||
|
|
||||||
@@ -73,7 +76,8 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
|
|||||||
`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
|
`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
|
||||||
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
|
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
|
||||||
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
||||||
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `INTERNAL`.
|
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
|
||||||
|
`REGISTRATIONS_DISABLED`, `INTERNAL`.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: System Endpoints
|
||||||
|
description: Bootstrap payload, event status, and SSE streams.
|
||||||
|
tags: [api, system, bootstrap, event, sse]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|--------|---------------------------------|--------|-----------------------------------------------------|
|
||||||
|
| `GET` | `/api/v1/bootstrap` | Public | `backend/src/modules/system/system.controller.ts` |
|
||||||
|
| `GET` | `/api/v1/event/status` | Public | Same. |
|
||||||
|
| `GET` | `/api/v1/event/stream` | Public (SSE) | Same. |
|
||||||
|
| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. |
|
||||||
|
|
||||||
|
# `GET /api/v1/bootstrap`
|
||||||
|
|
||||||
|
Built by `SystemService.bootstrap()`. Public payload consumed by the
|
||||||
|
SPA's `BootstrapService`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"initialized": true,
|
||||||
|
"pageTitle": "HIPCTF",
|
||||||
|
"logo": "",
|
||||||
|
"welcomeMarkdown": "# Welcome\n\n...",
|
||||||
|
"theme": { "id": "classic", "tokens": { "...": "..." } },
|
||||||
|
"defaultChallengeIp": "127.0.0.1",
|
||||||
|
"registrationsEnabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `initialized` is `true` iff at least one admin user exists.
|
||||||
|
* `theme` is the resolved `Theme` (one of the 10 canonical themes; see
|
||||||
|
[Theming](/guides/theming.md)).
|
||||||
|
* `registrationsEnabled` is read from the `setting` table
|
||||||
|
(`SETTINGS_KEYS.REGISTRATIONS_ENABLED`) and gates the new public
|
||||||
|
register endpoint (see [Auth Endpoints](/api/auth.md)).
|
||||||
|
|
||||||
|
# `GET /api/v1/event/status`
|
||||||
|
|
||||||
|
Returns the current event window computed by `EventStatusService`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
`state` is `Stopped` when `now < eventStartUtc` (countdown), `Running`
|
||||||
|
between start and end, and `Stopped` once `now >= eventEndUtc`.
|
||||||
|
|
||||||
|
# SSE streams
|
||||||
|
|
||||||
|
* `/api/v1/event/stream` — emits the same payload as
|
||||||
|
`GET /api/v1/event/status` whenever `SseHubService.publish` is invoked
|
||||||
|
(typically via an admin cron tick).
|
||||||
|
* `/api/v1/scoreboard/stream` — emits the public scoreboard projection
|
||||||
|
after each new solve.
|
||||||
|
|
||||||
|
Both use NestJS `@Sse()` and are proxied through `SseHubService`
|
||||||
|
(`backend/src/common/services/sse-hub.service.ts`).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||||
|
- [Theming](/guides/theming.md)
|
||||||
|
- [Event Window](/guides/event-window.md)
|
||||||
|
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Uploads Endpoints
|
||||||
|
description: Admin-only multipart upload endpoints for category icons and challenge files.
|
||||||
|
tags: [api, uploads, multipart, admin]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|--------|---------------------------------------|-------|---------------------------------------------------------|
|
||||||
|
| `POST` | `/api/v1/uploads/category-icon` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. |
|
||||||
|
|
||||||
|
# Guard chain
|
||||||
|
|
||||||
|
Both handlers are gated by `JwtAuthGuard` + `AdminGuard`. CSRF is enforced
|
||||||
|
(standard cookie + header pattern; nothing is added to the skip list).
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
* Each endpoint uses `FileInterceptor('file')` and parses a single
|
||||||
|
multipart part named `file`.
|
||||||
|
* File size is validated by `parseUploadSizeLimit()` in
|
||||||
|
`backend/src/common/utils/upload.ts`.
|
||||||
|
* Filenames are sanitized by the same helper before being persisted
|
||||||
|
under `UPLOAD_DIR`.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
@@ -23,12 +23,13 @@ also registers two global providers:
|
|||||||
| `DatabaseModule` | `backend/src/database/database.module.ts` | Configures TypeORM with `better-sqlite3`, registers entities, runs migrations, enforces WAL. |
|
| `DatabaseModule` | `backend/src/database/database.module.ts` | Configures TypeORM with `better-sqlite3`, registers entities, runs migrations, enforces WAL. |
|
||||||
| `CommonModule` | `backend/src/common/common.module.ts` | Provides shared services (CSRF middleware class, backoff, registration rate limit, SSE hub, theme loader, etc.). |
|
| `CommonModule` | `backend/src/common/common.module.ts` | Provides shared services (CSRF middleware class, backoff, registration rate limit, SSE hub, theme loader, etc.). |
|
||||||
| `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). |
|
| `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). |
|
||||||
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).|
|
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin + public `register`). Imports `SettingsModule` so the public-register flow can read the `registrationsEnabled` flag. |
|
||||||
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). |
|
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). |
|
||||||
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
|
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
|
||||||
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
|
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
|
||||||
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. |
|
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. |
|
||||||
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
|
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
|
||||||
|
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
|
||||||
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
||||||
|
|
||||||
# Controllers
|
# Controllers
|
||||||
@@ -41,6 +42,7 @@ also registers two global providers:
|
|||||||
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
|
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
|
||||||
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
|
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
|
||||||
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
|
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||||
|
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
|
||||||
|
|
||||||
# Common services
|
# Common services
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Frontend Structure
|
title: Frontend Structure
|
||||||
description: Angular routes, components, services, guards, and interceptors.
|
description: Angular routes, components, services, guards, and interceptors.
|
||||||
tags: [architecture, frontend, angular]
|
tags: [architecture, frontend, angular]
|
||||||
timestamp: 2026-07-21T16:48:20Z
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
@@ -13,7 +13,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
|||||||
| Path | Component | Guard | Notes |
|
| Path | Component | Guard | Notes |
|
||||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. |
|
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. |
|
||||||
| `/login` | `LoginComponent` | — | Standard login form. |
|
| `/login` | `LandingComponent` | `landingGuard`| Public landing page that hosts the login (and registration, when enabled) modal. The `landingGuard` redirects to `/bootstrap` until the instance is initialized and to `/` if the user is already authenticated. |
|
||||||
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
||||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
||||||
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
||||||
@@ -28,15 +28,19 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
||||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
||||||
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
||||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
| `LoginComponent` | *(removed)* — replaced by `LandingComponent`. | |
|
||||||
|
| `LandingComponent` | `frontend/src/app/features/landing/landing.component.{ts,html,css}` | Public landing page: logo, page title, rendered `welcomeMarkdown`, "Login" button, blog post list, and the modal that contains both the login and registration forms (mode toggled by a single signal). |
|
||||||
|
| `LandingService` | `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals. |
|
||||||
|
| `LoginModalService` (pure helpers) | `frontend/src/app/features/landing/login-modal.service.ts` | Pure `buildLoginFailureMessage` that maps an API error envelope into user-facing copy (and extracts a `retryAfterSeconds` for `RATE_LIMITED`). |
|
||||||
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. |
|
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. |
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
|
|
||||||
| Service | Path | Purpose |
|
| Service | Path | Purpose |
|
||||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage` and exposes `restoreSession()` + `waitUntilHydrated()`. |
|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage`, exposes `restoreSession()` + `waitUntilHydrated()`, plus `login()` / `register()` (each calls `ensureCsrf()` then posts, returning a discriminated `LoginResult` / `RegisterResult`). |
|
||||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
||||||
|
| `MarkdownService` | `frontend/src/app/core/services/markdown.service.ts` | Wraps `DomSanitizer.bypassSecurityTrustHtml` around the pure `renderMarkdownToHtml` helper. Used to render `welcomeMarkdown` and blog post bodies. |
|
||||||
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
||||||
| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. |
|
| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. |
|
||||||
|
|
||||||
@@ -48,6 +52,8 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
|
| `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
|
||||||
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
||||||
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
||||||
|
| `landingGuard` | `frontend/src/app/core/guards/landing.guard.ts` | Async `CanActivateFn` for `/login`; awaits bootstrap + auth hydration, then delegates to the pure `decideLandingGuard`. Redirects to `/bootstrap` (not initialized) or `/` (already authenticated). |
|
||||||
|
| `decideLandingGuard`| `frontend/src/app/core/guards/landing.guard.decision.ts` | Pure decision function: returns `true` to render the landing page, or `UrlTree` to `/bootstrap` / `/`. |
|
||||||
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
||||||
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for every important source file in the repository.
|
description: One-line responsibility for every important source file in the repository.
|
||||||
tags: [architecture, index, key-files]
|
tags: [architecture, index, key-files]
|
||||||
timestamp: 2026-07-21T16:48:20Z
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
@@ -64,11 +64,11 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||||
| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`. |
|
| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`; imports `SettingsModule` for the public register flow. |
|
||||||
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{login,refresh,logout,csrf}`. |
|
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{register,login,refresh,logout,csrf}`. |
|
||||||
| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, mint JWT + refresh tokens. |
|
| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, public player self-registration (`registerPlayer`), session minting. |
|
||||||
| `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. |
|
| `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. |
|
||||||
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh. |
|
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh + `RegisterDtoSchema` (with `password === passwordConfirm` refine + username regex). |
|
||||||
| `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. |
|
| `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. |
|
||||||
| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService`. |
|
| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService`. |
|
||||||
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
|
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
|
||||||
@@ -87,6 +87,10 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. |
|
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. |
|
||||||
| `backend/src/modules/uploads/uploads.module.ts` | Wires `UploadsController`. |
|
| `backend/src/modules/uploads/uploads.module.ts` | Wires `UploadsController`. |
|
||||||
| `backend/src/modules/uploads/uploads.controller.ts` | `/api/v1/uploads/{category-icon,challenge-file}`. |
|
| `backend/src/modules/uploads/uploads.controller.ts` | `/api/v1/uploads/{category-icon,challenge-file}`. |
|
||||||
|
| `backend/src/modules/blog/blog.module.ts` | Wires `BlogController` + `BlogService` (registers `BlogPostEntity`). |
|
||||||
|
| `backend/src/modules/blog/blog.controller.ts` | `GET /api/v1/blog/posts` (public). |
|
||||||
|
| `backend/src/modules/blog/blog.service.ts` | `listPublished()` returns published posts ordered by `publishedAt DESC`. |
|
||||||
|
| `backend/src/modules/blog/dto/blog.dto.ts` | Zod schemas `PublicBlogPostSchema` + `PublicBlogListSchema`. |
|
||||||
| `backend/src/modules/settings/settings.module.ts` | `SettingsService` (get/set/getAll over the `setting` table). |
|
| `backend/src/modules/settings/settings.module.ts` | `SettingsService` (get/set/getAll over the `setting` table). |
|
||||||
| `backend/src/frontend/frontend.module.ts` | Wires SPA fallback + uploads static middleware. |
|
| `backend/src/frontend/frontend.module.ts` | Wires SPA fallback + uploads static middleware. |
|
||||||
| `backend/src/frontend/spa.controller.ts` | `SpaFallbackMiddleware` (returns `index.html` for client routes). |
|
| `backend/src/frontend/spa.controller.ts` | `SpaFallbackMiddleware` (returns `index.html` for client routes). |
|
||||||
@@ -108,17 +112,23 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
||||||
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. |
|
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. |
|
||||||
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
|
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
|
||||||
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()` and `waitUntilHydrated()`. |
|
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()`, `waitUntilHydrated()`, `login()`, `register()` (both with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`), `setSession()`. |
|
||||||
| `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. |
|
| `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. |
|
||||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; `load()` / `ready()` deduplicate the in-flight fetch. |
|
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; `load()` / `ready()` deduplicate the in-flight fetch. |
|
||||||
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
|
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
|
||||||
|
| `frontend/src/app/core/services/markdown.service.ts` | `MarkdownService` (Angular) wraps `DomSanitizer.bypassSecurityTrustHtml` around the rendered Markdown. |
|
||||||
|
| `frontend/src/app/core/services/markdown.pure.ts` | Pure `renderMarkdownToHtml` helper using `marked` + `DOMPurify` (kept in its own file so it can be unit-tested without Angular's ESM-only runtime). |
|
||||||
| `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
|
| `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
|
||||||
| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function for the auth guard (returns `true` or a redirect `UrlTree`). |
|
| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function for the auth guard (returns `true` or a redirect `UrlTree`). |
|
||||||
| `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn` for admin-only routes; awaits bootstrap + auth hydration, delegates to `decideAdminGuard`. |
|
| `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn` for admin-only routes; awaits bootstrap + auth hydration, delegates to `decideAdminGuard`. |
|
||||||
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
|
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
|
||||||
|
| `frontend/src/app/core/guards/landing.guard.ts` | Async `CanActivateFn` for `/login`; awaits bootstrap + auth hydration, then delegates to the pure `decideLandingGuard`. |
|
||||||
|
| `frontend/src/app/core/guards/landing.guard.decision.ts` | Pure decision: returns `true` to render the landing page, or `UrlTree` to `/bootstrap` (not initialized) or `/` (already authenticated). |
|
||||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
||||||
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
||||||
| `frontend/src/app/features/auth/login.component.ts` | Login form. |
|
| `frontend/src/app/features/landing/landing.component.{ts,html,css}` | Public landing page (logo, page title, rendered `welcomeMarkdown`, login button, blog list) + modal containing the login and registration forms (toggled by the same `mode` signal). |
|
||||||
|
| `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals, `refresh()`. |
|
||||||
|
| `frontend/src/app/features/landing/login-modal.service.ts` | Pure `buildLoginFailureMessage` helper that maps `{code,message}` into user-facing copy (handles `INVALID_CREDENTIALS`, `RATE_LIMITED`, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`). |
|
||||||
| `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list (signals: `loading`, `error`, `users`). |
|
| `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list (signals: `loading`, `error`, `users`). |
|
||||||
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
|
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
|
||||||
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
|
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
|
||||||
@@ -138,6 +148,12 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
|
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
|
||||||
| `tests/frontend/setup-create-admin.spec.ts` | Pure-Jest tests for `passwordMatchValidator` and the three field-error helpers (`usernameError`, `passwordError`, `confirmError`). |
|
| `tests/frontend/setup-create-admin.spec.ts` | Pure-Jest tests for `passwordMatchValidator` and the three field-error helpers (`usernameError`, `passwordError`, `confirmError`). |
|
||||||
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
|
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
|
||||||
|
| `tests/frontend/landing-guard.spec.ts` | Pins `decideLandingGuard` for the not-initialized / authenticated / anonymous branches. |
|
||||||
|
| `tests/frontend/landing-markdown.spec.ts` | Pins `renderMarkdownToHtml` (sanitization of raw `<script>` payloads, script URL schemes, etc.). |
|
||||||
|
| `tests/frontend/landing-modal.spec.ts` | Pins `buildLoginFailureMessage` (`INVALID_CREDENTIALS`, `RATE_LIMITED` with retry-seconds extraction, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`, default fallback). |
|
||||||
|
| `tests/backend/auth-register.spec.ts` | Backend integration tests for `POST /api/v1/auth/register` (happy path, disabled flag, duplicate username, weak password, rate limit). |
|
||||||
|
| `tests/backend/blog-public.spec.ts` | Backend integration tests for `GET /api/v1/blog/posts` (only published rows, descending order). |
|
||||||
|
| `tests/backend/csrf-protected-routes.spec.ts` | Pins CSRF enforcement on the formerly-skipped `/api/v1/auth/login` route. |
|
||||||
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
||||||
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
type: database
|
||||||
|
title: Blog Post Table
|
||||||
|
description: blog_post table — draft/published Markdown posts surfaced on the public landing page.
|
||||||
|
tags: [database, blog, markdown]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Schema
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|----------------|------------------|--------------------------------------------------------------|
|
||||||
|
| `id` | TEXT PK | UUID v4 (set by the service, not auto-generated). |
|
||||||
|
| `title` | TEXT | Post title. |
|
||||||
|
| `body_md` | TEXT (default `''`) | Markdown body. |
|
||||||
|
| `status` | TEXT (default `'draft'`) | One of `draft` or `published`. |
|
||||||
|
| `created_at` | TEXT | ISO 8601 (TypeORM `@CreateDateColumn`). |
|
||||||
|
| `updated_at` | TEXT | ISO 8601 (TypeORM `@UpdateDateColumn`). |
|
||||||
|
| `published_at` | TEXT, nullable | ISO 8601 timestamp the post was published (set when status flips). |
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
|
||||||
|
| Index | Columns | Purpose |
|
||||||
|
|------------------------------------|----------------------------------|------------------------------------------------------------|
|
||||||
|
| `idx_blog_status_published` | `(status, published_at)` | Lets `GET /api/v1/blog/posts` filter to `status='published'` and order by `published_at DESC` cheaply. |
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
* Only rows with `status = 'published'` are exposed through the public
|
||||||
|
endpoint `GET /api/v1/blog/posts` (see
|
||||||
|
[REST API Overview](/api/rest-overview.md)). Drafts are hidden.
|
||||||
|
* `BlogService.listPublished()` orders by `published_at DESC`, falling
|
||||||
|
back to `created_at` when `published_at` is null.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Database Schema Overview](/database/schema.md)
|
||||||
|
- [Landing Page Guide](/guides/landing-page.md)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Event Window
|
||||||
|
description: How the live event countdown is computed and surfaced via REST + SSE.
|
||||||
|
tags: [guide, event, countdown, sse]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
Two `setting` rows control the window:
|
||||||
|
|
||||||
|
| Setting key | Default (seeded) | Description |
|
||||||
|
|-------------------|------------------|--------------------------------------------|
|
||||||
|
| `eventStartUtc` | `now + 60s` | ISO 8601 instant the event becomes live. |
|
||||||
|
| `eventEndUtc` | `now + 7d` | ISO 8601 instant the event ends. |
|
||||||
|
|
||||||
|
Both are seeded by `SeedSystemData1700000000100` and editable through
|
||||||
|
`SettingsService`.
|
||||||
|
|
||||||
|
# State computation
|
||||||
|
|
||||||
|
`EventStatusService.getStatus(now)` (`backend/src/common/services/event-status.service.ts`)
|
||||||
|
returns one of three branches:
|
||||||
|
|
||||||
|
| Condition | `status` | `countdownMs` |
|
||||||
|
|-------------------------|----------|----------------------|
|
||||||
|
| `now < eventStartUtc` | `Stopped` (countdown) | `eventStartUtc - now` |
|
||||||
|
| `now ∈ [start, end]` | `Running` | `eventEndUtc - now` |
|
||||||
|
| `now >= eventEndUtc` | `Stopped` (ended) | `0` |
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
* `GET /api/v1/event/status` — one-shot read (see
|
||||||
|
[System Endpoints](/api/system.md)).
|
||||||
|
* `GET /api/v1/event/stream` — SSE stream that pushes the same payload
|
||||||
|
whenever `SseHubService.publish()` is called (typically by an admin
|
||||||
|
cron tick). The SPA subscribes to keep the countdown ticking without
|
||||||
|
polling.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||||
|
- [System Endpoints](/api/system.md)
|
||||||
|
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Landing Page
|
||||||
|
description: How the public landing page renders, how the login + registration modal is opened, and what each form does.
|
||||||
|
tags: [guide, landing, login, register, ui]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# What you see
|
||||||
|
|
||||||
|
`LandingComponent` (`frontend/src/app/features/landing/landing.component.{ts,html,css}`)
|
||||||
|
is the route mounted at `/login` (the path is historic — the page is now
|
||||||
|
the public landing page, not a bare login form).
|
||||||
|
|
||||||
|
On a fresh visit the page shows, in order:
|
||||||
|
|
||||||
|
1. **Logo** — `<img data-testid="landing-logo">` rendered from
|
||||||
|
`BootstrapService.payload().logo` (empty string → the `<img>` is not
|
||||||
|
rendered).
|
||||||
|
2. **Page title** — `<h1 data-testid="landing-title">` rendered from
|
||||||
|
`BootstrapService.payload().pageTitle` (defaults to `HIPCTF`).
|
||||||
|
3. **Welcome card** — `<article data-testid="landing-welcome">` whose
|
||||||
|
`innerHTML` is the sanitized render of
|
||||||
|
`BootstrapService.payload().welcomeMarkdown` (via
|
||||||
|
`MarkdownService.render` → `marked` + `DOMPurify`).
|
||||||
|
4. **Login button** — `<button data-testid="landing-open-login">`.
|
||||||
|
5. **Blog list** — `<section data-testid="landing-blog">` listing every
|
||||||
|
row returned by `GET /api/v1/blog/posts` (only `status='published'`
|
||||||
|
rows), or the text `No announcements yet.` when the list is empty.
|
||||||
|
|
||||||
|
# Guard
|
||||||
|
|
||||||
|
`/login` is gated by `landingGuard`
|
||||||
|
(`frontend/src/app/core/guards/landing.guard.ts`):
|
||||||
|
|
||||||
|
| `initialized` | `isAuthenticated` | Result |
|
||||||
|
|---------------|-------------------|-----------------------------------|
|
||||||
|
| `false` | any | Redirect to `/bootstrap` (first-admin modal). |
|
||||||
|
| `true` | `true` | Redirect to `/` (already signed in). |
|
||||||
|
| `true` | `false` | Render the landing page. |
|
||||||
|
|
||||||
|
# Login modal
|
||||||
|
|
||||||
|
Clicking the **Login** button sets `mode = 'login'`, which renders an
|
||||||
|
overlay containing:
|
||||||
|
|
||||||
|
| Field | Selector (`data-testid`) | Notes |
|
||||||
|
|------------|--------------------------|-----------------------------------------|
|
||||||
|
| Username | `login-username` | `autocomplete="username"` |
|
||||||
|
| Password | `login-password` | `autocomplete="current-password"` |
|
||||||
|
| Field error| `login-field-error` | Inline `required` message. |
|
||||||
|
| Server error | `login-server-error` | Mapped via `buildLoginFailureMessage`. |
|
||||||
|
| Submit | `login-submit` | Disabled while `submitting()` is true. |
|
||||||
|
| Switch | `login-switch-btn` | Visible only when `registrationsEnabled` is true; opens the registration modal. |
|
||||||
|
|
||||||
|
Submission flow:
|
||||||
|
|
||||||
|
1. `submitLogin()` → `AuthService.login({ username, password })`.
|
||||||
|
2. On `ok: true`, `AuthService.setSession(...)` is called and the user
|
||||||
|
is navigated to `/`.
|
||||||
|
3. On `ok: false`, the modal renders the mapped failure text and a
|
||||||
|
`(Ns remaining)` hint when `code === 'RATE_LIMITED'` and the server
|
||||||
|
message contains a wait-seconds value.
|
||||||
|
|
||||||
|
# Registration modal
|
||||||
|
|
||||||
|
When `bootstrap.payload().registrationsEnabled === true`, the **Login**
|
||||||
|
modal shows a "Register here" link (`login-switch-btn`) that swaps the
|
||||||
|
form to `mode = 'register'`. The registration form contains:
|
||||||
|
|
||||||
|
| Field | Selector (`data-testid`) | Notes |
|
||||||
|
|-----------------|--------------------------|------------------------------------------------------|
|
||||||
|
| Username | `register-username` | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$`. |
|
||||||
|
| Password | `register-password` | Argon2id policy enforced server-side. |
|
||||||
|
| Confirm password| `register-confirm` | Must match (group-level `passwordMatchValidator`). |
|
||||||
|
| Field error | `register-field-error` | Inline message covering required / minLength / maxLength / pattern / mismatch. |
|
||||||
|
| Server error | `register-server-error` | Warn-styled when `code` is `RATE_LIMITED` or `REGISTRATIONS_DISABLED`. |
|
||||||
|
| Submit | `register-submit` | Disabled while submitting. |
|
||||||
|
| Switch | `register-switch-btn` | Goes back to the login form. |
|
||||||
|
|
||||||
|
Submission calls `AuthService.register(...)` which posts to
|
||||||
|
`POST /api/v1/auth/register`. On `ok: true` the user is signed in
|
||||||
|
immediately and navigated to `/`.
|
||||||
|
|
||||||
|
The modal is dismissible via the **×** button (`landing-modal-close`),
|
||||||
|
clicking the overlay backdrop, or pressing `Escape`. Submit-in-flight
|
||||||
|
dismissal is suppressed.
|
||||||
|
|
||||||
|
# Server error code → user copy
|
||||||
|
|
||||||
|
`buildLoginFailureMessage` (`frontend/src/app/features/landing/login-modal.service.ts`)
|
||||||
|
maps the API error envelope into modal copy:
|
||||||
|
|
||||||
|
| Code | Rendered text |
|
||||||
|
|----------------------------|------------------------------------------------------------|
|
||||||
|
| `INVALID_CREDENTIALS` | `Invalid username or password.` |
|
||||||
|
| `RATE_LIMITED` | `Please wait N seconds before trying again.` + `(Ns remaining)` hint when `N` is found in the server message. |
|
||||||
|
| `CSRF_INVALID` | `Session expired. Please reload the page and try again.` |
|
||||||
|
| `USERNAME_TAKEN` | `Username already exists.` |
|
||||||
|
| `WEAK_PASSWORD` | `Password does not meet the security policy.` |
|
||||||
|
| `REGISTRATIONS_DISABLED` | `Registrations are currently disabled.` |
|
||||||
|
| `VALIDATION_FAILED` | `Please check the form fields and try again.` |
|
||||||
|
| other / default | Raw `message` (or `Something went wrong. Please try again.` if empty). |
|
||||||
|
|
||||||
|
# CSRF
|
||||||
|
|
||||||
|
`AuthService.ensureCsrf()` (`frontend/src/app/core/services/auth.service.ts`)
|
||||||
|
calls `GET /api/v1/auth/csrf` before both `login()` and `register()` to
|
||||||
|
materialise the `csrf` cookie if it isn't already set. The call swallows
|
||||||
|
errors because the CSRF middleware also mints the cookie on the
|
||||||
|
response to the unsafe call itself.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Auth Endpoints](/api/auth.md)
|
||||||
|
- [Database Schema Overview](/database/schema.md) (`blog_post`)
|
||||||
|
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||||
|
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Scoreboard Stream
|
||||||
|
description: How live solves are broadcast to the SPA over Server-Sent Events.
|
||||||
|
tags: [guide, scoreboard, sse, stream]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoint
|
||||||
|
|
||||||
|
`GET /api/v1/scoreboard/stream` (`@Sse()` handler in
|
||||||
|
`backend/src/modules/system/system.controller.ts`) opens an SSE stream
|
||||||
|
that pushes the public scoreboard projection every time a new solve is
|
||||||
|
recorded.
|
||||||
|
|
||||||
|
# Push path
|
||||||
|
|
||||||
|
1. The challenge solve path (admin validation, player submit) updates the
|
||||||
|
`solve` table.
|
||||||
|
2. The same code path calls `SseHubService.publish('scoreboard', payload)`
|
||||||
|
(`backend/src/common/services/sse-hub.service.ts`).
|
||||||
|
3. Every subscriber to `/api/v1/scoreboard/stream` receives the payload.
|
||||||
|
|
||||||
|
The hub is in-process; multi-replica deployments need a shared pub/sub
|
||||||
|
to fan out across pods (not in scope for this repo).
|
||||||
|
|
||||||
|
# Frontend consumer
|
||||||
|
|
||||||
|
The authenticated SPA shell subscribes on mount and updates the scoreboard
|
||||||
|
view whenever a new event arrives. The exact rendering lives inside the
|
||||||
|
authenticated shell's scoreboard component (see
|
||||||
|
[Frontend Structure](/architecture/frontend-structure.md)).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Event Window](/guides/event-window.md)
|
||||||
|
- [System Endpoints](/api/system.md)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Theming
|
||||||
|
description: How the 10 canonical themes are loaded, validated, and applied to the SPA via CSS custom properties.
|
||||||
|
tags: [guide, theming, design]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theme catalog
|
||||||
|
|
||||||
|
HIPCTF ships 10 canonical themes defined as JSON files in
|
||||||
|
`backend/themes/` (`01-classic.json` … `10-monochrome.json`). The full
|
||||||
|
list lives in `backend/src/common/types/theme-ids.ts`:
|
||||||
|
|
||||||
|
`classic`, `midnight`, `sunset`, `forest`, `cyber`, `paper`, `crimson`,
|
||||||
|
`ocean`, `neon`, `monochrome`.
|
||||||
|
|
||||||
|
Each theme is a flat map of CSS custom properties (colors, radii, font
|
||||||
|
family) consumed by `frontend/src/styles.css`.
|
||||||
|
|
||||||
|
# Backend loading
|
||||||
|
|
||||||
|
`ThemeLoaderService` (`backend/src/common/utils/theme-loader.service.ts`)
|
||||||
|
runs on module init:
|
||||||
|
|
||||||
|
1. Reads every `*.json` file in `backend/themes/`.
|
||||||
|
2. Validates each against the `Theme` interface
|
||||||
|
(`backend/src/common/types/theme.ts`) — unknown theme ids, malformed
|
||||||
|
JSON, or missing tokens throw `ApiError(THEME_INVALID)`.
|
||||||
|
3. Backfills any of the 10 canonical themes that are missing from disk
|
||||||
|
(safety net so a partial checkout still boots).
|
||||||
|
4. Reads `SETTINGS_KEYS.THEME_KEY` (default `'classic'`). If the value is
|
||||||
|
not in `THEME_IDS`, the loader falls back to `classic`, logs a
|
||||||
|
warning, and persists `'classic'` back to settings so the next request
|
||||||
|
is self-healing.
|
||||||
|
|
||||||
|
The resolved theme object is sent to the SPA in the
|
||||||
|
`GET /api/v1/bootstrap` payload (see [System Endpoints](/api/system.md)).
|
||||||
|
|
||||||
|
# Frontend application
|
||||||
|
|
||||||
|
`BootstrapService` (`frontend/src/app/core/services/bootstrap.service.ts`)
|
||||||
|
writes each theme token onto `document.documentElement` (e.g.
|
||||||
|
`--color-primary`, `--radius-md`, `--font-family`). Because `styles.css`
|
||||||
|
references the tokens via `var(--token)`, swapping themes is instant.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [System Endpoints](/api/system.md)
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
+4
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
|||||||
controls.
|
controls.
|
||||||
|
|
||||||
The docs below are organized by purpose so agents can pull just the slice
|
The docs below are organized by purpose so agents can pull just the slice
|
||||||
they need. Last regenerated 2026-07-21T17:37:18Z.
|
they need. Last regenerated 2026-07-21T18:28:00Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -61,6 +61,9 @@ they need. Last regenerated 2026-07-21T17:37:18Z.
|
|||||||
the SPA keeps a user signed in across hard refreshes and how the
|
the SPA keeps a user signed in across hard refreshes and how the
|
||||||
guards wait for bootstrap + auth hydration before deciding where to
|
guards wait for bootstrap + auth hydration before deciding where to
|
||||||
route.
|
route.
|
||||||
|
* [Landing Page](/guides/landing-page.md) - Public landing page
|
||||||
|
(logo, welcome Markdown, blog list) and the login + registration modal
|
||||||
|
rendered at `/login` once the instance is initialized.
|
||||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||||
countdown work.
|
countdown work.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"@angular/platform-browser": "^17.3.0",
|
"@angular/platform-browser": "^17.3.0",
|
||||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||||
"@angular/router": "^17.3.0",
|
"@angular/router": "^17.3.0",
|
||||||
|
"dompurify": "^3.1.6",
|
||||||
|
"marked": "^14.1.3",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"zone.js": "~0.14.4"
|
"zone.js": "~0.14.4"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { authGuard } from './core/guards/auth.guard';
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
import { adminGuard } from './core/guards/admin.guard';
|
import { adminGuard } from './core/guards/admin.guard';
|
||||||
|
import { landingGuard } from './core/guards/landing.guard';
|
||||||
|
|
||||||
export const APP_ROUTES: Routes = [
|
export const APP_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
@@ -12,7 +13,9 @@ export const APP_ROUTES: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
loadComponent: () => import('./features/auth/login.component').then((m) => m.LoginComponent),
|
loadComponent: () =>
|
||||||
|
import('./features/landing/landing.component').then((m) => m.LandingComponent),
|
||||||
|
canActivate: [landingGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Router } from '@angular/router';
|
||||||
|
|
||||||
|
export interface LandingGuardInput {
|
||||||
|
initialized: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideLandingGuard(
|
||||||
|
input: LandingGuardInput,
|
||||||
|
router: Pick<Router, 'createUrlTree'>,
|
||||||
|
): true | ReturnType<Router['createUrlTree']> {
|
||||||
|
if (!input.initialized) {
|
||||||
|
return router.createUrlTree(['/bootstrap']);
|
||||||
|
}
|
||||||
|
if (input.isAuthenticated) {
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { BootstrapService } from '../services/bootstrap.service';
|
||||||
|
import { decideLandingGuard } from './landing.guard.decision';
|
||||||
|
|
||||||
|
export { decideLandingGuard } from './landing.guard.decision';
|
||||||
|
export type { LandingGuardInput } from './landing.guard.decision';
|
||||||
|
|
||||||
|
export const landingGuard: CanActivateFn = async () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const bootstrap = inject(BootstrapService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
await bootstrap.ready();
|
||||||
|
await auth.waitUntilHydrated();
|
||||||
|
|
||||||
|
return decideLandingGuard(
|
||||||
|
{
|
||||||
|
initialized: bootstrap.initialized(),
|
||||||
|
isAuthenticated: auth.isAuthenticated(),
|
||||||
|
},
|
||||||
|
router,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
readStoredSession,
|
readStoredSession,
|
||||||
@@ -20,6 +20,38 @@ interface RefreshResponse {
|
|||||||
user: CurrentUser;
|
user: CurrentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginSuccess {
|
||||||
|
ok: true;
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
user: CurrentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginFailure {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginResult = LoginSuccess | LoginFailure;
|
||||||
|
|
||||||
|
export interface RegisterSuccess {
|
||||||
|
ok: true;
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
user: CurrentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterFailure {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegisterResult = RegisterSuccess | RegisterFailure;
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private http: HttpClient;
|
private http: HttpClient;
|
||||||
@@ -36,6 +68,64 @@ export class AuthService {
|
|||||||
readonly currentUser = computed(() => this.user());
|
readonly currentUser = computed(() => this.user());
|
||||||
readonly isAuthenticated = computed(() => !!this.user());
|
readonly isAuthenticated = computed(() => !!this.user());
|
||||||
|
|
||||||
|
async ensureCsrf(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await firstValueFrom(
|
||||||
|
this.http.get('/api/v1/auth/csrf', { withCredentials: true }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// middleware will still mint the cookie on the next response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(creds: { username: string; password: string }): Promise<LoginResult> {
|
||||||
|
await this.ensureCsrf();
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
|
||||||
|
'/api/v1/auth/login',
|
||||||
|
creds,
|
||||||
|
{ withCredentials: true, observe: 'response' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const success: LoginSuccess = {
|
||||||
|
ok: true,
|
||||||
|
accessToken: res.body!.accessToken,
|
||||||
|
expiresIn: res.body!.expiresIn,
|
||||||
|
user: res.body!.user,
|
||||||
|
};
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
return mapAuthError(e as HttpErrorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(creds: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
}): Promise<RegisterResult> {
|
||||||
|
await this.ensureCsrf();
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
|
||||||
|
'/api/v1/auth/register',
|
||||||
|
creds,
|
||||||
|
{ withCredentials: true, observe: 'response' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const success: RegisterSuccess = {
|
||||||
|
ok: true,
|
||||||
|
accessToken: res.body!.accessToken,
|
||||||
|
expiresIn: res.body!.expiresIn,
|
||||||
|
user: res.body!.user,
|
||||||
|
};
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
return mapAuthError(e as HttpErrorResponse) as RegisterFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSession(token: string, user: CurrentUser): void {
|
setSession(token: string, user: CurrentUser): void {
|
||||||
this.accessToken.set(token);
|
this.accessToken.set(token);
|
||||||
this.user.set(user);
|
this.user.set(user);
|
||||||
@@ -104,4 +194,14 @@ export class AuthService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAuthError(err: HttpErrorResponse): LoginFailure {
|
||||||
|
const body = err.error as { code?: string; message?: string } | null;
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: err.status ?? 0,
|
||||||
|
code: body?.code ?? 'INTERNAL',
|
||||||
|
message: body?.message ?? err.message ?? 'Authentication failed',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper: render Markdown to a sanitized HTML string. Kept in its own
|
||||||
|
* file so it can be unit-tested without pulling in Angular's ESM-only
|
||||||
|
* runtime.
|
||||||
|
*/
|
||||||
|
export function renderMarkdownToHtml(md: string | null | undefined): string {
|
||||||
|
const html = marked.parse(md ?? '', { async: false }) as string;
|
||||||
|
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { renderMarkdownToHtml } from './markdown.pure';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MarkdownService {
|
||||||
|
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||||
|
|
||||||
|
render(md: string | null | undefined): SafeHtml {
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(renderMarkdownToHtml(md));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-login',
|
|
||||||
standalone: true,
|
|
||||||
imports: [FormsModule],
|
|
||||||
template: `
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<h1>Sign in</h1>
|
|
||||||
<label>Username<input [(ngModel)]="username" name="u" /></label>
|
|
||||||
<label>Password<input [(ngModel)]="password" name="p" type="password" /></label>
|
|
||||||
<p style="color:var(--color-danger)">{{ error }}</p>
|
|
||||||
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Signing in...' : 'Sign in' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class LoginComponent {
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
private auth = inject(AuthService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
username = '';
|
|
||||||
password = '';
|
|
||||||
loading = false;
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
async submit(): Promise<void> {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = '';
|
|
||||||
try {
|
|
||||||
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
|
|
||||||
const res: any = await this.http
|
|
||||||
.post('/api/v1/auth/login', { username: this.username, password: this.password }, { withCredentials: true })
|
|
||||||
.toPromise();
|
|
||||||
this.auth.setSession(res.accessToken, res.user);
|
|
||||||
await this.router.navigateByUrl('/');
|
|
||||||
} catch (e: any) {
|
|
||||||
this.error = e?.error?.message ?? 'Failed';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
.landing {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-logo {
|
||||||
|
max-height: 120px;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-welcome {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid var(--color-surface, #eee);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
background: var(--color-surface, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-btn {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-blog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-blog-item {
|
||||||
|
border-top: 1px solid var(--color-surface, #eee);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-blog-title {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-blog-date {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-blog-body {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-blog-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #222);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--radius-lg, 8px);
|
||||||
|
width: min(420px, 92vw);
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-surface, #ccc);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--color-danger, #c00);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
background: rgba(204, 0, 0, 0.08);
|
||||||
|
color: var(--color-danger, #c00);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error-warn {
|
||||||
|
background: rgba(204, 153, 0, 0.12);
|
||||||
|
color: var(--color-warning, #b80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error-hint {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--color-primary, #06c);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-link-row {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary, #06c);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<section class="landing">
|
||||||
|
<header class="landing-header">
|
||||||
|
@if (logo()) {
|
||||||
|
<img class="landing-logo" [src]="logo()" alt="" data-testid="landing-logo" />
|
||||||
|
}
|
||||||
|
<h1 class="landing-title" data-testid="landing-title">{{ pageTitle() }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="landing-welcome" [innerHTML]="welcomeHtml" data-testid="landing-welcome"></article>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="landing-login-btn"
|
||||||
|
(click)="openLogin()"
|
||||||
|
data-testid="landing-open-login"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (landing.posts().length > 0) {
|
||||||
|
<section class="landing-blog" data-testid="landing-blog">
|
||||||
|
@for (post of landing.posts(); track post.id) {
|
||||||
|
<article class="landing-blog-item">
|
||||||
|
<h2 class="landing-blog-title">{{ post.title }}</h2>
|
||||||
|
<time class="landing-blog-date">{{ post.publishedAt | date: 'medium' }}</time>
|
||||||
|
<div class="landing-blog-body" [innerHTML]="markdown.render(post.bodyMd)"></div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
} @else if (!landing.loading()) {
|
||||||
|
<p class="landing-blog-empty">No announcements yet.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (mode() !== 'closed') {
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
(click)="closeModal()"
|
||||||
|
data-testid="landing-modal-overlay"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-labelledby]="mode() === 'login' ? 'login-title' : 'register-title'"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-close"
|
||||||
|
(click)="closeModal()"
|
||||||
|
aria-label="Close"
|
||||||
|
data-testid="landing-modal-close"
|
||||||
|
>×</button>
|
||||||
|
|
||||||
|
@if (mode() === 'login') {
|
||||||
|
<h2 id="login-title">Sign in</h2>
|
||||||
|
<form [formGroup]="loginForm" (ngSubmit)="submitLogin()" novalidate>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
formControlName="username"
|
||||||
|
data-testid="login-username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
formControlName="password"
|
||||||
|
data-testid="login-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
@if (loginError()) {
|
||||||
|
<small class="field-error" data-testid="login-field-error">{{ loginError() }}</small>
|
||||||
|
}
|
||||||
|
@if (errorText()) {
|
||||||
|
<div
|
||||||
|
class="server-error"
|
||||||
|
role="alert"
|
||||||
|
[class.server-error-warn]="errorCode() === 'RATE_LIMITED'"
|
||||||
|
data-testid="login-server-error"
|
||||||
|
>
|
||||||
|
<span>{{ errorText() }}</span>
|
||||||
|
@if (retryAfterSeconds() !== null) {
|
||||||
|
<span class="server-error-hint">
|
||||||
|
({{ retryAfterSeconds() }}s remaining)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary"
|
||||||
|
[disabled]="submitting()"
|
||||||
|
data-testid="login-submit"
|
||||||
|
>
|
||||||
|
{{ submitting() ? 'Signing in...' : 'Login' }}
|
||||||
|
</button>
|
||||||
|
@if (registrationsEnabled()) {
|
||||||
|
<p class="modal-link-row" data-testid="login-switch">
|
||||||
|
No account?
|
||||||
|
<button type="button" class="link" (click)="switchToRegister()" data-testid="login-switch-btn">
|
||||||
|
Register here
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (mode() === 'register') {
|
||||||
|
<h2 id="register-title">Create your account</h2>
|
||||||
|
<form [formGroup]="registerForm" (ngSubmit)="submitRegister()" novalidate>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
formControlName="username"
|
||||||
|
data-testid="register-username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
formControlName="password"
|
||||||
|
data-testid="register-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
formControlName="passwordConfirm"
|
||||||
|
data-testid="register-confirm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
@if (registerError()) {
|
||||||
|
<small class="field-error" data-testid="register-field-error">{{ registerError() }}</small>
|
||||||
|
}
|
||||||
|
@if (errorText()) {
|
||||||
|
<div
|
||||||
|
class="server-error"
|
||||||
|
role="alert"
|
||||||
|
[class.server-error-warn]="errorCode() === 'RATE_LIMITED' || errorCode() === 'REGISTRATIONS_DISABLED'"
|
||||||
|
data-testid="register-server-error"
|
||||||
|
>
|
||||||
|
<span>{{ errorText() }}</span>
|
||||||
|
@if (retryAfterSeconds() !== null) {
|
||||||
|
<span class="server-error-hint">
|
||||||
|
({{ retryAfterSeconds() }}s remaining)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary"
|
||||||
|
[disabled]="submitting()"
|
||||||
|
data-testid="register-submit"
|
||||||
|
>
|
||||||
|
{{ submitting() ? 'Registering...' : 'Register' }}
|
||||||
|
</button>
|
||||||
|
<p class="modal-link-row" data-testid="register-switch">
|
||||||
|
Already have an account?
|
||||||
|
<button type="button" class="link" (click)="switchToLogin()" data-testid="register-switch-btn">
|
||||||
|
Login here
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
HostListener,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||||
|
import { MarkdownService } from '../../core/services/markdown.service';
|
||||||
|
import { LandingService } from './landing.service';
|
||||||
|
import { buildLoginFailureMessage } from './login-modal.service';
|
||||||
|
import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
|
||||||
|
|
||||||
|
type ModalMode = 'closed' | 'login' | 'register';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-landing',
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './landing.component.html',
|
||||||
|
styleUrls: ['./landing.component.css'],
|
||||||
|
})
|
||||||
|
export class LandingComponent implements OnInit {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly bootstrap = inject(BootstrapService);
|
||||||
|
private readonly markdown = inject(MarkdownService);
|
||||||
|
readonly landing = inject(LandingService);
|
||||||
|
|
||||||
|
readonly mode = signal<ModalMode>('closed');
|
||||||
|
readonly submitting = signal(false);
|
||||||
|
readonly errorText = signal<string | null>(null);
|
||||||
|
readonly errorCode = signal<string | null>(null);
|
||||||
|
readonly retryAfterSeconds = signal<number | null>(null);
|
||||||
|
|
||||||
|
readonly pageTitle = computed(() => this.bootstrap.payload()?.pageTitle ?? 'HIPCTF');
|
||||||
|
readonly logo = computed(() => this.bootstrap.payload()?.logo ?? '');
|
||||||
|
readonly welcomeHtml = computed(() => this.markdown.render(this.bootstrap.payload()?.welcomeMarkdown ?? ''));
|
||||||
|
readonly registrationsEnabled = computed(
|
||||||
|
() => this.bootstrap.payload()?.registrationsEnabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly loginForm = this.fb.nonNullable.group({
|
||||||
|
username: this.fb.nonNullable.control('', [Validators.required]),
|
||||||
|
password: this.fb.nonNullable.control('', [Validators.required]),
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly registerForm = this.fb.nonNullable.group(
|
||||||
|
{
|
||||||
|
username: this.fb.nonNullable.control('', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(3),
|
||||||
|
Validators.maxLength(32),
|
||||||
|
Validators.pattern(/^[a-zA-Z0-9_.-]+$/),
|
||||||
|
]),
|
||||||
|
password: this.fb.nonNullable.control('', [Validators.required]),
|
||||||
|
passwordConfirm: this.fb.nonNullable.control('', [Validators.required]),
|
||||||
|
},
|
||||||
|
{ validators: passwordMatchValidator },
|
||||||
|
);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
void this.landing.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
openLogin(): void {
|
||||||
|
this.mode.set('login');
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
openRegister(): void {
|
||||||
|
this.mode.set('register');
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.submitting()) return;
|
||||||
|
this.mode.set('closed');
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToLogin(): void {
|
||||||
|
this.mode.set('login');
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToRegister(): void {
|
||||||
|
this.mode.set('register');
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginError(): string | null {
|
||||||
|
const c = this.loginForm.controls;
|
||||||
|
if (!c.username.touched && !c.password.touched) return null;
|
||||||
|
if (c.username.errors?.['required']) return 'Username is required';
|
||||||
|
if (c.password.errors?.['required']) return 'Password is required';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerError(): string | null {
|
||||||
|
const c = this.registerForm.controls;
|
||||||
|
if (!c.username.touched && !c.password.touched && !c.passwordConfirm.touched && !this.registerForm.errors) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (c.username.errors?.['required']) return 'Username is required';
|
||||||
|
if (c.username.errors?.['minlength']) return 'Username must be at least 3 characters';
|
||||||
|
if (c.username.errors?.['maxlength']) return 'Username must be at most 32 characters';
|
||||||
|
if (c.username.errors?.['pattern']) {
|
||||||
|
return 'Username may only contain letters, digits, dot, dash and underscore';
|
||||||
|
}
|
||||||
|
if (c.password.errors?.['required']) return 'Password is required';
|
||||||
|
if (c.passwordConfirm.errors?.['required']) return 'Please confirm your password';
|
||||||
|
if (this.registerForm.errors?.['passwordMismatch']) return 'Passwords do not match';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitLogin(): Promise<void> {
|
||||||
|
if (this.submitting()) return;
|
||||||
|
this.loginForm.markAllAsTouched();
|
||||||
|
if (this.loginForm.invalid) return;
|
||||||
|
this.submitting.set(true);
|
||||||
|
this.clearError();
|
||||||
|
const { username, password } = this.loginForm.getRawValue();
|
||||||
|
const result: { ok: true; accessToken: string; expiresIn: number; user: any } | {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
} = await this.auth.login({ username, password });
|
||||||
|
this.submitting.set(false);
|
||||||
|
if (result.ok === true) {
|
||||||
|
this.auth.setSession(result.accessToken, result.user);
|
||||||
|
this.mode.set('closed');
|
||||||
|
await this.router.navigateByUrl('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.ok === false) {
|
||||||
|
this.showFailure(result.code, result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitRegister(): Promise<void> {
|
||||||
|
if (this.submitting()) return;
|
||||||
|
this.registerForm.markAllAsTouched();
|
||||||
|
if (this.registerForm.invalid) return;
|
||||||
|
this.submitting.set(true);
|
||||||
|
this.clearError();
|
||||||
|
const { username, password, passwordConfirm } = this.registerForm.getRawValue();
|
||||||
|
const result: { ok: true; accessToken: string; expiresIn: number; user: any } | {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
} = await this.auth.register({ username, password, passwordConfirm });
|
||||||
|
this.submitting.set(false);
|
||||||
|
if (result.ok === true) {
|
||||||
|
this.auth.setSession(result.accessToken, result.user);
|
||||||
|
this.mode.set('closed');
|
||||||
|
await this.router.navigateByUrl('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.ok === false) {
|
||||||
|
this.showFailure(result.code, result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showFailure(code: string, message: string): void {
|
||||||
|
this.errorCode.set(code);
|
||||||
|
const built = buildLoginFailureMessage({ code, message });
|
||||||
|
this.errorText.set(built.text);
|
||||||
|
this.retryAfterSeconds.set(built.retryAfterSeconds ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearError(): void {
|
||||||
|
this.errorCode.set(null);
|
||||||
|
this.errorText.set(null);
|
||||||
|
this.retryAfterSeconds.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
export interface PublicBlogPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishedAt: string;
|
||||||
|
bodyMd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LandingService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
readonly posts = signal<PublicBlogPost[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.get<{ posts: PublicBlogPost[] }>('/api/v1/blog/posts', {
|
||||||
|
withCredentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.posts.set(res.posts ?? []);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error.set(e?.error?.message ?? 'Failed to load blog posts');
|
||||||
|
this.posts.set([]);
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface FailureMessageInput {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailureMessageOutput {
|
||||||
|
text: string;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRY_REGEX = /try again in (\d+)\s*s/i;
|
||||||
|
const WAIT_REGEX = /wait (\d+)\s*s/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper that translates an API error envelope into user-facing copy.
|
||||||
|
* Returns the wait-seconds for backoff-style errors so the UI can show a
|
||||||
|
* "please wait N seconds" hint.
|
||||||
|
*/
|
||||||
|
export function buildLoginFailureMessage(input: FailureMessageInput): FailureMessageOutput {
|
||||||
|
switch (input.code) {
|
||||||
|
case 'INVALID_CREDENTIALS':
|
||||||
|
return { text: 'Invalid username or password.' };
|
||||||
|
case 'RATE_LIMITED': {
|
||||||
|
const m = RETRY_REGEX.exec(input.message) || WAIT_REGEX.exec(input.message);
|
||||||
|
const seconds = m ? parseInt(m[1], 10) : undefined;
|
||||||
|
return seconds
|
||||||
|
? {
|
||||||
|
text: `Please wait ${seconds} seconds before trying again.`,
|
||||||
|
retryAfterSeconds: seconds,
|
||||||
|
}
|
||||||
|
: { text: 'Too many attempts. Please wait a moment before trying again.' };
|
||||||
|
}
|
||||||
|
case 'CSRF_INVALID':
|
||||||
|
return { text: 'Session expired. Please reload the page and try again.' };
|
||||||
|
case 'USERNAME_TAKEN':
|
||||||
|
return { text: 'Username already exists.' };
|
||||||
|
case 'WEAK_PASSWORD':
|
||||||
|
return { text: 'Password does not meet the security policy.' };
|
||||||
|
case 'REGISTRATIONS_DISABLED':
|
||||||
|
return { text: 'Registrations are currently disabled.' };
|
||||||
|
case 'VALIDATION_FAILED':
|
||||||
|
return { text: 'Please check the form fields and try again.' };
|
||||||
|
default:
|
||||||
|
return { text: input.message || 'Something went wrong. Please try again.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+30
@@ -87,6 +87,8 @@
|
|||||||
"@angular/platform-browser": "^17.3.0",
|
"@angular/platform-browser": "^17.3.0",
|
||||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||||
"@angular/router": "^17.3.0",
|
"@angular/router": "^17.3.0",
|
||||||
|
"dompurify": "^3.1.6",
|
||||||
|
"marked": "^14.1.3",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"zone.js": "~0.14.4"
|
"zone.js": "~0.14.4"
|
||||||
@@ -5698,6 +5700,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "9.0.8",
|
"version": "9.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||||
@@ -8146,6 +8155,15 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||||
|
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -11895,6 +11913,18 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "14.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
|
||||||
|
"integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ describe('Admin route protection', () => {
|
|||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
const login = await request(server).post('/api/v1/auth/login')
|
const login = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${csrfToken}`)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
adminToken = login.body.accessToken;
|
adminToken = login.body.accessToken;
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ beforeAll(async () => {
|
|||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
const login = await request(server).post('/api/v1/auth/login')
|
const login = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${csrfToken}`)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
adminToken = login.body.accessToken;
|
adminToken = login.body.accessToken;
|
||||||
|
|||||||
@@ -52,16 +52,30 @@ beforeAll(async () => {
|
|||||||
expect(cookies?.some((c) => c.startsWith('csrf='))).toBe(true);
|
expect(cookies?.some((c) => c.startsWith('csrf='))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function primeCsrfAndLogin(agent: any): Promise<void> {
|
||||||
|
await agent.get('/api/v1/auth/csrf');
|
||||||
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
const csrf = cookies.find((c: any) => c.name === 'csrf');
|
||||||
|
await agent.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', csrf.value)
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
}
|
||||||
|
|
||||||
it('login returns an access token and sets the rt refresh cookie', async () => {
|
it('login returns an access token and sets the rt refresh cookie', async () => {
|
||||||
const server = app.getHttpServer();
|
const server = app.getHttpServer();
|
||||||
const agent = request.agent(server);
|
const agent = request.agent(server);
|
||||||
|
|
||||||
|
await agent.get('/api/v1/auth/csrf');
|
||||||
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
const csrf = cookies.find((c: any) => c.name === 'csrf');
|
||||||
const res = await agent.post('/api/v1/auth/login')
|
const res = await agent.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', csrf.value)
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(res.body.accessToken).toBeDefined();
|
expect(res.body.accessToken).toBeDefined();
|
||||||
const cookies = res.headers['set-cookie'] as unknown as string[];
|
const setCookies = res.headers['set-cookie'] as unknown as string[];
|
||||||
const rt = cookies.find((c) => c.startsWith('rt='));
|
const rt = setCookies.find((c) => c.startsWith('rt='));
|
||||||
expect(rt).toBeDefined();
|
expect(rt).toBeDefined();
|
||||||
expect(rt).toMatch(/HttpOnly/);
|
expect(rt).toMatch(/HttpOnly/);
|
||||||
});
|
});
|
||||||
@@ -70,9 +84,7 @@ beforeAll(async () => {
|
|||||||
const server = app.getHttpServer();
|
const server = app.getHttpServer();
|
||||||
const agent = request.agent(server);
|
const agent = request.agent(server);
|
||||||
|
|
||||||
await agent.post('/api/v1/auth/login')
|
await primeCsrfAndLogin(agent);
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
const rt = cookies.find((c: any) => c.name === 'rt');
|
const rt = cookies.find((c: any) => c.name === 'rt');
|
||||||
@@ -93,9 +105,7 @@ beforeAll(async () => {
|
|||||||
const server = app.getHttpServer();
|
const server = app.getHttpServer();
|
||||||
const agent = request.agent(server);
|
const agent = request.agent(server);
|
||||||
|
|
||||||
await agent.post('/api/v1/auth/login')
|
await primeCsrfAndLogin(agent);
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
const rt = cookies.find((c: any) => c.name === 'rt');
|
const rt = cookies.find((c: any) => c.name === 'rt');
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||||
|
|
||||||
|
describe('POST /api/v1/auth/register (player self-registration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let settings: SettingsService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
settings = app.get(SettingsService);
|
||||||
|
await settings.set('registrationsEnabled', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function primeCsrf(agent: any): Promise<string> {
|
||||||
|
const res = await agent.get('/api/v1/auth/csrf');
|
||||||
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
return cookies.find((c: any) => c.name === 'csrf').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects without CSRF token', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
await agent.get('/api/v1/auth/csrf');
|
||||||
|
await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.send({ username: 'alice', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a new player and returns a session', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
const csrf = await primeCsrf(agent);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'alice', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.accessToken).toBeDefined();
|
||||||
|
expect(res.body.user.username).toBe('alice');
|
||||||
|
expect(res.body.user.role).toBe('player');
|
||||||
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
expect(cookies.find((c: any) => c.name === 'rt')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mismatched password confirmation', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
const csrf = await primeCsrf(agent);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'bob', password: 'Sup3rSecret!Pass', passwordConfirm: 'OtherP4ss!' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('VALIDATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects weak password', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
const csrf = await primeCsrf(agent);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'weak', password: 'short', passwordConfirm: 'short' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('WEAK_PASSWORD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate username', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
const csrf = await primeCsrf(agent);
|
||||||
|
await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'carol', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
const csrf2 = await primeCsrf(agent);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf2)
|
||||||
|
.send({ username: 'carol', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe('USERNAME_TAKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when registrations are disabled', async () => {
|
||||||
|
await settings.set('registrationsEnabled', 'false');
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
const csrf = await primeCsrf(agent);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'dave', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('REGISTRATIONS_DISABLED');
|
||||||
|
await settings.set('registrationsEnabled', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when per-IP registration rate limit exceeded', async () => {
|
||||||
|
const { RegistrationRateLimitService } = await import('../../backend/src/common/services/registration-rate-limit.service');
|
||||||
|
const rl = app.get(RegistrationRateLimitService);
|
||||||
|
const now = Date.now();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
rl.record('::ffff:127.0.0.1', now + i);
|
||||||
|
rl.record('127.0.0.1', now + i);
|
||||||
|
}
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
const csrf = await primeCsrf(agent);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'eve', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body.code).toBe('RATE_LIMITED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
describe('GET /api/v1/blog/posts (public)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
|
const repo = app.get<any>(getRepositoryToken(BlogPostEntity));
|
||||||
|
await repo.insert([
|
||||||
|
{ id: 'p1', title: 'Published A', bodyMd: '# Hello', status: 'published', publishedAt: '2026-07-20T10:00:00.000Z', createdAt: '2026-07-19T00:00:00.000Z', updatedAt: '2026-07-20T10:00:00.000Z' },
|
||||||
|
{ id: 'p2', title: 'Draft B', bodyMd: 'should not appear', status: 'draft', publishedAt: null, createdAt: '2026-07-18T00:00:00.000Z', updatedAt: '2026-07-18T00:00:00.000Z' },
|
||||||
|
{ id: 'p3', title: 'Published C', bodyMd: 'safe content', status: 'published', publishedAt: '2026-07-21T10:00:00.000Z', createdAt: '2026-07-21T00:00:00.000Z', updatedAt: '2026-07-21T10:00:00.000Z' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only published posts, ordered by publishedAt DESC', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const res = await request(server).get('/api/v1/blog/posts').expect(200);
|
||||||
|
expect(res.body.posts).toHaveLength(2);
|
||||||
|
expect(res.body.posts.map((p: any) => p.id)).toEqual(['p3', 'p1']);
|
||||||
|
expect(res.body.posts[0].title).toBe('Published C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Markdown bodies and does not include HTML fields', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const res = await request(server).get('/api/v1/blog/posts').expect(200);
|
||||||
|
const c = res.body.posts.find((p: any) => p.id === 'p3');
|
||||||
|
expect(c.bodyMd).toBe('safe content');
|
||||||
|
expect(c.bodyHtml).toBeUndefined();
|
||||||
|
const a = res.body.posts.find((p: any) => p.id === 'p1');
|
||||||
|
expect(a.bodyMd).toBe('# Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak non-public fields', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const res = await request(server).get('/api/v1/blog/posts').expect(200);
|
||||||
|
for (const post of res.body.posts) {
|
||||||
|
expect(post.id).toBeDefined();
|
||||||
|
expect(post.title).toBeDefined();
|
||||||
|
expect(post.bodyMd).toBeDefined();
|
||||||
|
expect(post.publishedAt).toBeDefined();
|
||||||
|
expect(post.status).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unrelated endpoints (no admin/scoreboard leakage)', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server).get('/api/v1/blog/posts/drafts').expect(404);
|
||||||
|
await request(server).get('/api/v1/admin/users').expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
|
||||||
|
describe('CSRF: login is now protected', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects POST /api/v1/auth/login without CSRF header', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server)
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts POST /api/v1/auth/login with valid CSRF header', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const prime = await request(server).get('/api/v1/auth/csrf').expect(200);
|
||||||
|
const csrfCookie = (prime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
|
await request(server)
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${csrfToken}`)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows /api/v1/auth/register-first-admin without CSRF', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server)
|
||||||
|
.post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: 'noone', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,7 +88,12 @@ describe('Uploads endpoint integration', () => {
|
|||||||
await request(server).post('/api/v1/auth/register-first-admin')
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
const login = await request(server).post('/api/v1/auth/login')
|
const login = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${csrfToken}`)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
adminToken = login.body.accessToken;
|
adminToken = login.body.accessToken;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { decideLandingGuard } from '../../frontend/src/app/core/guards/landing.guard.decision';
|
||||||
|
|
||||||
|
describe('decideLandingGuard', () => {
|
||||||
|
const router = {
|
||||||
|
createUrlTree: jest.fn((commands: string[]) => ({ kind: 'UrlTree', commands })),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(router.createUrlTree as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /bootstrap when not initialized', () => {
|
||||||
|
const r = decideLandingGuard({ initialized: false, isAuthenticated: false }, router as any);
|
||||||
|
expect(router.createUrlTree).toHaveBeenCalledWith(['/bootstrap']);
|
||||||
|
expect((r as any).commands).toEqual(['/bootstrap']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to / when initialized and authenticated', () => {
|
||||||
|
const r = decideLandingGuard({ initialized: true, isAuthenticated: true }, router as any);
|
||||||
|
expect(router.createUrlTree).toHaveBeenCalledWith(['/']);
|
||||||
|
expect((r as any).commands).toEqual(['/']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when initialized and not authenticated', () => {
|
||||||
|
expect(decideLandingGuard({ initialized: true, isAuthenticated: false }, router as any)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { renderMarkdownToHtml } from '../../frontend/src/app/core/services/markdown.pure';
|
||||||
|
|
||||||
|
describe('renderMarkdownToHtml', () => {
|
||||||
|
it('renders headings', () => {
|
||||||
|
expect(renderMarkdownToHtml('# Hello')).toContain('<h1>Hello</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bold and italic', () => {
|
||||||
|
const out = renderMarkdownToHtml('**bold** and *italic*');
|
||||||
|
expect(out).toContain('<strong>bold</strong>');
|
||||||
|
expect(out).toContain('<em>italic</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safe links', () => {
|
||||||
|
expect(renderMarkdownToHtml('[a](https://example.com)')).toContain('href="https://example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips <script> tags', () => {
|
||||||
|
const out = renderMarkdownToHtml('<script>alert(1)</script>safe');
|
||||||
|
expect(out).not.toContain('<script>');
|
||||||
|
expect(out).toContain('safe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips javascript: hrefs', () => {
|
||||||
|
const out = renderMarkdownToHtml('[click](javascript:alert(1))');
|
||||||
|
expect(out).not.toMatch(/href="javascript:/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty/null/undefined input', () => {
|
||||||
|
expect(renderMarkdownToHtml('')).toBeDefined();
|
||||||
|
expect(renderMarkdownToHtml(null as unknown as string)).toBeDefined();
|
||||||
|
expect(renderMarkdownToHtml(undefined as unknown as string)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { buildLoginFailureMessage } from '../../frontend/src/app/features/landing/login-modal.service';
|
||||||
|
|
||||||
|
describe('buildLoginFailureMessage', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'INVALID_CREDENTIALS',
|
||||||
|
'Invalid credentials',
|
||||||
|
{ text: 'Invalid username or password.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'USERNAME_TAKEN',
|
||||||
|
'Username already taken',
|
||||||
|
{ text: 'Username already exists.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'REGISTRATIONS_DISABLED',
|
||||||
|
'Registrations are currently disabled',
|
||||||
|
{ text: 'Registrations are currently disabled.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'CSRF_INVALID',
|
||||||
|
'CSRF token missing or invalid',
|
||||||
|
{ text: 'Session expired. Please reload the page and try again.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'WEAK_PASSWORD',
|
||||||
|
'Password must be at least 12 characters',
|
||||||
|
{ text: 'Password does not meet the security policy.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'passwordConfirm: must match',
|
||||||
|
{ text: 'Please check the form fields and try again.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'RATE_LIMITED',
|
||||||
|
'Too many failed attempts. Try again in 7s.',
|
||||||
|
{ text: 'Please wait 7 seconds before trying again.', retryAfterSeconds: 7 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'RATE_LIMITED',
|
||||||
|
'Too many registration attempts; please wait a minute before trying again.',
|
||||||
|
{ text: 'Too many attempts. Please wait a moment before trying again.', retryAfterSeconds: undefined },
|
||||||
|
],
|
||||||
|
])('maps code=%s to expected message', (code, message, expected) => {
|
||||||
|
expect(buildLoginFailureMessage({ code, message })).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to raw message for unknown codes', () => {
|
||||||
|
expect(buildLoginFailureMessage({ code: 'EXOTIC', message: 'Quux' }).text).toBe('Quux');
|
||||||
|
expect(buildLoginFailureMessage({ code: 'EXOTIC', message: '' }).text).toBe('Something went wrong. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user