Architecture - Fighter90/career-ops-ui GitHub Wiki
The five-minute tour. The canonical source is docs/architecture/ in the repo (OVERVIEW.md, SERVER.md, FRONTEND.md, API.md, DATA-FLOWS.md).
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>.mdis 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.buildAskPromptinlines the top-ranked chunks up toMAX_CONTEXT(14 KB) and skips what does not fit rather than stopping. It used tobreak, 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.
-
index.mjsโ acreateApp()factory, ~130 LOC after the P-2 refactor split (was 1230 LOC at v1.7.x). It is a pure orchestrator: wires middleware, callsregister<Topic>Routes(app)for each of the 37 route modules, mounts static serving frompublic/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-safesafeGet(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 onapplications.md/pipeline.md. -
llm-dispatch.mjsโ the shared provider cascade (runActiveProvider/providerAvailable) overanthropic.mjs,openai.mjs(OpenAI/Qwen/OpenRouter),gemini.mjs. -
prompts.mjsโbundleProjectContextand 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#/usageand the sidebar HUD. -
ru-scanner.mjs/en-scanner.mjsโ the two in-process scanners. -
runner.mjsโrunNodeScript(buffered) +streamNodeScript(SSE) for spawning parent.mjsscripts.
-
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.
-
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). Wrapsfetch, 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-i18nwalker),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=.
Adding an EN job board touches two registries โ this is deliberate:
-
server/lib/sources/<slug>.mjsโ the source meta registry. Exportsexport const meta = { value, label, region, configKey? }.server/lib/sources/registry.mjsreaddirSync-scans the folder at boot and dynamicallyimport()s every*.mjs, collecting eachmetablock (P-14, v1.69.0). This drivesGET /api/scan/sourcesand the#/scansource dropdown. 94 files = 89 EN + 5 RU. -
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 === 89is asserted bytests/adapter-registry.test.mjswith the exact sorted id list.
RU sources additionally need a RU_DISPATCH row in ru-scanner.mjs. See Scanner Providers for the full walkthrough.
-
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(onlyKNOWN_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()appendsrussian_portals:toportals.ymlon 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.mjsfilenames).
- Parent layout discovery is dynamic โ always
PATHS.<thing>, never literal... - CSP excludes
'unsafe-inline'/'unsafe-eval'fromscript-src. - Writes to the parent are explicit user actions only.
- Sanitizers are never duplicated โ one
isValidJobUrl, onestripDangerousMarkdown, onesanitizeJobDescription.
Scanner memory (v1.227.5).
ru-scannerdeduplicates 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 aSetof 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.