Skip to content

Commit 609771f

Browse files
feat(organizations): org.addMember(userId) + consent flow + source discriminator (P5a) (#3823)
* feat(organizations): org.addMember + consent split (owner_add) (#3813) Add a consent-safe owner-add flow replacing the deleted org email-invite: an owner/admin adds an existing user, creating a PENDING owner_add membership the INVITED USER must accept — the owner can never approve it. - model: new `source` enum {join_request, owner_add} with NO default + a pre('validate') guard that throws if a PENDING row has no source (a forgotten source must fail loudly, never become an owner-approvable join request); +optional addedBy (audit). Hook throws (no next arg) — the function(next) signature was invoked with no next under doc.validate() at runtime. - constants: PENDING_SOURCES. - service: addMember (status set EXPLICITLY to PENDING, rejects any existing membership for (user,org), last-owner-safe by construction) + acceptMembership (flips PENDING->ACTIVE only for a source:owner_add membership whose userId is the caller). createJoinRequest single-pending rule now source-scoped to join_request (owner_add and join_request are orthogonal, both directions). +listPendingOwnerAddsByUser, +findUserByExactEmail. - E16 audit: approval surface (listPending, listPendingByUser, requestByID approve/reject gate) scoped to source:join_request with an E17 $exists:false legacy fallback so an owner_add is invisible to the owner. Member-count aggregation stays ACTIVE-only. billing: no coupling (confirmed). auth pendingRequests shape unchanged (still the user's own join requests). - routes: POST /organizations/:id/members (owner/admin add), GET /organizations/:id/members/search?email= (exact-email lookup, owner/admin, GDPR no fuzzy enumeration), GET /membership-requests/mine/pending (owner_add invitations), PUT /membership-requests/:membershipId/accept (invited user, auth-only, consent gate in service). - migration 20260610140000: backfill source=join_request on existing PENDING rows (idempotent, raw driver). +MIGRATIONS.md (E17 ordering note for P9). - tests: consent service/model/controller + migration (+23/+6/+9/+4 new); full repo suite 2345 green, lint clean. * refactor(organizations): harden consent gate + co-locate owner-approvable predicate Phase-5a review hardening (Approved-with-minors), 3 fixes: - acceptMembership: add self-defending early guard (!acceptingUserId -> null) so the consent identity-compare never leans on the auth-only route / sentinel-collides on String(undefined). + unit test. - isOwnerApprovable(source) predicate co-located with PENDING_SOURCES as the single source of truth for consent invariant #1; controller requestByID now calls it instead of an inline literal. joinRequestSourceFilter keeps the $or Mongo query (DB layer) with a cross-reference comment to the doc-level twin. - findUserByExactEmail JSDoc: clarify exact-after-normalization (lowercased/ trimmed, matches the CI-unique-email storage), not byte-exact. No behaviour change. Full suite green (unit 1891, integration 444), lint clean.
1 parent 532f535 commit 609771f

15 files changed

Lines changed: 1270 additions & 15 deletions

MIGRATIONS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## org.addMember + membership consent split (2026-06-10)
8+
9+
Phase 5a of the invitations↔org decouple epic (#3813). Replaces the deleted org email-invite with a **consent-safe add-member flow**: an owner/admin adds an *existing* user, creating a **PENDING `owner_add`** membership that the **invited user** must accept — the owner can NEVER approve it (consent invariant).
10+
11+
### What changed (this repo)
12+
13+
- **Membership model** (`organizations.membership.model.mongoose.js`) — new `source` enum field `{ 'join_request', 'owner_add' }` **with NO default** + a `pre('validate')` hook that throws if a PENDING row has no `source` (a forgotten source must fail loudly, never silently become an owner-approvable join request). New optional `addedBy` (ObjectId, audit-only).
14+
- **Constants** — new `PENDING_SOURCES = { JOIN_REQUEST:'join_request', OWNER_ADD:'owner_add' }`.
15+
- **Service** (`organizations.membership.service.js`) — `addMember(orgId, userId, role, addedBy)` creates a PENDING owner_add (status set EXPLICITLY — the schema defaults status to `'active'`); rejects if ANY membership already exists for (user, org); last-owner-safe. `acceptMembership(id, userId)` flips PENDING→ACTIVE **only** for a `source:'owner_add'` membership whose `userId` is the caller (sets `currentOrganization` if unset). `createJoinRequest`'s single-pending-global rule is now **source-scoped to join_request** (a pending owner_add no longer blocks a join request, and vice-versa). New `listPendingOwnerAddsByUser`.
16+
- **Approval surface scoped to join_request**`listPending`, `listPendingByUser`, and the `requestByID` approve/reject gate now match `source:'join_request'` (with an E17 `source $exists:false` legacy fallback) so an owner_add is **invisible** to the owner-approval surface. The auth-payload `pendingRequests` is unchanged in shape — still the user's own join requests.
17+
- **Routes**`POST /api/organizations/:organizationId/members` (owner/admin; CASL `create Membership`) adds a member; `GET /api/organizations/:organizationId/members/search?email=` (owner/admin) looks up a user by **exact email** (GDPR: no fuzzy directory enumeration); `GET /api/membership-requests/mine/pending` lists the user's pending owner_add invitations; `PUT /api/membership-requests/:membershipId/accept` lets the **invited user** accept (auth-only; consent gate in the service, no org-CASL).
18+
- **Migration** `modules/organizations/migrations/20260610140000-backfill-membership-source.js`: sets `source:'join_request'` on all existing PENDING memberships (they were all join requests pre-change). Idempotent (filter requires `source` absent). Raw collection driver (house style).
19+
20+
### Action required for downstream projects (`/update-project`)
21+
22+
1. Module/model/migration changes are devkit-owned → arrive via `/update-stack` (`--theirs`).
23+
2. **Migration ORDERING (E17 — critical):** the backfill `20260610140000` MUST run BEFORE the source-filtering code deploys, so no pre-existing join request is hidden from the approval list. The migration runs at boot before `listen()`; on Trawl this is sequenced in epic Phase 9 (#3815). The service/controller carry a temporary `source $exists:false` fallback so legacy rows stay visible even if the code lands first; that fallback is removed in a follow-up once every environment's backfill is confirmed.
24+
3. No platform-invitation (`sign.cap` / `?inviteToken=`) behavior changes. Vue add-member UI + pending-invitation list land in Vue #4281.
25+
26+
---
27+
728
## Remove organization email-invite feature (2026-06-10)
829

930
Phase 4 of the invitations↔org decouple epic (#3812). The organization's **own** email-invite flow is **deleted** — distinct from the platform `invitations` module (single-use signup-token gate), which is unchanged. This is the owner-invites-an-email-to-join-their-org flow that lived on the membership doc as `status:'invited'` + `inviteToken` / `invitedEmail` / `inviteExpiresAt`. The 2-step "invite to platform, then add the resulting user as a member" flow replaces it (`org.addMember` lands in a later phase / #3813).

modules/organizations/controllers/organizations.membership.controller.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,77 @@ const list = async (req, res) => {
2626
}
2727
};
2828

29+
/**
30+
* @function findUserByEmail
31+
* @description Endpoint for an org owner/admin to look up a single user by EXACT
32+
* email, to add them as a member (P5b affordance). GDPR/privacy: deliberately
33+
* exact-match only (no fuzzy name enumeration of the user directory) and gated by
34+
* the same owner/admin CASL `create Membership` ability the add-member route uses.
35+
* Returns minimal identity (id, displayName, email) for a match, or null.
36+
* @param {Object} req - Express request object
37+
* @param {Object} res - Express response object
38+
* @returns {void}
39+
*/
40+
const findUserByEmail = async (req, res) => {
41+
try {
42+
// CASL on the GET /members surface grants `read Membership` to plain members too,
43+
// which is too broad for a user-directory lookup. Restrict to owner/admin (or
44+
// global admin) explicitly — only roles that can actually add a member.
45+
const isPlatformAdmin = isGlobalAdmin(req.user);
46+
const actorRole = req.membership?.role;
47+
const canEnumerate = isPlatformAdmin
48+
|| actorRole === MEMBERSHIP_ROLES.OWNER
49+
|| actorRole === MEMBERSHIP_ROLES.ADMIN;
50+
if (!canEnumerate) {
51+
return responses.error(res, 403, 'Forbidden', 'Only owners or admins can look up users')();
52+
}
53+
54+
const email = req.query.email;
55+
if (!email || typeof email !== 'string') {
56+
return responses.error(res, 422, 'Unprocessable Entity', 'An email query parameter is required')();
57+
}
58+
const match = await MembershipService.findUserByExactEmail(email);
59+
responses.success(res, 'user lookup')(match);
60+
} catch (err) {
61+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
62+
}
63+
};
64+
65+
/**
66+
* @function addMember
67+
* @description Endpoint for an org owner/admin to add a user as a member. Creates a
68+
* PENDING owner_add membership the INVITED USER must accept (consent — invariant #1).
69+
* Role gate mirrors updateRole: only OWNERS may grant owner/admin; admins may only
70+
* add plain members. CASL (`create Membership`) on the /members POST route already
71+
* restricts this to org owners/admins (+ global admins).
72+
* @param {Object} req - Express request object
73+
* @param {Object} res - Express response object
74+
* @returns {void}
75+
*/
76+
const addMember = async (req, res) => {
77+
try {
78+
const { userId, role } = req.body;
79+
const requestedRole = role || MEMBERSHIP_ROLES.MEMBER;
80+
81+
// Elevated-role guard: only owners (or global admins) may invite an owner/admin.
82+
const isPlatformAdmin = isGlobalAdmin(req.user);
83+
const actorIsOwner = req.membership?.role === MEMBERSHIP_ROLES.OWNER;
84+
if (requestedRole !== MEMBERSHIP_ROLES.MEMBER && !isPlatformAdmin && !actorIsOwner) {
85+
return responses.error(res, 403, 'Forbidden', 'Only owners can add a member with an elevated role')();
86+
}
87+
88+
const membership = await MembershipService.addMember(
89+
req.organization._id || req.organization.id,
90+
userId,
91+
requestedRole,
92+
req.user._id || req.user.id,
93+
);
94+
responses.success(res, 'membership invitation created')(membership);
95+
} catch (err) {
96+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
97+
}
98+
};
99+
29100
/**
30101
* @function updateRole
31102
* @description Endpoint to change the role of a member in an organization.
@@ -110,6 +181,8 @@ const memberByID = async (req, res, next, id) => {
110181

111182
export default {
112183
list,
184+
findUserByEmail,
185+
addMember,
113186
updateRole,
114187
remove,
115188
memberByID,

modules/organizations/controllers/organizations.membershipRequest.controller.js

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import errors from '../../../lib/helpers/errors.js';
55
import responses from '../../../lib/helpers/responses.js';
66
import MembershipService from '../services/organizations.membership.service.js';
7-
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js';
7+
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES, isOwnerApprovable } from '../lib/constants.js';
88

99
/**
1010
* @function create
@@ -81,7 +81,9 @@ const reject = async (req, res) => {
8181

8282
/**
8383
* @function listMine
84-
* @description Endpoint to list the authenticated user's own pending requests.
84+
* @description Endpoint to list the authenticated user's own pending JOIN REQUESTS
85+
* (requests they initiated, awaiting an owner's approval). Owner_add invitations
86+
* to accept are a separate surface — see listMinePending.
8587
* @param {Object} req - Express request object
8688
* @param {Object} res - Express response object
8789
* @returns {Promise<void>}
@@ -95,6 +97,52 @@ const listMine = async (req, res) => {
9597
}
9698
};
9799

100+
/**
101+
* @function listMinePending
102+
* @description Endpoint to list the authenticated user's pending OWNER_ADD
103+
* invitations — memberships an owner/admin created for them that they must ACCEPT
104+
* to activate. Feeds the Vue "pending invitations to accept" list (P5b).
105+
* @param {Object} req - Express request object
106+
* @param {Object} res - Express response object
107+
* @returns {Promise<void>}
108+
*/
109+
const listMinePending = async (req, res) => {
110+
try {
111+
const invitations = await MembershipService.listPendingOwnerAddsByUser(req.user._id || req.user.id);
112+
responses.success(res, 'membership invitation list')(invitations);
113+
} catch (err) {
114+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
115+
}
116+
};
117+
118+
/**
119+
* @function accept
120+
* @description Endpoint for the INVITED USER to accept a pending owner_add membership.
121+
* Authentication is required (passport); the consent gate lives in the service —
122+
* acceptMembership activates ONLY when the membership is a PENDING owner_add AND
123+
* belongs to the authenticated caller. A mismatch (wrong user, a join_request, an
124+
* already-active or unknown membership) returns null → 404, never leaking which.
125+
* No org membership / CASL check: the invitee is by definition NOT yet a member,
126+
* so this route is intentionally outside the /members + /requests CASL surfaces.
127+
* @param {Object} req - Express request object
128+
* @param {Object} res - Express response object
129+
* @returns {Promise<void>}
130+
*/
131+
const accept = async (req, res) => {
132+
try {
133+
const membership = await MembershipService.acceptMembership(
134+
req.params.membershipId,
135+
req.user._id || req.user.id,
136+
);
137+
if (!membership) {
138+
return responses.error(res, 404, 'Not Found', 'No pending invitation with that identifier has been found')();
139+
}
140+
responses.success(res, 'membership invitation accepted')(membership);
141+
} catch (err) {
142+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
143+
}
144+
};
145+
98146
/**
99147
* @function requestByID
100148
* @description Middleware to fetch a pending membership by its ID.
@@ -109,7 +157,12 @@ const requestByID = async (req, res, next, id) => {
109157
const membership = await MembershipService.get(id);
110158
const organizationId = String(req.organization._id || req.organization.id);
111159
const membershipOrgId = String(membership?.organizationId?._id || membership?.organizationId);
112-
if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || membershipOrgId !== organizationId) {
160+
// CONSENT GATE (invariant #1): the owner-approval surface (approve/reject) must
161+
// ONLY ever see JOIN REQUESTS. An owner_add awaits the INVITED USER's consent —
162+
// it must be invisible here, else an owner could approve it and bypass consent.
163+
// `isOwnerApprovable` is the shared source of truth (covers join_request + the
164+
// E17 legacy no-source case); see its doc-level twin note on joinRequestSourceFilter.
165+
if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || !isOwnerApprovable(membership.source) || membershipOrgId !== organizationId) {
113166
return responses.error(res, 404, 'Not Found', 'No pending request with that identifier has been found')();
114167
}
115168
req.membershipRequest = membership;
@@ -125,5 +178,7 @@ export default {
125178
approve,
126179
reject,
127180
listMine,
181+
listMinePending,
182+
accept,
128183
requestByID,
129184
};

modules/organizations/lib/constants.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,43 @@ export const MEMBERSHIP_ROLES = {
88
ADMIN: 'admin',
99
MEMBER: 'member',
1010
};
11+
12+
/**
13+
* Source discriminator for PENDING memberships — the consent split.
14+
*
15+
* A PENDING membership can exist for two distinct reasons, and the two MUST stay
16+
* separable so the owner-approval surface never sees (and so never auto-approves)
17+
* a membership the invited user has not consented to:
18+
* - JOIN_REQUEST: the user asked to join (owner approves → ACTIVE). The owner is
19+
* the one who consents; this is the legacy/default join flow.
20+
* - OWNER_ADD: an owner/admin added a user (the INVITED USER accepts → ACTIVE).
21+
* The owner must NEVER be able to approve this — only the invited user can,
22+
* via acceptMembership. An owner_add appearing in the approval list would be a
23+
* consent bypass.
24+
*
25+
* Stored on `membership.source`. There is intentionally NO schema default — a
26+
* forgotten `source` on a PENDING row must fail loudly (pre('validate') guard)
27+
* rather than silently default to a join_request the owner could approve.
28+
*/
29+
export const PENDING_SOURCES = {
30+
JOIN_REQUEST: 'join_request',
31+
OWNER_ADD: 'owner_add',
32+
};
33+
34+
/**
35+
* @function isOwnerApprovable
36+
* @description Single source of truth for the consent invariant #1: a PENDING
37+
* membership is owner-approvable (appears in the owner's join-request / approve
38+
* surface) ONLY when it is NOT an owner_add. An owner_add requires the INVITED
39+
* user's consent via acceptMembership, never the owner's approval — letting one
40+
* into the approval surface would be a consent bypass.
41+
*
42+
* The doc-level twin of the DB-layer `joinRequestSourceFilter` `$or` query in
43+
* `organizations.membership.service.js`; the two MUST stay in lock-step. A Mongo
44+
* filter can't be a predicate, so the query stays separate — keep them aligned.
45+
* E17: a legacy PENDING with no `source` (pre-backfill, all join_requests) is
46+
* owner-approvable — `!== OWNER_ADD` covers both join_request and absent.
47+
* @param {String} [source] - The membership's PENDING source discriminator.
48+
* @returns {Boolean} True if an owner/admin may approve this PENDING membership.
49+
*/
50+
export const isOwnerApprovable = (source) => source !== PENDING_SOURCES.OWNER_ADD;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js';
7+
8+
/**
9+
* Migration: backfill `source` on existing PENDING memberships.
10+
*
11+
* The consent split (P5a) adds a `source` discriminator to PENDING memberships:
12+
* - 'join_request' — the user asked to join (owner approves → ACTIVE);
13+
* - 'owner_add' — an owner added the user (the invited user accepts → ACTIVE).
14+
*
15+
* Every PENDING membership that existed BEFORE this change was a join request (the
16+
* owner_add flow did not exist), so they are all backfilled to 'join_request'.
17+
*
18+
* Why this matters (E17 migration ordering): the new code reads the owner-approval
19+
* surface as `source: 'join_request' OR source absent` so legacy rows stay visible
20+
* until this backfill runs. After this migration, every PENDING row carries an
21+
* explicit source and the `$exists:false` fallback in the service/controller can be
22+
* removed (follow-up). P9 MUST run this migration on Trawl BEFORE deploying any code
23+
* that filters owner_adds out of the approval surface, so no join request is ever
24+
* hidden.
25+
*
26+
* `source` IS declared on the schema now, so a model `updateMany` `$set` would NOT
27+
* be stripped by strict mode. We nonetheless use the RAW collection driver to match
28+
* the house migration style and to be robust regardless of model-registration state
29+
* at migration time. ACTIVE memberships are intentionally left untouched (source is
30+
* only meaningful while PENDING).
31+
*
32+
* Idempotent: the filter requires `source` ABSENT, so a second run matches nothing.
33+
* @returns {Promise<void>} Resolves when the backfill has completed.
34+
*/
35+
export async function up() {
36+
// Model `Membership` → default collection `memberships`.
37+
const db = mongoose.connection.db;
38+
const memberships = db.collection('memberships');
39+
40+
const result = await memberships.updateMany(
41+
{ status: MEMBERSHIP_STATUSES.PENDING, source: { $exists: false } },
42+
{ $set: { source: PENDING_SOURCES.JOIN_REQUEST } },
43+
);
44+
45+
const modified = result?.modifiedCount ?? 0;
46+
console.info(`[migration] backfill-membership-source: set source='join_request' on ${modified} pending membership(s)`);
47+
}
48+
49+
/**
50+
* Down migration: unset `source` on the rows this backfill set (PENDING
51+
* join_requests). ACTIVE rows and owner_adds are untouched. Best-effort reversal.
52+
* @returns {Promise<void>} Resolves when the unset has completed.
53+
*/
54+
export async function down() {
55+
const db = mongoose.connection.db;
56+
const memberships = db.collection('memberships');
57+
await memberships.updateMany(
58+
{ status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST },
59+
{ $unset: { source: '' } },
60+
);
61+
console.warn('[migration] backfill-membership-source DOWN: unset source on pending join_requests');
62+
}

modules/organizations/models/organizations.membership.model.mongoose.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Module dependencies
33
*/
44
import mongoose from 'mongoose';
5+
import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js';
56

67
const Schema = mongoose.Schema;
78

@@ -30,12 +31,43 @@ const MembershipMongoose = new Schema(
3031
enum: ['active', 'pending'],
3132
default: 'active',
3233
},
34+
// Consent discriminator for PENDING memberships. Intentionally NO default:
35+
// a forgotten `source` on a PENDING row must FAIL (pre-validate guard below),
36+
// never silently default to 'join_request' (which an owner could approve →
37+
// consent bypass). See PENDING_SOURCES in lib/constants.js.
38+
source: {
39+
type: String,
40+
enum: Object.values(PENDING_SOURCES),
41+
},
42+
// Provenance: the owner/admin who created an owner_add invitation (audit only).
43+
addedBy: {
44+
type: Schema.ObjectId,
45+
ref: 'User',
46+
default: null,
47+
},
3348
},
3449
{
3550
timestamps: true,
3651
},
3752
);
3853

54+
/**
55+
* Consent guard: a PENDING membership MUST declare an explicit source.
56+
* Without this, an owner_add that forgot to set `source` would be
57+
* indistinguishable from a join_request and could be approved by the owner,
58+
* bypassing the invited user's consent. Use the constant (status is stored
59+
* lowercase as MEMBERSHIP_STATUSES.PENDING — a `=== 'PENDING'` check is inert).
60+
*
61+
* Implemented as a no-arg sync hook that throws: mongoose rejects the validate()
62+
* promise on a thrown pre-hook error. (A `function(next)` signature is brittle here
63+
* — mongoose's arity detection can invoke it with no `next` under doc.validate().)
64+
*/
65+
MembershipMongoose.pre('validate', function enforcePendingSource() {
66+
if (this.status === MEMBERSHIP_STATUSES.PENDING && !this.source) {
67+
throw new Error('PENDING membership requires explicit source');
68+
}
69+
});
70+
3971
/**
4072
* Compound unique index to prevent duplicate memberships
4173
*/

modules/organizations/models/organizations.membership.schema.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ const MembershipUpdate = z.object({
1010
role: z.enum(['owner', 'admin', 'member']),
1111
});
1212

13+
/**
14+
* Owner/admin add-member payload. `userId` is the target user's id (required —
15+
* the user must already exist; resolve from an email via the /members/search
16+
* lookup first). `role` is the role to grant on acceptance (defaults to member).
17+
*/
18+
const MembershipAdd = z.object({
19+
userId: z.string().min(1),
20+
role: z.enum(['owner', 'admin', 'member']).optional(),
21+
});
22+
1323
export default {
1424
MembershipUpdate,
25+
MembershipAdd,
1526
};

0 commit comments

Comments
 (0)