Architecture - Fighter90/career-ops-ui GitHub Wiki

Architecture

The five-minute tour. The canonical source is docs/architecture/ in the repo (OVERVIEW.md, SERVER.md, FRONTEND.md, API.md, DATA-FLOWS.md).

Top-level shape

Browser (SPA)                          Express server (Node โ‰ฅ18)              Parent career-ops project
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                           โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€              โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
public/index.html                      server/index.mjs (~130 LOC             cv.md  config/profile.yml
  โ””โ–บ js/app.js                           orchestrator)                        portals.yml  data/*.md
      โ””โ–บ js/router.js  โ”€โ”€HTTP+SSEโ”€โ”€โ–บ     โ”œโ”€ server/lib/routes/*.mjs (31)  โ”€โ”€โ–บ  reports/*.md  modes/*.md
          โ””โ–บ js/views/*.js                โ”œโ”€ server/lib/*.mjs (helpers)         jds/*.txt  output/*.pdf
      โ””โ–บ js/api.js                        โ””โ”€ server/lib/sources/*.mjs (59)      scan.mjs  doctor.mjs  โ€ฆ
                                        127.0.0.1:4317

Docs-assistant retrieval (v1.228.3). docs/help/<lang>.md is split on ##, and any section past 6 KB is split again at its ### boundaries โ€” 32 chunks become 75. Sections that fit stay whole: a ### belongs with its parent. buildAskPrompt inlines the top-ranked chunks up to MAX_CONTEXT (14 KB) and skips what does not fit rather than stopping. It used to break, so a single oversized section left the context empty and the assistant reported that nothing matched โ€” the English ยง5 had reached 16 081 bytes while the Russian one fit at 13 009, which is why the same question worked in one language and not the other. If a help section grows past 6 KB and carries no ###, it becomes unretrievable in full: give it subheadings.

Server (server/)

  • index.mjs โ€” a createApp() factory, ~130 LOC after the P-2 refactor split (was 1230 LOC at v1.7.x). It is a pure orchestrator: wires middleware, calls register<Topic>Routes(app) for each of the 37 route modules, mounts static serving from public/ and the SPA catch-all. No inline route handlers remain.
    • Middleware: JSON/text body parsing, unconditional security headers (CSP is always on since v1.58.4), the activity-logging middleware, static file serving.
  • server/lib/*.mjs โ€” shared helpers. Highlights:
    • paths.mjs โ€” the single source of truth for parent-project paths (resolveProjectRoot(), PATHS.*). Resolves once per process at import time.
    • security.mjs โ€” the one-and-only sanitizers: isValidJobUrl, stripDangerousMarkdown, sanitizeJobDescription, sanitizePathName, isPubliclyExposed.
    • safe-fetch.mjs โ€” DNS-rebind-safe safeGet (one lookup, pinned TCP connect, per-hop redirect revalidation, byte cap).
    • rate-limit.mjs โ€” llmRateLimit (no-op on loopback; 10 req/min/IP on public bind).
    • file-lock.mjs โ€” withFileLock(path, fn) per-path async mutex for read-modify-write on applications.md / pipeline.md.
    • llm-dispatch.mjs โ€” the shared provider cascade (runActiveProvider / providerAvailable) over anthropic.mjs, openai.mjs (OpenAI/Qwen/OpenRouter), gemini.mjs.
    • prompts.mjs โ€” bundleProjectContext and the prompt builders that inline parent files into LLM requests.
    • store.mjs โ€” defensive readers (safeReadApps/Pipeline/Reports) + ensureRussianPortalsDefaults.
    • parent-relay.mjs โ€” the fail-soft shell-out relay contract (used by followup/patterns/lifetime/salary-gap).
    • llm-usage.mjs / llm-pricing.mjs โ€” usage rollup + editable price table for #/usage and the sidebar HUD.
    • ru-scanner.mjs / en-scanner.mjs โ€” the two in-process scanners.
    • runner.mjs โ€” runNodeScript (buffered) + streamNodeScript (SSE) for spawning parent .mjs scripts.

The 37 route modules (server/lib/routes/)

Each exports register<Topic>Routes(app). New routes go into server/lib/routes/<topic>.mjs, never back into index.mjs.

activity  assessments  auto-pipeline  batch  career-plan  cli-detect  config  content
cv-studio  cv-sync  discover-ats  docs-assistant  export  followup  funded  health
help  interview  jds  liveness  llm  logos  market  memory
networking  openrouter  orientation  outcome  pipeline  portals  reports  runners
scan  stats  tracker  two-pager  usage

See API Reference for the endpoint inventory each one owns, and Features for the views they back.

SPA (public/)

  • index.html โ€” one page, ~150 LOC. Loads CSS + scripts via <script src> (no bundler), mounts #content, renders the sidebar nav + footer.
  • js/app.js โ€” boot: loads /api/health, renders the language switcher, starts the router, wires global shortcuts (Ctrl+K, Esc), the mobile drawer, and the notifications drawer that re-surfaces the per-tab toast journal.
  • js/router.js โ€” hash-router. Router.register('name', renderer) per view; renderers return a DOM node or HTML string. Aliases keep URLs stable across renames; dedicated 404 view.
  • js/api.js โ€” API.get/post/put/delete + API.stream (SSE). Wraps fetch, normalizes errors, manages the connection-error banner.
  • js/views/*.js โ€” one file per route. Pure render-and-wire functions; no client-side state library.
  • js/lib/*.js โ€” self-contained widgets and helpers: i18n.js (locale loader + data-i18n walker), usage-hud.js (sidebar usage meter), docs-fab.js (floating Ask-the-docs launcher), bug-report.js + logbuf.js (in-app bug reporter), cv-diagnostics.js, cv-privacy.js, company-logo.js, report-export.js (Markdown/PDF/DOCX export).

Styling is hand-written CSS with docs-style tokens in public/css/app.css, theme-aware (light/dark) and RTL-mirrored ([dir="rtl"]) for Arabic. CSP forbids inline scripts โ€” every handler is addEventListener, never inline onclick=.

The two scanner registries (important)

Adding an EN job board touches two registries โ€” this is deliberate:

  1. server/lib/sources/<slug>.mjs โ€” the source meta registry. Exports export const meta = { value, label, region, configKey? }. server/lib/sources/registry.mjs readdirSync-scans the folder at boot and dynamically import()s every *.mjs, collecting each meta block (P-14, v1.69.0). This drives GET /api/scan/sources and the #/scan source dropdown. 94 files = 89 EN + 5 RU.
  2. server/lib/portals/adapters/<slug>.mjs + server/lib/portals/registry.mjs (ALL_ADAPTERS) โ€” the fetch-walk registry. 89 EN adapters that actually perform the HTTP fetch/parse walk. ALL_ADAPTERS.length === 89 is asserted by tests/adapter-registry.test.mjs with the exact sorted id list.

RU sources additionally need a RU_DISPATCH row in ru-scanner.mjs. See Scanner Providers for the full walkthrough.

Parent read-only contract

  • Reads are unrestricted โ€” the UI reads cv.md, config/profile.yml, portals.yml, data/*, reports/*, modes/*, jds/*, output/*, .env.
  • Writes are explicit-user-action only โ€” every write corresponds to a documented HTTP action initiated by a UI control. The complete list lives in docs/architecture/DATA-FLOWS.md:
    • PUT /api/cv โ†’ cv.md (sanitized, 1 MB cap)
    • POST /api/tracker โ†’ data/applications.md (dedup, file-locked)
    • POST/DELETE /api/pipeline โ†’ data/pipeline.md (URL-gated, file-locked)
    • scan runs โ†’ data/scan-history.tsv + data/last-scan.json (append/atomic replace)
    • POST /api/jds / /api/evaluate โ†’ jds/*.txt
    • POST /api/deep {run:true} โ†’ interview-prep/<company>-<role>.md
    • POST /api/config โ†’ parent .env (only KNOWN_KEYS)
    • user-layer config writes: config/career-plan.md, config/memory.md, config/two-pager.yml, networking/net-*.md, data/role-stats.jsonl, data/follow-ups.md
    • the single auto-write: ensureRussianPortalsDefaults() appends russian_portals: to portals.yml on first boot (idempotent).
  • Boundaries never crossed: no write outside PROJECT_ROOT, no symlink writes, no execution of arbitrary user-supplied scripts (runners invoke only a hardcoded list of .mjs filenames).

Key invariants

  1. Parent layout discovery is dynamic โ€” always PATHS.<thing>, never literal ...
  2. CSP excludes 'unsafe-inline'/'unsafe-eval' from script-src.
  3. Writes to the parent are explicit user actions only.
  4. Sanitizers are never duplicated โ€” one isValidJobUrl, one stripDangerousMarkdown, one sanitizeJobDescription.

Scanner memory (v1.227.5). ru-scanner deduplicates and filters per query, not at the end of the run. The query list is deliberately full of near-synonyms, so the same vacancy comes back once per query; accumulating every raw hit โ€” descriptions included โ€” peaked at 742 MB on a 21-query run against the server's 490 MB Node heap cap and OOM-killed the service four times in one day. Per-query reduction brings the same run to 177 MB. Reported counts are unchanged: the unique total is carried by a Set of URL strings rather than by retaining the objects. If the query list grows substantially, watch peak heap โ€” the ceiling is the box's RAM, not the code.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ