Files
HIPCTF2/.kilo/plans/863-followup-2.md
T
2026-07-23 00:13:57 +00:00

13 KiB

Implementation Plan: REST Event-State Snapshot, SSE De-dup, Solve Publish-Once, REST Error Notification

0. Goal

Four small refinements called out by the reviewer:

  1. Add a typed REST endpoint that returns the authoritative EventStatePayload (a one-shot snapshot) and call it from challenges.page.ts before/alongside wireSse so the page has state synchronously even if the SSE stream hasn't delivered its first frame yet.
  2. In challenges.store.ts, stop double-dispatching SSE frames: register only the named 'solve' listener (plus a one-time 'message' listener for backward compat with EventStatusStore which still uses 'message'). Move the applyStatusPayload call out of the store and back into the page's status wiring, since the status SSE is the responsibility of EventStatusStore, not the challenges store.
  3. In backend/src/modules/challenges/challenges.service.ts, remove the two publishSolveEvent(...) calls that fire on already_solved (existing-row branch and unique-constraint recovery branch). Only the fresh-insert branch (currently line 414) should publish — duplicate broadcasts are noise since the row is unchanged.
  4. Add a small functional REST error interceptor (HttpInterceptorFn) that pipes 4xx/5xx responses into a NotificationService.error(...) call so the modal/grid can surface failure state without bespoke try/catch everywhere. Register it in frontend/src/main.ts.

1. Architectural Reconnaissance

  • Backend controller surface for events: backend/src/modules/challenges/events.controller.ts exposes GET /api/v1/events (SSE) and backend/src/modules/system/system.controller.ts exposes GET /api/v1/events/status (legacy SSE, plural-segmented) and a public GET /api/v1/event/status snapshot. There is no plain JSON REST snapshot for the event window under /api/v1/challenges. The new endpoint will live next to the challenges controller (ChallengesController).
  • DTO: EventStatusService.getState() returns EventStatePayload which matches the snake_case status shape (serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd). We reuse it.
  • Frontend SSE wiring today: ChallengesStore.wireSse registers the same handler for 'message', 'solve', 'status'. Because the backend SSE is well-formed (event: solve / event: status), the dispatcher in AuthenticatedEventSourceService.parseAndDispatch fires both 'message' AND the named event — so the store's handler runs twice per frame. Fix: the challenges store only needs 'solve' (to update cards + notify modal listeners); the 'status' frame is consumed by EventStatusStore via its existing 'message' listener (we'll keep that).
  • EventStatusStore SSE wiring: it still uses addEventListener('message', ...) and ignores named events. That's correct for backward compat — it doesn't double-process frames.
  • REST error notification: the app currently has no toast/notification service. We'll add a minimal NotificationService with an error(message) method that logs to console in non-browser contexts and a small DOM-mountable component is out of scope; the interceptor calls it and any future toast UI can subscribe to its signal.
  • Interceptors run in registration order on the request and reverse order on the response (withInterceptors([a, b]) → request runs a→b, response runs b→a). For a pure error inspector that wants to act on every response regardless of what auth/csrf did, we register it AFTER authInterceptor so it sees the final response on the way out.

2. Impacted Files

Backend — To Modify

  • backend/src/modules/challenges/challenges.controller.ts — add @Get('status') returning EventStatePayload.
  • backend/src/modules/challenges/challenges.service.ts — remove two publishSolveEvent(...) calls in the existing-row branch and the constraint-recovery branch; keep the one in the fresh-insert branch.

Frontend — To Create

  • frontend/src/app/core/services/notification.service.ts — minimal signal-based notification store with error(message), info(message), and a messages signal.
  • frontend/src/app/core/interceptors/error-notification.interceptor.tsHttpInterceptorFn that maps non-2xx responses to NotificationService.error(...).

Frontend — To Modify

  • frontend/src/app/features/challenges/challenges.service.ts — add getEventState() returning Observable<EventStatePayload>.
  • frontend/src/app/features/challenges/challenges.store.ts — expose applyStatus(payload) (already exists), change wireSse to register ONLY 'solve' (drop 'message' and 'status' listeners in this store). Move the local handler logic so it doesn't process status frames.
  • frontend/src/app/features/challenges/challenges.page.ts — call store.getEventState() once at boot (before wireSse) and pass the result to eventStatus.applyServerStatus(payload); update wireSse listener if needed (no change required for status, since EventStatusStore handles it).
  • frontend/src/main.ts — register errorNotificationInterceptor AFTER authInterceptor.

Tests — To Modify

  • tests/backend/challenges-submit-flag.spec.ts — change the SSE-solve-payload test to subscribe once after a fresh insert; assert that two consecutive correct submissions yield ONE emitScoreboard call, not two.
  • tests/frontend/event-status.store.spec.ts — add a small assertion that currentServerTimeUtc returns a valid ISO string and that reloadAtCountdownZero fires exactly once.
  • New test file tests/frontend/notification-interceptor.spec.ts — assert the interceptor calls NotificationService.error(...) on a 500 response and does NOT call it on a 2xx response.

3. Proposed Changes

Backend

  1. challenges.controller.ts — add (after the existing board/detail/submit handlers):

    @Get('status')
    @ApiOperation({ summary: 'Authenticated event-window snapshot (REST JSON)' })
    async eventState(): Promise<EventStatePayload> {
      return this.statusSvc.getState();
    }
    

    Inject EventStatusService into the controller (add to constructor). The existing getState() already returns EventStatePayload. This endpoint sits at /api/v1/challenges/status. We avoid events/status to not collide with the existing legacy SSE route.

  2. challenges.service.ts — remove the publishSolveEvent(...) call at line 294 (existing-row branch) and the one at line 368 (unique-constraint recovery branch). Keep only the line 414 call (fresh-insert path). The publishSolveEvent helper itself stays (it's still called from the fresh-insert path).

Frontend

  1. notification.service.ts (new):

    import { Injectable, signal } from '@angular/core';
    export interface Notification { id: number; kind: 'error' | 'info'; message: string; ts: number; }
    @Injectable({ providedIn: 'root' })
    export class NotificationService {
      private next = 1;
      readonly messages = signal<Notification[]>([]);
      error(message: string): void {
        this.push('error', message);
        // eslint-disable-next-line no-console
        console.error('[notification]', message);
      }
      info(message: string): void {
        this.push('info', message);
        // eslint-disable-next-line no-console
        console.info('[notification]', message);
      }
      dismiss(id: number): void {
        this.messages.update((cur) => cur.filter((m) => m.id !== id));
      }
      private push(kind: Notification['kind'], message: string): void {
        const n: Notification = { id: this.next++, kind, message, ts: Date.now() };
        this.messages.update((cur) => [...cur, n]);
      }
    }
    
  2. error-notification.interceptor.ts (new):

    import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
    import { inject } from '@angular/core';
    import { catchError, throwError } from 'rxjs';
    import { NotificationService } from '../services/notification.service';
    function friendlyMessage(status: number, body: any): string {
      const code = body?.code as string | undefined;
      const msg = (body?.message as string | undefined) ?? '';
      if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.';
      if (code === 'FLAG_INCORRECT') return 'Incorrect flag.';
      if (status === 0) return 'Network error. Please try again.';
      if (status === 401 || status === 403) return 'Your session is no longer valid.';
      if (status >= 500) return 'Server error. Please try again later.';
      return msg || `Request failed (${status}).`;
    }
    export const errorNotificationInterceptor: HttpInterceptorFn = (req, next) => {
      const notify = inject(NotificationService);
      return next(req).pipe(
        catchError((err) => {
          if (err instanceof HttpErrorResponse) {
            notify.error(friendlyMessage(err.status, err.error));
          } else {
            notify.error('Unexpected error.');
          }
          return throwError(() => err);
        }),
      );
    };
    
  3. challenges.service.ts — add:

    getEventState(): Observable<EventStatePayload> {
      return this.http
        .get<EventStatePayload>('/api/v1/challenges/status', { withCredentials: true })
        .pipe(catchError((err) => throwError(() => toEnvelope(err))));
    }
    

    Add EventStatePayload to the import from ./challenges.pure.

  4. challenges.store.ts — update wireSse to register only 'solve':

    const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev);
    src.addEventListener('solve', handler);
    

    Drop the addEventListener('message', ...) and addEventListener('status', ...) calls inside wireSse. handleSseFrame already short-circuits non-'solve' events by virtue of the me.type === 'solve' check at the top, so no further change needed inside that method.

  5. challenges.page.ts — at ngOnInit, before wiring the SSE:

    // REST snapshot first so the page has synchronous state.
    this.store.getEventState().subscribe({
      next: (payload) => this.eventStatus.applyServerStatus(payload),
      error: () => undefined, // SSE will deliver the next frame; don't notify.
    });
    

    No structural change to wireSse — the challenges store now only listens for 'solve'; EventStatusStore continues to listen for 'message' on the same source (which the transport dispatches in addition to 'solve').

  6. main.ts — register the error interceptor last so it sees the final response:

    provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor])),
    

4. Test Strategy

  • Backend (tests/backend/challenges-submit-flag.spec.ts): change the SSE-payload test to subscribe ONCE on hub.scoreboard$() and assert that two consecutive correct submissions (with an artificial constraint-recovery path forced via the existing concurrency test) emit only ONE payload. Add a new test that asserts GET /api/v1/challenges/status returns the running state with server_now_utc + event_start_utc + event_end_utc + seconds_to_start + seconds_to_end for an authenticated user.
  • Frontend (tests/frontend/event-status.store.spec.ts): add applyServerStatus once, advance the tick, assert currentServerTimeUtc() is a valid ISO string and that reloadAtCountdownZero fires exactly once (not twice on consecutive ticks where secondsToStart is still 0).
  • Frontend (tests/frontend/notification-interceptor.spec.ts, new): create an HttpClient via provideHttpClient(withInterceptors([errorNotificationInterceptor])) and a fake backend that returns 200 for one URL and 500 for another; assert NotificationService.messages() only contains the 500 message.
  • All existing 504 tests must continue to pass.

5. Notes / Trade-offs

  • We use a plain REST snapshot instead of "best-authenticated equivalent" of the public /api/v1/event/status because the public endpoint is unauthenticated and returns a slightly different shape (legacy status field, no state enum, only countdownMs). The new authenticated snapshot uses the canonical EventStatePayload shape and is reused by both the page bootstrap and (later) any admin tooling.
  • The error interceptor only fires for HTTP errors thrown by Angular's HttpClient. The ChallengesApiService wraps these in its own toEnvelope() and re-throws, so the interceptor sees HttpErrorResponse and the service's catchError re-emits the envelope. We do not double-notify.
  • Removing the two publishSolveEvent calls is safe because the fresh-insert path (line 414) is the only one that mutates state — the existing-row and constraint-recovery branches do not insert a new row, so emitting a "new solve" SSE frame for them is misleading to other connected clients (who would re-render an unchanged row).