Merge branch 'canary' into feat/quick-service-switcher

This commit is contained in:
Mauricio Siu
2026-03-19 00:43:03 -06:00
169 changed files with 91767 additions and 28607 deletions

View File

@@ -1,274 +1,299 @@
// import { relations } from "drizzle-orm";
// import {
// pgTable,
// text,
// timestamp,
// boolean,
// integer,
// index,
// uniqueIndex,
// } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import {
pgTable,
text,
timestamp,
boolean,
integer,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
// export const user = pgTable("user", {
// id: text("id").primaryKey(),
// firstName: text("first_name").notNull(),
// email: text("email").notNull().unique(),
// emailVerified: boolean("email_verified").default(false).notNull(),
// image: text("image"),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// twoFactorEnabled: boolean("two_factor_enabled").default(false),
// role: text("role"),
// ownerId: text("owner_id"),
// allowImpersonation: boolean("allow_impersonation").default(false),
// lastName: text("last_name").default(""),
// });
export const user = pgTable("user", {
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"),
ownerId: text("owner_id"),
allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
});
// export const session = pgTable(
// "session",
// {
// id: text("id").primaryKey(),
// expiresAt: timestamp("expires_at").notNull(),
// token: text("token").notNull().unique(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// ipAddress: text("ip_address"),
// userAgent: text("user_agent"),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// activeOrganizationId: text("active_organization_id"),
// },
// (table) => [index("session_userId_idx").on(table.userId)],
// );
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
// export const account = pgTable(
// "account",
// {
// id: text("id").primaryKey(),
// accountId: text("account_id").notNull(),
// providerId: text("provider_id").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// accessToken: text("access_token"),
// refreshToken: text("refresh_token"),
// idToken: text("id_token"),
// accessTokenExpiresAt: timestamp("access_token_expires_at"),
// refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
// scope: text("scope"),
// password: text("password"),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// },
// (table) => [index("account_userId_idx").on(table.userId)],
// );
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
// export const verification = pgTable(
// "verification",
// {
// id: text("id").primaryKey(),
// identifier: text("identifier").notNull(),
// value: text("value").notNull(),
// expiresAt: timestamp("expires_at").notNull(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// },
// (table) => [index("verification_identifier_idx").on(table.identifier)],
// );
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
// export const apikey = pgTable(
// "apikey",
// {
// id: text("id").primaryKey(),
// name: text("name"),
// start: text("start"),
// prefix: text("prefix"),
// key: text("key").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// refillInterval: integer("refill_interval"),
// refillAmount: integer("refill_amount"),
// lastRefillAt: timestamp("last_refill_at"),
// enabled: boolean("enabled").default(true),
// rateLimitEnabled: boolean("rate_limit_enabled").default(true),
// rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
// rateLimitMax: integer("rate_limit_max").default(10),
// requestCount: integer("request_count").default(0),
// remaining: integer("remaining"),
// lastRequest: timestamp("last_request"),
// expiresAt: timestamp("expires_at"),
// createdAt: timestamp("created_at").notNull(),
// updatedAt: timestamp("updated_at").notNull(),
// permissions: text("permissions"),
// metadata: text("metadata"),
// },
// (table) => [
// index("apikey_key_idx").on(table.key),
// index("apikey_userId_idx").on(table.userId),
// ],
// );
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
);
// export const ssoProvider = pgTable("sso_provider", {
// id: text("id").primaryKey(),
// issuer: text("issuer").notNull(),
// oidcConfig: text("oidc_config"),
// samlConfig: text("saml_config"),
// userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
// providerId: text("provider_id").notNull().unique(),
// organizationId: text("organization_id"),
// domain: text("domain").notNull(),
// });
export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
// export const twoFactor = pgTable(
// "two_factor",
// {
// id: text("id").primaryKey(),
// secret: text("secret").notNull(),
// backupCodes: text("backup_codes").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// },
// (table) => [
// index("twoFactor_secret_idx").on(table.secret),
// index("twoFactor_userId_idx").on(table.userId),
// ],
// );
export const twoFactor = pgTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
);
// export const organization = pgTable(
// "organization",
// {
// id: text("id").primaryKey(),
// name: text("name").notNull(),
// slug: text("slug").notNull().unique(),
// logo: text("logo"),
// createdAt: timestamp("created_at").notNull(),
// metadata: text("metadata"),
// },
// (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
// );
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
// export const member = pgTable(
// "member",
// {
// id: text("id").primaryKey(),
// organizationId: text("organization_id")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// role: text("role").default("member").notNull(),
// createdAt: timestamp("created_at").notNull(),
// },
// (table) => [
// index("member_organizationId_idx").on(table.organizationId),
// index("member_userId_idx").on(table.userId),
// ],
// );
export const organizationRole = pgTable(
"organization_role",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(),
),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
);
// export const invitation = pgTable(
// "invitation",
// {
// id: text("id").primaryKey(),
// organizationId: text("organization_id")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// email: text("email").notNull(),
// role: text("role"),
// status: text("status").default("pending").notNull(),
// expiresAt: timestamp("expires_at").notNull(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// inviterId: text("inviter_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// },
// (table) => [
// index("invitation_organizationId_idx").on(table.organizationId),
// index("invitation_email_idx").on(table.email),
// ],
// );
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
// export const userRelations = relations(user, ({ many }) => ({
// sessions: many(session),
// accounts: many(account),
// apikeys: many(apikey),
// ssoProviders: many(ssoProvider),
// twoFactors: many(twoFactor),
// members: many(member),
// invitations: many(invitation),
// }));
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
// export const sessionRelations = relations(session, ({ one }) => ({
// user: one(user, {
// fields: [session.userId],
// references: [user.id],
// }),
// }));
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
}));
// export const accountRelations = relations(account, ({ one }) => ({
// user: one(user, {
// fields: [account.userId],
// references: [user.id],
// }),
// }));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
// export const apikeyRelations = relations(apikey, ({ one }) => ({
// user: one(user, {
// fields: [apikey.userId],
// references: [user.id],
// }),
// }));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
// export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
// user: one(user, {
// fields: [ssoProvider.userId],
// references: [user.id],
// }),
// }));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
// export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
// user: one(user, {
// fields: [twoFactor.userId],
// references: [user.id],
// }),
// }));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
}));
// export const organizationRelations = relations(organization, ({ many }) => ({
// members: many(member),
// invitations: many(invitation),
// }));
export const organizationRelations = relations(organization, ({ many }) => ({
organizationRoles: many(organizationRole),
members: many(member),
invitations: many(invitation),
}));
// export const memberRelations = relations(member, ({ one }) => ({
// organization: one(organization, {
// fields: [member.organizationId],
// references: [organization.id],
// }),
// user: one(user, {
// fields: [member.userId],
// references: [user.id],
// }),
// }));
export const organizationRoleRelations = relations(
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
);
// export const invitationRelations = relations(invitation, ({ one }) => ({
// organization: one(organization, {
// fields: [invitation.organizationId],
// references: [organization.id],
// }),
// user: one(user, {
// fields: [invitation.inviterId],
// references: [user.id],
// }),
// }));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@@ -27,7 +27,7 @@
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
"typecheck": "tsc --noEmit",
"dbml:generate": "npx tsx src/db/schema/dbml.ts",
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth-cli.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.44",
@@ -37,6 +37,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/utils": "0.3.1",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
@@ -44,13 +46,13 @@
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso": "1.5.0-beta.16",
"@trpc/server": "11.10.0",
"adm-zip": "^0.5.16",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.5.0-beta.16",
"better-auth": "1.5.4",
"better-call": "2.0.2",
"bl": "6.0.11",
"boxen": "^7.1.1",
"date-fns": "3.6.0",
@@ -59,7 +61,6 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.1",
"drizzle-zod": "0.5.1",
"yaml": "2.8.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
@@ -76,19 +77,17 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"zod": "^4.3.6",
"semver": "7.7.3",
"better-call": "1.3.2"
"yaml": "2.8.1",
"zod": "^4.3.6"
},
"devDependencies": {
"rimraf": "6.1.3",
"@better-auth/cli": "1.5.0-beta.13",
"@types/semver": "7.7.1",
"@better-auth/cli": "1.4.21",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
@@ -100,6 +99,7 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
@@ -107,6 +107,7 @@
"esbuild": "0.20.2",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",
"rimraf": "6.1.3",
"tailwindcss": "^3.4.17",
"tsc-alias": "1.8.10",
"tsx": "^4.16.2",

View File

@@ -2,22 +2,23 @@ import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
export const DOCKER_HOST = process.env.DOCKER_HOST;
export const DOCKER_PORT = process.env.DOCKER_PORT
? Number(process.env.DOCKER_PORT)
export const DOKPLOY_DOCKER_API_VERSION =
process.env.DOKPLOY_DOCKER_API_VERSION;
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
? Number(process.env.DOKPLOY_DOCKER_PORT)
: undefined;
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker({
...(DOCKER_API_VERSION && {
version: DOCKER_API_VERSION,
...(DOKPLOY_DOCKER_API_VERSION && {
version: DOKPLOY_DOCKER_API_VERSION,
}),
...(DOCKER_HOST && {
host: DOCKER_HOST,
...(DOKPLOY_DOCKER_HOST && {
host: DOKPLOY_DOCKER_HOST,
}),
...(DOCKER_PORT && {
port: DOCKER_PORT,
...(DOKPLOY_DOCKER_PORT && {
port: DOKPLOY_DOCKER_PORT,
}),
});

View File

@@ -1,6 +1,7 @@
import { relations, sql } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
text,
@@ -69,6 +70,36 @@ export const organization = pgTable("organization", {
.references(() => user.id, { onDelete: "cascade" }),
});
export const organizationRole = pgTable(
"organization_role",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
);
export const organizationRoleRelations = relations(
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
);
export const organizationRelations = relations(
organization,
({ one, many }) => ({
@@ -80,6 +111,7 @@ export const organizationRelations = relations(
projects: many(projects),
members: many(member),
ssoProviders: many(ssoProvider),
roles: many(organizationRole),
}),
);
@@ -93,7 +125,9 @@ export const member = pgTable("member", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
role: text("role")
.notNull()
.$type<"owner" | "member" | "admin" | (string & {})>(),
createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"),
isDefault: boolean("is_default").notNull().default(false),
@@ -148,7 +182,7 @@ export const invitation = pgTable("invitation", {
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role").$type<"owner" | "member" | "admin">(),
role: text("role").$type<"owner" | "member" | "admin" | (string & {})>(),
status: text("status").notNull(),
expiresAt: timestamp("expires_at").notNull(),
inviterId: text("inviter_id")
@@ -180,7 +214,8 @@ export const apikey = pgTable("apikey", {
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: text("user_id")
configId: text("config_id").default("default").notNull(),
referenceId: text("reference_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
@@ -202,7 +237,7 @@ export const apikey = pgTable("apikey", {
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(user, {
fields: [apikey.userId],
fields: [apikey.referenceId],
references: [user.id],
}),
}));

View File

@@ -0,0 +1,94 @@
import { relations } from "drizzle-orm";
import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { organization } from "./account";
import { user } from "./user";
export const auditLog = pgTable(
"audit_log",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "set null",
}),
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
userEmail: text("user_email").notNull(),
userRole: text("user_role").notNull(),
action: text("action").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id"),
resourceName: text("resource_name"),
metadata: text("metadata"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => ({
orgIdx: index("auditLog_organizationId_idx").on(t.organizationId),
userIdx: index("auditLog_userId_idx").on(t.userId),
createdAtIdx: index("auditLog_createdAt_idx").on(t.createdAt),
}),
);
export const auditLogRelations = relations(auditLog, ({ one }) => ({
organization: one(organization, {
fields: [auditLog.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [auditLog.userId],
references: [user.id],
}),
}));
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
export type AuditAction =
| "create"
| "update"
| "delete"
| "deploy"
| "cancel"
| "redeploy"
| "login"
| "logout"
| "restore"
| "run"
| "start"
| "stop"
| "reload"
| "rebuild"
| "move";
export type AuditResourceType =
| "project"
| "service"
| "environment"
| "deployment"
| "user"
| "customRole"
| "domain"
| "certificate"
| "registry"
| "server"
| "sshKey"
| "gitProvider"
| "destination"
| "notification"
| "settings"
| "session"
| "port"
| "redirect"
| "security"
| "schedule"
| "backup"
| "volumeBackup"
| "docker"
| "swarm"
| "previewDeployment"
| "organization"
| "cluster"
| "mount"
| "application"
| "compose";

View File

@@ -1,5 +1,6 @@
export * from "./account";
export * from "./ai";
export * from "./audit-log";
export * from "./application";
export * from "./backups";
export * from "./bitbucket";

View File

@@ -202,6 +202,7 @@ export const apiUpdateMariaDB = createSchema
.partial()
.extend({
mariadbId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -191,6 +191,7 @@ export const apiUpdateMongo = createSchema
.partial()
.extend({
mongoId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -199,6 +199,7 @@ export const apiUpdateMySql = createSchema
.partial()
.extend({
mysqlId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -192,6 +192,7 @@ export const apiUpdatePostgres = createSchema
.partial()
.extend({
postgresId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -178,6 +178,7 @@ export const apiUpdateRedis = createSchema
.partial()
.extend({
redisId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -66,6 +66,36 @@ export const webServerSettings = pgTable("webServerSettings", {
},
},
}),
// Whitelabeling Configuration (Enterprise / Proprietary)
whitelabelingConfig: jsonb("whitelabelingConfig")
.$type<{
appName: string | null;
appDescription: string | null;
logoUrl: string | null;
faviconUrl: string | null;
customCss: string | null;
loginLogoUrl: string | null;
supportUrl: string | null;
docsUrl: string | null;
errorPageTitle: string | null;
errorPageDescription: string | null;
metaTitle: string | null;
footerText: string | null;
}>()
.default({
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
}),
// Cache Cleanup Configuration
cleanupCacheApplications: boolean("cleanupCacheApplications")
.notNull()
@@ -154,6 +184,33 @@ export const apiUpdateDockerCleanup = z.object({
serverId: z.string().optional(),
});
// Whitelabeling validation schemas
const safeUrl = z
.string()
.refine((url) => /^https?:\/\//i.test(url), {
message: "Only http:// and https:// URLs are allowed",
})
.nullable();
export const whitelabelingConfigSchema = z.object({
appName: z.string().nullable(),
appDescription: z.string().nullable(),
logoUrl: safeUrl,
faviconUrl: safeUrl,
customCss: z.string().nullable(),
loginLogoUrl: safeUrl,
supportUrl: safeUrl,
docsUrl: safeUrl,
errorPageTitle: z.string().nullable(),
errorPageDescription: z.string().nullable(),
metaTitle: z.string().nullable(),
footerText: z.string().nullable(),
});
export const apiUpdateWhitelabeling = z.object({
whitelabelingConfig: whitelabelingConfigSchema,
});
export const apiUpdateWebServerMonitoring = z.object({
metricsConfig: z
.object({

View File

@@ -0,0 +1,190 @@
import { createAccessControl } from "better-auth/plugins/access";
/**
* Dokploy Access Control Statements
*
* Defines all resources and their possible actions across the platform.
* The first 5 (organization, member, invitation, team, ac) are better-auth defaults
* used internally by the organization plugin.
* The rest are Dokploy-specific resources.
*
* Enterprise-only resources (only assignable via custom roles):
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
*/
export const statements = {
// better-auth organization plugin defaults
organization: ["update", "delete"],
member: ["read", "create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
ac: ["create", "read", "update", "delete"],
// Dokploy core resources (free tier)
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
// Enterprise-only resources (custom roles only)
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
} as const;
/**
* Enterprise-only resources. For static roles (owner/admin/member),
* permission checks on these resources are bypassed — they only apply
* when using custom roles with an enterprise license.
*/
export const enterpriseOnlyResources = new Set<string>([
"volume",
"deployment",
"envVars",
"projectEnvVars",
"environmentEnvVars",
"server",
"registry",
"certificate",
"backup",
"volumeBackup",
"schedule",
"domain",
"destination",
"notification",
"logs",
"monitoring",
"auditLog",
]);
export const ac = createAccessControl(statements);
/**
* Owner role — full access to everything
*/
export const ownerRole = ac.newRole({
organization: ["update", "delete"],
member: ["read", "create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
ac: ["create", "read", "update", "delete"],
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
});
/**
* Admin role — same as owner but cannot delete the organization
*/
export const adminRole = ac.newRole({
organization: ["update"],
member: ["read", "create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
ac: ["create", "read", "update", "delete"],
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
});
/**
* Member role (free tier) — read-only base permissions.
* Members can read projects/services/environments they have access to,
* but cannot create, delete, or access admin resources.
* Enterprise resources are not available to the base member role.
*/
export const memberRole = ac.newRole({
organization: [],
member: [],
invitation: [],
team: [],
ac: ["read"],
project: [],
service: ["read"],
environment: ["read"],
docker: [],
sshKeys: [],
gitProviders: [],
traefikFiles: [],
api: [],
// Service-level enterprise resources — member can do everything within services they have access to
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
logs: ["read"],
monitoring: ["read"],
// Org-level enterprise resources — member cannot manage these
server: [],
registry: [],
certificate: [],
destination: [],
notification: [],
auditLog: [],
});

View File

@@ -1,10 +1,11 @@
import type { IncomingMessage } from "node:http";
import { apiKey } from "@better-auth/api-key";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
import { admin, organization, twoFactor } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
import { db } from "../db";
@@ -14,6 +15,7 @@ import {
getTrustedProviders,
getUserByToken,
} from "../services/admin";
import { createAuditLog } from "../services/proprietary/audit-log";
import {
getWebServerSettings,
updateWebServerSettings,
@@ -21,6 +23,7 @@ import {
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -113,7 +116,7 @@ const { handler, api } = betterAuth({
emailAndPassword: {
enabled: true,
autoSignIn: !IS_CLOUD,
requireEmailVerification: IS_CLOUD,
requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production",
password: {
async hash(password) {
return bcrypt.hashSync(password, 10);
@@ -269,6 +272,52 @@ const { handler, api } = betterAuth({
},
};
},
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",
});
},
},
},
},
@@ -318,10 +367,21 @@ const { handler, api } = betterAuth({
plugins: [
apiKey({
enableMetadata: true,
references: "user",
}),
sso(),
twoFactor(),
organization({
ac,
roles: {
owner: ownerRole,
admin: adminRole,
member: memberRole,
},
dynamicAccessControl: {
enabled: true,
maximumRolesPerOrganization: 10,
},
async sendInvitationEmail(data, _request) {
if (IS_CLOUD) {
const host =

View File

@@ -0,0 +1,431 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import {
ac,
adminRole,
enterpriseOnlyResources,
memberRole,
ownerRole,
statements,
} from "../lib/access-control";
type Statements = typeof statements;
type Resource = keyof Statements;
type Action<R extends Resource> = Statements[R][number];
type Permissions = {
[R in Resource]?: Action<R>[];
};
export type PermissionCtx = {
user: { id: string };
session: { activeOrganizationId: string };
};
export type ResolvedPermissions = {
[R in Resource]: {
[A in Statements[R][number]]: boolean;
};
};
const staticRoles: Record<string, ReturnType<typeof ac.newRole>> = {
owner: ownerRole,
admin: adminRole,
member: memberRole,
};
const resolveRole = async (
roleName: string,
organizationId: string,
): Promise<ReturnType<typeof ac.newRole> | null> => {
if (staticRoles[roleName]) {
return staticRoles[roleName];
}
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
return null;
}
const customRoles = await db.query.organizationRole.findMany({
where: and(
eq(organizationRole.organizationId, organizationId),
eq(organizationRole.role, roleName),
),
});
if (customRoles.length === 0) {
return null;
}
const merged: Record<string, string[]> = {};
for (const entry of customRoles) {
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
for (const [resource, actions] of Object.entries(parsed)) {
merged[resource] = [
...new Set([...(merged[resource] ?? []), ...actions]),
];
}
}
return ac.newRole(merged as any);
};
export const checkPermission = async (
ctx: PermissionCtx,
permissions: Permissions,
) => {
const { id: userId } = ctx.user;
const { activeOrganizationId: organizationId } = ctx.session;
const memberRecord = await findMemberByUserId(userId, organizationId);
const isStaticRole = memberRecord.role in staticRoles;
if (isStaticRole) {
const allEnterprise = Object.keys(permissions).every((r) =>
enterpriseOnlyResources.has(r),
);
if (allEnterprise) return;
}
const role = await resolveRole(memberRecord.role, organizationId);
if (!role) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid role",
});
}
const result = role.authorize(permissions);
if (result.success) {
return;
}
if (memberRecord.role === "member") {
const overrides = getLegacyOverrides(memberRecord);
const allGranted = Object.entries(permissions).every(
([resource, actions]) =>
(actions as string[]).every(
(action) =>
!!(overrides[resource] as Record<string, boolean> | undefined)?.[
action
],
),
);
if (allGranted) {
return;
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: result.error || "Permission denied",
});
};
export const hasPermission = async (
ctx: PermissionCtx,
permissions: Permissions,
): Promise<boolean> => {
try {
await checkPermission(ctx, permissions);
return true;
} catch {
return false;
}
};
const getLegacyOverrides = (
memberRecord: Awaited<ReturnType<typeof findMemberByUserId>>,
): Partial<Record<string, Record<string, boolean>>> => {
return {
project: {
create: !!memberRecord.canCreateProjects,
delete: !!memberRecord.canDeleteProjects,
},
service: {
create: !!memberRecord.canCreateServices,
delete: !!memberRecord.canDeleteServices,
},
environment: {
create: !!memberRecord.canCreateEnvironments,
delete: !!memberRecord.canDeleteEnvironments,
},
traefikFiles: {
read: !!memberRecord.canAccessToTraefikFiles,
},
docker: {
read: !!memberRecord.canAccessToDocker,
},
api: {
read: !!memberRecord.canAccessToAPI,
},
sshKeys: {
read: !!memberRecord.canAccessToSSHKeys,
},
gitProviders: {
read: !!memberRecord.canAccessToGitProviders,
},
};
};
export const resolvePermissions = async (
ctx: PermissionCtx,
): Promise<ResolvedPermissions> => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
const role = await resolveRole(memberRecord.role, organizationId);
const legacyOverrides =
memberRecord.role === "member" ? getLegacyOverrides(memberRecord) : {};
const isPrivilegedRole =
memberRecord.role === "owner" || memberRecord.role === "admin";
const result = {} as ResolvedPermissions;
for (const [resource, actions] of Object.entries(statements)) {
const resourcePerms = {} as Record<string, boolean>;
for (const action of actions) {
if (isPrivilegedRole && enterpriseOnlyResources.has(resource)) {
resourcePerms[action] = true;
continue;
}
if (!role) {
resourcePerms[action] = false;
continue;
}
const check = role.authorize({ [resource]: [action] });
resourcePerms[action] =
check.success ||
!!(legacyOverrides[resource] as Record<string, boolean> | undefined)?.[
action
];
}
(result as any)[resource] = resourcePerms;
}
return result;
};
export const checkProjectAccess = async (
ctx: PermissionCtx,
action: "create" | "delete",
projectId?: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { project: [action] });
if (
action !== "create" &&
projectId &&
memberRecord.role !== "owner" &&
memberRecord.role !== "admin"
) {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const checkServicePermissionAndAccess = async (
ctx: PermissionCtx,
serviceId: string,
permissions: Permissions,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, permissions);
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedServices.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this service",
});
}
}
};
export const checkServiceAccess = async (
ctx: PermissionCtx,
serviceId: string,
action: "create" | "read" | "delete" = "read",
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { service: [action] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (action === "create") {
if (!memberRecord.accessedProjects.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
} else {
if (!memberRecord.accessedServices.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this service",
});
}
}
}
};
export const checkEnvironmentAccess = async (
ctx: PermissionCtx,
environmentId: string,
action: "read" | "create" | "delete" = "read",
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: [action] });
if (
action !== "create" &&
memberRecord.role !== "owner" &&
memberRecord.role !== "admin"
) {
if (!memberRecord.accessedEnvironments.includes(environmentId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this environment",
});
}
}
};
export const checkEnvironmentCreationPermission = async (
ctx: PermissionCtx,
projectId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: ["create"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const checkEnvironmentDeletionPermission = async (
ctx: PermissionCtx,
projectId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: ["delete"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const addNewProject = async (ctx: PermissionCtx, projectId: string) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedProjects: [...memberRecord.accessedProjects, projectId],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const addNewEnvironment = async (
ctx: PermissionCtx,
environmentId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedEnvironments: [
...memberRecord.accessedEnvironments,
environmentId,
],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const addNewService = async (ctx: PermissionCtx, serviceId: string) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedServices: [...memberRecord.accessedServices, serviceId],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const findMemberByUserId = async (
userId: string,
organizationId: string,
) => {
const result = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, organizationId),
),
with: {
user: true,
},
});
if (!result) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
return result;
};

View File

@@ -0,0 +1,95 @@
import { db } from "@dokploy/server/db";
import { auditLog } from "@dokploy/server/db/schema";
import type { AuditAction, AuditResourceType } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { and, desc, eq, gte, ilike, lte } from "drizzle-orm";
export type { AuditAction, AuditResourceType };
export interface CreateAuditLogInput {
organizationId: string;
userId: string;
userEmail: string;
userRole: string;
action: AuditAction;
resourceType: AuditResourceType;
resourceId?: string;
resourceName?: string;
metadata?: Record<string, unknown>;
}
/**
* Creates an audit log entry. Fire-and-forget safe — errors are swallowed
* so a logging failure never breaks the main operation.
*/
export const createAuditLog = async (input: CreateAuditLogInput) => {
try {
const licensed = await hasValidLicense(input.organizationId);
if (!licensed) return;
await db.insert(auditLog).values({
organizationId: input.organizationId,
userId: input.userId,
userEmail: input.userEmail,
userRole: input.userRole,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
resourceName: input.resourceName,
metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
});
} catch (err) {
console.error("[audit-log] Failed to create audit log entry:", err);
}
};
export interface GetAuditLogsInput {
organizationId: string;
userId?: string;
userEmail?: string;
resourceName?: string;
action?: AuditAction;
resourceType?: AuditResourceType;
from?: Date;
to?: Date;
limit?: number;
offset?: number;
}
export const getAuditLogs = async (input: GetAuditLogsInput) => {
const {
organizationId,
userId,
userEmail,
resourceName,
action,
resourceType,
from,
to,
limit = 50,
offset = 0,
} = input;
const conditions = [eq(auditLog.organizationId, organizationId)];
if (userId) conditions.push(eq(auditLog.userId, userId));
if (userEmail) conditions.push(ilike(auditLog.userEmail, `%${userEmail}%`));
if (resourceName)
conditions.push(ilike(auditLog.resourceName, `%${resourceName}%`));
if (action) conditions.push(eq(auditLog.action, action));
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType));
if (from) conditions.push(gte(auditLog.createdAt, from));
if (to) conditions.push(lte(auditLog.createdAt, to));
const [logs, total] = await Promise.all([
db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset,
}),
db.$count(auditLog, and(...conditions)),
]);
return { logs, total };
};

View File

@@ -432,7 +432,7 @@ export const createApiKey = async (
refillInterval?: number;
},
) => {
const apiKey = await auth.createApiKey({
const result = await auth.createApiKey({
body: {
name: input.name,
expiresIn: input.expiresIn,
@@ -450,10 +450,9 @@ export const createApiKey = async (
if (input.metadata) {
await db
.update(apikey)
.set({
metadata: JSON.stringify(input.metadata),
})
.where(eq(apikey.id, apiKey.id));
.set({ metadata: JSON.stringify(input.metadata) })
.where(eq(apikey.id, result.id));
}
return apiKey;
return result;
};

View File

@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(cleanupCommand);
await execAsync(
`rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");

View File

@@ -182,7 +182,11 @@ export const mechanizeDockerContainer = async (
});
} catch (error) {
console.log(error);
await docker.createService(settings);
if (authConfig) {
await docker.createService(authConfig, settings);
} else {
await docker.createService(settings);
}
}
};

View File

@@ -5,9 +5,9 @@ import { db } from "../../db/index";
import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
process.env.NODE_ENV === "development"
? "http://localhost:4002"
: "https://licenses-api.dokploy.com";
// process.env.NODE_ENV === "development"
// ? "http://localhost:4002"
"https://licenses-api.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {

View File

@@ -741,3 +741,177 @@ export const getComposeContainer = async (
throw error;
}
};
type ServiceHealthStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
const checkSwarmServiceRunning = async (
serviceName: string,
): Promise<ServiceHealthStatus> => {
try {
const service = docker.getService(serviceName);
const info = await service.inspect();
const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0;
if (replicas === 0) {
return {
status: "unhealthy",
message: "Service has 0 replicas configured",
};
}
// Check that at least one task is actually running
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
if (!runningTask) {
const latestTask = tasks[0];
const taskState = latestTask?.Status?.State ?? "unknown";
return {
status: "unhealthy",
message: `No running tasks (current state: ${taskState})`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Service not found",
};
}
};
const getSwarmServiceContainerId = async (
serviceName: string,
): Promise<string | null> => {
try {
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
return runningTask?.Status?.ContainerStatus?.ContainerID ?? null;
} catch {
return null;
}
};
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify PostgreSQL actually accepts connections
const containerId = await getSwarmServiceContainerId("dokploy-postgres");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["pg_isready", "-U", "dokploy"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
const inspectResult = await exec.inspect();
if (inspectResult.ExitCode !== 0) {
return {
status: "unhealthy",
message: `PostgreSQL not ready: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message:
error instanceof Error ? error.message : "Failed to check PostgreSQL",
};
}
};
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify Redis actually responds to PING
const containerId = await getSwarmServiceContainerId("dokploy-redis");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["redis-cli", "ping"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
if (!output.includes("PONG")) {
return {
status: "unhealthy",
message: `Redis did not respond with PONG: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Failed to check Redis",
};
}
};
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
// Traefik can run as a standalone container or a swarm service
try {
const container = docker.getContainer("dokploy-traefik");
const info = await container.inspect();
if (!info.State.Running) {
return {
status: "unhealthy",
message: "Container is not running",
};
}
return { status: "healthy" };
} catch {
// Not a standalone container, check as swarm service
return checkSwarmServiceRunning("dokploy-traefik");
}
};

View File

@@ -153,7 +153,7 @@ export const sendDatabaseBackupNotifications = async ({
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
value: `\`\`\`${errorMessage.length > 1010 ? `${errorMessage.substring(0, 1010)}...` : errorMessage}\`\`\``,
},
]
: []),

View File

@@ -161,7 +161,7 @@ export const sendVolumeBackupNotifications = async ({
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
value: `\`\`\`${errorMessage.substring(0, 1010)}\`\`\``,
},
]
: []),

View File

@@ -39,7 +39,7 @@ export const backupVolume = async (
const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`;
const baseCommand = `
const backupCommand = `
set -e
echo "Volume name: ${volumeName}"
echo "Backup file name: ${backupFileName}"
@@ -52,6 +52,9 @@ export const backupVolume = async (
ubuntu \
bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ."
echo "Volume backup done ✅"
`;
const uploadCommand = `
echo "Starting upload to S3..."
${rcloneCommand}
echo "Upload to S3 done ✅"
@@ -61,7 +64,10 @@ export const backupVolume = async (
`;
if (!turnOff) {
return baseCommand;
return `
${backupCommand}
${uploadCommand}
`;
}
const serviceLockId =
@@ -110,9 +116,10 @@ export const backupVolume = async (
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
echo "Actual replicas: $ACTUAL_REPLICAS"
docker service update --replicas=0 ${volumeBackup.application?.appName}
${baseCommand}
${backupCommand}
echo "Starting application to $ACTUAL_REPLICAS replicas"
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
${uploadCommand}
`);
}
if (serviceType === "compose") {
@@ -147,8 +154,9 @@ export const backupVolume = async (
}
return lockWrapper(`
${stopCommand}
${baseCommand}
${backupCommand}
${startCommand}
${uploadCommand}
`);
}
};

View File

@@ -1,7 +1,4 @@
import {
sendDiscordNotification,
sendEmailNotification,
} from "../utils/notifications/utils";
import { sendEmailNotification } from "../utils/notifications/utils";
export const sendEmail = async ({
email,
subject,
@@ -26,26 +23,3 @@ export const sendEmail = async ({
return true;
};
export const sendDiscordNotificationWelcome = async (email: string) => {
await sendDiscordNotification(
{
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
},
{
title: "New User Registered",
color: 0x00ff00,
fields: [
{
name: "Email",
value: email,
inline: true,
},
],
timestamp: new Date(),
footer: {
text: "Dokploy User Registration Notification",
},
},
);
};