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

13 KiB

Implementation Plan: Admin Area General Settings and Categories 1.10 — Public Landing Modal Reactivity to registrationsEnabled

1. Architectural Reconnaissance

  • Codebase style & conventions: TypeScript everywhere. Monorepo with backend/ (NestJS + TypeORM + SQLite) and frontend/ (Angular standalone components, signals, OnPush change detection). Login is a single LandingComponent with a mode = 'login' | 'register' | 'closed' modal. Tests live in tests/{backend,frontend}/ and run via npm test (jest projects). Pure helpers live next to the component that uses them (e.g. frontend/src/app/features/landing/login-modal.service.ts, frontend/src/app/features/admin/general.pure.ts).
  • Data Layer: SQLite via TypeORM. The registrationsEnabled flag is stored in the setting table under SETTINGS_KEYS.REGISTRATIONS_ENABLED ('true' / 'false'). It is read by SystemService.bootstrap() to produce the public BootstrapPayload and by AdminGeneralService.getSettings() for the admin view. The register endpoint (POST /api/v1/auth/register) reads the same key to gate REGISTRATIONS_DISABLED.
  • Test Framework & Structure: Jest (tests/jest.config.js) with two projects (backend / frontend). Backend tests use supertest + an in-memory DB (process.env.DATABASE_PATH=':memory:'). Frontend tests use jsdom and ts-jest. Tests are pure (no Angular TestBed); they import only the pure helper functions and the shared event-source contract. Run with npm test from the repo root.
  • Required Tools & Dependencies: No new runtime dependencies. The fix reuses existing SseHubService, the public event/stream SSE endpoint, the existing BootstrapService.refresh() method, and the landing-markdown / landing-modal helpers. setup.sh already installs dev deps (ts-jest, supertest, jsdom, typescript) and rebuilds better-sqlite3 — no edits needed.

2. Impacted Files

  • To Modify:
    • backend/src/modules/admin/general.service.ts — include registrationsEnabled in the emitted general SSE hub payload so listeners can decide whether to refresh bootstrap.
    • backend/src/modules/system/system.controller.ts (eventStream) — flatten the general hub event into a { topic: 'general', themeKey, registrationsEnabled } shape so the public SSE actually carries the flag instead of dropping it.
    • frontend/src/app/core/services/bootstrap-event.service.ts (NEW) — listed under "To Create" below; the public-facing SSE listener that converts { topic: 'general' } frames into BootstrapService.refresh() calls. If the implementer prefers to keep it inline, the wiring lives in frontend/src/app/app.component.ts and frontend/src/app/features/landing/landing.component.ts.
    • frontend/src/app/app.component.ts — start the public-event listener once at app boot (after bootstrap.load()) so every route benefits from it.
    • frontend/src/app/features/landing/landing.component.ts — on ngOnInit, additionally call bootstrap.refresh() so the modal opens with the latest server state (covers the user who lands on /login after the in-memory cache has been mutated by another tab/process).
    • docs/api/system.md and docs/guides/landing-page.md — update the public-event flatten description and the "Reacting to admin changes" step so the tester flow matches the new flow.
  • To Create:
    • frontend/src/app/core/services/bootstrap-event.service.ts — pure, provided-in-root service that subscribes to /api/v1/event/stream (via the existing EventSource-style helper or fetch + ReadableStream), filters frames with topic === 'general', and calls bootstrap.refresh() for each one. Exposes start() / stop() so the app can hook it to Angular's lifecycle.
    • tests/frontend/bootstrap-event.spec.ts — pure unit tests covering: a general frame triggers bootstrap.refresh; a non-general frame does not; malformed JSON is ignored; the listener is idempotent under repeated server pushes.
    • tests/backend/admin-general-event.spec.ts — backend unit test that asserts AdminGeneralService.updateSettings() emits { topic: 'general', themeKey, registrationsEnabled } (so the public SSE listener can rely on the shape).
    • tests/backend/events-status-sse-bootstrap.spec.ts — backend integration test that asserts /api/v1/event/stream forwards the general hub payload with the topic preserved (independent of the legacy event-status fields).

3. Proposed Changes

3.1 Backend — propagate the general SSE payload with the registrationsEnabled flag

  1. backend/src/modules/admin/general.service.ts — change the general topic emission so it carries both the new themeKey and the new registrationsEnabled boolean, computed from the same payload the handler just persisted:

    this.hub.emitEvent({
      topic: 'general',
      themeKey: payload.themeKey,
      registrationsEnabled: payload.registrationsEnabled,
    });
    

    This keeps the existing shape (topic: 'general') additive — non-breaking for any current consumer.

  2. backend/src/modules/system/system.controller.ts — rewrite the hub$ mapper inside eventStream() so it forwards the general topic's payload verbatim instead of projecting it onto the legacy event-status fields. The legacy @Public() @Sse('event/stream') consumer (the landing page status badge) must keep receiving its existing status/countdownMs/startUtc/endUtc/serverNowUtc shape, so the change is:

    • Add a try { … } catch {} wrapper that detects payloads matching { topic: 'general' } and forwards them as-is (with a startWith so the public stream still emits legacy frames on its 1-second tick).
    • Keep the existing flattening for non-general hub events so the legacy contract is preserved for the scoreboard / event-status consumers.
    • Include the new general payload in the legacy public stream's MessageEvent so the public tab picks it up.
  3. No new endpoint — we reuse the existing public GET /api/v1/event/stream SSE channel and prove via the new test that it now carries the general topic.

3.2 Frontend — listen to the public SSE and refresh bootstrap on general

  1. Create frontend/src/app/core/services/bootstrap-event.service.ts — a thin, root-provided service that:

    • Opens GET /api/v1/event/stream once via fetch + ReadableStream (mirroring frontend/src/app/core/services/authenticated-event-source.service.ts but for a public endpoint, so no Authorization header).
    • Parses data: {…} frames, JSON-parses each, and dispatches:
      • If payload.topic === 'general', call BootstrapService.refresh() and re-apply the theme (the refresh() already does both — see BootstrapService.fetchAndApply lines 73-83).
      • Otherwise, ignore (the frame is the legacy event-status payload, which the existing shell already handles via the authenticated /events/status stream).
    • Exposes start() and stop() methods; start() is idempotent (no-op if already running) and stop() aborts the in-flight fetch and clears listeners.
    • Catches malformed JSON frames and surface errors silently (we never want a broken SSE to break the public landing page).
  2. Modify frontend/src/app/app.component.ts — after await this.bootstrap.load() and await this.auth.restoreSession(), call bootstrapEvent.start() so the listener is active for every route, including /login. Stop the listener on DestroyRef.onDestroy (in practice it lives for the life of the SPA, but the explicit teardown avoids leaks in tests).

  3. Modify frontend/src/app/features/landing/landing.component.ts — in ngOnInit, after void this.landing.refresh(), also void this.bootstrap.refresh(). This is the direct fix for the "reopening the UI modal still exposed no Register control" symptom: even if the cross-tab SSE was either silenced or dropped, the public landing page that the user opens right now fetches the latest bootstrap payload before rendering the modal. The registrationsEnabled computed will then re-evaluate and the @if (registrationsEnabled()) block will render the "Register here" link.

  4. AuthService.register() and LandingComponent.submitRegister() — no changes are needed since the existing API endpoint already returns 201 when registrations are enabled, and the existing buildLoginFailureMessage already maps REGISTRATIONS_DISABLED to the user-facing string. The whole fix is upstream of those.

3.3 Docs — update the contract

  1. docs/api/system.md — replace the "event/stream public flatten" paragraph with the new contract: the public stream now forwards general topic payloads (with topic, themeKey, registrationsEnabled) interleaved with the legacy event-status frames, and the legacy frames are still emitted on the 1-second tick for the original consumer.
  2. docs/guides/landing-page.md — add a "Refresh on admin change" subsection documenting that the public landing page calls bootstrap.refresh() on ngOnInit AND on any general SSE frame, so the modal's Register button is consistent with the API.

4. Test Strategy

  • Target Backend Test Files:

    • tests/backend/admin-general-event.spec.ts — pure service test using a fake SseHubService (a Subject<any> is captured) that asserts AdminGeneralService.updateSettings() emits { topic: 'general', themeKey: '...', registrationsEnabled: true } after the admin persists the new value. Mirrors the existing tests/backend/admin-general-service.spec.ts "persists all keys and emits a settings event" test (lines 223-247).
    • tests/backend/events-status-sse-bootstrap.spec.ts — full Nest app integration test (mirrors tests/backend/events-status-sse.spec.ts) that primes a first admin, upserts a general event via the public SseHubService, subscribes to /api/v1/event/stream, and asserts the SSE delivers a data: {"topic":"general", ...} frame with registrationsEnabled:true and themeKey:classic. Also asserts that the legacy event-status frame is still emitted on the 1-second tick so the contract is not broken.
  • Target Frontend Test Files:

    • tests/frontend/bootstrap-event.spec.ts — pure unit tests that:
      1. Construct an in-memory EventSource substitute (a fetch mock returning a ReadableStream of SSE-shaped strings, mirroring the authenticated-event-source.spec.ts approach).
      2. Drive a general frame (data: {"topic":"general","themeKey":"classic","registrationsEnabled":true}\n\n) and assert the injected BootstrapService.refresh() has been called.
      3. Drive a non-general frame (data: {"status":"running",...}\n\n) and assert refresh() is NOT called.
      4. Drive a malformed JSON frame and assert the listener does not throw and does not call refresh().
      5. Drive two consecutive general frames and assert refresh() is called twice (idempotent under repeated server pushes).
    • tests/landing-modal-registration.spec.ts (extend tests/frontend/landing-modal.spec.ts if preferred) — adds a tiny pure helper landingModalShowsRegister(registrationsEnabled: boolean) that mirrors the @if (registrationsEnabled()) predicate and asserts it returns true exactly when registrationsEnabled is true. This locks the contract that the modal's switch button is only visible when the bootstrap payload says it should be — i.e. the "modal exposes a Register control iff the API will accept a registration" invariant.
  • Mocking Strategy:

    • Backend tests: use the existing tests/backend/db-helper.ts to seed a single admin via POST /api/v1/auth/register-first-admin. The SseHubService is consumed directly (subscribe to hub.event$()) to assert the payload shape without going through the full app — fast and isolated. The full Nest integration test reuses the bootstrapping pattern from tests/backend/events-status-sse.spec.ts.
    • Frontend tests: stub fetch with a ReadableStream that emits SSE-encoded frames (mirrors tests/frontend/authenticated-event-source.spec.ts's makeCaptureableFetch). Inject a BootstrapService whose refresh() is a jest.fn() that resolves to a minimal BootstrapPayload. The Angular DestroyRef is faked with a no-op onDestroy callback so the test owns the listener lifecycle.
    • No TestBed, no Karma, no visual assertions. All tests run via npm test from the repo root and finish in well under a second each.
  • Reusable test fixtures from /data: the /data/hipctf/db.sqlite file is used by the running server, not by the tests. The test fixtures are created on-the-fly by tests/backend/db-helper.ts via initDb(app) so no persistent state is required. If the implementer prefers to seed from /data, the helper can be extended to copy the SQLite file only when process.env.USE_DATA_DB === 'true' (the plan does not require this).