From d92c6ddf77856341a3815753721e191f8fe905c0 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:17:00 +0530 Subject: [PATCH 1/3] fix(enterprise): restore LEARNER lazy-create on invitation-accept (who-is-acting rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fa769af1's strict identity gate on invitation-accept broke sponsored onboarding: lib/auth.ts deliberately defers ConsulteeProfile creation to the first consumer action and names invite-accept-as-LEARNER as a sanctioned trigger, so a fresh employee accepting an org invite hit NOT_A_CONSULTEE with no self-service remedy. Rule settled with #819: identity creation requires the user's OWN action — accept keeps the EXPERT strict gate (consultant identity has real prerequisites) but lazy-creates the lightweight consumer profile; admin direct-add stays strict for BOTH roles; SSO JIT unchanged. Source-pin tests stop the gates drifting between surfaces. Closes #819. Co-Authored-By: Claude Opus 4.8 --- .../enterprise/invitation-accept.test.ts | 32 +++++++++++++ .../organizations/invitations/accept/route.ts | 46 ++++++------------- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/__tests__/enterprise/invitation-accept.test.ts b/__tests__/enterprise/invitation-accept.test.ts index af1bf4fc4..6bd9f9a47 100644 --- a/__tests__/enterprise/invitation-accept.test.ts +++ b/__tests__/enterprise/invitation-accept.test.ts @@ -15,6 +15,9 @@ * predicates do the right thing. */ +import { readFileSync } from "fs"; +import { join } from "path"; + import { isOnboardingBlocked } from "@/lib/enterprise/org-status"; describe("invitation-accept guards", () => { @@ -42,3 +45,32 @@ describe("invitation-accept guards", () => { // A full unit test would require mocking the entire $transaction // chain, which the integration smoke covers more robustly. }); + +// #819 — who-is-acting identity rule, pinned at the source level (the +// route handlers need the full auth/tx harness, which the integration +// smoke owns; these pins stop the gates from drifting between surfaces). +// Rule: identity creation requires the user's OWN action. Invitation +// accept is the user's consenting click, so LEARNER lazy-creates the +// lightweight ConsulteeProfile there (lib/auth.ts sanctions exactly this) +// while EXPERT stays strict. Admin direct-add is strict for BOTH roles. +describe("who-is-acting identity gates (#819)", () => { + const read = (p: string) => + readFileSync(join(process.cwd(), p), "utf8"); + + it("invitation-accept keeps the EXPERT strict gate but NOT a LEARNER gate", () => { + const src = read("app/api/organizations/invitations/accept/route.ts"); + expect(src).toContain("NOT_A_CONSULTANT"); + expect(src).not.toContain("NOT_A_CONSULTEE"); + }); + + it("admin direct-add (POST /members) keeps strict gates for BOTH roles", () => { + const src = read("app/api/organizations/[orgId]/members/route.ts"); + expect(src).toContain("NOT_A_CONSULTANT"); + expect(src).toContain("NOT_A_CONSULTEE"); + }); + + it("the lazy-create sanction for invite-accept-as-LEARNER still stands in lib/auth.ts", () => { + const src = read("lib/auth.ts"); + expect(src).toContain("invite-accept as LEARNER"); + }); +}); diff --git a/app/api/organizations/invitations/accept/route.ts b/app/api/organizations/invitations/accept/route.ts index ee7a1c735..a554b2cef 100644 --- a/app/api/organizations/invitations/accept/route.ts +++ b/app/api/organizations/invitations/accept/route.ts @@ -214,14 +214,15 @@ export async function POST(req: NextRequest) { return { membership: existing, organization: org, alreadyMember: true }; } - // #729 §AC4/AC5 — strict identity gate. `applyMembershipRoleEffects` - // lazy-creates a missing profile, which would silently promote - // an invited user to consultant / consumer identity. For the - // dashboard-driven invitation accept flow we refuse instead: - // the invitee must already have the matching profile on - // Familiarise. SSO JIT auto-join keeps the lazy-create path - // because that is a separate provisioning channel with its own - // authorization layer. + // #729 §AC4/AC5 + #819 — who-is-acting identity rule. Accepting an + // invitation is the USER'S OWN consenting action, so the lightweight + // ConsulteeProfile may be lazy-created here (lib/auth.ts names + // "invite-accept as LEARNER" as a sanctioned creation point — gating + // it broke sponsored-employee onboarding). EXPERT stays strict: a + // consultant identity carries domain/rates/verification/payout + // prerequisites that no invite click can substitute for. Admin + // direct-add (POST /members) stays strict for BOTH roles, and SSO + // JIT keeps its own lazy path. if (normalizedRole === "EXPERT") { const existingConsultant = await tx.consultantProfile.findUnique({ where: { userId }, @@ -234,32 +235,15 @@ export async function POST(req: NextRequest) { ); } } - if (normalizedRole === "LEARNER") { - const existingConsultee = await tx.consulteeProfile.findUnique({ - where: { userId }, - select: { id: true }, - }); - if (!existingConsultee) { - throw Object.assign( - new Error("NOT_A_CONSULTEE"), - { httpStatus: 400 }, - ); - } - } // Profile FK + payoutRecipient defaults are computed by the // shared helper (see lib/api/organizations/membership-transitions.ts). - // LEARNER lazy-creates ConsulteeProfile; EXPERT lazy-creates - // ConsultantProfile with the "General" placeholder domain + - // WEEKLY schedule + PENDING_VERIFICATION (hidden from - // /explore/experts until a platform admin reviews). Operator - // roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs null. - // - // The EXPERT pre-check above means we'll never reach the - // lazy-create branch for EXPERT here — but we keep the call - // unchanged so LEARNER's lazy-create stays consistent with - // the rest of the flow. Multi-org experts are still - // first-class; see docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md. + // LEARNER lazy-creates ConsulteeProfile (first consumer action). + // Operator roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs + // null. The EXPERT pre-check above means the helper never reaches + // its EXPERT lazy-create branch from this surface. Multi-org experts + // and learners stay first-class; see + // docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md. const roleEffects = await applyMembershipRoleEffects(tx, { userId, role: normalizedRole, From 8c3ae3111e79bad3dec6063b998cbbfb9c07205d Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:43:25 +0530 Subject: [PATCH 2/3] fix(tests): update read function in invitation-accept test to use __dirname for path resolution Modified the read function in the invitation-accept test to ensure it correctly resolves file paths relative to the test directory, improving test reliability across different working directories. --- __tests__/enterprise/invitation-accept.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/enterprise/invitation-accept.test.ts b/__tests__/enterprise/invitation-accept.test.ts index 6bd9f9a47..6b9e9ee7e 100644 --- a/__tests__/enterprise/invitation-accept.test.ts +++ b/__tests__/enterprise/invitation-accept.test.ts @@ -54,8 +54,9 @@ describe("invitation-accept guards", () => { // lightweight ConsulteeProfile there (lib/auth.ts sanctions exactly this) // while EXPERT stays strict. Admin direct-add is strict for BOTH roles. describe("who-is-acting identity gates (#819)", () => { + // __dirname-relative so the pins survive jest being invoked from any cwd. const read = (p: string) => - readFileSync(join(process.cwd(), p), "utf8"); + readFileSync(join(__dirname, "..", "..", p), "utf8"); it("invitation-accept keeps the EXPERT strict gate but NOT a LEARNER gate", () => { const src = read("app/api/organizations/invitations/accept/route.ts"); From ba0bace481d8ca9480bcf602bdce2d4be54602c0 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:05:33 +0530 Subject: [PATCH 3/3] docs(members): explain the who-is-acting asymmetry at the strict-gate site (#819) The accept route explains why ITS LEARNER gate is absent; the members route's gates never said why admin direct-add stays strict for both roles. A future reader of this file alone now sees the full rule and where the drift pins live. Co-Authored-By: Claude Fable 5 --- .../organizations/[orgId]/members/route.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/api/organizations/[orgId]/members/route.ts b/app/api/organizations/[orgId]/members/route.ts index 9f2b29b70..1a409a826 100644 --- a/app/api/organizations/[orgId]/members/route.ts +++ b/app/api/organizations/[orgId]/members/route.ts @@ -265,15 +265,18 @@ export async function POST( } const userId = user.id; - // #729 §AC4/AC5 — strict identity gate. The shared - // `applyMembershipRoleEffects` helper lazy-creates the matching - // profile when one is missing, which silently promotes strangers - // to consultant / consumer identities. For the dashboard - // add-member surface we want the safer interpretation: the target - // must already have the correct profile before being added under - // that role. SSO JIT auto-join keeps the lazy-create path because - // that is a separate provisioning channel with its own - // authorization layer. + // #729 §AC4/AC5 + #819 — who-is-acting identity rule. Direct-add is an + // ADMIN acting on someone else, so BOTH roles require a pre-existing + // profile: the shared `applyMembershipRoleEffects` helper would + // otherwise lazy-create one and silently promote a stranger to a + // consultant / consumer identity they never asked for. Contrast + // invitation-accept, where the LEARNER gate is deliberately absent — + // accepting is the user's OWN consenting click, so the lightweight + // ConsulteeProfile lazy-creates there (EXPERT stays strict on every + // surface). SSO JIT auto-join keeps its lazy path as a separate + // provisioning channel with its own authorization layer. The + // who-is-acting pins in __tests__/enterprise/invitation-accept.test.ts + // keep the three surfaces from drifting apart. if (role === "EXPERT") { const existingConsultant = await prisma.consultantProfile.findUnique({ where: { userId },