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
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ describe('SystemAdminController', () => {
expect(mockAuthProvider.createInvitation).toHaveBeenCalledWith({
email: 'test@example.com',
role: getRequiredAdminRoleId(),
organizationId: null, // System invite
organizationId: getRequiredSystemTenantId(), // System invite
inviterId: 'admin1',
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class SystemAdminController {
await this.authProvider.createInvitation({
email: user.email,
role: getRequiredAdminRoleId(),
organizationId: null, // System Invite
organizationId: getRequiredSystemTenantId(), // System Tenant ID
inviterId: ctx.user.id,
});

Expand Down
101 changes: 72 additions & 29 deletions packages/identity/src/adapters/better-auth.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import type { BetterAuthAdapterConfig } from "../interfaces/better-auth-config.interface";
import * as schema from "../schema";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { getBetterAuthPlugins } from "../better-auth.config";

vi.mock("better-auth", () => ({
betterAuth: vi.fn((opts: any) => ({
Expand Down Expand Up @@ -35,6 +36,10 @@ vi.mock("better-auth/node", () => ({
fromNodeHeaders: vi.fn((h) => h),
}));

vi.mock("better-auth/api", () => ({
createAuthMiddleware: vi.fn((fn) => fn),
}));

vi.mock("bcryptjs", () => ({
default: {
hash: vi.fn(async (v: string) => `hashed:${v}`),
Expand Down Expand Up @@ -85,6 +90,7 @@ const mkTenantProvider = () => ({
findAllForUser: vi.fn(async () => []),
provisionTenantForUser: vi.fn(),
findOneForUser: vi.fn(),
findPendingInvitation: vi.fn(),
});

const cfg = (
Expand Down Expand Up @@ -407,27 +413,7 @@ describe("BetterAuthAdapter", () => {
const email = mkEmail();
const adapter = new BetterAuthAdapter(db, email as any, cfg(), mkTenantProvider() as any, mkOptions());

// System
db.returning.mockResolvedValueOnce([
{
id: "inv1",
email: "a@b.com",
role: "admin",
organizationId: null,
inviterId: "u1",
status: "pending",
expiresAt: new Date(),
createdAt: new Date(),
},
]);

const inv1 = await adapter.createInvitation({
email: "a@b.com",
role: "admin",
inviterId: "u1",
} as any);
expect(inv1.id).toBe("inv1");
expect(email.sendEmail).toHaveBeenCalled();

// Org
const auth: any = (adapter as any).auth;
Expand Down Expand Up @@ -565,15 +551,37 @@ describe("BetterAuthAdapter", () => {
await adapter.acceptInvitation("i1", "u1");
expect(db.insert).toHaveBeenCalled();

// system role path
const sysInv = { ...validInv, organizationId: null };
// System Tenant Acceptance (organizationId: null)
const sysInv = { ...validInv, id: "sys1", organizationId: null };
db.query.invitation.findFirst.mockResolvedValueOnce(sysInv);
db.query.user.findFirst.mockResolvedValueOnce({
id: "u1",
email: "a@b.com",
});
await adapter.acceptInvitation("i1", "u1");
expect(db.update).toHaveBeenCalled();
db.query.user.findFirst.mockResolvedValueOnce({ id: "u1", email: "a@b.com" });

// Mock existing membership check to return nothing (success path)
db.query.member.findFirst.mockResolvedValueOnce(null);

await adapter.acceptInvitation("sys1", "u1");
// Verify it used system tenant ID from config
expect(db.values).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: "system-tenant-id",
role: "admin", // Passed through directly from invitation
})
);

// Clear mocks to track calls for this step specifically
vi.clearAllMocks();

// System Tenant Duplicate Membership Check
db.query.invitation.findFirst.mockResolvedValueOnce(sysInv);
db.query.user.findFirst.mockResolvedValueOnce({ id: "u1", email: "a@b.com" });
// Mock existing membership (failure/idempotent path)
db.query.member.findFirst.mockResolvedValueOnce({ id: "m1" });

// Should NOT throw, but also should NOT insert
await adapter.acceptInvitation("sys1", "u1");
expect(db.insert).not.toHaveBeenCalled();


});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("setPassword upserts credential account", async () => {
Expand Down Expand Up @@ -638,9 +646,44 @@ describe("BetterAuthAdapter", () => {
expect(email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "invite@test.com",
subject: expect.stringContaining("invited to join"),
}),
);

// 4. Verify Orchestration Hook Logic
const controlledProvider = mkTenantProvider();
const plugins = getBetterAuthPlugins(email as any, cfg(), controlledProvider as any);
const orchPlugin = plugins.find((p: any) => p.id === "signup-orchestration");
if (!orchPlugin) throw new Error("Orchestration plugin not found");
// @ts-ignore - We know the structure from the config
const myHandler = orchPlugin.hooks.after[0].handler;

const mockCtx = {
context: {
returned: { user: { email: "test@example.com" } },
options: {
emailVerification: { sendVerificationEmail: true },
},
api: {
sendVerificationEmail: vi.fn(),
},
runInBackgroundOrAwait: async (fn: any) => fn(),
},
request: { headers: {} },
};

// Case A: Pending Invite -> Suppress
controlledProvider.findPendingInvitation.mockResolvedValue(true);
await myHandler(mockCtx);
expect(mockCtx.context.api.sendVerificationEmail).not.toHaveBeenCalled();

// Case B: No Invite -> Send Email
controlledProvider.findPendingInvitation.mockResolvedValue(false);
await myHandler(mockCtx);
expect(mockCtx.context.api.sendVerificationEmail).toHaveBeenCalledWith(
expect.objectContaining({
body: { email: "test@example.com" },
})
);
Comment on lines +651 to +686

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Orchestration hook tests are well-structured and cover both branches.

The mock context correctly simulates the plugin's runtime environment, including the runInBackgroundOrAwait synchronous execution for testability. Both the suppression (pending invite) and send (no invite) paths are verified.

Minor suggestion: consider resetting the sendVerificationEmail mock between Case A and Case B (mockCtx.context.api.sendVerificationEmail.mockClear()) to make the assertion at line 682 strictly prove Case B triggered the call, rather than relying on it not being called in Case A.

Optional improvement
     // Case B: No Invite -> Send Email
     controlledProvider.findPendingInvitation.mockResolvedValue(false);
+    mockCtx.context.api.sendVerificationEmail.mockClear();
     await myHandler(mockCtx);
     expect(mockCtx.context.api.sendVerificationEmail).toHaveBeenCalledWith(
🤖 Prompt for AI Agents
In `@packages/identity/src/adapters/better-auth.adapter.spec.ts` around lines 651
- 686, The test relies on mockCtx.context.api.sendVerificationEmail state from
the previous assertion, so clear the mock between the two cases to ensure Case
B's assertion proves the call came from that branch; update the test around the
orchestration handler invocation by calling
mockCtx.context.api.sendVerificationEmail.mockClear() (or reset) after the Case
A assertion, referencing the same myHandler, mockCtx, and
controlledProvider.findPendingInvitation used in the existing test.

});

it("findById returns user with permissions from eager-loaded members", async () => {
Expand Down
47 changes: 1 addition & 46 deletions packages/identity/src/adapters/better-auth.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export class BetterAuthAdapter implements IAuthProvider {
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignUp: false, // Orchestrated by config hooks
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url, token }) => {
// Enterprise pattern: Explicitly construct the URL using URL object for robustness
Expand Down Expand Up @@ -366,11 +366,6 @@ export class BetterAuthAdapter implements IAuthProvider {
}

async createInvitation(input: CreateInvitationInput): Promise<Invitation> {
if (!input.organizationId) {
// System invite
return this.createSystemInvitation(input);
}

try {
const result = await this.api.createInvitation({
body: {
Expand Down Expand Up @@ -482,46 +477,6 @@ export class BetterAuthAdapter implements IAuthProvider {
};
}

private async createSystemInvitation(
input: CreateInvitationInput,
): Promise<Invitation> {
const id = uuidv4();
const expiresAt = new Date();
// Convert expiresIn (seconds) to hours - default 48h
expiresAt.setHours(
expiresAt.getHours() + (input.expiresIn ? input.expiresIn / 3600 : 48),
);

const [invitation] = await this.db
.insert(schema.invitation)
.values({
id,
email: input.email,
role: input.role,
organizationId: null,
inviterId: input.inviterId,
status: "pending",
expiresAt,
createdAt: new Date(),
})
.returning();

// Send Email
const frontendUrl = validateFrontendUrl(
this.config.frontendUrl,
this.config.allowedOrigins,
);
const inviteUrl = `${frontendUrl}/invite/accept?id=${invitation.id}&email=${encodeURIComponent(invitation.email)}`;
await this.emailService.sendEmail({
to: input.email,
subject: "You have been invited to join Nexiom",
text: `You have been invited to join Nexiom. Click here to accept: ${inviteUrl}`,
html: `<p>You have been invited to join <strong>Nexiom</strong>.</p><p><a href="${inviteUrl}">Click here to accept</a></p>`,
});

return this.mapInvitation(invitation);
}

async getInvitation(id: string): Promise<Invitation | null> {
const inv = await this.db.query.invitation.findFirst({
where: (t, { eq, and, gt }) =>
Expand Down
13 changes: 13 additions & 0 deletions packages/identity/src/adapters/drizzle-tenant.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,19 @@ export class DrizzleTenantAdapter implements ITenantProvider {
return this.create(userId, companyName);
}

async findPendingInvitation(email: string): Promise<boolean> {
const normalizedEmail = email.toLowerCase().trim();
const invite = await this.db.query.invitation.findFirst({
where: (t, { eq, and, gt }) =>
and(
eq(t.email, normalizedEmail),
eq(t.status, "pending"),
gt(t.expiresAt, new Date()),
),
});
return !!invite;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private mapTenant(dbOrg: schema.Organization): TenantInterface {
return {
id: dbOrg.id,
Expand Down
113 changes: 112 additions & 1 deletion packages/identity/src/better-auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,47 @@ import type { HookEndpointContext } from "better-auth";
* Factory to configure Better Auth plugins.
* Separation of concerns: Adapter handles execution, Factory handles configuration.
*/

// Define SafeContext for type-safe property access
interface SafeContext {
request?: {
headers: Headers;
};
context?: {
api?: {
sendVerificationEmail?: unknown;
};
};
}

const getApiFromContext = (
ctx: unknown,
): {
sendVerificationEmail: (opts: {
body: { email: string };
headers: Headers;
}) => Promise<void>;
} | null => {
const safeCtx = ctx as SafeContext;
if (
safeCtx.context &&
safeCtx.context.api &&
typeof safeCtx.context.api.sendVerificationEmail === "function"
) {
return (
safeCtx.context as {
api: {
sendVerificationEmail: (opts: {
body: { email: string };
headers: Headers;
}) => Promise<void>;
};
}
).api;
}
return null;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const getBetterAuthPlugins = (
emailService: IEmailProvider,
config: BetterAuthAdapterConfig,
Expand All @@ -25,7 +66,7 @@ export const getBetterAuthPlugins = (
member: ["create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
};
} as const;

const ac = createAccessControl(statement);

Expand Down Expand Up @@ -134,5 +175,75 @@ export const getBetterAuthPlugins = (
}),
admin(),
tenantProvisioningPlugin,
{
id: "signup-orchestration",
hooks: {
after: [
{
matcher: (context: HookEndpointContext) => {
if (!context.path) return false;
return context.path === "/sign-up/email";
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
handler: createAuthMiddleware(async (ctx) => {
const response = (ctx.context as { returned?: unknown }).returned;

// Ensure we have a successful response with a user
if (
response &&
typeof response === "object" &&
"user" in response &&
(response as { user: UserInterface }).user
) {
const user = (response as { user: UserInterface }).user;
const email = user.email;

const { emailVerification } = ctx.context.options;
if (emailVerification?.sendVerificationEmail) {
await ctx.context.runInBackgroundOrAwait(async () => {
try {
// Check for pending invite
const hasPendingInvite =
await tenantProvider.findPendingInvitation(email);

if (!hasPendingInvite) {
const api = getApiFromContext(ctx);

if (api && ctx.request) {
await api.sendVerificationEmail({
body: { email },
headers: ctx.request.headers,
});
} else {
const reason = !ctx.request
? "Request context not available"
: "Internal API sendVerificationEmail not available";
console.error(
JSON.stringify({
event: "signup_orchestration_failure",
error: reason,
userId: user.id,
timestamp: new Date().toISOString(),
}),
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (error) {
console.error(
JSON.stringify({
event: "signup_orchestration_error",
error: error instanceof Error ? error.message : error,
userId: user.id,
timestamp: new Date().toISOString(),
}),
);
}
});
}
}
}),
},
],
},
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
};
6 changes: 6 additions & 0 deletions packages/identity/src/interfaces/tenant-provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ export interface ITenantProvider {

// High-level orchestration
provisionTenantForUser(userId: string): Promise<Tenant>;

/**
* Checks for a pending invitation for the given email.
* Used to suppress verification emails if the user is accepting an invite.
*/
findPendingInvitation(email: string): Promise<boolean>;
}