fix(employer): enforce plan tier from persisted employer record - #19
Merged
merlik787-droi merged 3 commits intoAug 19, 2026
Merged
Conversation
Replace the client-controlled x-employer-plan header with a persisted plan field on the User model (default starter). searchTalent and contactCandidate now resolve the plan from the authenticated employer's database row, so the per-plan page cap and the pro/enterprise contact gate can no longer be bypassed by a spoofed header. Adds a Prisma migration and tests for starter/pro/enterprise resolution and header spoofing.
merlik787-droi
requested changes
Aug 19, 2026
merlik787-droi
left a comment
Contributor
There was a problem hiding this comment.
@P3az3 resolve conflicts please.
Contributor
Author
|
@merlik787-droi conflicts resolved, kindly review. |
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #9
getEmployerPlanread the billing tier straight from thex-employer-planrequest header, so any employer could sendx-employer-plan: enterpriseand receive the largest search page cap and pass thecontactCandidate402 paywall. This PR persists the plan on theUsermodel (defaultstarter) and resolves it from the authenticated employer's database row, removing the header as an authorization input entirely.Why
There was no durable plan source to enforce against: no Prisma column stored a plan, and both
searchTalentandcontactCandidateindependently re-derived the tier from the request. The fix adds a single persisted source of truth and centralizes resolution in onegetEmployerPlanhelper so the two endpoints cannot drift again. A missing profile/row falls back tostarter(fail closed for paid features), so the spoofed-header path now gets exactly the starter limits.What was built
prisma/schema.prismaplan String @default("starter")to theUsermodel.prisma/migrations/20260819140000_add_employer_plan/migration.sqlALTER TABLE "users" ADD COLUMN "plan" TEXT NOT NULL DEFAULT 'starter'.src/controllers/employer.controller.tsgetEmployerPlanis now async and readsprisma.user.findUnique({ where: { id: req.user.id }, select: { plan: true } }); header parsing removed.searchTalentandcontactCandidateawaitit.docs/API.mdtests/unit/employer.controller.test.ts(+integrations/mirror)Integration changes outside
src/controllers/employer.controller.tsprisma/schema.prisma— one new column onUser(with migration).docs/API.md— employer search auth note updated.tests/unit/employer.controller.test.ts/integrations/unit/employer.controller.test.ts— tests rewritten for persistence.Acceptance criteria coverage
Enforcement
searchTalentapplies the per-planmaxLimitfrom the authenticated employer's persisted plan, not from the request header. (getEmployerPlanreadsprisma.user.findUnique;searchTalentuses the resolved plan forPLAN_MAX_SEARCH_LIMIT)contactCandidatereturns 402 for astarteremployer and succeeds for apro/enterpriseemployer based on the persisted plan. (contactCandidateawaits the persisted plan before thePLAN_RANKgate)x-employer-plan: enterprisewithout a corresponding persisted plan does not raise the limit or bypass the 402. (employer.controller.test.ts— spoofed-header tests for both endpoints)Persistence
starter, with a migration. (prisma/schema.prisma+prisma/migrations/20260819140000_add_employer_plan/migration.sql)Tests
tests/unit/employer.controller.test.ts— 8 tests incl. enterprise limit, starter+spoofed-header limit, starter+spoofed-header 402)Documentation
docs/API.mdand the Swagger annotations reflect the new plan source and remove the header from the documented inputs. (docs/API.mdupdated;employer.controller.tshas no@openapiannotations and no longer references the header anywhere)Test plan
pnpm test:ci— 281/281 passing (3 new employer-plan tests)pnpm exec tsc --noEmit— no type errorspnpm lint— no errors or warningspnpm build— succeedsnpx prisma generate— client regenerated (ran withDATABASE_URLset)Env vars / Notes
No new environment variables or config keys. The migration targets the
userstable name declared by the current@@map("users")on theUsermodel. Note the repository's committed migration history (a single20260307143903_init) predates the currentschema.prismaand uses unmapped table names; that pre-existing drift is out of scope for this issue. The role-model defect (noEMPLOYERin the PrismaRoleenum) remains a separate, blocking-adjacent issue (#4) and is intentionally not addressed here.