From 780906a232858fcedc24825128e515d0df84a48a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 16:57:18 -0400 Subject: [PATCH] Move certificates --- server/db/pg/schema/privateSchema.ts | 20 -- server/db/pg/schema/schema.ts | 20 ++ server/db/sqlite/schema/privateSchema.ts | 20 -- server/db/sqlite/schema/schema.ts | 20 ++ server/index.ts | 2 +- server/lib/blueprints/privateResources.ts | 2 +- server/lib/blueprints/publicResources.ts | 2 +- server/lib/certificates.ts | 235 ++++++++++++++++- server/lib/ip.ts | 2 +- server/lib/traefik/TraefikConfigManager.ts | 5 +- server/middlewares/index.ts | 1 + .../middlewares/verifyCertificateAccess.ts | 13 - server/private/lib/certificates.ts | 240 ------------------ .../private/lib/traefik/getTraefikConfig.ts | 2 +- server/private/middlewares/index.ts | 1 - .../routers/certificates/createCertificate.ts | 115 --------- server/private/routers/certificates/index.ts | 17 -- server/private/routers/external.ts | 28 -- server/private/routers/integration.ts | 2 +- .../routers/loginPage/createLoginPage.ts | 2 +- .../routers/loginPage/updateLoginPage.ts | 3 +- .../routers/certificates/createCertificate.ts | 97 ++++++- .../certificates/getBatchedCertificates.ts | 12 - .../routers/certificates/getCertificate.ts | 13 - server/routers/certificates/index.ts | 5 + .../certificates/restartCertificate.ts | 13 - .../routers/certificates/syncCertToNewts.ts | 17 +- server/routers/external.ts | 33 ++- server/routers/resource/createResource.ts | 2 +- server/routers/resource/updateResource.ts | 2 +- .../siteResource/createSiteResource.ts | 2 +- 31 files changed, 407 insertions(+), 541 deletions(-) rename server/{private => }/middlewares/verifyCertificateAccess.ts (92%) delete mode 100644 server/private/lib/certificates.ts delete mode 100644 server/private/routers/certificates/createCertificate.ts delete mode 100644 server/private/routers/certificates/index.ts rename server/{private => }/routers/certificates/getBatchedCertificates.ts (94%) rename server/{private => }/routers/certificates/getCertificate.ts (93%) create mode 100644 server/routers/certificates/index.ts rename server/{private => }/routers/certificates/restartCertificate.ts (89%) rename server/{private => }/routers/certificates/syncCertToNewts.ts (77%) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index e41498264..e10b459e9 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -29,25 +29,6 @@ import { labels } from "./schema"; -export const certificates = pgTable("certificates", { - certId: serial("certId").primaryKey(), - domain: varchar("domain", { length: 255 }).notNull().unique(), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "cascade" - }), - wildcard: boolean("wildcard").default(false), - status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed - expiresAt: bigint("expiresAt", { mode: "number" }), - lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }), - createdAt: bigint("createdAt", { mode: "number" }).notNull(), - updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), - orderId: varchar("orderId", { length: 500 }), - errorMessage: text("errorMessage"), - renewalCount: integer("renewalCount").default(0), - certFile: text("certFile"), - keyFile: text("keyFile") -}); - export const dnsChallenge = pgTable("dnsChallenges", { dnsChallengeId: serial("dnsChallengeId").primaryKey(), domain: varchar("domain", { length: 255 }).notNull(), @@ -633,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", { export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; -export type Certificate = InferSelectModel; export type DnsChallenge = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 432afbbc1..24fa5d795 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -2017,6 +2017,25 @@ export const aiSessionLog = pgTable( ] ); +export const certificates = pgTable("certificates", { + certId: serial("certId").primaryKey(), + domain: varchar("domain", { length: 255 }).notNull().unique(), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + wildcard: boolean("wildcard").default(false), + status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed + expiresAt: bigint("expiresAt", { mode: "number" }), + lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), + orderId: varchar("orderId", { length: 500 }), + errorMessage: text("errorMessage"), + renewalCount: integer("renewalCount").default(0), + certFile: text("certFile"), + keyFile: text("keyFile") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2117,3 +2136,4 @@ export type SiteResourceAiProvider = InferSelectModel< >; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; +export type Certificate = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index f8d2f5f09..da77bfed2 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -23,25 +23,6 @@ import { users } from "./schema"; -export const certificates = sqliteTable("certificates", { - certId: integer("certId").primaryKey({ autoIncrement: true }), - domain: text("domain").notNull().unique(), - domainId: text("domainId").references(() => domains.domainId, { - onDelete: "cascade" - }), - wildcard: integer("wildcard", { mode: "boolean" }).default(false), - status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed - expiresAt: integer("expiresAt"), - lastRenewalAttempt: integer("lastRenewalAttempt"), - createdAt: integer("createdAt").notNull(), - updatedAt: integer("updatedAt").notNull(), - orderId: text("orderId"), - errorMessage: text("errorMessage"), - renewalCount: integer("renewalCount").default(0), - certFile: text("certFile"), - keyFile: text("keyFile") -}); - export const dnsChallenge = sqliteTable("dnsChallenges", { dnsChallengeId: integer("dnsChallengeId").primaryKey({ autoIncrement: true @@ -628,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", { export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; -export type Certificate = InferSelectModel; export type DnsChallenge = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c70424152..e3ec840bd 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -2013,6 +2013,25 @@ export const aiSessionLog = sqliteTable( ] ); +export const certificates = sqliteTable("certificates", { + certId: integer("certId").primaryKey({ autoIncrement: true }), + domain: text("domain").notNull().unique(), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + wildcard: integer("wildcard", { mode: "boolean" }).default(false), + status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed + expiresAt: integer("expiresAt"), + lastRenewalAttempt: integer("lastRenewalAttempt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull(), + orderId: text("orderId"), + errorMessage: text("errorMessage"), + renewalCount: integer("renewalCount").default(0), + certFile: text("certFile"), + keyFile: text("keyFile") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2111,3 +2130,4 @@ export type SiteResourceAiProvider = InferSelectModel< >; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; +export type Certificate = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index c7b0a5b6e..5c7102847 100644 --- a/server/index.ts +++ b/server/index.ts @@ -27,7 +27,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; -import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; +import { initAcmeCertSync } from "@server/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; import { initAiModelCatalog } from "@server/lib/aiModelCatalog"; diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index cb8f8e8b5..93e663fb7 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -23,7 +23,7 @@ import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels"; import logger from "@server/logger"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { getNextAvailableAliasAddress } from "../ip"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; import { build } from "@server/build"; diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 61985a06e..c1e4a29e3 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -1,5 +1,5 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { hashPassword } from "@server/auth/password"; import { generateId } from "@server/auth/sessions/app"; import { build } from "@server/build"; diff --git a/server/lib/certificates.ts b/server/lib/certificates.ts index 6d24d2996..53d826586 100644 --- a/server/lib/certificates.ts +++ b/server/lib/certificates.ts @@ -1,17 +1,226 @@ +import config from "@server/lib/config"; +import { certificates, db } from "@server/db"; +import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { build } from "@server/build"; + +// Define the return type for clarity and type safety +export type CertificateResult = { + id: number; + domain: string; + queriedDomain: string; // The domain that was originally requested (may differ for wildcards) + wildcard: boolean | null; + certFile: string | null; + keyFile: string | null; + expiresAt: number | null; + updatedAt?: number | null; +}; + export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true -): Promise< - Array<{ - id: number; - domain: string; - queriedDomain: string; - wildcard: boolean | null; - certFile: string | null; - keyFile: string | null; - expiresAt: number | null; - updatedAt?: number | null; - }> -> { - return []; // stub +): Promise> { + const finalResults: CertificateResult[] = []; + const domainsToQuery = new Set(); + + // 1. Check cache first if enabled + if (useCache) { + for (const domain of domains) { + const cacheKey = `cert:${domain}`; + const cachedCert = await cache.get(cacheKey); + if (cachedCert) { + finalResults.push(cachedCert); // Valid cache hit + } else { + // Also check for a wildcard cache entry covering this domain's parent + const parts = domain.split("."); + let wildcardHit = false; + if (parts.length > 1) { + const parentDomain = parts.slice(1).join("."); + const wildcardCacheKey = `cert:*.${parentDomain}`; + const cachedWildcard = + await cache.get(wildcardCacheKey); + if (cachedWildcard) { + // Re-stamp queriedDomain so callers see the originally requested domain + finalResults.push({ + ...cachedWildcard, + queriedDomain: domain + }); + wildcardHit = true; + } + } + if (!wildcardHit) { + domainsToQuery.add(domain); // Cache miss or expired + } + } + } + } else { + // If caching is disabled, add all domains to the query set + domains.forEach((d) => domainsToQuery.add(d)); + } + + // 2. If all domains were resolved from the cache, return early + if (domainsToQuery.size === 0) { + const decryptedResults = decryptFinalResults( + finalResults, + config.getRawConfig().server.secret! + ); + return decryptedResults; + } + + // 3. Prepare domains for the database query + const domainsToQueryArray = Array.from(domainsToQuery); + const parentDomainsToQuery = new Set(); + + domainsToQueryArray.forEach((domain) => { + const parts = domain.split("."); + // A wildcard can only match a domain with at least two parts (e.g., example.com) + if (parts.length > 1) { + parentDomainsToQuery.add(parts.slice(1).join(".")); + } + }); + + const parentDomainsArray = Array.from(parentDomainsToQuery); + + // Build wildcard variants: for each parent domain "example.com", also query "*.example.com" + const wildcardPrefixedArray = + build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; + + // 4. Build and execute a single, efficient Drizzle query + // This query fetches all potential exact and wildcard matches in one database round-trip. + const potentialCerts = await db + .select() + .from(certificates) + .where( + and( + eq(certificates.status, "valid"), + isNotNull(certificates.certFile), + isNotNull(certificates.keyFile), + or( + // Condition for exact matches on the requested domains + inArray(certificates.domain, domainsToQueryArray), + // Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com") + parentDomainsArray.length > 0 + ? and( + inArray(certificates.domain, [ + ...parentDomainsArray, + ...wildcardPrefixedArray + ]), + eq(certificates.wildcard, true) + ) + : // If there are no possible parent domains, this condition is false + sql`false` + ) + ) + ); + + // Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.") + const normalizeWildcardDomain = (domain: string): string => + domain.startsWith("*.") ? domain.slice(2) : domain; + + // 5. Process the database results, prioritizing exact matches over wildcards + const exactMatches = new Map(); + const wildcardMatches = new Map(); + + for (const cert of potentialCerts) { + if (cert.wildcard) { + // Normalize to bare parent domain so lookups are consistent regardless of storage format + wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert); + } else { + exactMatches.set(cert.domain, cert); + } + } + + for (const domain of domainsToQuery) { + let foundCert: (typeof potentialCerts)[0] | undefined = undefined; + + // Priority 1: Check for an exact match (non-wildcard) + if (exactMatches.has(domain)) { + foundCert = exactMatches.get(domain); + } + // Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain + else { + const normalizedDomain = normalizeWildcardDomain(domain); + if (wildcardMatches.has(normalizedDomain)) { + foundCert = wildcardMatches.get(normalizedDomain); + } + // Priority 3: Check for a wildcard match on the parent domain + else { + const parts = normalizedDomain.split("."); + if (parts.length > 1) { + const parentDomain = parts.slice(1).join("."); + if (wildcardMatches.has(parentDomain)) { + foundCert = wildcardMatches.get(parentDomain); + } + } + } + } + + // If a certificate was found, format it, add to results, and cache it + if (foundCert) { + logger.debug( + `Creating result cert for ${domain} using cert from ${foundCert.domain}` + ); + const resultCert: CertificateResult = { + id: foundCert.certId, + domain: foundCert.domain, // The actual domain of the cert record + queriedDomain: domain, // The domain that was originally requested + wildcard: foundCert.wildcard, + certFile: foundCert.certFile, + keyFile: foundCert.keyFile, + expiresAt: foundCert.expiresAt, + updatedAt: foundCert.updatedAt + }; + + finalResults.push(resultCert); + + // Add to cache for future requests, using the *requested domain* as the key + if (useCache) { + const cacheKey = `cert:${domain}`; + await cache.set(cacheKey, resultCert, 180); + + // Also cache wildcard certs under a pattern key so other subdomains + // can find them without a DB round-trip + if (resultCert.wildcard) { + const normalizedCertDomain = normalizeWildcardDomain( + resultCert.domain + ); + const wildcardCacheKey = `cert:*.${normalizedCertDomain}`; + await cache.set(wildcardCacheKey, resultCert, 180); + } + } + } + } + + const decryptedResults = decryptFinalResults( + finalResults, + config.getRawConfig().server.secret! + ); + return decryptedResults; +} + +function decryptFinalResults( + finalResults: CertificateResult[], + secret: string +): CertificateResult[] { + const validCertsDecrypted = finalResults.map((cert) => { + // Decrypt and save certificate file + const decryptedCert = decrypt( + cert.certFile!, // is not null from query + secret + ); + + // Decrypt and save key file + const decryptedKey = decrypt(cert.keyFile!, secret); + + // Return only the certificate data without org information + return { + ...cert, + certFile: decryptedCert, + keyFile: decryptedKey + }; + }); + + return validCertsDecrypted; } diff --git a/server/lib/ip.ts b/server/lib/ip.ts index bb21d28d6..518bb36db 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -6,7 +6,7 @@ import z from "zod"; import logger from "@server/logger"; import semver from "semver"; import { createHash } from "crypto"; -import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; +import { getValidCertificatesForDomains } from "@server/lib/certificates"; import { lockManager } from "#dynamic/lib/lock"; interface IPRange { diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 91bc37249..bc1bac221 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -8,7 +8,7 @@ import { db, exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import { getCurrentExitNodeId } from "@server/lib/exitNodes"; import { getTraefikConfig } from "#dynamic/lib/traefik"; -import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; +import { getValidCertificatesForDomains } from "@server/lib/certificates"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; @@ -628,8 +628,7 @@ export class TraefikConfigManager { .name, remoteRoleHeader: - config.getRawConfig().server.remote_headers - .role + config.getRawConfig().server.remote_headers.role } } }; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index cd09add8a..5b309a4c4 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -38,3 +38,4 @@ export * from "./logActionAudit"; export * from "./verifyOlmAccess"; export * from "./verifyLimits"; export * from "./verifyResourcePolicyAccess"; +export * from "./verifyCertificateAccess"; diff --git a/server/private/middlewares/verifyCertificateAccess.ts b/server/middlewares/verifyCertificateAccess.ts similarity index 92% rename from server/private/middlewares/verifyCertificateAccess.ts rename to server/middlewares/verifyCertificateAccess.ts index 3d86db0ef..1d499939c 100644 --- a/server/private/middlewares/verifyCertificateAccess.ts +++ b/server/middlewares/verifyCertificateAccess.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { Request, Response, NextFunction } from "express"; import { db, domainNamespaces } from "@server/db"; import { certificates } from "@server/db"; diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts deleted file mode 100644 index 03ea6a58c..000000000 --- a/server/private/lib/certificates.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import privateConfig from "./config"; -import config from "@server/lib/config"; -import { certificates, db } from "@server/db"; -import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; -import { decrypt } from "@server/lib/crypto"; -import logger from "@server/logger"; -import { regionalCache as cache } from "#private/lib/cache"; -import { build } from "@server/build"; - -// Define the return type for clarity and type safety -export type CertificateResult = { - id: number; - domain: string; - queriedDomain: string; // The domain that was originally requested (may differ for wildcards) - wildcard: boolean | null; - certFile: string | null; - keyFile: string | null; - expiresAt: number | null; - updatedAt?: number | null; -}; - -export async function getValidCertificatesForDomains( - domains: Set, - useCache: boolean = true -): Promise> { - const finalResults: CertificateResult[] = []; - const domainsToQuery = new Set(); - - // 1. Check cache first if enabled - if (useCache) { - for (const domain of domains) { - const cacheKey = `cert:${domain}`; - const cachedCert = await cache.get(cacheKey); - if (cachedCert) { - finalResults.push(cachedCert); // Valid cache hit - } else { - // Also check for a wildcard cache entry covering this domain's parent - const parts = domain.split("."); - let wildcardHit = false; - if (parts.length > 1) { - const parentDomain = parts.slice(1).join("."); - const wildcardCacheKey = `cert:*.${parentDomain}`; - const cachedWildcard = - await cache.get(wildcardCacheKey); - if (cachedWildcard) { - // Re-stamp queriedDomain so callers see the originally requested domain - finalResults.push({ - ...cachedWildcard, - queriedDomain: domain - }); - wildcardHit = true; - } - } - if (!wildcardHit) { - domainsToQuery.add(domain); // Cache miss or expired - } - } - } - } else { - // If caching is disabled, add all domains to the query set - domains.forEach((d) => domainsToQuery.add(d)); - } - - // 2. If all domains were resolved from the cache, return early - if (domainsToQuery.size === 0) { - const decryptedResults = decryptFinalResults( - finalResults, - config.getRawConfig().server.secret! - ); - return decryptedResults; - } - - // 3. Prepare domains for the database query - const domainsToQueryArray = Array.from(domainsToQuery); - const parentDomainsToQuery = new Set(); - - domainsToQueryArray.forEach((domain) => { - const parts = domain.split("."); - // A wildcard can only match a domain with at least two parts (e.g., example.com) - if (parts.length > 1) { - parentDomainsToQuery.add(parts.slice(1).join(".")); - } - }); - - const parentDomainsArray = Array.from(parentDomainsToQuery); - - // Build wildcard variants: for each parent domain "example.com", also query "*.example.com" - const wildcardPrefixedArray = - build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; - - // 4. Build and execute a single, efficient Drizzle query - // This query fetches all potential exact and wildcard matches in one database round-trip. - const potentialCerts = await db - .select() - .from(certificates) - .where( - and( - eq(certificates.status, "valid"), - isNotNull(certificates.certFile), - isNotNull(certificates.keyFile), - or( - // Condition for exact matches on the requested domains - inArray(certificates.domain, domainsToQueryArray), - // Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com") - parentDomainsArray.length > 0 - ? and( - inArray(certificates.domain, [ - ...parentDomainsArray, - ...wildcardPrefixedArray - ]), - eq(certificates.wildcard, true) - ) - : // If there are no possible parent domains, this condition is false - sql`false` - ) - ) - ); - - // Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.") - const normalizeWildcardDomain = (domain: string): string => - domain.startsWith("*.") ? domain.slice(2) : domain; - - // 5. Process the database results, prioritizing exact matches over wildcards - const exactMatches = new Map(); - const wildcardMatches = new Map(); - - for (const cert of potentialCerts) { - if (cert.wildcard) { - // Normalize to bare parent domain so lookups are consistent regardless of storage format - wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert); - } else { - exactMatches.set(cert.domain, cert); - } - } - - for (const domain of domainsToQuery) { - let foundCert: (typeof potentialCerts)[0] | undefined = undefined; - - // Priority 1: Check for an exact match (non-wildcard) - if (exactMatches.has(domain)) { - foundCert = exactMatches.get(domain); - } - // Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain - else { - const normalizedDomain = normalizeWildcardDomain(domain); - if (wildcardMatches.has(normalizedDomain)) { - foundCert = wildcardMatches.get(normalizedDomain); - } - // Priority 3: Check for a wildcard match on the parent domain - else { - const parts = normalizedDomain.split("."); - if (parts.length > 1) { - const parentDomain = parts.slice(1).join("."); - if (wildcardMatches.has(parentDomain)) { - foundCert = wildcardMatches.get(parentDomain); - } - } - } - } - - // If a certificate was found, format it, add to results, and cache it - if (foundCert) { - logger.debug( - `Creating result cert for ${domain} using cert from ${foundCert.domain}` - ); - const resultCert: CertificateResult = { - id: foundCert.certId, - domain: foundCert.domain, // The actual domain of the cert record - queriedDomain: domain, // The domain that was originally requested - wildcard: foundCert.wildcard, - certFile: foundCert.certFile, - keyFile: foundCert.keyFile, - expiresAt: foundCert.expiresAt, - updatedAt: foundCert.updatedAt - }; - - finalResults.push(resultCert); - - // Add to cache for future requests, using the *requested domain* as the key - if (useCache) { - const cacheKey = `cert:${domain}`; - await cache.set(cacheKey, resultCert, 180); - - // Also cache wildcard certs under a pattern key so other subdomains - // can find them without a DB round-trip - if (resultCert.wildcard) { - const normalizedCertDomain = normalizeWildcardDomain( - resultCert.domain - ); - const wildcardCacheKey = `cert:*.${normalizedCertDomain}`; - await cache.set(wildcardCacheKey, resultCert, 180); - } - } - } - } - - const decryptedResults = decryptFinalResults( - finalResults, - config.getRawConfig().server.secret! - ); - return decryptedResults; -} - -function decryptFinalResults( - finalResults: CertificateResult[], - secret: string -): CertificateResult[] { - const validCertsDecrypted = finalResults.map((cert) => { - // Decrypt and save certificate file - const decryptedCert = decrypt( - cert.certFile!, // is not null from query - secret - ); - - // Decrypt and save key file - const decryptedKey = decrypt(cert.keyFile!, secret); - - // Return only the certificate data without org information - return { - ...cert, - certFile: decryptedCert, - keyFile: decryptedKey - }; - }); - - return validCertsDecrypted; -} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index bea78fa3e..016200de7 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -53,7 +53,7 @@ import createPathRewriteMiddleware from "@server/lib/traefik/middleware"; import { CertificateResult, getValidCertificatesForDomains -} from "#private/lib/certificates"; +} from "@server/lib/certificates"; import { build } from "@server/build"; import regionalCache from "#private/lib/cache"; import { diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index 4b598b4bf..11e10a7cf 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -11,7 +11,6 @@ * This file is not licensed under the AGPLv3. */ -export * from "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts deleted file mode 100644 index 2f2e50fdc..000000000 --- a/server/private/routers/certificates/createCertificate.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Certificate, certificates, db, domains } from "@server/db"; -import logger from "@server/logger"; -import { Transaction } from "@server/db"; -import { eq, or, and, like } from "drizzle-orm"; - -/** - * Checks if a certificate exists for the given domain. - * If not, creates a new certificate in 'pending' state. - * Wildcard certs cover subdomains. - */ -export async function createCertificate( - domainId: string, - domain: string, - trx: Transaction | typeof db -) { - const [domainRecord] = await trx - .select() - .from(domains) - .where(eq(domains.domainId, domainId)) - .limit(1); - - if (!domainRecord) { - throw new Error(`Domain with ID ${domainId} not found`); - } - - let existing: Certificate[] = []; - if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { - const domainLevelDown = domain.split(".").slice(1).join("."); - const wildcardPrefixed = `*.${domainLevelDown}`; - - existing = await trx - .select() - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) - ) - ) - ) - ); - } else { - // For non-NS domains, we only match exact domain names - existing = await trx - .select() - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - eq(certificates.domain, domain) // exact match for non-NS domains - ) - ); - } - - if (existing.length > 0) { - logger.info(`Certificate already exists for domain ${domain}`); - return; - } - - let domainToWrite = domain; - if ( - domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD - domainRecord.preferWildcardCert && - !domain.startsWith("*.") - ) { - // in this case traefik is going to generate a domain one level down so we need to store it that way - const parts = domain.split("."); - if (parts.length > 2) { - domainToWrite = parts.slice(1).join("."); - domainToWrite = `*.${domainToWrite}`; - } - } else if (domainRecord.type == "ns") { - if (domain == domainRecord.baseDomain) { - domainToWrite = domainRecord.baseDomain; - } else { - const parts = domain.split("."); - if (parts.length > 2) { - domainToWrite = parts.slice(1).join("."); - } - } - } - - // No cert found, create a new one in pending state - await trx.insert(certificates).values({ - domain: domainToWrite, - domainId, - wildcard: - domainRecord.type == "ns" || - (domainRecord.type == "wildcard" && - domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains - status: "pending", - updatedAt: Math.floor(Date.now() / 1000), - createdAt: Math.floor(Date.now() / 1000) - }); -} diff --git a/server/private/routers/certificates/index.ts b/server/private/routers/certificates/index.ts deleted file mode 100644 index 54b11aa1e..000000000 --- a/server/private/routers/certificates/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -export * from "./getCertificate"; -export * from "./restartCertificate"; -export * from "./syncCertToNewts"; -export * from "./getBatchedCertificates"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 32cb68945..3ea095bd1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -11,7 +11,6 @@ * This file is not licensed under the AGPLv3. */ -import * as certificates from "#private/routers/certificates"; import { createStore } from "#private/lib/rateLimitStore"; import * as billing from "#private/routers/billing"; import * as remoteExitNode from "#private/routers/remoteExitNode"; @@ -50,7 +49,6 @@ import { import { ActionsEnum } from "@server/auth/actions"; import { logActionAudit, - verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, verifyRemoteExitNodeAccess, @@ -164,32 +162,6 @@ authenticated.get( orgIdp.listUserAdminOrgIdps ); -authenticated.get( - "/org/:orgId/certificate/:domainId/:domain", - verifyOrgAccess, - verifyCertificateAccess, - verifyUserHasAction(ActionsEnum.getCertificate), - certificates.getCertificate -); - -authenticated.get( - "/org/:orgId/batched-certificates", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.getCertificate), - certificates.getBatchedCertificates -); - -authenticated.post( - "/org/:orgId/certificate/:certId/restart", - verifyValidLicense, - verifyOrgAccess, - verifyCertificateAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.restartCertificate), - logActionAudit(ActionsEnum.restartCertificate), - certificates.restartCertificate -); - if (build === "saas") { authenticated.post( "/org/:orgId/billing/create-checkout-session", diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 2e53b5b9d..8a1e15c2f 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -15,7 +15,7 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; -import * as certificates from "#private/routers/certificates"; +import * as certificates from "@server/routers/certificates"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as policy from "#private/routers/policy"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index 044d292fb..6336d8e76 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -29,7 +29,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; -import { createCertificate } from "#private/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { CreateLoginPageResponse } from "@server/routers/loginPage/types"; diff --git a/server/private/routers/loginPage/updateLoginPage.ts b/server/private/routers/loginPage/updateLoginPage.ts index 679d03fbc..6cc873729 100644 --- a/server/private/routers/loginPage/updateLoginPage.ts +++ b/server/private/routers/loginPage/updateLoginPage.ts @@ -22,7 +22,7 @@ import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { subdomainSchema } from "@server/lib/schemas"; -import { createCertificate } from "#private/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { UpdateLoginPageResponse } from "@server/routers/loginPage/types"; @@ -85,7 +85,6 @@ export async function updateLoginPage( const { loginPageId, orgId } = parsedParams.data; - const [existingLoginPage] = await db .select() .from(loginPage) diff --git a/server/routers/certificates/createCertificate.ts b/server/routers/certificates/createCertificate.ts index e858e5cda..e75bfe05f 100644 --- a/server/routers/certificates/createCertificate.ts +++ b/server/routers/certificates/createCertificate.ts @@ -1,9 +1,102 @@ -import { db, Transaction } from "@server/db"; +import { Certificate, certificates, db, domains } from "@server/db"; +import logger from "@server/logger"; +import { Transaction } from "@server/db"; +import { eq, or, and, like } from "drizzle-orm"; +/** + * Checks if a certificate exists for the given domain. + * If not, creates a new certificate in 'pending' state. + * Wildcard certs cover subdomains. + */ export async function createCertificate( domainId: string, domain: string, trx: Transaction | typeof db ) { - return; + const [domainRecord] = await trx + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domainRecord) { + throw new Error(`Domain with ID ${domainId} not found`); + } + + let existing: Certificate[] = []; + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + + existing = await trx + .select() + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) + ) + ) + ); + } else { + // For non-NS domains, we only match exact domain names + existing = await trx + .select() + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.domain, domain) // exact match for non-NS domains + ) + ); + } + + if (existing.length > 0) { + logger.info(`Certificate already exists for domain ${domain}`); + return; + } + + let domainToWrite = domain; + if ( + domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD + domainRecord.preferWildcardCert && + !domain.startsWith("*.") + ) { + // in this case traefik is going to generate a domain one level down so we need to store it that way + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + domainToWrite = `*.${domainToWrite}`; + } + } else if (domainRecord.type == "ns") { + if (domain == domainRecord.baseDomain) { + domainToWrite = domainRecord.baseDomain; + } else { + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + } + } + } + + // No cert found, create a new one in pending state + await trx.insert(certificates).values({ + domain: domainToWrite, + domainId, + wildcard: + domainRecord.type == "ns" || + (domainRecord.type == "wildcard" && + domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains + status: "pending", + updatedAt: Math.floor(Date.now() / 1000), + createdAt: Math.floor(Date.now() / 1000) + }); } diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/routers/certificates/getBatchedCertificates.ts similarity index 94% rename from server/private/routers/certificates/getBatchedCertificates.ts rename to server/routers/certificates/getBatchedCertificates.ts index 2ab5fd288..caa179b6e 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/routers/certificates/getBatchedCertificates.ts @@ -1,15 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/private/routers/certificates/getCertificate.ts b/server/routers/certificates/getCertificate.ts similarity index 93% rename from server/private/routers/certificates/getCertificate.ts rename to server/routers/certificates/getCertificate.ts index 60a6de59f..deb816cea 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/routers/certificates/getCertificate.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { certificates, db, domains } from "@server/db"; diff --git a/server/routers/certificates/index.ts b/server/routers/certificates/index.ts new file mode 100644 index 000000000..b5177c19d --- /dev/null +++ b/server/routers/certificates/index.ts @@ -0,0 +1,5 @@ +export * from "./getCertificate"; +export * from "./restartCertificate"; +export * from "./syncCertToNewts"; +export * from "./getBatchedCertificates"; +export * from "./createCertificate"; diff --git a/server/private/routers/certificates/restartCertificate.ts b/server/routers/certificates/restartCertificate.ts similarity index 89% rename from server/private/routers/certificates/restartCertificate.ts rename to server/routers/certificates/restartCertificate.ts index 05614b078..5c758e7e0 100644 --- a/server/private/routers/certificates/restartCertificate.ts +++ b/server/routers/certificates/restartCertificate.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { certificates, db } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/private/routers/certificates/syncCertToNewts.ts b/server/routers/certificates/syncCertToNewts.ts similarity index 77% rename from server/private/routers/certificates/syncCertToNewts.ts rename to server/routers/certificates/syncCertToNewts.ts index ac6089acb..e843af018 100644 --- a/server/private/routers/certificates/syncCertToNewts.ts +++ b/server/routers/certificates/syncCertToNewts.ts @@ -1,19 +1,6 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync"; +import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -65,4 +52,4 @@ export async function syncCertToNewts( ) ); } -} \ No newline at end of file +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 9b7d8e8b0..3acce3491 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -50,20 +50,21 @@ import { verifyAiProviderAccess, verifyAiModelAccess, verifyAiBudgetAccess, - verifyVirtualApiKeyAccess + verifyVirtualApiKeyAccess, + logActionAudit, + verifyCertificateAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; -import { logActionAudit, verifyValidLicense } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; import * as aiBudget from "@server/routers/aiBudget"; import * as virtualApiKey from "@server/routers/virtualApiKey"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import * as certificates from "@server/routers/certificates"; // Root routes export const unauthenticated = Router(); @@ -1858,7 +1859,6 @@ authenticated.put( authenticated.post( "/org/:orgId/ssh/sign-key", - verifyValidLicense, verifyOrgAccess, verifyLimits, // verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now @@ -1878,6 +1878,31 @@ authenticated.post( client.rebuildClientAssociationsCacheRoute ); +authenticated.get( + "/org/:orgId/certificate/:domainId/:domain", + verifyOrgAccess, + verifyCertificateAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getCertificate +); + +authenticated.get( + "/org/:orgId/batched-certificates", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getBatchedCertificates +); + +authenticated.post( + "/org/:orgId/certificate/:certId/restart", + verifyOrgAccess, + verifyCertificateAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.restartCertificate), + logActionAudit(ActionsEnum.restartCertificate), + certificates.restartCertificate +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 094e092b3..cb2aed4d0 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -24,7 +24,7 @@ import logger from "@server/logger"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates"; import { validateAndConstructDomain, checkWildcardDomainConflict diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 46e547b0b..9363b35d6 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -38,7 +38,7 @@ import { } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { validateAndConstructDomain, checkWildcardDomainConflict diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 54a1bc004..e900d46d0 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -32,7 +32,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; import { LimitId } from "@server/lib/billing";