AI Implementation feature(823): Landing Page and Login/Register Modal #11

Merged
m0rph3us1987 merged 2 commits from feature-823-1784657364974 into dev 2026-07-21 18:34:46 +00:00
51 changed files with 2354 additions and 226 deletions
+351
View File
@@ -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.
-148
View File
@@ -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.
+2
View File
@@ -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: [
+1
View File
@@ -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',
]; ];
+24 -1
View File
@@ -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' })
+2
View File
@@ -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],
+50 -1
View File
@@ -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
View File
@@ -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 };
}
}
+12
View File
@@ -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 {}
+26
View File
@@ -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 ?? '',
}));
}
}
+14
View File
@@ -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>;
+36
View File
@@ -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)
+90
View File
@@ -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 | 332 chars, regex `^[a-zA-Z0-9_.-]+$` |
| `password` | string | 1256 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)
+7 -3
View File
@@ -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
+69
View File
@@ -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)
+33
View File
@@ -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)
+3 -1
View File
@@ -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
+10 -4
View File
@@ -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. |
+23 -7
View File
@@ -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. |
+38
View File
@@ -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)
+45
View File
@@ -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)
+118
View File
@@ -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` | 332 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)
+37
View File
@@ -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)
+50
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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"
+4 -1
View File
@@ -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,
);
};
+101 -1
View File
@@ -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.' };
}
}
+30
View File
@@ -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",
+5
View File
@@ -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;
+5
View File
@@ -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;
+18 -8
View File
@@ -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');
+148
View File
@@ -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');
});
});
+81
View File
@@ -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);
});
});
+5
View File
@@ -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;
+27
View File
@@ -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);
});
});
+34
View File
@@ -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();
});
});
+53
View File
@@ -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.');
});
});