mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-09 16:05:23 +02:00
Merge pull request #4777 from Dokploy/perf/dedupe-module-singletons
perf: share db, docker and auth singletons across duplicated bundles
This commit is contained in:
@@ -69,4 +69,5 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
|
||||
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
|
||||
|
||||
@@ -81,7 +81,17 @@ const getDockerConfig = (): Docker => {
|
||||
return new Docker({ ...versionOption });
|
||||
};
|
||||
|
||||
export const docker = getDockerConfig();
|
||||
// Igual que db: el módulo se evalúa una vez por copia bundleada, el cache
|
||||
// evita crear un cliente (y loguear la detección del socket) por cada una.
|
||||
const globalForDocker = globalThis as unknown as {
|
||||
docker?: Docker;
|
||||
};
|
||||
|
||||
if (!globalForDocker.docker) {
|
||||
globalForDocker.docker = getDockerConfig();
|
||||
}
|
||||
|
||||
export const docker = globalForDocker.docker;
|
||||
|
||||
export const paths = (isServer = false) => {
|
||||
const BASE_PATH =
|
||||
|
||||
@@ -9,32 +9,19 @@ export * from "./schema";
|
||||
|
||||
type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
/**
|
||||
* Evita problemas de redeclaración global en monorepos.
|
||||
* No usamos `declare global`.
|
||||
*/
|
||||
// Este módulo se evalúa varias veces por proceso (copias duplicadas por
|
||||
// esbuild y los chunks de Next); el cache en globalThis garantiza un solo
|
||||
// pool de conexiones por proceso.
|
||||
const globalForDb = globalThis as unknown as {
|
||||
db?: Database;
|
||||
};
|
||||
|
||||
let dbConnection: Database;
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// En producción no usamos global cache
|
||||
dbConnection = drizzle(postgres(dbUrl), {
|
||||
if (!globalForDb.db) {
|
||||
globalForDb.db = drizzle(postgres(dbUrl), {
|
||||
schema,
|
||||
});
|
||||
} else {
|
||||
// En desarrollo reutilizamos conexión para evitar múltiples conexiones
|
||||
if (!globalForDb.db) {
|
||||
globalForDb.db = drizzle(postgres(dbUrl), {
|
||||
schema,
|
||||
});
|
||||
}
|
||||
|
||||
dbConnection = globalForDb.db;
|
||||
}
|
||||
|
||||
export const db: Database = dbConnection;
|
||||
export const db: Database = globalForDb.db;
|
||||
|
||||
export { dbUrl };
|
||||
|
||||
@@ -61,404 +61,418 @@ const resolveTrustedOrigins = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: schema,
|
||||
}),
|
||||
disabledPaths: [
|
||||
"/sso/register",
|
||||
"/organization/create",
|
||||
"/organization/update",
|
||||
"/organization/delete",
|
||||
...(!IS_CLOUD ? ["/verify-email"] : []),
|
||||
],
|
||||
secret: betterAuthSecret,
|
||||
...(!IS_CLOUD
|
||||
? {
|
||||
advanced: {
|
||||
useSecureCookies: false,
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
async trustedProviders() {
|
||||
const fromDb = await getTrustedProviders();
|
||||
return ["github", "google", ...fromDb];
|
||||
},
|
||||
allowDifferentEmails: true,
|
||||
},
|
||||
},
|
||||
appName: "Dokploy",
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
disabled: process.env.NODE_ENV === "production",
|
||||
},
|
||||
trustedOrigins: resolveTrustedOrigins,
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
ctx.context.trustedOrigins = [
|
||||
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
|
||||
...(await resolveTrustedOrigins()),
|
||||
].filter(Boolean);
|
||||
const createBetterAuth = () =>
|
||||
betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: schema,
|
||||
}),
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
disabledPaths: [
|
||||
"/sso/register",
|
||||
"/organization/create",
|
||||
"/organization/update",
|
||||
"/organization/delete",
|
||||
...(!IS_CLOUD ? ["/verify-email"] : []),
|
||||
],
|
||||
secret: betterAuthSecret,
|
||||
...(!IS_CLOUD
|
||||
? {
|
||||
advanced: {
|
||||
useSecureCookies: false,
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
async trustedProviders() {
|
||||
const fromDb = await getTrustedProviders();
|
||||
return ["github", "google", ...fromDb];
|
||||
},
|
||||
allowDifferentEmails: true,
|
||||
},
|
||||
},
|
||||
appName: "Dokploy",
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
disabled: process.env.NODE_ENV === "production",
|
||||
},
|
||||
trustedOrigins: resolveTrustedOrigins,
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
ctx.context.trustedOrigins = [
|
||||
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
|
||||
...(await resolveTrustedOrigins()),
|
||||
].filter(Boolean);
|
||||
}),
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
email: user.email,
|
||||
verificationUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: !IS_CLOUD,
|
||||
requireEmailVerification:
|
||||
IS_CLOUD && process.env.NODE_ENV === "production",
|
||||
password: {
|
||||
async hash(password) {
|
||||
return bcrypt.hashSync(password, 10);
|
||||
},
|
||||
async verify({ hash, password }) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
},
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
verificationUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: !IS_CLOUD,
|
||||
requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production",
|
||||
password: {
|
||||
async hash(password) {
|
||||
return bcrypt.hashSync(password, 10);
|
||||
},
|
||||
async verify({ hash, password }) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
},
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: "Reset your password",
|
||||
text: `
|
||||
subject: "Reset your password",
|
||||
text: `
|
||||
<p>Click the link to reset your password: <a href="${url}">Reset Password</a></p>
|
||||
`,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (_user, context) => {
|
||||
if (!IS_CLOUD) {
|
||||
const xDokployToken =
|
||||
context?.request?.headers?.get("x-dokploy-token");
|
||||
if (xDokployToken) {
|
||||
let invitation: Awaited<ReturnType<typeof getUserByToken>>;
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (_user, context) => {
|
||||
if (!IS_CLOUD) {
|
||||
const xDokployToken =
|
||||
context?.request?.headers?.get("x-dokploy-token");
|
||||
if (xDokployToken) {
|
||||
let invitation: Awaited<ReturnType<typeof getUserByToken>>;
|
||||
try {
|
||||
invitation = await getUserByToken(xDokployToken);
|
||||
} catch {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid invitation token",
|
||||
});
|
||||
}
|
||||
if (invitation.isExpired) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has expired",
|
||||
});
|
||||
}
|
||||
if (invitation.status !== "pending") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has already been used",
|
||||
});
|
||||
}
|
||||
if (
|
||||
_user.email.toLowerCase().trim() !==
|
||||
invitation.email.toLowerCase().trim()
|
||||
) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Email does not match invitation",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
if (isSSORequest || isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
if (isAdminPresent) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Admin is already created",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
|
||||
if (!IS_CLOUD && !isAdminPresent) {
|
||||
await updateWebServerSettings({
|
||||
serverIp: await getPublicIpWithFallback(),
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
invitation = await getUserByToken(xDokployToken);
|
||||
} catch {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid invitation token",
|
||||
});
|
||||
}
|
||||
if (invitation.isExpired) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has expired",
|
||||
});
|
||||
}
|
||||
if (invitation.status !== "pending") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has already been used",
|
||||
});
|
||||
}
|
||||
if (
|
||||
_user.email.toLowerCase().trim() !==
|
||||
invitation.email.toLowerCase().trim()
|
||||
) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Email does not match invitation",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
if (isSSORequest || isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
if (isAdminPresent) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Admin is already created",
|
||||
});
|
||||
const hutk = getHubSpotUTK(
|
||||
context?.request?.headers?.get("cookie") || undefined,
|
||||
);
|
||||
// Cast to include additional fields
|
||||
const userWithFields = user as typeof user & {
|
||||
lastName?: string;
|
||||
};
|
||||
const hubspotSuccess = await submitToHubSpot(
|
||||
{
|
||||
email: user.email,
|
||||
firstName: user.name || "", // name is mapped to firstName column
|
||||
lastName: userWithFields.lastName || "",
|
||||
},
|
||||
hutk,
|
||||
);
|
||||
if (!hubspotSuccess) {
|
||||
console.error("Failed to submit to HubSpot");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting to HubSpot", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
|
||||
if (!IS_CLOUD && !isAdminPresent) {
|
||||
await updateWebServerSettings({
|
||||
serverIp: await getPublicIpWithFallback(),
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
const hutk = getHubSpotUTK(
|
||||
context?.request?.headers?.get("cookie") || undefined,
|
||||
);
|
||||
// Cast to include additional fields
|
||||
const userWithFields = user as typeof user & {
|
||||
lastName?: string;
|
||||
};
|
||||
const hubspotSuccess = await submitToHubSpot(
|
||||
{
|
||||
email: user.email,
|
||||
firstName: user.name || "", // name is mapped to firstName column
|
||||
lastName: userWithFields.lastName || "",
|
||||
},
|
||||
hutk,
|
||||
);
|
||||
if (!hubspotSuccess) {
|
||||
console.error("Failed to submit to HubSpot");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting to HubSpot", error);
|
||||
if (isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
name: "My Organization",
|
||||
ownerId: user.id,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
name: "My Organization",
|
||||
ownerId: user.id,
|
||||
await tx.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: organization?.id || "",
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
isDefault: true, // Mark first organization as default
|
||||
});
|
||||
});
|
||||
} else if (isSSORequest) {
|
||||
const providerId = context?.params?.providerId;
|
||||
if (!providerId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider ID is required",
|
||||
});
|
||||
}
|
||||
const provider = await db.query.ssoProvider.findFirst({
|
||||
where: eq(schema.ssoProvider.providerId, providerId),
|
||||
});
|
||||
|
||||
await tx.insert(schema.member).values({
|
||||
if (!provider) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider not found",
|
||||
});
|
||||
}
|
||||
await db.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: organization?.id || "",
|
||||
role: "owner",
|
||||
organizationId: provider?.organizationId || "",
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
isDefault: true, // Mark first organization as default
|
||||
});
|
||||
});
|
||||
} else if (isSSORequest) {
|
||||
const providerId = context?.params?.providerId;
|
||||
if (!providerId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider ID is required",
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
const provider = await db.query.ssoProvider.findFirst({
|
||||
where: eq(schema.ssoProvider.providerId, providerId),
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Find the default organization for this user
|
||||
// Priority: 1) isDefault=true, 2) most recently created
|
||||
const member = await db.query.member.findFirst({
|
||||
where: eq(schema.member.userId, session.userId),
|
||||
orderBy: [
|
||||
desc(schema.member.isDefault),
|
||||
desc(schema.member.createdAt),
|
||||
],
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!provider) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider not found",
|
||||
});
|
||||
}
|
||||
await db.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: provider?.organizationId || "",
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
isDefault: true,
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: member?.organization.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
}
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
});
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "logout",
|
||||
resourceType: "session",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Find the default organization for this user
|
||||
// Priority: 1) isDefault=true, 2) most recently created
|
||||
const member = await db.query.member.findFirst({
|
||||
where: eq(schema.member.userId, session.userId),
|
||||
orderBy: [
|
||||
desc(schema.member.isDefault),
|
||||
desc(schema.member.createdAt),
|
||||
],
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
expiresIn: 60 * 60 * 24 * 3,
|
||||
updateAge: 60 * 60 * 24,
|
||||
},
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
||||
},
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
ownerId: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
allowImpersonation: {
|
||||
fieldName: "allowImpersonation",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: true,
|
||||
defaultValue: "",
|
||||
},
|
||||
enableEnterpriseFeatures: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isValidEnterpriseLicense: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso({
|
||||
saml: {
|
||||
enableInResponseToValidation: false,
|
||||
},
|
||||
}),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, user.id),
|
||||
columns: { enableEnterpriseFeatures: true },
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: member?.organization.id,
|
||||
},
|
||||
};
|
||||
if (!dbUser?.enableEnterpriseFeatures) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "SCIM provisioning requires an enterprise license",
|
||||
});
|
||||
}
|
||||
},
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
});
|
||||
}),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "logout",
|
||||
resourceType: "session",
|
||||
});
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 3,
|
||||
updateAge: 60 * 60 * 24,
|
||||
},
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
||||
},
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
ownerId: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
allowImpersonation: {
|
||||
fieldName: "allowImpersonation",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: true,
|
||||
defaultValue: "",
|
||||
},
|
||||
enableEnterpriseFeatures: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isValidEnterpriseLicense: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso({
|
||||
saml: {
|
||||
enableInResponseToValidation: false,
|
||||
},
|
||||
}),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, user.id),
|
||||
columns: { enableEnterpriseFeatures: true },
|
||||
});
|
||||
}),
|
||||
// Self-hosted needs the admin plugin too: SCIM deactivation (active: false)
|
||||
// maps to the admin plugin's `banned` field and is rejected without it.
|
||||
// adminRoles: [] keeps every /admin/* endpoint locked on self-hosted.
|
||||
admin(
|
||||
IS_CLOUD
|
||||
? { adminUserIds: [process.env.USER_ADMIN_ID as string] }
|
||||
: { adminRoles: [] },
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
if (!dbUser?.enableEnterpriseFeatures) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "SCIM provisioning requires an enterprise license",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
},
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
// Self-hosted needs the admin plugin too: SCIM deactivation (active: false)
|
||||
// maps to the admin plugin's `banned` field and is rejected without it.
|
||||
// adminRoles: [] keeps every /admin/* endpoint locked on self-hosted.
|
||||
admin(
|
||||
IS_CLOUD
|
||||
? { adminUserIds: [process.env.USER_ADMIN_ID as string] }
|
||||
: { adminRoles: [] },
|
||||
),
|
||||
],
|
||||
});
|
||||
// Una sola instancia de better-auth por proceso aunque el módulo esté
|
||||
// duplicado en varios bundles.
|
||||
const globalForAuth = globalThis as unknown as {
|
||||
betterAuthInstance?: ReturnType<typeof createBetterAuth>;
|
||||
};
|
||||
|
||||
if (!globalForAuth.betterAuthInstance) {
|
||||
globalForAuth.betterAuthInstance = createBetterAuth();
|
||||
}
|
||||
|
||||
const { handler, api } = globalForAuth.betterAuthInstance;
|
||||
|
||||
const _auth = {
|
||||
handler,
|
||||
|
||||
Reference in New Issue
Block a user