Skip to content

Commit 313d7be

Browse files
os-zhuangclaude
andauthored
feat(auth): onInvitationAccepted host seam — afterAcceptInvitation forwarded to the host (ADR-0105 D8 prerequisite) (#3645)
An invitation may carry placement intent (target BU + positions, extension fields on sys_invitation per the ADR-0092 whitelist), but there was no server-side seam to apply it at accept time — better-auth's org-plugin models don't fire core databaseHooks (#3541 D8 note: 'there is no afterAcceptInvitation hook today'). AuthManagerConfig.onInvitationAccepted mirrors onOrganizationCreated: invoked from organizationHooks.afterAcceptInvitation with mapped ids (invitationId, organizationId, userId, memberId, role, email) plus the RAW invitation/member rows so a host reads its own extension columns without a second query. Failure-isolated — acceptance never rolls back on a side-effect miss; hosts needing effectively-atomic placement make the callback idempotent and reconcile on retry. Tests: payload mapping + raw-row pass-through, failure isolation, no-op without the callback. plugin-auth 508/508; guards clean; changeset (minor). Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3eb93f commit 313d7be

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): `onInvitationAccepted` host seam — better-auth's
6+
`afterAcceptInvitation` forwarded to the host (ADR-0105 D8 prerequisite)
7+
8+
An invitation may carry placement intent (target business unit + positions,
9+
extension fields on `sys_invitation` per the ADR-0092 whitelist), but there
10+
was no server-side seam to apply it when the invitation is accepted —
11+
better-auth's org-plugin models don't fire core `databaseHooks` (framework
12+
#3541 D8 note).
13+
14+
`AuthManagerConfig.onInvitationAccepted` mirrors `onOrganizationCreated`:
15+
invoked from `organizationHooks.afterAcceptInvitation` with the mapped ids
16+
(`invitationId`, `organizationId`, `userId`, `memberId`, `role`, `email`)
17+
plus the RAW `invitation` / `member` rows so a host reads its own extension
18+
columns without a second query. Failure-isolated — acceptance never rolls
19+
back on a side-effect miss; hosts needing effectively-atomic placement
20+
should make the callback idempotent and reconcile on retry.

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,113 @@ describe('AuthManager', () => {
599599
expect(dataEngine.find).not.toHaveBeenCalled();
600600
});
601601

602+
// [ADR-0105 D8] afterAcceptInvitation → onInvitationAccepted host seam.
603+
it('forwards afterAcceptInvitation to onInvitationAccepted with the mapped payload + raw rows', async () => {
604+
let capturedConfig: any;
605+
(betterAuth as any).mockImplementation((config: any) => {
606+
capturedConfig = config;
607+
return { handler: vi.fn(), api: {} };
608+
});
609+
610+
const onInvitationAccepted = vi.fn();
611+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
612+
const manager = new AuthManager({
613+
secret: 'test-secret-at-least-32-chars-long',
614+
baseUrl: 'http://localhost:3000',
615+
plugins: { organization: true },
616+
onInvitationAccepted,
617+
});
618+
await manager.getAuthInstance();
619+
warnSpy.mockRestore();
620+
621+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
622+
const afterAccept = orgPlugin._opts.organizationHooks?.afterAcceptInvitation;
623+
expect(typeof afterAccept).toBe('function');
624+
625+
// The invitation row carries D8 placement intent as extension fields —
626+
// the seam hands the RAW row through so the host reads them directly.
627+
const invitation = { id: 'inv-1', organizationId: 'org-42', email: 'p@x.test', role: 'member', business_unit_id: 'bu-7' };
628+
const member = { id: 'mem-9', userId: 'u-3', organizationId: 'org-42', role: 'member' };
629+
await afterAccept({
630+
invitation,
631+
member,
632+
user: { id: 'u-3' },
633+
organization: { id: 'org-42' },
634+
});
635+
636+
expect(onInvitationAccepted).toHaveBeenCalledTimes(1);
637+
expect(onInvitationAccepted).toHaveBeenCalledWith({
638+
invitationId: 'inv-1',
639+
organizationId: 'org-42',
640+
userId: 'u-3',
641+
memberId: 'mem-9',
642+
role: 'member',
643+
email: 'p@x.test',
644+
invitation,
645+
member,
646+
});
647+
});
648+
649+
it('onInvitationAccepted failures are isolated — acceptance never rolls back', async () => {
650+
let capturedConfig: any;
651+
(betterAuth as any).mockImplementation((config: any) => {
652+
capturedConfig = config;
653+
return { handler: vi.fn(), api: {} };
654+
});
655+
656+
const onInvitationAccepted = vi.fn(async () => {
657+
throw new Error('placement service down');
658+
});
659+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
660+
const manager = new AuthManager({
661+
secret: 'test-secret-at-least-32-chars-long',
662+
baseUrl: 'http://localhost:3000',
663+
plugins: { organization: true },
664+
onInvitationAccepted,
665+
});
666+
await manager.getAuthInstance();
667+
668+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
669+
const afterAccept = orgPlugin._opts.organizationHooks.afterAcceptInvitation;
670+
671+
await expect(
672+
afterAccept({
673+
invitation: { id: 'inv-1' },
674+
member: { id: 'mem-9', userId: 'u-3' },
675+
user: { id: 'u-3' },
676+
organization: { id: 'org-42' },
677+
}),
678+
).resolves.toBeUndefined();
679+
expect(onInvitationAccepted).toHaveBeenCalledTimes(1);
680+
expect(
681+
warnSpy.mock.calls.some((c) => String(c[0]).includes('onInvitationAccepted callback failed')),
682+
).toBe(true);
683+
warnSpy.mockRestore();
684+
});
685+
686+
it('no onInvitationAccepted configured — the hook is a no-op', async () => {
687+
let capturedConfig: any;
688+
(betterAuth as any).mockImplementation((config: any) => {
689+
capturedConfig = config;
690+
return { handler: vi.fn(), api: {} };
691+
});
692+
693+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
694+
const manager = new AuthManager({
695+
secret: 'test-secret-at-least-32-chars-long',
696+
baseUrl: 'http://localhost:3000',
697+
plugins: { organization: true },
698+
});
699+
await manager.getAuthInstance();
700+
warnSpy.mockRestore();
701+
702+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
703+
const afterAccept = orgPlugin._opts.organizationHooks.afterAcceptInvitation;
704+
await expect(
705+
afterAccept({ invitation: { id: 'i' }, member: {}, user: {}, organization: {} }),
706+
).resolves.toBeUndefined();
707+
});
708+
602709
it('should register twoFactor plugin with schema mapping when enabled', async () => {
603710
let capturedConfig: any;
604711
(betterAuth as any).mockImplementation((config: any) => {

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,33 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
268268
slug?: string;
269269
}) => void | Promise<void>;
270270

271+
/**
272+
* [ADR-0105 D8] Optional callback invoked AFTER an invitation is accepted
273+
* via better-auth (`organizationHooks.afterAcceptInvitation`) — the seam a
274+
* host uses to apply an invitation's PLACEMENT INTENT (business-unit
275+
* membership + position assignments, carried as extension fields on
276+
* `sys_invitation` per the ADR-0092 whitelist) the moment the better-auth
277+
* membership lands. Same rationale as {@link onOrganizationCreated}: the
278+
* org-plugin models don't fire core `databaseHooks`, so this is the only
279+
* server-side seam for accept-time side effects.
280+
*
281+
* `invitation` / `member` are the full better-auth rows, so a host reads
282+
* its own extension columns without a second query. Failure-isolated: the
283+
* acceptance is never rolled back on a side-effect miss — a host that
284+
* needs placement to be effectively atomic should make the callback
285+
* idempotent and reconcile on retry.
286+
*/
287+
onInvitationAccepted?: (data: {
288+
invitationId?: string;
289+
organizationId?: string;
290+
userId?: string;
291+
memberId?: string;
292+
role?: string;
293+
email?: string;
294+
invitation?: Record<string, unknown>;
295+
member?: Record<string, unknown>;
296+
}) => void | Promise<void>;
297+
271298
/**
272299
* D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment).
273300
* When set, it is called for an AUTHENTICATED subject on
@@ -1577,6 +1604,29 @@ export class AuthManager {
15771604
console.warn('[auth] onOrganizationCreated callback failed:', err?.message ?? String(err));
15781605
}
15791606
},
1607+
// [ADR-0105 D8] Accept-time seam — the host applies the invitation's
1608+
// placement intent (BU membership + positions, extension fields on
1609+
// sys_invitation) as soon as the better-auth membership lands. Same
1610+
// shape and failure isolation as afterCreateOrganization above:
1611+
// acceptance never rolls back on a side-effect miss.
1612+
afterAcceptInvitation: async ({ invitation, member, user, organization }: any) => {
1613+
const cb = this.config.onInvitationAccepted;
1614+
if (typeof cb !== 'function') return;
1615+
try {
1616+
await cb({
1617+
invitationId: invitation?.id,
1618+
organizationId: organization?.id ?? invitation?.organizationId ?? member?.organizationId,
1619+
userId: user?.id ?? member?.userId,
1620+
memberId: member?.id,
1621+
role: member?.role ?? invitation?.role,
1622+
email: invitation?.email,
1623+
invitation,
1624+
member,
1625+
});
1626+
} catch (err: any) {
1627+
console.warn('[auth] onInvitationAccepted callback failed:', err?.message ?? String(err));
1628+
}
1629+
},
15801630
beforeUpdateOrganization: async ({ organization, member }: any) => {
15811631
const newSlug = organization?.slug;
15821632
const orgId = member?.organizationId;

0 commit comments

Comments
 (0)