# Jobjakt — Current Architecture > **This document describes the system as it actually is.** Every claim was verified against code. > Last verified: 2026-07-31. Supersedes the archived `docs/_archive/SYSTEM_OVERVIEW.md` (2026-07-02). > > **Rule:** if this document and the code disagree, the code wins — and this document is a bug. Fix it. > Topic documentation is maintained alongside this file; dated completion and review reports remain > historical snapshots. When documentation conflicts, prefer this file and the code. --- ## 1. What the product is Jobjakt is a self-hosted, multi-user job application tracking platform with local-AI career assistance: - Track jobs and applications end-to-end (pipeline stages, follow-ups, deadlines, salary, tags, notes). - Company CRM (pipeline stage, contact dates, recruiter details) — company-level only, no people entities. - Correspondence log per application: **Gmail OAuth import with a human review queue**, IMAP, Microsoft Graph. - Attachments per application with purpose metadata and AI-inclusion toggles. - CV platform: upload → OCR/extraction → structured parsing → per-job tailored CV drafts → templated PDF via Playwright. - AI drafts: cover letters, recruiter messages, follow-up drafts, job summaries, match scoring, interview prep. - Rules engine (auto-ghosting), reminder emails, daily JSON export, event trail, automated DB backup. - Admin: user management, audit log, system readiness. - Production: `https://jobs.cesnimda.uk` via Gitea Actions → SSH → Docker Compose. **Product hierarchy** (from `docs/MASTER_IMPLEMENTATION_GUIDE.md` — job tracking is the core; career tools support it): Job Tracking → Applications → Workflow → Follow-ups → Communication, then Career Profile → Master CV → CV Builder → Cover Letters → Portfolio → Interview Prep, then Job Discovery. --- ## 2. Architecture overview ```mermaid flowchart LR subgraph Client UI[React 19 SPA
MUI 7, react-router 6
Next.js 16 CSR shell] end subgraph Frontend container NGINX[nginx 1.29-alpine
serves static export + proxies /api] end subgraph Backend container API[ASP.NET Core net9.0
JobTrackerApi host] BG[7 hosted services:
Rules, FollowUpReminder, DailyExport,
JobEnrichment, SummarizerProbe,
CvProcessing, DatabaseBackup] DB[(SQLite default
or MariaDB/MySQL)] FS[/Data root:
Attachments, CvArtifacts,
exports, DP keys/] end subgraph AI stack AISVC[FastAPI ai-service :8001
distilbart summarizer,
OCR, docx/pdf extraction] PROV[Provider via AI_PROVIDER env:
ollama qwen2.5:7b / gemini / groq] end EXT1[Google OAuth / Gmail API] EXT2[Microsoft Graph / IMAP] EXT3[Job sites: Finn, NAV,
LinkedIn, Jobbnorge] EXT4[SMTP] EXT5[LibreTranslate optional] UI --> NGINX --> API API --> DB API --> FS API --> AISVC --> PROV API --> EXT1 API --> EXT2 API --> EXT3 API --> EXT4 API --> EXT5 BG --> DB ``` ### Solution layout | Project | Role | |---|---| | `JobTrackerApi/` | ASP.NET Core host plus its controllers, services, EF models, `JobTrackerContext`, migrations, and Dockerfile. | | `JobTrackerApi.Tests/` | xUnit, 36 test files incl. authorization + hostile-fixture suites. | | `job-tracker-ui/` | React SPA inside a Next.js shell. | | `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). | | `tools/hostile-fixture-db/` | Security test fixture generator. | | `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD. | > Corrected 2026-07-31: the transitional `JobTrackerBackend` link-compilation project and root > `Models/`/`Data/` directories were retired. Source now compiles from the project that owns it. --- ## 3. Technology stack **Backend:** ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB via `Database:Provider`), ASP.NET Identity Core, JWT bearer (smart policy scheme: local + Google), built-in RateLimiter, DataProtection (file-system keys), Playwright (PDF export). **Frontend:** **Next.js 16** + React 19 + **TypeScript 5.9** + MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, i18n EN + NB (custom provider), Jest/RTL. > Corrected 2026-07-31: the CRA migration is complete; direct Jest/Babel configuration replaced `react-scripts`. **AI:** FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; **one** generation provider selected by the `AI_PROVIDER` env var ∈ {`ollama` (default, `qwen2.5:7b`), `gemini`, `groq`}; TTL cache. **Infra:** Docker Compose (backend, frontend/nginx 1.29-alpine, ai-service, ollama opt-in via `bundled-ollama` profile w/ GPU), Gitea Actions CI → SSH deploy → `deploy/deploy.sh`, external `jobtracker_shared` network. --- ## 4. Frontend architecture **Two routing layers coexist intentionally:** 1. **Next.js 16 App Router** (`app/layout.tsx`, `app/page.tsx`) — a thin shell that mounts a client-side app. The CRA→Next migration was a **CSR lift-and-shift**: no SSR, no server components, no Next routing, no data fetching. Next is effectively a build tool here. Static export → nginx. 2. **react-router-dom v6** — does the actual routing, in **two different patterns inside one file** (`src/App.tsx`): `createBrowserRouter` for public routes (`/`, `/login`, `/forgot-password`, `/reset-password`, `/verify-email`) and a nested `` inside a catch-all `Shell` for authenticated routes. Development leaves static-export mode disabled and rewrites deep links to the root shell; production exports that shell and nginx falls back to `index.html` for unknown paths. **Routes** (`src/App.tsx`): public — `/`, `/login`, `/forgot-password`, `/reset-password`, `/verify-email`. Authenticated — `/dashboard`, `/jobs`, `/reminders`, `/kanban`, `/companies`, `/correspondence`, `/correspondence/review`, `/profile`, `/career`, `/trash`, `/settings`, `/settings/connected-accounts`, `/admin/{audit,users,system}`. > `/register` reuses the hardened auth form. Submission remains disabled until registration is enabled by production configuration (§5). **State management: none.** No Redux/Zustand/React Query. Local `useState` + `axios` per component, with a hand-rolled `refreshToken` counter threaded through props. Two workspace-cache hooks exist (`components/job-workspace/useWorkspaceTabCache.ts`, `useJobWorkspaceBaseData.ts`). This is the root cause of the oversized components below. **Styling:** MUI `sx` + custom `src/theme.ts` (439 lines), light/dark. Design tokens live inline in component `sx` props rather than in the theme (e.g. the same `boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)"` is repeated across pages). There is no component primitives layer and no Storybook. **Oversized components** (refactor targets, in order): `JobDetailsDialog.tsx` (1400), `CareerProfilePage.tsx` (1293), `JobTable.tsx` (786), `Correspondence.tsx` (732), `DashboardView.tsx` (666), `AdminSystemPage.tsx` (623), `AddJobModal.tsx` (618). (`ProfilePage.tsx` was 1368; Phase 2.2 split it — see §4a.) --- ## 4a. Profile / Career separation (Phase 2, 2026-07-17) `/profile` and `/career` were one 1368-line component (`ProfilePage`) forked by a `careerOnly` boolean. Phase 2 scoped their saves; Phase 2.2 split them into two dedicated components. This is the reference model for how account identity and the master career profile relate. ### Components & routes | Route | Component | Owns | |---|---|---| | `/profile` | `views/ProfilePage.tsx` (~490 lines) | Account identity + security + preferences | | `/career` | `views/CareerWorkspacePage.tsx` → `views/CareerProfilePage.tsx` (~1293 lines) | The **master career profile** — the single editable source of truth | `CareerWorkspacePage` is a thin shell (heading + source-of-truth notice) around `CareerProfilePage`. The CV Builder is a separate routed workspace and consumes the Career Profile as its source of truth. ### Request flow ``` /profile → ProfilePage load: GET /auth/me (account row only) save: PUT /auth/profile { email, userName, firstName, lastName, displayName } /career → CareerWorkspacePage → CareerProfilePage load: GET /auth/me GET /profile-cv/runs (extraction history) GET /jobapplications?… (for per-job CV tailoring context) save: PUT /auth/profile { profileCvText, profileCvStructureJson } (ProfileCvController paths additionally dual-write CareerProfileService — CareerProfiles / CareerProfileVersions — see §9 / §16) ``` ### Data ownership (the invariant) Both surfaces persist through the **same** endpoint, `PUT /auth/profile`, which does **partial updates** (`AuthController.UpdateProfile`): a field is touched only if the request carries it — `null`/omitted leaves it unchanged, `""` clears it, a value sets it. Email and UserName are never cleared (login identifiers). | Field(s) on `ApplicationUser` | Owner (only surface that writes them) | |---|---| | `Email`, `UserName`, `FirstName`, `LastName`, `DisplayName`, `AvatarImageDataUrl`, password, TOTP/2FA, linked OAuth accounts | **/profile** | | `ProfileCvText`, `ProfileCvStructureJson` (the master career profile) | **/career** | Because the endpoint is partial, `/profile` saving identity does **not** null the master profile, and `/career` saving the profile does **not** null identity. This is enforced by tests (`AuthAndSystemControllerTests`: identity-save-keeps-CV, career-save-keeps-identity, empty-clears, null-leaves). ### API responsibilities - `AuthController.UpdateProfile` (`PUT /auth/profile`) — partial update of the account row; the single write path for both surfaces. Local accounts only. - `AuthController` (`GET /auth/me`) — returns the whole account row; each surface reads the fields it owns. - `ProfileCvController` — CV ingest/parse/rewrite/export and the `CareerProfileService` dual-write. Called from `/career`. - `JobApplicationsController` — `/career` reads job list for tailoring context only. ### Future extension points - **`CareerProfilePage` is the foundation for all future career outputs** (Phase 3/4): CV Builder, tailored CVs, cover letters, portfolio, interview prep. They attach here, referencing the master profile — never duplicating it (per `docs/MASTER_IMPLEMENTATION_GUIDE.md`). - **Source-of-truth flip (F5):** today `ProfileCvStructureJson` is authoritative and `CareerProfileService` mirrors it. A later phase makes `CareerProfiles`/`CareerProfileVersions` authoritative; the `/career` save would then route through `CareerProfileService` rather than the blob column. The partial-update endpoint and the ownership split above do not change. - **Full decomposition (roadmap 2.2 residue):** `CareerProfilePage` is still large because it owns the whole master-CV surface; the CV Builder work will extract sub-components from it. --- ## 5. Authentication & authorization - **Smart policy scheme:** inspects the bearer token issuer — Google ID tokens (`accounts.google.com`) → `google` handler (validated against `Auth:GoogleClientId`); everything else → `local` JWT (symmetric `Auth:JwtKey`, issuer/audience validated, 2-min clock skew). - **Cookie sessions:** local handler also reads `jobtracker_auth` (HttpOnly, SameSite=Lax, Secure-configurable, 30d when persistent). **CSRF double-submit** middleware enforces cookie+header match on all mutating requests when a session cookie is present (login/register/reset/csrf exempt). - `Auth:Require=true` sets a fallback authorize-all policy (prod compose sets it). Dev without a JWT key generates an ephemeral key + warning; **fails closed** if auth is required but unconfigured. - Local tokens **must** carry a subject claim (`LocalAuthIdentity`), enforced in `OnTokenValidated` — hardened after finding M013-2. - **Multi-tenancy:** every tenant entity carries `OwnerUserId`; `JobTrackerContext` applies global query filters `CurrentUserId != null && OwnerUserId == CurrentUserId` (**deny-on-null**). Correspondence/JobEvent/CV entities filter through their parent's owner. Covered by `JobApplicationsAuthorizationTests`, `OwnershipGuardTests`. - Roles via Identity: admin-only `UsersController`, `AdminAuditController`, `AdminSystemController`. - **2FA:** TOTP (`Otp.NET`), encrypted secrets, QR enrolment (`QRCoder`), recovery codes, trusted devices (`jobtracker_td` cookie), pending-token flow. - **Sessions:** `UserSession` entity + `SessionsController` — list/revoke active sessions. - Password policy: min 8, digit + lowercase. Reset via emailed token (SMTP required). - **Registration is disabled by default** — `AuthController.cs:135` reads `Auth:AllowRegistration` defaulting to `false` and returns HTTP 403. There is no CAPTCHA anywhere. - **Rate limiting:** login, auth email, 2FA challenge, and anonymous public-CV PDF export have dedicated fixed-window policies. AI usage is bounded by per-account monthly generation and token ceilings rather than request-window throttling. --- ## 6. Database EF Core, **11 migrations**. App DbSets + Identity tables. ```mermaid erDiagram ApplicationUser ||--o{ Company : owns ApplicationUser ||--o{ Job : owns ApplicationUser ||--o{ JobApplication : owns ApplicationUser ||--o| UserRuleSettings : has ApplicationUser ||--o{ GmailConnection : has ApplicationUser ||--o{ CvUploadArtifact : owns ApplicationUser ||--o{ CvExtractionRun : owns ApplicationUser ||--o{ UserSession : has ApplicationUser ||--o{ TrustedDevice : has Company ||--o{ Job : "posts" Company ||--o{ JobApplication : "has jobs" Job ||--o{ JobApplication : "applied to via" JobApplication ||--o{ Correspondence : messages JobApplication ||--o{ Attachment : attachments JobApplication ||--o{ JobEvent : events JobApplication ||--o| TailoredCvDraft : "1:1 draft" CvUploadArtifact ||--o{ CvExtractionRun : "source of" ``` **Entities:** `Company`, **`Job`**, `JobApplication`, `Correspondence`, `GmailConnection`, `GmailReviewDecision`, `MicrosoftGraphConnection`, `ImapConnection`, `Attachment`, `RuleSettings`, `UserRuleSettings`, `SystemEmailSettings`, `JobEvent`, `CvUploadArtifact`, `CvExtractionRun`, `TailoredCvDraft`, `TwoFactorRecoveryCode`, `TrustedDevice`, `UserSession`. **Key notes:** - `ApplicationUser` (IdentityUser) also stores `ProfileCvText`, **`ProfileCvStructureJson`** (the master career profile — a JSON blob, not relational), `AvatarImageDataUrl` (base64 in a column, on the `/auth/me` hot path), Google/Microsoft link info, TOTP secrets, current CV artifact/run pointers. - **`Job` vs `JobApplication`** — `Job` is the opportunity (title, company, description, URL, salary, location, deadline, tags); `JobApplication` is the user's pursuit of it (status, dates, follow-ups, correspondence, attachments). `JobApplication.JobId` remains nullable for compatibility, but startup backfills missing links and persistence paths synchronize both copies. Legacy reads and columns remain until the production cutover report passes. See §16 and `docs/decisions/ADR-002-job-application-model.md`. - Salary is **structured**: `SalaryMin`, `SalaryMax`, `SalaryCurrency`, `SalaryPeriod` (plus a legacy free-text `Salary`). - `Tags` is a **JSON array in a string column** — not queryable; `/tags` and `/tag-trends` must scan. - Denormalized `HasResume`/`HasCoverLetter`/`HasPortfolio`/`HasOtherAttachment` flags duplicate `Attachments`; kept honest by `AttachmentFlagsRecomputeTests`. - CV extraction runs retain the newest 20 completed runs per user; expired runs and unreferenced upload artifacts are pruned while the current artifact is preserved. - `Status` is free-text at the DB level; canonicalized only in the application layer by `JobPipeline.Normalize` — deliberately, so custom user values are never destroyed. - Indexes include owner-prefixed list/board/reminder composites `(OwnerUserId, IsDeleted)`, `(OwnerUserId, IsDeleted, Status)`, and `(OwnerUserId, FollowUpAt)`, plus CV and provider-specific indexes. - SQLite lives at `DataRoot/jobtracker.db` (WAL); migrations and legacy-schema reconciliation run at startup through `StartupInitializationExtensions`. > Corrected 2026-07-31: the model snapshot is current and the API directly carries the EF Design package and `JobTrackerContext`; no temporary project edit is required for `dotnet ef`. --- ## 7. API surface (19 controllers, all under `/api`) | Controller | Lines | Highlights | |---|---|---| | `JobApplicationsController` | **2313** | **38 endpoints.** CRUD, paging/filter/sort, board, reminders, stats, analytics, history, timeline, status/follow-up PATCH, soft delete/restore, duplicate-check, **plus** the whole AI surface: match-score, candidate-fit, focus-plan, interview-prep, readiness, tailored-CV draft/preview/export/generate, application-drafts, application-package, follow-up drafts + send, ai-metrics. | | `ProfileCvController` | **2249** | CV upload artifacts, extraction runs, structure parsing, reprocess/rebuild/improve, rewrite-section, rewrite-preview, templates, Playwright PDF export, benchmark harness. | | `GmailController` | **1023** | OAuth connect/callback, sync, review queue, import decisions, job matching. | | `AuthController` | **879** | login/register/me/config, Google + Microsoft exchange and link/unlink, avatar, password change/reset, email verification, session cookie + CSRF. | | `AdminSystemController` | 342 | System readiness (DB/Gmail/AI). | | `TwoFactorController` | 341 | TOTP enrol/verify/disable, recovery codes. | | `AttachmentsController` | 245 | Multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata. | | `UsersController` | 229 | Admin user/role management. | | `AdminAuditController` | 219 | Audit trail. | | `CorrespondenceController` | 185 | Per-job messages CRUD. | | `CompaniesController` | 150 | CRUD, idempotent create-by-name, recruiter/pipeline fields. | | `MicrosoftGraphController` | 150 | Outlook/M365 mail linking. | | `SessionsController` | 104 | List/revoke sessions. | | `ExportController` | 102 | JSON/CSV export. | | `RulesController` | 101 | Global + per-user rule settings, clamped. | | `ClientErrorsController` | 100 | Frontend error intake → logs. | | `ImapController` | 96 | IMAP mail linking (SSRF-guarded). | | `BackupController` | 89 | Manual backup trigger. | | `JobImportController` | **27** | **One endpoint:** `POST /preview`. URL parse only — no persistence, no import history. | **God controllers are a top debt.** `JobApplicationsController` and `ProfileCvController` mix HTTP, business logic, AI prompt construction, and persistence. `docs/MASTER_IMPLEMENTATION_GUIDE.md` forbids exactly this ("Avoid: Massive controllers"). Refactor needs test cover first — the tests exist. **OpenAPI** is wired (`AddOpenApi` / `MapOpenApi`) but **dev-only** — guarded by `app.Environment.IsDevelopment()`, not exposed in production. --- ## 8. Background services (7 hosted services) | Service | Function | |---|---| | `RulesHostedService` → `RulesEngine` | Periodic auto-transitions (e.g. → Ghosted) from rule settings | | `FollowUpReminderHostedService` | Reminder emails for due/upcoming follow-ups (dedup via `LastReminderEmailSentAt`) | | `DailyExportHostedService` | Daily JSON export at a configured local hour | | `JobEnrichmentHostedService` | Backfills summaries/enrichment | | `SummarizerProbeHostedService` | Probes AI service readiness | | `CvProcessingHostedService` + `CvProcessingQueue` | Process-local wake-up queue for CV extraction; queued/running database work is recovered at startup | | `DatabaseBackupHostedService` → `DatabaseBackupRunner` | Automated DB backup (`VACUUM INTO`, server-derived path) | Caches and worker coordination are process-local. CV work itself is durable and recovered after restart, but the worker remains a deliberate single-instance design without database leasing. --- ## 9. AI pipeline **Architecture:** the backend does **not** call any LLM in-process. It HTTP-calls a FastAPI sidecar (`tools/summarizer/app.py`) exposing `/health`, `/cv/normalize`, `/cv/classify-block`, `/cv/rewrite`, `/summarize`, `/extract-text`. The sidecar picks **one** provider from the process-wide `AI_PROVIDER` env var ∈ {`ollama`, `gemini`, `groq`}. > **Important — `docs/00-ai-context.md` is wrong about this.** It describes a provider interface fanning out to OpenAI/Gemini/Claude/Ollama, admin-controlled, with users never locked to one model. **None of that exists.** There is one env var, one provider per deployment, no OpenAI, no Claude, no admin control, no per-user selection. Product decision 2026-07-17: **the docs get fixed, the abstraction does not get built** — revisit only if a customer asks. **Data flow:** 1. **Job import:** URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge) or universal JSON-LD parser → optional LibreTranslate → language detect + skill tagging → preview → user accepts → persisted. 2. **Summaries:** `SummarizerService` → `/summarize` (distilbart, TTL-cached, GPU if available) → persisted `ShortSummary`. 3. **CV ingest:** upload (PDF/DOCX/image) → `/extract-text` (OCR) → block classification (`CvAiClassifier`/`CvAiNormalizer` via `/cv/classify-block`) → `StructuredCvProfile` → `ProfileCvStructureJson` on the user. 4. **Tailoring:** job description + structured CV → `/cv/rewrite` → `TailoredCvDraft` (separate entity, per application) → `CvTemplateRenderer` → Playwright → PDF. 5. **Drafts:** cover letter / recruiter message / follow-up per job, attachment-aware context selection. **Invariant that holds:** the master profile is **never** auto-overwritten. Tailored output lands in `TailoredCvDraft`, a separate entity. This is the most important documented rule and it is correctly implemented — do not break it. **Degradation:** if the AI service or provider is down, core tracking still works (probe service; AI is not a deploy gate). **CV templates are hardcoded** — `CvTemplateRenderer.Render` is a C# `switch` over 6 template IDs (`ats-minimal`, `harvard`, `auckland`, `edinburgh`, `monarch`, `fjord`), each a function interpolating HTML strings, with booleans like `roundedPhoto`/`curvedHeader`. **There is no theme model and nothing is user-customisable.** This is a structural dead end for the CV Builder — see `docs/application-discovery-report.md` §10. --- ## 10. Email `SmtpEmailSender` + `EmailSettingsResolver`: config from env/appsettings **or** DB-stored `SystemEmailSettings` (admin-editable). Gmail SMTP + app password in prod. Flows: password reset, email verification, follow-up reminders. `App:PublicBaseUrl` builds links. Inbound: `GmailOAuthService` (655), `MicrosoftGraphOAuthService` (507), `ImapService` (345, SSRF-guarded). --- ## 11. Configuration & secrets - `.env` (git-ignored) → docker-compose env → ASP.NET config. `.env.example` documents the shape. - `appsettings.Development.json` holds only `CHANGE_ME_*` placeholders. - Key knobs: `Database:Provider`, `ConnectionStrings:JobTracker`, `Data:Root`, `Cors:Origins`, `Ai:BaseUrl`, `Summarizer:BaseUrl`, **`Ai:ServiceToken`**, `Auth:*` (incl. `Auth:AllowRegistration`), `Email:*`, `Exports:*`, `App:*`, `HttpsRedirection:*` (TLS terminated at the reverse proxy). - AI service knobs (compose): `AI_PROVIDER`, **`AI_SERVICE_TOKEN`**, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, `GEMINI_API_KEY`, `GROQ_API_KEY`. - **`AI_SERVICE_TOKEN` is mandatory.** Both `Ai__ServiceToken` (backend) and `AI_SERVICE_TOKEN` (ai-service) use `${AI_SERVICE_TOKEN:?...}`, so `docker compose up` fails loudly rather than booting an unauthenticated AI service. Generate with `python -c "import secrets; print(secrets.token_hex(32))"`. Rotating it requires recreating **both** containers together — they must agree. - Note both those compose entries are **quoted**: the `:?` error message contains a colon-space, which YAML would otherwise parse as a map (`services.backend.environment.[20]: unexpected type map[string]interface{}`). - `ProductionConfigTests.cs` guards prod config shape. - Ollama is **intentionally not bundled by default** (`bundled-ollama` compose profile) so deploys reuse a shared instance. `AI_PROVIDER=gemini` exists specifically to offload a weak local GPU in prod. --- ## 12. Build, CI/CD, deployment - **CI is Gitea, not GitHub** — `.gitea/workflows/ci-deploy.yml`. **There is no `.github/` directory.** - On PR + push-to-main: build backend (Release) → run **all** backend tests → `npm ci` → run the **whole** frontend suite → build frontend. > Corrected 2026-07-17: the archived overview said CI runs "an explicit whitelist of 10 frontend test files". **The whitelist is gone.** The workflow now runs `npm test -- --watchAll=false --runInBand` and carries a comment forbidding its return: the previous whitelist "silently skipped new suites and let two regressions reach main." - The workflow is heavily defended against a flaky self-hosted runner: dotnet install retry, `npm ci` SIGSEGV retry, frontend build OOM retry. - **Deploy** (push to main only): SSH to prod → `git reset --hard ` in `/opt/job-tracker/app` → `deploy/deploy.sh` (compose build/up with retry + cache-prune fallbacks) → verify containers. AI health is non-blocking. - **No staging environment.** Deploys go straight to prod after CI. --- ## 13. Testing - **Backend:** xUnit integration-style via `TestHostFactory`. 36 test files. Notable: `JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, `ImapServiceSsrfGuardTests`, `ProductionConfigTests`, `AttachmentFlagsRecomputeTests`, `CvCorpusHarnessTests`, `SqliteMigrationHelperTests`, `JobPipelineTests`, plus a `tools/hostile-fixture-db` project. - **Frontend:** ~20 Jest/RTL files — **all run in CI**. - **AI service:** pytest (`tools/summarizer/tests/`). - **Browser smoke:** Playwright drives login/session cookies, saved-job creation, Career Workspace routing, and anonymous public-CV rendering/PDF download against isolated API/SQLite and Next.js processes. CI installs Chromium and runs all four flows. - **Gap:** no load/performance suite. CI reports NuGet transitive vulnerabilities and production npm audit findings. --- ## 14. Logging & error handling Development uses simple console/debug logging; production emits structured JSON. Middleware records method, path, status, duration, trace ID, and subject. Unhandled errors return Problem Details with the same trace ID. Client errors POST to `/api/client-errors`; the frontend has an `ErrorBoundary` and route error page. Compose bounds each container's local logs to three 10 MB files. There is still no external sink (Seq/OTLP) or cross-host aggregation. --- ## 15. Security posture **Verified strong:** - Multi-tenancy via deny-on-null global query filters, with a dedicated authorization test suite. - CSRF double-submit on mutating requests; HttpOnly SameSite=Lax session cookie. - Auth fails closed when required but unconfigured; subjectless-JWT rejected (M013-2). - SSRF on job import and IMAP fixed and retested (DNS resolution check, private/loopback/link-local rejection, redirects disabled). - Rate-limited login/email/2FA endpoints; Identity PBKDF2 hashing. - OpenAPI dev-only. `.env` git-ignored; DP keys and runtime exports untracked (`519c32e`). - 2FA + recovery codes + trusted devices + session revocation. - **AI sidecar: backend-only.** Unpublished, on a private two-member network, and token-authenticated (§16). Verified against the running stack, not just configured. **Findings status** (detail in `docs/application-discovery-report.md` §12 and `docs/phase-0-foundation-report.md`): | Sev | Finding | Status | |---|---|---| | Medium | DataProtection keys recoverable from git history (`519c32e`, `955cae6`) | **Open — rotation required, needs an operator** | | Medium | Wildcard credentialed CORS configuration | **Closed — startup rejects it** | | Medium | AI cost ceiling | **Closed — monthly generation/token limits are enforced by plan** | | Low | Public-registration abuse control | Implemented with Turnstile; production keys/configuration still required | | Low | Unbounded storage | **Closed — attachment quotas, extraction/artifact pruning, file-backed avatars, and PDF-export retention are implemented** | | Low | Backup / DPAPI is Windows-oriented — verify behaviour on Linux prod | Unverified | | Low | No dependency CVE scanning in CI | **Closed — NuGet and production npm audit reporting are in CI** | --- ## 16. Phase 0 changes (2026-07-17) Full record: `docs/phase-0-foundation-report.md`. What changed architecturally: - **AI sidecar secured — three layers, verified against the running stack (2026-07-17):** 1. **No host port.** `ports: "8001:8001"` removed; `expose:` only. 2. **Private network.** `ai-service` sits on a new `ai_internal` bridge and **nothing else**. It was removed from `default` (which the frontend shares) and from `shared_services` — the latter is `external: true` (`jobtracker_shared`), so any other compose stack on the host could join it and reach port 8001. `ai_internal` has exactly two members: `ai-service` and `backend`. It is **not** `internal: true`, because ai-service needs egress to Gemini/Groq. 3. **Shared secret.** `X-Ai-Service-Token` required on every endpoint except `/health`, compared with `hmac.compare_digest`. Backend sends it via `Ai:ServiceToken`; sidecar reads `AI_SERVICE_TOKEN`. Unset = open (local dev/tests), but compose declares both with `:?` so the stack **refuses to start** without it. **Only the backend can reach the AI service.** Verified live: host → connection refused; frontend container → cannot even resolve `ai-service`; unauthenticated calls to `/summarize`, `/cv/rewrite`, `/extract-text` → 401; wrong token → 401; backend (172.23.0.3) with token → **200 OK**. > If you point `OLLAMA_BASE_URL` at an Ollama in **another** compose stack, address it by host IP (e.g. `http://:11435`) — `ai-service` can no longer resolve container names on `shared_services`, by design. The bundled `ollama` profile is on `ai_internal` and still works by name. - **Pipeline expanded beyond `Applied`** — `JobPipeline` now models pre-application stages (`Saved`, `Interested`, `Preparing`) in a new `PipelineCategory.Prospect`, so a job can be tracked before it is applied to. `Saved` is the new default for wizard-created jobs; `Applied` remains the default for the legacy create path. - **`DateApplied` is nullable** + `SavedAt` added — a saved job no longer carries a fabricated application date. - **`Job` entity introduced** alongside `JobApplication`; linked rows are now created and backfilled, while reads remain on the legacy columns pending production cutover validation. --- ## 17. Known debt The live, prioritized ledger is `docs/architecture/technical-debt.md`. The principal remaining items are the legacy `JobApplication` opportunity columns, single-replica worker coordination, and optional cross-host log aggregation. Large orchestration files are refactored only when a cohesive behavior change provides a safe seam. --- ## 18. Historical decisions worth knowing Recorded nowhere else in active docs: 1. `Status` is free-text and canonicalized in the application layer **specifically so custom user values are never destroyed** (`JobPipeline.cs` docstring). Deliberate; do not "fix" it with a DB enum. 2. The CI frontend-test whitelist was removed after it "silently skipped new suites and let two regressions reach main." 3. Ollama is intentionally not bundled by default so deploys reuse a shared instance. 4. `AI_PROVIDER=gemini` exists to offload a weak local GPU in prod. 5. `TailoredCvDraft` is a separate entity specifically to guarantee the master CV is never auto-modified.