Skip to content

Commit ac772a4

Browse files
fix(organizations): route user-deletion org cascade through the removal seam and make org removal atomic (#3971)
* fix(users): route sole-owner org deletion through the organization removal seam users.service.js#remove() called OrganizationsRepository.remove() directly when deleting a sole-owned org, bypassing organizations.crud.service.js#remove() and its runOrganizationRemovedHandlers call — the onOrganizationRemoved seam modules/tasks/tasks.init.js registers for org-scoped task cleanup. Org-scoped tasks (and any future handler) were silently orphaned. The cascade now delegates the full org teardown to OrganizationsCrudService.remove() (lazy-imported to avoid a static cycle with users.service.js, matching billing.init.js's existing pattern). A handler error propagates out of the crud service by design (aborts that org's own removal) but is caught + logged at this call site so it never aborts the surrounding user deletion. Centralized the co-member currentOrganization reassignment's null-org guard (#3709) into organizations.crud.service.js#remove() since it's now the sole implementation — the crud service's own version lacked it, which would have crashed on a dangling membership once other callers started relying on this path too. Also fixes a latent test-fragility bug in tasks.integration.tests.js: two "not a user's task" tests referenced a task orphaned by a sibling test's user deletion, which only stayed queryable because of the very bug fixed here; replaced with a properly isolated foreign-user fixture. Closes #3965 Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup * fix(organizations): make org removal atomic so user deletion cannot orphan an org Phase-0 BLOCK finding on #3965: users.service.js#remove() wrapped the ENTIRE OrganizationsCrudService.remove() call in try/catch, then unconditionally deleted the user. Because the crud remove() wiped memberships and reassigned co-members BEFORE running onOrganizationRemoved handlers and the final OrganizationsRepository.remove(), a handler failure anywhere in that cascade was swallowed while the user still got deleted — a half-removed org (zombie doc, or membership rows dangling on a deleted userId). Fix (Option A — atomic reorder, chosen over narrowing the seam's error posture per-caller): organizations.crud.service.js#remove() now removes the org repository doc BEFORE running onOrganizationRemoved handlers, so once removal starts the org always ends fully removed regardless of handler outcome. Handler failures are caught + logged there for reconciliation (never re-thrown) — same posture for every caller, not special-cased for the user-deletion path. A STRUCTURAL failure (membership wipe, reassignment, or the org repository delete itself) is not caught and still propagates. Verified only two callers of the crud remove()/removeById() exist: organizations.controller.js's admin delete-org endpoint and users.service.js's sole-owner cascade — both go through the same function, so no per-caller option/param was needed; relaxing the handler-error posture to best-effort is safe for both. users.service.js#remove() drops its blanket try/catch around the seam call: handler failures are already swallowed inside the seam, so anything that still escapes is a structural failure, which must propagate and abort the whole user deletion (pre-diff behavior) rather than delete the user on top of a genuinely broken teardown. Tests: organizations.crud.orgRemoval.unit.tests.js now asserts a handler throw is logged but the org is still removed, plus a new structural-failure case that still propagates. users.service.remove.orgRemovalSeam.unit.tests.js adds org-doc-removed assertions on handler throw (no zombie) and two new structural-failure cases (org repo delete / membership wipe throwing) that assert the user is NOT removed. Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup * fix(organizations): isolate org-removal cleanup handlers so one failure never skips the rest Per-handler try/catch in runOrganizationRemovedHandlers: every handler runs even if an earlier one throws (a bug in one module's cleanup can no longer orphan another module's org-scoped rows). Failures are collected and re-raised as a single AggregateError; the crud service logs each one individually for reconciliation. Best-effort semantics and the atomic-by-ordering removal are unchanged. Addresses CodeRabbit review on #3971.
1 parent 9e6bc2d commit ac772a4

8 files changed

Lines changed: 424 additions & 45 deletions

ERRORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ Use this file as a compact memory of recurring AI mistakes.
3232
- [2026-06-15] deps/audit: leaving `npm audit` advisories unaddressed on the assumption they need a major bump -> run `npm audit fix` (never `--force`) first; the runtime-tree DoS/ReDoS items (`qs`, `path-to-regexp`, `brace-expansion`) all fixed via in-range bumps, no residual. These are DoS-class but NOT attacker-reachable in this stack: Express route patterns are static (no user-controlled `path-to-regexp` input) and `qs`/`brace-expansion` only parse server-side query strings under fixed code paths — still bump them to keep the tree clean and avoid scanner noise.
3333
- [2026-07-16] security: `users.repository.js findByIdAndUpdatePopulated()` does `.populate()` with no `.select()`, so `organizations.controller.js switchOrganization` serialized the raw doc (password hash + OAuth tokens + reset/verification tokens) straight to the client; `users.account.controller.js me()` separately forwarded `providerData` (OAuth tokens) verbatim -> any endpoint returning a Mongoose user doc must go through `UserService.removeSensitive()` (whitelist, `modules/users/utils/sanitizeUser.js`) at the response boundary, never serialize `req.user`/a populated doc directly; a local-signup fixture's `providerData` defaults to `{}` so a naive falsy check won't catch this — seed a fake OAuth token in tests to prove the leak is actually closed; see pierreb-devkit/Node#3963
3434
- [2026-07-16] billing/stripe: the #3742 priceId-map fix (`resolvePlan`/`buildPriceIdToPlanMap`) was applied only to the webhook handler; `billing.admin.service.js` `resolveStripePlan` (admin force-sync, a DB WRITE path) and `billing.reconcile.service.js` `resolveStripePlan` (LOG-ONLY divergence check) kept their own copies reading only `metadata.planId` -> a paid org's Stripe subscription (which never carries `metadata.planId`) resolved to `'free'` on admin sync (silently downgrading a paying org) and produced a false `planMismatch` alert on every reconcile run; fix = one shared resolver (`modules/billing/lib/billing.planResolver.js`) used by all three call sites, and a null-unresolved sentinel (never guess `'free'`) so the admin write path ABORTS instead of downgrading and the reconcile log path skips the comparison instead of alerting; see pierreb-devkit/Node#3964
35+
- [2026-07-16] architecture: `users.service.js remove()`'s sole-owner cascade called `OrganizationsRepository.remove()` directly instead of `organizations.crud.service.js#remove()`, silently skipping `runOrganizationRemovedHandlers` (the `onOrganizationRemoved` seam `modules/tasks/tasks.init.js` registers for org-scoped task cleanup) -> deleting an org must always route through the owning module's SERVICE method, never straight to its Repository, even for a "bare" cascade delete from another module — a direct-repository shortcut silently bypasses any registered removal hook; a stale cross-test fixture in `tasks.integration.tests.js` (an orphaned-but-still-existing task reused across unrelated test cases) was inadvertently relying on this bug and needed a properly isolated fixture once the cascade actually cleaned up; see pierreb-devkit/Node#3965

modules/organizations/lib/orgRemoval.registry.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@
22
* @module organizations/lib/orgRemoval.registry
33
* @description Subscriber registry for organization-removal cleanup.
44
* Optional modules register a handler at load time via `onOrganizationRemoved`;
5-
* the organization crud service runs them sequentially on org delete and
6-
* propagates their errors (no silent swallow). Config-free, import-safe leaf —
7-
* it must not import organization/tasks services (avoids an import cycle).
5+
* `runOrganizationRemovedHandlers` runs every handler sequentially, ISOLATING each —
6+
* one handler throwing never prevents the remaining handlers from running (so a bug
7+
* in one module's cleanup can't orphan another module's org-scoped rows). Failures are
8+
* collected and, after the full run, re-raised together as a single AggregateError to
9+
* ITS caller (no silent swallow inside this module). The current sole caller,
10+
* organizations.crud.service.js#remove, calls this AFTER the org doc and its
11+
* memberships are already gone, and deliberately catches + logs each aggregated error
12+
* there (best-effort) so a handler bug can never leave a zombie org or abort an
13+
* unrelated caller's own work (#3965) — see that function's docblock. Config-free,
14+
* import-safe leaf — it must not import organization/tasks services (avoids an
15+
* import cycle).
816
*/
917

1018
const handlers = new Set();
@@ -24,13 +32,24 @@ export const onOrganizationRemoved = (fn) => {
2432

2533
/**
2634
* @function runOrganizationRemovedHandlers
27-
* @description Run every registered handler sequentially. Errors propagate (not swallowed).
35+
* @description Run every registered handler sequentially, isolating each: a handler
36+
* throwing is caught so every later handler still runs, then all failures are re-raised
37+
* together as a single AggregateError once the full run completes (not swallowed).
2838
* @param {Object} payload - { organizationId, organization }.
2939
* @returns {Promise<void>}
40+
* @throws {AggregateError} If one or more handlers threw — carries every handler error.
3041
*/
3142
export const runOrganizationRemovedHandlers = async (payload) => {
43+
const errors = [];
3244
for (const fn of handlers) {
33-
await fn(payload); // sequential: each handler must resolve before the next runs
45+
try {
46+
await fn(payload); // sequential: each handler must resolve before the next runs
47+
} catch (err) {
48+
errors.push(err); // isolate: a failing handler must not block the remaining ones
49+
}
50+
}
51+
if (errors.length > 0) {
52+
throw new AggregateError(errors, `${errors.length} organization-removed handler(s) failed`);
3453
}
3554
};
3655

modules/organizations/services/organizations.crud.service.js

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,22 @@ const update = async (organization, body) => {
188188

189189
/**
190190
* @function remove
191-
* @description Service to delete an organization, all its memberships, and any
192-
* data owned by optional modules that registered an org-removal handler via
193-
* `onOrganizationRemoved`. Handlers run sequentially after membership cleanup;
194-
* any handler error propagates and aborts the delete before the repository
195-
* removal (no silent swallow).
191+
* @description Service to delete an organization and all its memberships. Removal is
192+
* atomic-by-ordering: membership cleanup, affected-user reassignment, and the org
193+
* repository delete itself all happen BEFORE any onOrganizationRemoved handler runs —
194+
* once removal starts, the org always ends fully removed (no zombie org doc, no
195+
* memberships left pointing at it), regardless of what a handler does (#3965).
196+
* Handlers (data owned by optional modules, e.g. tasks) then run sequentially and
197+
* ISOLATED — one throwing never skips the rest — BEST-EFFORT: every handler error is
198+
* logged here for manual reconciliation of that module's leftover org-scoped rows, but
199+
* never re-thrown. This is deliberate — a
200+
* handler failure must not resurrect/block a removal that has already committed, and
201+
* must not propagate into an unrelated caller (e.g. users.service.js#remove's
202+
* sole-owner cascade, which deletes the user right after this call and must not have
203+
* that deletion aborted by a downstream task-cleanup bug).
204+
* A STRUCTURAL failure — the membership wipe, the reassignment loop, or the org
205+
* repository delete itself throwing — is NOT caught here and propagates to the
206+
* caller, so a genuinely broken teardown is never reported as success.
196207
* @param {Object} organization - The organization to delete.
197208
* @returns {Promise<Object>} A promise resolving to a confirmation of the deletion.
198209
*/
@@ -205,20 +216,41 @@ const remove = async (organization) => {
205216
// Remove all memberships for this organization
206217
await MembershipRepository.deleteMany({ organizationId: orgId });
207218

208-
// For each affected user, switch to their next available org or set null
219+
// For each affected user, switch to their next available org or set null.
220+
// Guard against a dangling membership whose populated organizationId is null (ref
221+
// to another, already-deleted org) — otherwise `.organizationId._id` throws (#3709,
222+
// centralized here from users.service.js's own cascade by #3965).
209223
await Promise.all(affectedUsers.map(async (u) => {
210224
const remaining = await MembershipRepository.list({ userId: u._id, status: MEMBERSHIP_STATUSES.ACTIVE });
211-
const nextOrg = remaining.length > 0
212-
? (remaining[0].organizationId._id || remaining[0].organizationId)
225+
const liveMemberships = remaining.filter((m) => m.organizationId != null);
226+
const nextOrg = liveMemberships.length > 0
227+
? (liveMemberships[0].organizationId._id || liveMemberships[0].organizationId)
213228
: null;
214229
await UserService.updateById(u._id, { currentOrganization: nextOrg });
215230
}));
216231

217-
// Run org-removal cleanup handlers registered by optional modules (e.g. tasks).
218-
// Errors propagate and abort the delete before the repository removal.
219-
await runOrganizationRemovedHandlers({ organizationId: orgId, organization });
220-
232+
// Structural teardown is complete — memberships gone, affected users reassigned.
233+
// Remove the org doc itself BEFORE running any optional cleanup handler, so the org
234+
// can never be left half-removed by a handler throwing (#3965).
221235
const result = await OrganizationsRepository.remove(organization);
236+
237+
// Run org-removal cleanup handlers registered by optional modules (e.g. tasks) AFTER
238+
// the org is structurally gone. Best-effort: log and continue on failure (see docblock).
239+
try {
240+
await runOrganizationRemovedHandlers({ organizationId: orgId, organization });
241+
} catch (err) {
242+
// The registry isolates each handler and re-raises every failure as one AggregateError,
243+
// so later handlers already ran. Log each failure individually for reconciliation.
244+
const failures = err instanceof AggregateError ? err.errors : [err];
245+
failures.forEach((failure) => {
246+
logger.error('organizations.crud.remove: org-removal cleanup handler failed after the org was removed (needs reconciliation)', {
247+
organizationId: String(orgId),
248+
message: failure?.message,
249+
stack: failure?.stack,
250+
});
251+
});
252+
}
253+
222254
return result;
223255
};
224256

modules/organizations/tests/organizations.crud.orgRemoval.unit.tests.js

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/**
2-
* Unit tests — OrgCrudService.remove() fires the org-removal registry and propagates handler errors.
2+
* Unit tests — OrgCrudService.remove() fires the org-removal registry and is atomic:
3+
* the org doc + memberships are always gone together before any handler runs, and a
4+
* handler error is caught + logged (best-effort), never re-thrown (#3965).
35
*/
46
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
57

@@ -8,9 +10,10 @@ const mockMembershipDeleteMany = jest.fn().mockResolvedValue({ deletedCount: 0 }
810
const mockMembershipList = jest.fn().mockResolvedValue([]);
911
const mockUpdateById = jest.fn().mockResolvedValue({});
1012
const mockFindWithFilter = jest.fn().mockResolvedValue([]);
13+
const mockLoggerError = jest.fn();
1114

1215
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
13-
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() },
16+
default: { error: mockLoggerError, warn: jest.fn(), info: jest.fn() },
1417
}));
1518

1619
jest.unstable_mockModule('../../../config/index.js', () => ({
@@ -61,17 +64,103 @@ describe('OrgCrudService.remove() — org-removal registry', () => {
6164
expect(mockOrgRemove).toHaveBeenCalledWith(organization);
6265
});
6366

64-
test('propagates a handler error and aborts before the repository remove', async () => {
67+
test('a handler error is best-effort — logged, never re-thrown, and the org is still fully removed (#3965)', async () => {
6568
onOrganizationRemoved(async () => {
6669
throw new Error('tasks cleanup failed');
6770
});
6871

69-
await expect(OrgCrudService.remove(organization)).rejects.toThrow('tasks cleanup failed');
70-
expect(mockOrgRemove).not.toHaveBeenCalled();
72+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
73+
// The org repository delete ran BEFORE the handler — removal is atomic-by-ordering.
74+
expect(mockOrgRemove).toHaveBeenCalledWith(organization);
75+
expect(mockLoggerError).toHaveBeenCalledWith(
76+
'organizations.crud.remove: org-removal cleanup handler failed after the org was removed (needs reconciliation)',
77+
expect.objectContaining({ organizationId: 'org-1', message: 'tasks cleanup failed' }),
78+
);
79+
});
80+
81+
test('isolates handler failures — a failing handler does not skip a later one, and every failure is logged (#3965)', async () => {
82+
const failingFirst = jest.fn().mockRejectedValue(new Error('tasks cleanup failed'));
83+
const succeedingSecond = jest.fn().mockResolvedValue(undefined);
84+
onOrganizationRemoved(failingFirst);
85+
onOrganizationRemoved(succeedingSecond);
86+
87+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
88+
89+
// The second handler still ran despite the first throwing (per-handler isolation).
90+
expect(failingFirst).toHaveBeenCalledTimes(1);
91+
expect(succeedingSecond).toHaveBeenCalledTimes(1);
92+
expect(succeedingSecond).toHaveBeenCalledWith({ organizationId: 'org-1', organization });
93+
// The single failure is logged for reconciliation; the org is still fully removed.
94+
expect(mockOrgRemove).toHaveBeenCalledWith(organization);
95+
expect(mockLoggerError).toHaveBeenCalledTimes(1);
96+
expect(mockLoggerError).toHaveBeenCalledWith(
97+
'organizations.crud.remove: org-removal cleanup handler failed after the org was removed (needs reconciliation)',
98+
expect.objectContaining({ organizationId: 'org-1', message: 'tasks cleanup failed' }),
99+
);
100+
});
101+
102+
test('logs each failure when multiple handlers throw, and still runs every handler (#3965)', async () => {
103+
const failingA = jest.fn().mockRejectedValue(new Error('tasks cleanup failed'));
104+
const failingB = jest.fn().mockRejectedValue(new Error('files cleanup failed'));
105+
const succeeding = jest.fn().mockResolvedValue(undefined);
106+
onOrganizationRemoved(failingA);
107+
onOrganizationRemoved(failingB);
108+
onOrganizationRemoved(succeeding);
109+
110+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
111+
112+
expect(failingA).toHaveBeenCalledTimes(1);
113+
expect(failingB).toHaveBeenCalledTimes(1);
114+
expect(succeeding).toHaveBeenCalledTimes(1);
115+
// One log line per failing handler (both aggregated failures surfaced individually).
116+
expect(mockLoggerError).toHaveBeenCalledTimes(2);
117+
expect(mockLoggerError).toHaveBeenCalledWith(
118+
'organizations.crud.remove: org-removal cleanup handler failed after the org was removed (needs reconciliation)',
119+
expect.objectContaining({ organizationId: 'org-1', message: 'tasks cleanup failed' }),
120+
);
121+
expect(mockLoggerError).toHaveBeenCalledWith(
122+
'organizations.crud.remove: org-removal cleanup handler failed after the org was removed (needs reconciliation)',
123+
expect.objectContaining({ organizationId: 'org-1', message: 'files cleanup failed' }),
124+
);
125+
});
126+
127+
test('a STRUCTURAL failure (org repository delete itself throws) propagates and is NOT swallowed', async () => {
128+
mockOrgRemove.mockRejectedValueOnce(new Error('db delete failed'));
129+
130+
await expect(OrgCrudService.remove(organization)).rejects.toThrow('db delete failed');
71131
});
72132

73133
test('with zero handlers registered, removes the organization without throwing', async () => {
74134
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
75135
expect(mockOrgRemove).toHaveBeenCalledWith(organization);
76136
});
137+
138+
test('reassigns an affected user without throwing when their remaining membership.organizationId is null (dangling ref, #3709)', async () => {
139+
mockFindWithFilter.mockResolvedValueOnce([{ _id: 'coUid' }]);
140+
mockMembershipList.mockResolvedValueOnce([{ _id: 'm2', organizationId: null, status: 'active' }]);
141+
142+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
143+
expect(mockUpdateById).toHaveBeenCalledWith('coUid', { currentOrganization: null });
144+
});
145+
146+
test('skips a null-org membership and picks the first live org when reassigning an affected user (mixed, #3709)', async () => {
147+
mockFindWithFilter.mockResolvedValueOnce([{ _id: 'coUid' }]);
148+
mockMembershipList.mockResolvedValueOnce([
149+
{ _id: 'm2', organizationId: null, status: 'active' },
150+
{ _id: 'm3', organizationId: { _id: 'orgY' }, status: 'active' },
151+
]);
152+
153+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
154+
expect(mockUpdateById).toHaveBeenCalledWith('coUid', { currentOrganization: 'orgY' });
155+
});
156+
157+
test('reassigns to a non-populated (raw id) organizationId when the membership was not populated', async () => {
158+
mockFindWithFilter.mockResolvedValueOnce([{ _id: 'coUid' }]);
159+
mockMembershipList.mockResolvedValueOnce([
160+
{ _id: 'm4', organizationId: 'orgZ', status: 'active' },
161+
]);
162+
163+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
164+
expect(mockUpdateById).toHaveBeenCalledWith('coUid', { currentOrganization: 'orgZ' });
165+
});
77166
});

modules/organizations/tests/organizations.orgRemoval.registry.unit.tests.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,35 @@ describe('orgRemoval.registry', () => {
3535
expect(order).toEqual(['first', 'second']);
3636
});
3737

38-
test('propagates a handler error (does not swallow) and aborts the remaining handlers', async () => {
38+
test('isolates a handler error — the remaining handlers still run, then failures re-raise as one AggregateError', async () => {
3939
const boom = new Error('cleanup failed');
4040
const after = jest.fn().mockResolvedValue(undefined);
4141
onOrganizationRemoved(async () => {
4242
throw boom;
4343
});
4444
onOrganizationRemoved(after);
4545

46-
await expect(runOrganizationRemovedHandlers({ organizationId: 'org-1' })).rejects.toThrow('cleanup failed');
47-
expect(after).not.toHaveBeenCalled();
46+
await expect(runOrganizationRemovedHandlers({ organizationId: 'org-1' })).rejects.toThrow(AggregateError);
47+
// A failing handler must not block a later one (avoids orphaning another module's rows).
48+
expect(after).toHaveBeenCalledTimes(1);
49+
});
50+
51+
test('aggregates every handler failure — each error is carried on the AggregateError', async () => {
52+
const boomA = new Error('tasks cleanup failed');
53+
const boomB = new Error('files cleanup failed');
54+
const middle = jest.fn().mockResolvedValue(undefined);
55+
onOrganizationRemoved(async () => {
56+
throw boomA;
57+
});
58+
onOrganizationRemoved(middle);
59+
onOrganizationRemoved(async () => {
60+
throw boomB;
61+
});
62+
63+
await expect(runOrganizationRemovedHandlers({ organizationId: 'org-1' })).rejects.toMatchObject({
64+
errors: [boomA, boomB],
65+
});
66+
expect(middle).toHaveBeenCalledTimes(1);
4867
});
4968

5069
test('runs zero handlers without throwing when none are registered', async () => {

0 commit comments

Comments
 (0)