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
20 changes: 20 additions & 0 deletions .changeset/invitation-accepted-host-seam.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 107 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
50 changes: 50 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,33 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
slug?: string;
}) => void | Promise<void>;

/**
* [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<string, unknown>;
member?: Record<string, unknown>;
}) => void | Promise<void>;

/**
* D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment).
* When set, it is called for an AUTHENTICATED subject on
Expand Down Expand Up @@ -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;
Expand Down
Loading