§ unswayed-backend
Hardening & async (Phase 16)
Hardening & async (Phase 16)
ADR-0045 / ADR-0046. This phase implemented the remediation for the 2026-06-22 whole-system audit (55 confirmed findings) and added async alternatives for the heavy synchronous AI/file endpoints — in one PR, built foundations-first then four parallel TDD cluster workflows (security/privacy, performance, async, resilience+infra).
The shared foundations
Three new building blocks the clusters depend on:
src/common/oauth/oauth-state.ts— an HMAC-signed, expiring, single-use OAuthstatetoken (signState/verifyState). The audit's #1 finding was that the Google/LinkedIn callbacks trusted a plaintext, attacker-forgeablestate(account-linking CSRF). The token is nowbase64url(payload).hmackeyed onOAUTH_STATE_SECRET(falling back toJWT_ACCESS_SECRET); the callback resolves the user only from the verified payload.src/common/redis/OptionalRedisService— the singleREDIS_URLdetection point. Returns a sharedioredisclient when configured, elsenull. Every Redis-backed feature checks it and falls back to in-memory when Redis is absent — so the single-process dev/deploy and the e2e suite are unchanged.src/async-tasks/— a generic async-task engine: anAsyncTaskmodel,AsyncTaskService(enqueue / ownership-scopedfindOwned/ lifecycle), anAsyncTaskConsumer(one queue worker), and anAsyncTaskHandlerRegistry.
Why a registry, not a DI multi-provider token
The obvious way to let feature modules contribute per-kind handlers is a multi-provider injection token. It doesn't work here: the consumer lives in AsyncTaskModule, and Nest can't aggregate a token across modules without AsyncTaskModule importing the feature modules — but those feature modules already depend on AsyncTaskService (to enqueue), so that's an import cycle. The fix: AsyncTaskModule is @Global and exports a runtime AsyncTaskHandlerRegistry; each feature handler injects it and self-registers in onModuleInit, and the consumer looks up by kind. One-directional dependency (feature → engine), no cycle.
Async alternatives (ADR-0046)
The sync endpoints are untouched (frozen wire). Each heavy one gains a sibling that enqueues and returns 202 { task_id }, with an ownership-scoped status read:
| Sync | Async sibling | Status read |
|---|---|---|
| resume build/upload/update | POST …/resume/{build,upload,update}/async |
GET …/resume/async-tasks/:id |
| interview-coaching response | POST …/interview-coaching/sessions/:id/responses/async |
GET …/async-tasks/:id |
| cover-letter generate | POST …/cover-letters/generate/async |
GET …/cover-letters/async-tasks/:id |
Each async handler calls the same service method as the sync route; the queue worker runs it and stores result/error. File uploads ride the bounded base64 payload (the pre-existing affindaGenerateAsync precedent), so no ResumeService refactor was needed.
Security & privacy (ADR-0045)
HMAC OAuth state; multer fileSize limits on 9 upload routes (early 413 instead of buffering); verify-checkout session-ownership; an interview-question candidate-visibility gate; helmet; Swagger 404 in prod; an env-overridable login brute-force @Throttle (LOGIN_THROTTLE_LIMIT — the hardcoded 10/min broke the e2e suites, which log in many times per IP, so it's relaxed in .env.test exactly like the global THROTTLE_LIMIT); and npm audit fix for the ws DoS CVE.
The privacy gates (a deliberate wire deviation). The shared profile viewer and the application-detail read were leaking special-category PII to employers — UCN / full DOB / gender / ethnicity, and disability / veteran status. These are now gated by viewer: emitted only to the subject (self) or an admin; omitted for an employer/other viewer. The contract froze a privacy defect, so we fixed it and recorded the deviation (ADR-0045 + API-CONTRACT.md).
Performance & resilience (ADR-0046)
~8 N+1 hot paths were batched into set-based findMany/groupBy (talent-pool, scoring, alerts, analytics, chat last-message, popular-employers, network-jobs, external-jobs geo); 3 @@indexes added (EmployerJob[status,createdAt], Post[type,status,createdAt], JobAlert[countryId]); Cloudinary uploads moved out of the apply $transaction (so a slow upload no longer holds a DB connection + row lock). Every external call (OpenAI/Affinda/JSearch/FCM/Cloudinary) got an explicit timeout. score-candidates became idempotent — the previously-dead recompute flag now skips already-scored candidates, so a retry never appends duplicate score rows.
Horizontal-scaling infra (opt-in, Redis-optional)
All of this is off until you turn it on, so the current single-process box is unchanged:
- Redis-backed when
REDIS_URLis set (in-memory otherwise): the global throttler, the Lenux rate-limit store, the Lexi session store, and the JSearch cache. WORKER_ROLEsplits the queue worker:all(default) runs API + worker in one process;apienqueues but skips worker startup (the adapter still produces via lazyqueueFor);workerruns only the worker. Run dedicatedWORKER_ROLE=workerprocesses to scale workers independently.pg_try_advisory_lockleader-guards the crons (subscription-expiry, analytics-funnel, and a new retention cron that purges completed queue/async-task rows — including OTP payloads at rest) so only one instance runs each.- A Socket.IO Redis adapter installs when
REALTIME_ENABLEDand Redis are both set, so realtime broadcasts survive multiple nodes.
Gotchas
- Everything Redis-backed degrades gracefully to in-memory when
REDIS_URLis unset — verified by the e2e suite, which has noREDIS_URLand therefore exercises the fallbacks. The Redis paths are unit-tested with a mocked client. - The login throttle is a real brute-force guard (10/min in prod). It is env-overridable purely so tests (and any legitimate burst environment) can relax it; do not raise it in production.
- OAuth state is single-use (replay guard). The in-process nonce set is per-process; under a
WORKER_ROLEsplit, sign and verify must happen in the same process (the API), which they do. - New deps:
helmet,@nest-lab/throttler-storage-redis,@socket.io/redis-adapter.