Recover the F1 Career Profile foundation + AI-workspace persistence from the
unmerged feature/career-workspace branch, so Phase 2 builds on the documented,
tested target state instead of re-deriving it. Foundation only — CV Builder
commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV
Builder yet". See docs/career-workspace-branch-assessment.md.
Squashed from 3 branch commits (235e291, 5916f09, 00a035e), resolved against
main + Phase 0:
- CareerProfile + CareerProfileVersion (append-only history), dual-written from
every profile save path via CareerProfileService. ApplicationUser.
ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item
IDs assigned to jobs/education/certifications/projects (the prerequisite for
future variant lineage). CvDateNormalizer for free-text -> YYYY-MM.
- InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit /
focus plan keyed by an attachment-context signature, so they stop regenerating
(and re-spending the provider) on every open.
Conflict resolutions (union, favouring current code + Phase 0):
- JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and
reconciler blocks, added the career/interview/ai-note tables (both SQLite and
MySQL dialects).
- ProfileCvController: dropped the branch's in-file DTO records (main defines them
in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred
ATS-badge work), keeping main's 7-arg CvTemplateDescriptor.
- JobApplicationsController: kept the branch's cache-check, restored main's
AsNoTracking on the read-only user load.
Tables ship empty (verified dev); nothing to migrate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.
Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
tables the scaffolder re-emitted; verified against the real dev DB.
Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).
DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.
AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.
Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.
Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JWTs were previously fully stateless -- the token alone was the credential
until its own expiry, with no way to list or kill a session server-side. Add
a UserSession table alongside every JWT issued (AppSessionIssuer), embed its
id as a "sid" claim, and check that claim against the DB on every "local"
scheme request (Program.cs OnTokenValidated) so a session can actually be
revoked before its JWT naturally expires. New /api/auth/sessions endpoints
(list, revoke one, revoke-others) plus a Sessions card on the profile page.
Fails closed on a missing "sid" claim: every JWT issued going forward has
one, so a token without it is either pre-deploy (forces one re-login for
already-signed-in users at deploy time, same additive-forward cost the
2FA/trusted-device work on this branch already paid) or forged.
Adds three layers of account-security hardening, all gated behind the
existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so
every sign-in path -- local, Google, Microsoft -- goes through the same
lockout/2FA checks:
- Per-account lockout: Identity's built-in lockout store (columns already
provisioned, previously unused) is now wired up in AuthController.Login
via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5
failed attempts / 15 min, same generic 401 as wrong-password to avoid
enumeration.
- RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/
offline) on a new TwoFactorController: setup requires password
re-confirmation and returns a pending (unconfirmed) secret + QR; the
secret is only persisted as active once verify-setup checks a real
code. Secrets are encrypted at rest via the same IDataProtector pattern
already used for Gmail/Microsoft OAuth refresh tokens.
- Login/OAuth exchange now checks TwoFactorEnabled before issuing a real
session. If enabled, it hands back an opaque, server-side (IMemoryCache)
pending token via a new ITwoFactorPendingTokenService -- deliberately
NOT a JWT, so it can never be presented as a bearer token to bypass the
2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can
redeem it, rate-limited at 5/5min (tighter than password login, since a
6-digit space is far more brute-forceable).
- One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at
rest, shown once in plaintext) accepted in the same challenge endpoint
as an alternative to a TOTP code.
Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted
/ TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both
the SQLite and MySQL dialect blocks in the startup schema reconciler.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs
smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/
unlink endpoints, ApplicationUser fields, reconciler columns) for
Microsoft Entra ID + personal accounts via the multi-tenant "common"
endpoint.
Google/Microsoft sign-in previously only worked for accounts already
linked to an existing local user -- there was no way to actually sign
up via OAuth. Both exchange endpoints now create a new user when no
match is found and Auth:AllowRegistration is true, same gate as
email/password registration.
Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no
vanilla-JS equivalent to Google's Identity Services script) wired into
the login page's provider tabs and the profile page's account-linking
section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId
config gate on the backend.
b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).
Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
UIDVALIDITY.
Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.
Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.
168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape
end-to-end so the two stay structurally interchangeable:
- MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL,
same shape as GmailConnection: encrypted refresh/access token, sync state).
- MicrosoftGraphOAuthService: auth-code + offline-access flow against
login.microsoftonline.com, encrypted token storage via IDataProtector,
message search/thread/detail fetch against Microsoft Graph (conversationId
stands in for Gmail's threadId), attachment listing.
- MicrosoftGraphProvider implements IEmailProvider — no contract changes;
the existing seam was already provider-neutral.
- MicrosoftGraphController: connect-url/oauth/callback/status/disconnect,
mirrors GmailController's OAuth surface exactly (including the popup
postMessage handshake). Job-matching/review endpoints stay Gmail-only for
now, per the roadmap — generalising those needs the frontend provider
picker work, not this slice.
- Registered in DI + IEmailProviderRegistry (multi-registration of
IEmailProvider, resolved by ProviderKey).
- Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through
docker-compose.yml + .env.example alongside the existing Google:Gmail* keys.
- Tests: MicrosoftGraphControllerTests (OAuth lifecycle) +
MicrosoftGraphProviderTests (DTO mapping onto the neutral contract).
147/147 green (135 existing + 12 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice toward multi-provider email (Gmail + Microsoft Graph + IMAP +
manual/free-text, per docs/remaster/PRODUCT_DIRECTION.md). Adds a provider-
neutral contract (search / list-thread / get-message / get-connection) with
neutral DTOs, a registry to resolve providers by key, and a GmailProvider that
adapts the existing IGmailOAuthService to it.
No behaviour change: the seam is registered in DI but not yet consumed. Follow-up
slices migrate GmailController's read paths onto IEmailProvider (folding in the
N+1 fixes) and add MicrosoftGraphProvider / ImapProvider / a manual provider.
Build clean; backend suite 135/135 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:
- useViewResource.ts: main's e352aae already fixes the render loop the same way
(load in a ref, dropped from deps) — took main's canonical version. My
independent fix is superseded (my branch predated e352aae, which is why the
loop reproduced live).
- JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my
AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays
delegated to AnalyticsService.
- Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven
funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add
StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API
contract the frontend expects.
Build clean; backend suite 135/135 green.
First Wave 2 (safe refactor) slice. Move the read-only stats/overview aggregation
out of the 3.3k-line JobApplicationsController into a dedicated, injectable
AnalyticsService, and lift its response DTOs (JobStats, FunnelStagePoint,
ResponseRatePoint, CompanyActivityPoint, AnalyticsOverviewDto) into
Models/AnalyticsDtos.cs.
- GetStats: ~44 lines -> 3 (delegates to AnalyticsService.GetStatsAsync).
- GetAnalyticsOverview: ~82 lines -> 3 (delegates to GetAnalyticsOverviewAsync).
- Registered AddScoped<AnalyticsService>(); controller keeps an optional ctor
param with a `?? new AnalyticsService(db)` fallback so the 6 test sites that
construct the controller directly keep compiling.
- Logic is byte-identical (same tenant-scoped context, same projections) so
behaviour is preserved.
Backend suite: 92/92 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New JobCvMatchService: a pure, AI-free keyword-coverage scorer that
returns a stable, reproducible 0-100 match score plus matched/missing
keyword lists and per-CV-section coverage. Unlike candidate-fit (AI
narrative), it makes no model calls, so results are instant and
identical for identical inputs - the Jobscan-style differentiator.
- GET /api/jobapplications/{id}/match-score
- keywords = curated SkillTagger tags (high weight) + salient posting
terms (title terms boosted); word-boundary matching avoids false hits
- section coverage shows where CV evidence is concentrated
- fix(SkillTagger): punctuation-tolerant C#/.NET patterns; the old \b
boundaries silently missed 'C#,' and '.NET,' everywhere they are used
- 7 unit tests on the pure scorer; full backend suite green (104)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AddOpenApi/MapOpenApi (anonymous, Development environment only).
- security: mark ProfileCvController.ProcessQueuedRunAsync [NonAction] -
the controller-level [Route] exposed this background-service hook as a
routable any-verb endpoint, which also broke OpenAPI generation.
96 endpoint paths documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New DatabaseBackupHostedService + SqliteDatabaseBackupRunner:
- daily VACUUM INTO snapshot to <Data:Root>/backups (safe with WAL)
- catch-up backup at startup when none exists from the last 24h
- retention pruning (Backups:RetainCount, default 14)
- warns and stays idle on MySQL/MariaDB where external backups apply
Production previously had no automated database backup on Linux
(the /api/backup endpoint is Windows-DPAPI-only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>