better-auth
Version:
The most comprehensive authentication framework for TypeScript.
706 lines (705 loc) • 19.7 kB
JavaScript
import { getDate } from "../../utils/date.mjs";
import { parseJSON } from "../../client/parser.mjs";
import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
import { BetterAuthError } from "@better-auth/core/error";
import { filterOutputFields } from "@better-auth/core/utils/db";
//#region src/plugins/organization/adapter.ts
/**
* Resolves the configured per-team member cap to a concrete number for a given
* team-add. Returns `undefined` only when no cap is configured. Throws when the
* cap is a function but no session is available to evaluate it, so a sessionless
* server-side add fails closed instead of silently bypassing the limit.
*/
async function resolveMaximumMembersPerTeam(teams, context) {
const maximumMembersPerTeam = teams?.maximumMembersPerTeam;
if (maximumMembersPerTeam === void 0) return void 0;
if (typeof maximumMembersPerTeam === "number") return maximumMembersPerTeam;
if (!context.session) throw new BetterAuthError("`teams.maximumMembersPerTeam` is configured as a function but no session is available to evaluate it. Provide a session-bearing request or configure a numeric limit.");
return await maximumMembersPerTeam({
teamId: context.teamId,
session: context.session,
organizationId: context.organizationId
});
}
const getOrgAdapter = (context, options) => {
const baseAdapter = context.adapter;
const orgAdditionalFields = options?.schema?.organization?.additionalFields;
const memberAdditionalFields = options?.schema?.member?.additionalFields;
const invitationAdditionalFields = options?.schema?.invitation?.additionalFields;
const teamAdditionalFields = options?.schema?.team?.additionalFields;
return {
findOrganizationBySlug: async (slug) => {
return filterOutputFields(await (await getCurrentAdapter(baseAdapter)).findOne({
model: "organization",
where: [{
field: "slug",
value: slug
}]
}), orgAdditionalFields);
},
createOrganization: async (data) => {
const organization = await (await getCurrentAdapter(baseAdapter)).create({
model: "organization",
data: {
...data.organization,
metadata: data.organization.metadata ? JSON.stringify(data.organization.metadata) : void 0
},
forceAllowId: true
});
return filterOutputFields({
...organization,
metadata: organization.metadata && typeof organization.metadata === "string" ? JSON.parse(organization.metadata) : void 0
}, orgAdditionalFields);
},
findMemberByEmail: async (data) => {
const adapter = await getCurrentAdapter(baseAdapter);
const user = await adapter.findOne({
model: "user",
where: [{
field: "email",
value: data.email.toLowerCase()
}]
});
if (!user) return null;
const member = await adapter.findOne({
model: "member",
where: [{
field: "organizationId",
value: data.organizationId
}, {
field: "userId",
value: user.id
}]
});
if (!member) return null;
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image
}
};
},
listMembers: async (data) => {
const adapter = await getCurrentAdapter(baseAdapter);
const members = await Promise.all([adapter.findMany({
model: "member",
where: [{
field: "organizationId",
value: data.organizationId
}, ...data.filter?.field ? [{
field: data.filter?.field,
value: data.filter?.value,
...data.filter.operator ? { operator: data.filter.operator } : {}
}] : []],
limit: data.limit || (typeof options?.membershipLimit === "number" ? options.membershipLimit : 100) || 100,
offset: data.offset || 0,
sortBy: data.sortBy ? {
field: data.sortBy,
direction: data.sortOrder || "asc"
} : void 0
}), adapter.count({
model: "member",
where: [{
field: "organizationId",
value: data.organizationId
}, ...data.filter?.field ? [{
field: data.filter?.field,
value: data.filter?.value,
...data.filter.operator ? { operator: data.filter.operator } : {}
}] : []]
})]);
const users = await adapter.findMany({
model: "user",
where: [{
field: "id",
value: members[0].map((member) => member.userId),
operator: "in"
}],
limit: members[0].length
});
return {
members: members[0].map((member) => {
const user = users.find((user) => user.id === member.userId);
if (!user) throw new BetterAuthError("Unexpected error: User not found for member");
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image
}
};
}),
total: members[1]
};
},
findMemberByOrgId: async (data) => {
const result = await (await getCurrentAdapter(baseAdapter)).findOne({
model: "member",
where: [{
field: "userId",
value: data.userId
}, {
field: "organizationId",
value: data.organizationId
}],
join: { user: true }
});
if (!result || !result.user) return null;
const { user, ...member } = result;
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image
}
};
},
findMemberById: async (memberId) => {
const result = await (await getCurrentAdapter(baseAdapter)).findOne({
model: "member",
where: [{
field: "id",
value: memberId
}],
join: { user: true }
});
if (!result) return null;
const { user, ...member } = result;
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image
}
};
},
createMember: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).create({
model: "member",
data: {
...data,
createdAt: /* @__PURE__ */ new Date()
}
});
},
updateMember: async (memberId, role) => {
return await (await getCurrentAdapter(baseAdapter)).update({
model: "member",
where: [{
field: "id",
value: memberId
}],
update: { role }
});
},
deleteMember: async ({ memberId, organizationId, userId: _userId }) => {
return runWithTransaction(baseAdapter, async () => {
const adapter = await getCurrentAdapter(baseAdapter);
let userId;
if (!_userId) {
const member = await adapter.findOne({
model: "member",
where: [{
field: "id",
value: memberId
}]
});
if (!member) throw new BetterAuthError("Member not found");
userId = member.userId;
} else userId = _userId;
const member = await adapter.delete({
model: "member",
where: [{
field: "id",
value: memberId
}]
});
if (options?.teams?.enabled) {
const teams = await adapter.findMany({
model: "team",
where: [{
field: "organizationId",
value: organizationId
}]
});
if (teams.length > 0) await adapter.deleteMany({
model: "teamMember",
where: [{
field: "userId",
value: userId
}, {
field: "teamId",
value: teams.map((team) => team.id),
operator: "in"
}]
});
}
return member;
});
},
updateOrganization: async (organizationId, data) => {
const organization = await (await getCurrentAdapter(baseAdapter)).update({
model: "organization",
where: [{
field: "id",
value: organizationId
}],
update: {
...data,
metadata: typeof data.metadata === "object" ? JSON.stringify(data.metadata) : data.metadata
}
});
if (!organization) return null;
return filterOutputFields({
...organization,
metadata: organization.metadata ? parseJSON(organization.metadata) : void 0
}, orgAdditionalFields);
},
deleteOrganization: async (organizationId) => {
return runWithTransaction(baseAdapter, async () => {
const adapter = await getCurrentAdapter(baseAdapter);
await adapter.deleteMany({
model: "member",
where: [{
field: "organizationId",
value: organizationId
}]
});
await adapter.deleteMany({
model: "invitation",
where: [{
field: "organizationId",
value: organizationId
}]
});
await adapter.delete({
model: "organization",
where: [{
field: "id",
value: organizationId
}]
});
return organizationId;
});
},
setActiveOrganization: async (sessionToken, organizationId, ctx) => {
return await context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId });
},
findOrganizationById: async (organizationId) => {
return filterOutputFields(await (await getCurrentAdapter(baseAdapter)).findOne({
model: "organization",
where: [{
field: "id",
value: organizationId
}]
}), orgAdditionalFields);
},
checkMembership: async ({ userId, organizationId }) => {
return await (await getCurrentAdapter(baseAdapter)).findOne({
model: "member",
where: [{
field: "userId",
value: userId
}, {
field: "organizationId",
value: organizationId
}]
});
},
/**
* @requires db
*/
findFullOrganization: async ({ organizationId, isSlug, includeTeams, membersLimit }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const result = await adapter.findOne({
model: "organization",
where: [{
field: isSlug ? "slug" : "id",
value: organizationId
}],
join: {
invitation: true,
member: membersLimit ? { limit: membersLimit } : true,
...includeTeams ? { team: true } : {}
}
});
if (!result) return null;
const { invitation: invitations, member: members, team: teams, ...org } = result;
const userIds = members.map((member) => member.userId);
const users = userIds.length > 0 ? await adapter.findMany({
model: "user",
where: [{
field: "id",
value: userIds,
operator: "in"
}],
limit: (typeof options?.membershipLimit === "number" ? options.membershipLimit : 100) || 100
}) : [];
const userMap = new Map(users.map((user) => [user.id, user]));
const membersWithUsers = members.map((member) => {
const user = userMap.get(member.userId);
if (!user) throw new BetterAuthError("Unexpected error: User not found for member");
return {
...filterOutputFields(member, memberAdditionalFields),
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image
}
};
});
const filteredOrg = filterOutputFields(org, orgAdditionalFields);
const filteredInvitations = invitations.map((inv) => filterOutputFields(inv, invitationAdditionalFields));
const filteredTeams = teams?.map((team) => filterOutputFields(team, teamAdditionalFields));
return {
...filteredOrg,
invitations: filteredInvitations,
members: membersWithUsers,
teams: filteredTeams
};
},
listOrganizations: async (userId) => {
const result = await (await getCurrentAdapter(baseAdapter)).findMany({
model: "member",
where: [{
field: "userId",
value: userId
}],
join: { organization: true }
});
if (!result || result.length === 0) return [];
return result.map((member) => filterOutputFields(member.organization, orgAdditionalFields));
},
createTeam: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).create({
model: "team",
data,
forceAllowId: true
});
},
findTeamById: async ({ teamId, organizationId, includeTeamMembers }) => {
const result = await (await getCurrentAdapter(baseAdapter)).findOne({
model: "team",
where: [{
field: "id",
value: teamId
}, ...organizationId ? [{
field: "organizationId",
value: organizationId
}] : []],
join: { ...includeTeamMembers ? { teamMember: true } : {} }
});
if (!result) return null;
const { teamMember, ...team } = result;
return {
...team,
...includeTeamMembers ? { members: teamMember } : {}
};
},
updateTeam: async (teamId, data) => {
const adapter = await getCurrentAdapter(baseAdapter);
if ("id" in data) data.id = void 0;
return await adapter.update({
model: "team",
where: [{
field: "id",
value: teamId
}],
update: { ...data }
});
},
deleteTeam: async (teamId) => {
const adapter = await getCurrentAdapter(baseAdapter);
await adapter.deleteMany({
model: "teamMember",
where: [{
field: "teamId",
value: teamId
}]
});
return await adapter.delete({
model: "team",
where: [{
field: "id",
value: teamId
}]
});
},
listTeams: async (organizationId) => {
return await (await getCurrentAdapter(baseAdapter)).findMany({
model: "team",
where: [{
field: "organizationId",
value: organizationId
}]
});
},
createTeamInvitation: async ({ email, role, teamId, organizationId, inviterId, expiresIn = 1e3 * 60 * 60 * 48 }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const expiresAt = getDate(expiresIn);
return await adapter.create({
model: "invitation",
data: {
email,
role,
organizationId,
teamId,
inviterId,
status: "pending",
expiresAt
}
});
},
setActiveTeam: async (sessionToken, teamId, ctx) => {
return await context.internalAdapter.updateSession(sessionToken, { activeTeamId: teamId });
},
listTeamMembers: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).findMany({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}]
});
},
countTeamMembers: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).count({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}]
});
},
countMembers: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).count({
model: "member",
where: [{
field: "organizationId",
value: data.organizationId
}]
});
},
listTeamsByUser: async (data) => {
return (await (await getCurrentAdapter(baseAdapter)).findMany({
model: "teamMember",
where: [{
field: "userId",
value: data.userId
}],
join: { team: true }
})).map((result) => result.team);
},
findTeamMember: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).findOne({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}, {
field: "userId",
value: data.userId
}]
});
},
findOrCreateTeamMember: async (data) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.findOne({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}, {
field: "userId",
value: data.userId
}]
});
if (member) return member;
return await adapter.create({
model: "teamMember",
data: {
teamId: data.teamId,
userId: data.userId,
createdAt: /* @__PURE__ */ new Date()
}
});
},
/**
* Adds a user to a team only when the team is below its member limit,
* reading the count and creating the membership in one transaction.
* Returns the existing membership unchanged (no capacity charge) when the
* user already belongs to the team.
*
* FIXME(team-cap-race): the count-then-create is not atomic under READ
* COMMITTED, so two concurrent adds can both pass the count check and
* exceed maximumMembersPerTeam. A durable fix needs a unique constraint on
* teamMember(teamId, userId) or serializable isolation. Affects every
* caller (acceptInvitation, addMember, addTeamMember).
*/
addTeamMemberWithLimit: async (data) => {
return runWithTransaction(baseAdapter, async () => {
const adapter = await getCurrentAdapter(baseAdapter);
const existing = await adapter.findOne({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}, {
field: "userId",
value: data.userId
}]
});
if (existing) return {
status: "added",
member: existing
};
if (await adapter.count({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}]
}) >= data.maximumMembersPerTeam) return { status: "limitReached" };
return {
status: "added",
member: await adapter.create({
model: "teamMember",
data: {
teamId: data.teamId,
userId: data.userId,
createdAt: /* @__PURE__ */ new Date()
}
})
};
});
},
removeTeamMember: async (data) => {
await (await getCurrentAdapter(baseAdapter)).deleteMany({
model: "teamMember",
where: [{
field: "teamId",
value: data.teamId
}, {
field: "userId",
value: data.userId
}]
});
},
findInvitationsByTeamId: async (teamId) => {
return await (await getCurrentAdapter(baseAdapter)).findMany({
model: "invitation",
where: [{
field: "teamId",
value: teamId
}]
});
},
listUserInvitations: async (email) => {
return (await (await getCurrentAdapter(baseAdapter)).findMany({
model: "invitation",
where: [{
field: "email",
value: email.toLowerCase()
}],
join: { organization: true }
})).filter(Boolean).map(({ organization, ...inv }) => ({
...inv,
organizationName: organization?.name
}));
},
createInvitation: async ({ invitation, user }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const expiresAt = getDate(options?.invitationExpiresIn || 3600 * 48, "sec");
return await adapter.create({
model: "invitation",
data: {
status: "pending",
expiresAt,
createdAt: /* @__PURE__ */ new Date(),
inviterId: user.id,
...invitation,
teamId: invitation.teamIds.length > 0 ? invitation.teamIds.join(",") : null
},
forceAllowId: true
});
},
findInvitationById: async (id) => {
return await (await getCurrentAdapter(baseAdapter)).findOne({
model: "invitation",
where: [{
field: "id",
value: id
}]
});
},
findPendingInvitation: async (data) => {
return (await (await getCurrentAdapter(baseAdapter)).findMany({
model: "invitation",
where: [
{
field: "email",
value: data.email.toLowerCase()
},
{
field: "organizationId",
value: data.organizationId
},
{
field: "status",
value: "pending"
}
]
})).filter((invite) => new Date(invite.expiresAt) > /* @__PURE__ */ new Date());
},
findPendingInvitations: async (data) => {
return (await (await getCurrentAdapter(baseAdapter)).findMany({
model: "invitation",
where: [{
field: "organizationId",
value: data.organizationId
}, {
field: "status",
value: "pending"
}]
})).filter((invite) => new Date(invite.expiresAt) > /* @__PURE__ */ new Date());
},
listInvitations: async (data) => {
return await (await getCurrentAdapter(baseAdapter)).findMany({
model: "invitation",
where: [{
field: "organizationId",
value: data.organizationId
}]
});
},
updateInvitation: async (data) => {
const adapter = await getCurrentAdapter(baseAdapter);
const where = [{
field: "id",
value: data.invitationId
}];
if (data.fromStatus) where.push({
field: "status",
value: data.fromStatus
});
return await adapter.incrementOne({
model: "invitation",
where,
increment: {},
set: { status: data.status }
});
}
};
};
//#endregion
export { getOrgAdapter, resolveMaximumMembersPerTeam };