Files
HIPCTF2/.kilo/plans/823.md
T

28 KiB
Raw Blame History

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, ApiErrorGlobalExceptionFilter 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:
    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:
    { 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

@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

@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:

async login(creds: { username: string; password: string }): Promise<LoginResult>

where

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)

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)

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.