Skip to content

Signin 500 (null._id) when a user's active membership points to a deleted org — removeById skips cascade #3709

@PierreBrisorgueil

Description

@PierreBrisorgueil

Symptom

Sign-in returns HTTP 500 with Cannot read properties of null (reading '_id') for any user who has an ACTIVE membership whose organization no longer exists. The error is unhandled (signin has no try/catch) and bubbles to Express.

Observed live on TrawlNodeDev 2026-05-27: a user (test@gmail.com) had one active membership + currentOrganization pointing at an org that had been deleted; every sign-in 500'd. Clearing the dangling membership + nulling currentOrganization fixed it immediately.

Root cause (two layers)

1. Non-cascading org deletion leaves dangling refs.
modules/users/services/users.service.js:126 deletes a user's organizations via OrganizationsCrudService.removeById(orgId):

// organizations.crud.service.js:299
const removeById = (id) => OrganizationsRepository.remove({ _id: id });   // bare delete, NO cascade

Unlike remove(organization) (same file, ~L177) which does cascade (deletes memberships, reassigns/clears every affected user's currentOrganization), removeById just drops the org document. So when a user is deleted / closes their account, any co-members of that user's orgs are left with active memberships + currentOrganization pointing at a now-missing org.

2. Sign-in null-derefs on the dangling ref instead of tolerating it.
modules/organizations/services/organizations.crud.service.js:262 (autoSetCurrentOrganization, called first thing in signin):

const orgId = memberships[0].organizationId._id || memberships[0].organizationId;

When the membership's organizationId is populated and the org was deleted, the populated value is null -> null._id throws. The signin path (auth.controller.js) assumes a populated org is always non-null.

Fix

  • Root cause: route the user-deletion org cleanup through the cascading remove(organization) (or give removeById the same membership + currentOrganization cascade). Callers that drop an org must never leave orphan memberships.
  • Defensive (belt-and-suspenders): in autoSetCurrentOrganization (and any signin populate path), treat a membership whose populated organizationId is null as invalid — skip / clean it — instead of dereferencing. Pick the first membership with a live org; if none, set currentOrganization: null.
  • Wrap signin org-resolution so a data-integrity glitch degrades to "no current org" rather than a 500.

Repro

  1. User A and User B both members of Org X (B's membership active, currentOrganization = X).
  2. Delete User A (whose deletion removes Org X via users.service.js:126 -> removeById, no cascade).
  3. User B signs in -> 500 Cannot read properties of null (reading '_id').

Notes

  • Devkit-core (auth + organizations modules) -> fix upstream here, then propagate to downstreams via /update-stack.
  • Found while shipping an unrelated Trawl Vue UI PR (comes-io/trawl_vue#935); filed as orphan tracking issue (no assignee).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions