Relationships
#1810 Collective invitations expire after 48h and the auto-claim is gated on that window, so a late signup is never bound
Opened by keeb · 8/24/2026
Found by code scan of the collective invite and claim paths.
The defect
invitationExpiresIn is never set in the Better Auth organization plugin config in lib/auth.ts, so the plugin default applies: 3600 * 48 seconds, i.e. 48 hours. Every collective invitation dies two days after it is sent.
The auto-claim that binds an invitee to the collective when they establish a session filters on that same window — lib/app/claim-pending-invitations.ts:147:
const invites = await db.collection("invitation").find({
email,
status: "pending",
expiresAt: { $gt: new Date() },
}).toArray();The claim hook itself is wired correctly: lib/auth.ts:583 fires it on every session-establishing path, not just signup, so an operative who already had an account gets claimed on their next sign-in. But past the 48-hour mark the query returns nothing, forever. There is no re-send, no grace period, and no path back.
So the sequence that loses a member is entirely ordinary:
- Owner invites a colleague by email.
- Colleague does not act on it within two days — busy week, mail buried, waiting until they had time to look properly.
- Colleague signs up (or signs in) on day three with the exact invited address.
- Nothing binds. They land in the product with no collective.
The invitation row does not reflect any of this
The document is left at status: "pending" indefinitely. Nothing sweeps it to expired. So the stored state says the invitation is outstanding while every read path treats it as gone — see the sibling filters at lib/infrastructure/better-auth-collective-queries.ts:599 and :650, and #1653.
Suggested fix
- Set
invitationExpiresInexplicitly rather than inheriting the default. Two days is short for something delivered by email to a work address; a week or a fortnight is more honest about how people read mail. - Decouple the claim from the expiry. An invitation that has expired but was never declined still represents a real intent by the owner. Either let the claim bind on
status: "pending"regardless ofexpiresAt, or transition expired invitations to a distinct state that the owner can re-send from with one click. - Whichever route, move the row off
pendingwhen the window closes, so stored state and read behaviour agree.
Related: #1653 (nobody is told when an invite mis-lands), #1186 and #1287 (earlier claim-path failures, both shipped).
Open
No activity in this phase yet.
Sign in to post a ripple.