Skip to content

Commit 41cbc54

Browse files
os-trumpclaude
andauthored
fix(plugin-auth): GET /organization/list-user-invitations honours the declared requireEmailVerificationOnInvitation (#16730)
* fix(plugin-auth): list-user-invitations honours the declared requireEmailVerificationOnInvitation better-auth 1.7.2's listUserInvitations refuses every unverified session unconditionally, while accept / reject / get-invitation read the option AuthManager declares false. Rebuild the endpoint in place on the organization plugin's own endpoints record from the vendor's own options object, with the verification refusal asked against the declared option; the listing stays the vendor's getOrgAdapter(...).listUserInvitations (session email, pending only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(plugin-auth): pin the rebuilt listing endpoint by contract identity, not options identity createAuthEndpoint shallow-copies the options record to append its base middleware, and the vendor's $ERROR_CODES entry carries a toString helper; pin method/query/metadata by identity and code/message by value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(plugin-auth): gate follow-through for the rebuilt listing endpoint - classify the restated vendor code as foreign-vocabulary (dispatcher error vocabulary row, same shape as the impersonate precedent's) - let the engine-double ledger record the new suite's doubles - apply the fake engine's limit by presence, not truthiness - log the vendor-drift fallback at warn: a visible 403 is a functional degradation under AGENTS.md's one-question rule, not a durability one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(runtime): keep the tracker id out of the vocabulary row's prose check:doc-authoring — a runtime string reaches readers who cannot resolve an issue id; the anchor lives in git history and the source comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(gates): declare the list-user-invitations endpoint conformant in PLUGIN_ROUTE_MODULES The module added for the declared-verification fix writes a Hono response, so the route-envelope walk discovers it, and a discovered file absent from the table is an ERROR rather than a default. Measured with the gate's own `scanHonoRouteSource`: one body, all six asserted counters zero. The single write is `return ctx.json(pendingInvitations)`, whose argument is an identifier -- the deliberate relayed-body blindness -- and the three refusals are `throw APIError.*`, which this surface does not count. So `{}` is the honest declaration: nothing this file builds departs from the envelope. No ruled state applies and nothing was hoisted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f745c3 commit 41cbc54

7 files changed

Lines changed: 839 additions & 2 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
`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
6+
7+
`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.
8+
9+
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`.
10+
11+
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.

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Auth, BetterAuthOptions } from 'better-auth';
4+
import type { OrganizationOptions } from 'better-auth/plugins/organization';
45
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
56
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
67
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
@@ -72,6 +73,9 @@ import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-
7273
import {
7374
applyPlatformAdminImpersonation,
7475
} from './admin-impersonate-endpoint.js';
76+
import {
77+
applyDeclaredInvitationVerificationToListing,
78+
} from './list-user-invitations-verification.js';
7579
import {
7680
invitationRoleCapFailure,
7781
isPlainMemberInvitation,
@@ -2792,7 +2796,12 @@ export class AuthManager {
27922796
// [#8289] Same map, same request lifetime — see the field's doc for why
27932797
// the before-hook cannot read it back off `ctx`.
27942798
this.orgRolesMap = customOrgRoles;
2795-
return organization({
2799+
// [#16569] Held as a named object rather than an inline literal: the
2800+
// rebuilt `/organization/list-user-invitations` endpoint below needs the
2801+
// VERY object the vendor plugin was constructed with — it is what the
2802+
// vendor's own `getOrgAdapter(ctx.context, options)` reads, and the
2803+
// declaration it honours lives on it.
2804+
const organizationOptions = {
27962805
schema: buildOrganizationPluginSchema(),
27972806
// Enable the team sub-feature so the framework's `sys_team` /
27982807
// `sys_team_member` tables (already declared in platform-objects)
@@ -3168,7 +3177,46 @@ export class AuthManager {
31683177
console.error(`[AuthManager] sendInvitationEmail failed (swallowed): ${err?.message ?? err}`);
31693178
}
31703179
},
3171-
});
3180+
} satisfies OrganizationOptions;
3181+
const organizationPlugin: any = organization(organizationOptions);
3182+
3183+
// [#16569] `GET /organization/list-user-invitations` — make the vendor's
3184+
// listing honour the `requireEmailVerificationOnInvitation: false`
3185+
// declared above, the way `accept-invitation`, `reject-invitation` and
3186+
// `get-invitation` already do. better-auth 1.7.2's `listUserInvitations`
3187+
// refuses every unverified session UNCONDITIONALLY (it never reads the
3188+
// option), so on exactly the no-mailer deployment the declaration exists
3189+
// for, an invitee could accept an invitation but never list it and the
3190+
// SDK's `organizations.invitations.listMine()` inbox was empty-by-403.
3191+
// Rebuilt IN PLACE on this plugin's own endpoints record — the same
3192+
// shape as `applyPlatformAdminImpersonation` above, for the same reasons
3193+
// (one owner for the path; every hook keyed on it still fires; the
3194+
// request contract is the vendor's own options object, never a copy).
3195+
// The listing itself stays the vendor's `getOrgAdapter(...)
3196+
// .listUserInvitations(sessionEmail)`: no second definition of which
3197+
// rows a session may see. `list-user-invitations-verification.ts`
3198+
// carries the full reading.
3199+
const listingRewired = await applyDeclaredInvitationVerificationToListing(
3200+
organizationPlugin,
3201+
organizationOptions,
3202+
);
3203+
if (!listingRewired) {
3204+
// The vendor renamed or dropped the endpoint. Say so loudly: the
3205+
// route then falls back to the vendor's own handler, which refuses
3206+
// every unverified session — an empty inbox, not an open door.
3207+
// `warn`, not `error` (AGENTS.md → Degradation log levels): the
3208+
// system is VISIBLY smaller — the inbox answers a 403 the caller sees
3209+
// — and nothing claims a persistence it did not perform.
3210+
console.warn(
3211+
'[AuthManager] better-auth\'s organization plugin no longer exposes a ' +
3212+
'`listUserInvitations` endpoint at /organization/list-user-invitations, ' +
3213+
'so the declared `requireEmailVerificationOnInvitation` could NOT be ' +
3214+
'applied to the invitation inbox. Unverified users will be refused ' +
3215+
'(403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION) until ' +
3216+
'list-user-invitations-verification.ts is updated for the new vendor shape.',
3217+
);
3218+
}
3219+
return organizationPlugin;
31723220
});
31733221
}
31743222

0 commit comments

Comments
 (0)