diff --git a/.changeset/invitation-accepted-host-seam.md b/.changeset/invitation-accepted-host-seam.md new file mode 100644 index 0000000000..1a15197877 --- /dev/null +++ b/.changeset/invitation-accepted-host-seam.md @@ -0,0 +1,20 @@ +--- +"@objectstack/plugin-auth": minor +--- + +feat(auth): `onInvitationAccepted` host seam — better-auth's +`afterAcceptInvitation` forwarded to the host (ADR-0105 D8 prerequisite) + +An invitation may carry placement intent (target business unit + positions, +extension fields on `sys_invitation` per the ADR-0092 whitelist), but there +was no server-side seam to apply it when the invitation is accepted — +better-auth's org-plugin models don't fire core `databaseHooks` (framework +#3541 D8 note). + +`AuthManagerConfig.onInvitationAccepted` mirrors `onOrganizationCreated`: +invoked from `organizationHooks.afterAcceptInvitation` with the 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 +should make the callback idempotent and reconcile on retry. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 5868f35d35..88cd28639c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -599,6 +599,113 @@ describe('AuthManager', () => { expect(dataEngine.find).not.toHaveBeenCalled(); }); + // [ADR-0105 D8] afterAcceptInvitation → onInvitationAccepted host seam. + it('forwards afterAcceptInvitation to onInvitationAccepted with the mapped payload + raw rows', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const onInvitationAccepted = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + onInvitationAccepted, + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + const afterAccept = orgPlugin._opts.organizationHooks?.afterAcceptInvitation; + expect(typeof afterAccept).toBe('function'); + + // The invitation row carries D8 placement intent as extension fields — + // the seam hands the RAW row through so the host reads them directly. + const invitation = { id: 'inv-1', organizationId: 'org-42', email: 'p@x.test', role: 'member', business_unit_id: 'bu-7' }; + const member = { id: 'mem-9', userId: 'u-3', organizationId: 'org-42', role: 'member' }; + await afterAccept({ + invitation, + member, + user: { id: 'u-3' }, + organization: { id: 'org-42' }, + }); + + expect(onInvitationAccepted).toHaveBeenCalledTimes(1); + expect(onInvitationAccepted).toHaveBeenCalledWith({ + invitationId: 'inv-1', + organizationId: 'org-42', + userId: 'u-3', + memberId: 'mem-9', + role: 'member', + email: 'p@x.test', + invitation, + member, + }); + }); + + it('onInvitationAccepted failures are isolated — acceptance never rolls back', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const onInvitationAccepted = vi.fn(async () => { + throw new Error('placement service down'); + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + onInvitationAccepted, + }); + await manager.getAuthInstance(); + + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + const afterAccept = orgPlugin._opts.organizationHooks.afterAcceptInvitation; + + await expect( + afterAccept({ + invitation: { id: 'inv-1' }, + member: { id: 'mem-9', userId: 'u-3' }, + user: { id: 'u-3' }, + organization: { id: 'org-42' }, + }), + ).resolves.toBeUndefined(); + expect(onInvitationAccepted).toHaveBeenCalledTimes(1); + expect( + warnSpy.mock.calls.some((c) => String(c[0]).includes('onInvitationAccepted callback failed')), + ).toBe(true); + warnSpy.mockRestore(); + }); + + it('no onInvitationAccepted configured — the hook is a no-op', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + const afterAccept = orgPlugin._opts.organizationHooks.afterAcceptInvitation; + await expect( + afterAccept({ invitation: { id: 'i' }, member: {}, user: {}, organization: {} }), + ).resolves.toBeUndefined(); + }); + it('should register twoFactor plugin with schema mapping when enabled', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 26933764d7..f23200d1ed 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -268,6 +268,33 @@ export interface AuthManagerOptions extends Partial { slug?: string; }) => void | Promise; + /** + * [ADR-0105 D8] Optional callback invoked AFTER an invitation is accepted + * via better-auth (`organizationHooks.afterAcceptInvitation`) — the seam a + * host uses to apply an invitation's PLACEMENT INTENT (business-unit + * membership + position assignments, carried as extension fields on + * `sys_invitation` per the ADR-0092 whitelist) the moment the better-auth + * membership lands. Same rationale as {@link onOrganizationCreated}: the + * org-plugin models don't fire core `databaseHooks`, so this is the only + * server-side seam for accept-time side effects. + * + * `invitation` / `member` are the full better-auth rows, so a host reads + * its own extension columns without a second query. Failure-isolated: the + * acceptance is never rolled back on a side-effect miss — a host that + * needs placement to be effectively atomic should make the callback + * idempotent and reconcile on retry. + */ + onInvitationAccepted?: (data: { + invitationId?: string; + organizationId?: string; + userId?: string; + memberId?: string; + role?: string; + email?: string; + invitation?: Record; + member?: Record; + }) => void | Promise; + /** * D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment). * When set, it is called for an AUTHENTICATED subject on @@ -1577,6 +1604,29 @@ export class AuthManager { console.warn('[auth] onOrganizationCreated callback failed:', err?.message ?? String(err)); } }, + // [ADR-0105 D8] Accept-time seam — the host applies the invitation's + // placement intent (BU membership + positions, extension fields on + // sys_invitation) as soon as the better-auth membership lands. Same + // shape and failure isolation as afterCreateOrganization above: + // acceptance never rolls back on a side-effect miss. + afterAcceptInvitation: async ({ invitation, member, user, organization }: any) => { + const cb = this.config.onInvitationAccepted; + if (typeof cb !== 'function') return; + try { + await cb({ + invitationId: invitation?.id, + organizationId: organization?.id ?? invitation?.organizationId ?? member?.organizationId, + userId: user?.id ?? member?.userId, + memberId: member?.id, + role: member?.role ?? invitation?.role, + email: invitation?.email, + invitation, + member, + }); + } catch (err: any) { + console.warn('[auth] onInvitationAccepted callback failed:', err?.message ?? String(err)); + } + }, beforeUpdateOrganization: async ({ organization, member }: any) => { const newSlug = organization?.slug; const orgId = member?.organizationId;