Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/list-user-invitations-declared-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/plugin-auth": patch
---

`GET /organization/list-user-invitations` now honours the declared `requireEmailVerificationOnInvitation` — the per-user invitation inbox works for the unverified sessions it was declared open to

`AuthManager` constructs better-auth's organization plugin with `requireEmailVerificationOnInvitation: false` on purpose: without a mailer wired in, nothing can ever verify an invitee, so requiring verification would dead-end every invite flow. The pinned better-auth 1.7.2 reads that option on `accept-invitation`, `reject-invitation` and `get-invitation`, but its `listUserInvitations` handler refuses every unverified session unconditionally. Measured on the real pipeline: the same unverified invitee got `200` from all three id-addressed routes and `403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION` from the listing, so on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed but never list it, and the SDK's `organizations.invitations.listMine()` inbox page was empty-by-403 for every user.

The endpoint is now rebuilt in place on the organization plugin's own `endpoints` record, from the vendor endpoint's own options object (same path, method, query schema and OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. The listing itself is still the vendor's own `getOrgAdapter(...).listUserInvitations(sessionEmail)` — invitations addressed to the session's email, pending only — so nothing widens beyond what the same session can already accept one by one. A client-side `?email=` is still refused with the vendor's `400`, and a request with no session keeps the vendor's `400`.

Declared `true` keeps today's refusal byte-for-byte; an undeclared option keeps the vendor's list-route posture (refuse) rather than re-deriving the vendor-internal default the sibling routes use. No new public error code, no new export from the package entry.
52 changes: 50 additions & 2 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { OrganizationOptions } from 'better-auth/plugins/organization';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
Expand Down Expand Up @@ -72,6 +73,9 @@ import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-
import {
applyPlatformAdminImpersonation,
} from './admin-impersonate-endpoint.js';
import {
applyDeclaredInvitationVerificationToListing,
} from './list-user-invitations-verification.js';
import {
invitationRoleCapFailure,
isPlainMemberInvitation,
Expand Down Expand Up @@ -2792,7 +2796,12 @@ export class AuthManager {
// [#8289] Same map, same request lifetime — see the field's doc for why
// the before-hook cannot read it back off `ctx`.
this.orgRolesMap = customOrgRoles;
return organization({
// [#16569] Held as a named object rather than an inline literal: the
// rebuilt `/organization/list-user-invitations` endpoint below needs the
// VERY object the vendor plugin was constructed with — it is what the
// vendor's own `getOrgAdapter(ctx.context, options)` reads, and the
// declaration it honours lives on it.
const organizationOptions = {
schema: buildOrganizationPluginSchema(),
// Enable the team sub-feature so the framework's `sys_team` /
// `sys_team_member` tables (already declared in platform-objects)
Expand Down Expand Up @@ -3168,7 +3177,46 @@ export class AuthManager {
console.error(`[AuthManager] sendInvitationEmail failed (swallowed): ${err?.message ?? err}`);
}
},
});
} satisfies OrganizationOptions;
const organizationPlugin: any = organization(organizationOptions);

// [#16569] `GET /organization/list-user-invitations` — make the vendor's
// listing honour the `requireEmailVerificationOnInvitation: false`
// declared above, the way `accept-invitation`, `reject-invitation` and
// `get-invitation` already do. better-auth 1.7.2's `listUserInvitations`
// refuses every unverified session UNCONDITIONALLY (it never reads the
// option), so on exactly the no-mailer deployment the declaration exists
// for, an invitee could accept an invitation but never list it and the
// SDK's `organizations.invitations.listMine()` inbox was empty-by-403.
// Rebuilt IN PLACE on this plugin's own endpoints record — the same
// shape as `applyPlatformAdminImpersonation` above, for the same reasons
// (one owner for the path; every hook keyed on it still fires; the
// request contract is the vendor's own options object, never a copy).
// The listing itself stays the vendor's `getOrgAdapter(...)
// .listUserInvitations(sessionEmail)`: no second definition of which
// rows a session may see. `list-user-invitations-verification.ts`
// carries the full reading.
const listingRewired = await applyDeclaredInvitationVerificationToListing(
organizationPlugin,
organizationOptions,
);
if (!listingRewired) {
// The vendor renamed or dropped the endpoint. Say so loudly: the
// route then falls back to the vendor's own handler, which refuses
// every unverified session — an empty inbox, not an open door.
// `warn`, not `error` (AGENTS.md → Degradation log levels): the
// system is VISIBLY smaller — the inbox answers a 403 the caller sees
// — and nothing claims a persistence it did not perform.
console.warn(
'[AuthManager] better-auth\'s organization plugin no longer exposes a ' +
'`listUserInvitations` endpoint at /organization/list-user-invitations, ' +
'so the declared `requireEmailVerificationOnInvitation` could NOT be ' +
'applied to the invitation inbox. Unverified users will be refused ' +
'(403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION) until ' +
'list-user-invitations-verification.ts is updated for the new vendor shape.',
);
}
return organizationPlugin;
});
}

Expand Down
Loading
Loading