CODEX // oaoisme wiki

§ unswayed-backend

Hardening & async (Phase 16)

updated 2026-06-23

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 OAuth state token (signState/verifyState). The audit's #1 finding was that the Google/LinkedIn callbacks trusted a plaintext, attacker-forgeable state (account-linking CSRF). The token is now base64url(payload).hmac keyed on OAUTH_STATE_SECRET (falling back to JWT_ACCESS_SECRET); the callback resolves the user only from the verified payload.
  • src/common/redis/OptionalRedisService — the single REDIS_URL detection point. Returns a shared ioredis client when configured, else null. 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: an AsyncTask model, AsyncTaskService (enqueue / ownership-scoped findOwned / lifecycle), an AsyncTaskConsumer (one queue worker), and an AsyncTaskHandlerRegistry.

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_URL is set (in-memory otherwise): the global throttler, the Lenux rate-limit store, the Lexi session store, and the JSearch cache.
  • WORKER_ROLE splits the queue worker: all (default) runs API + worker in one process; api enqueues but skips worker startup (the adapter still produces via lazy queueFor); worker runs only the worker. Run dedicated WORKER_ROLE=worker processes to scale workers independently.
  • pg_try_advisory_lock leader-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_ENABLED and Redis are both set, so realtime broadcasts survive multiple nodes.

Gotchas

  • Everything Redis-backed degrades gracefully to in-memory when REDIS_URL is unset — verified by the e2e suite, which has no REDIS_URL and 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_ROLE split, 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.