diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 5aea423a..92e4e2da 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: - node-version: '22' + node-version: '24' - name: Install dependencies run: npm ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 58b4662c..82e3686a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: - name: Install Node uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: - node-version: '22' + node-version: '24' - name: Copy config file run: cp config/config.example.yml config/config.yml @@ -34,7 +34,7 @@ jobs: run: npm run set:oss - name: Generate database migrations - run: npm run db:sqlite:generate + run: npm run db:generate - name: Apply database migrations run: npm run db:sqlite:push @@ -64,9 +64,6 @@ jobs: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Copy config file - run: cp config/config.example.yml config/config.yml - - name: Build Docker image sqlite run: make dev-build-sqlite @@ -76,8 +73,5 @@ jobs: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Copy config file - run: cp config/config.example.yml config/config.yml - - name: Build Docker image pg run: make dev-build-pg diff --git a/Dockerfile b/Dockerfile index 487fa033..f82719a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,8 @@ COPY . . RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \ npm run set:$DATABASE && \ npm run set:$BUILD && \ - npm run db:$DATABASE:generate && \ - npm run build:$DATABASE && \ + npm run db:generate && \ + npm run build && \ npm run build:cli # test to make sure the build output is there and error if not diff --git a/esbuild.mjs b/esbuild.mjs index f20a1988..03697d4f 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -6,6 +6,12 @@ import path from "path"; import fs from "fs"; // import { glob } from "glob"; +// Read default build type from server/build.ts +let build = "oss"; +const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8"); +const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/); +if (m) build = m[1]; + const banner = ` // patch __dirname // import { fileURLToPath } from "url"; @@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv)) describe: "Build type (oss, saas, enterprise)", type: "string", choices: ["oss", "saas", "enterprise"], - default: "oss" + default: build }) .help() .alias("help", "h").argv; diff --git a/install/main.go b/install/main.go index 3ea6af22..242af741 100644 --- a/install/main.go +++ b/install/main.go @@ -6,7 +6,8 @@ import ( "fmt" "io" "io/fs" - "math/rand" + "crypto/rand" + "encoding/base64" "net" "net/http" "net/url" @@ -592,17 +593,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai } func generateRandomSecretKey() string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - const length = 32 - - var seededRand *rand.Rand = rand.New( - rand.NewSource(time.Now().UnixNano())) - - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] + secret := make([]byte, 32) + _, err := rand.Read(secret) + if err != nil { + panic(fmt.Sprintf("Failed to generate random secret key: %v", err)) } - return string(b) + return base64.StdEncoding.EncodeToString(secret) } func getPublicIP() string { diff --git a/messages/en-US.json b/messages/en-US.json index f2affe11..e9d8cc37 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1436,6 +1436,15 @@ "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", + "billingLicenseKeys": "License Keys", + "billingLicenseKeysDescription": "Manage your license key subscriptions", + "billingLicenseSubscription": "License Subscription", + "billingInactive": "Inactive", + "billingLicenseItem": "License Item", + "billingQuantity": "Quantity", + "billingTotal": "total", + "billingModifyLicenses": "Modify License Subscription", + "billingPricingCalculatorLink": "View Pricing Calculator", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "failed": "Failed", @@ -2113,6 +2122,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Get a license", + "description": "Choose a plan and tell us how you plan to use Pangolin.", + "chooseTier": "Choose your plan", + "viewPricingLink": "See pricing, features, and limits", + "tiers": { + "starter": { + "title": "Starter", + "description": "Enterprise features, 25 users, 25 sites, and community support." + }, + "scale": { + "title": "Scale", + "description": "Enterprise features, 50 users, 50 sites, and priority support." + } + }, + "personalUseOnly": "Personal use only (free license — no checkout)", + "buttons": { + "continueToCheckout": "Continue to Checkout" + }, + "toasts": { + "checkoutError": { + "title": "Checkout error", + "description": "Could not start checkout. Please try again." + } + } + }, "priority": "Priority", "priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.", "instanceName": "Instance Name", diff --git a/package.json b/package.json index 03b7ea17..6b9606b7 100644 --- a/package.json +++ b/package.json @@ -13,22 +13,19 @@ "scripts": { "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev:check": "npx tsc --noEmit && npm run format:check", - "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push", - "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts", - "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", + "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push", + "db:generate": "drizzle-kit generate --config=./drizzle.config.ts", "db:pg:push": "npx tsx server/db/pg/migrate.ts", "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", - "db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts", - "db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts", + "db:studio": "drizzle-kit studio --config=./drizzle.config.ts", "db:clear-migrations": "rm -rf server/migrations", "set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json", "set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json", "set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", - "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts", - "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts", + "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts", + "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts", "build:next": "next build", - "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", - "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", + "build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx new file mode 100644 index 00000000..44472c8a --- /dev/null +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; +import CopyCodeBox from "./components/CopyCodeBox"; +import ButtonLink from "./components/ButtonLink"; + +type EnterpriseEditionKeyGeneratedProps = { + keyValue: string; + personalUseOnly: boolean; + users: number; + sites: number; + modifySubscriptionLink?: string; +}; + +export const EnterpriseEditionKeyGenerated = ({ + keyValue, + personalUseOnly, + users, + sites, + modifySubscriptionLink +}: EnterpriseEditionKeyGeneratedProps) => { + const previewText = personalUseOnly + ? "Your Enterprise Edition key for personal use is ready" + : "Thank you for your purchase — your Enterprise Edition key is ready"; + + return ( + + + {previewText} + + + + + + Hi there, + + {personalUseOnly ? ( + + Your Enterprise Edition license key has been + generated. Qualifying users can use the + Enterprise Edition for free for{" "} + personal use only. + + ) : ( + <> + + Thank you for your purchase. Your Enterprise + Edition license key is ready. Below are the + terms of your license. + + + {modifySubscriptionLink && ( + + + Modify subscription + + + )} + + )} + + + Your license key: + + + + + If you need to purchase additional license keys or + modify your existing license, please reach out to + our support team at{" "} + + support@pangolin.net + + . + + + + + + + + + + ); +}; + +export default EnterpriseEditionKeyGenerated; diff --git a/server/emails/templates/components/CopyCodeBox.tsx b/server/emails/templates/components/CopyCodeBox.tsx index 3e4d1d08..497fe7a9 100644 --- a/server/emails/templates/components/CopyCodeBox.tsx +++ b/server/emails/templates/components/CopyCodeBox.tsx @@ -1,6 +1,14 @@ import React from "react"; -export default function CopyCodeBox({ text }: { text: string }) { +const DEFAULT_HINT = "Copy and paste this code when prompted"; + +export default function CopyCodeBox({ + text, + hint +}: { + text: string; + hint?: string; +}) { return (
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) { {text}
-

- Copy and paste this code when prompted -

+

{hint ?? DEFAULT_HINT}

); } diff --git a/server/lib/billing/licenses.ts b/server/lib/billing/licenses.ts new file mode 100644 index 00000000..3fecb32b --- /dev/null +++ b/server/lib/billing/licenses.ts @@ -0,0 +1,37 @@ +export enum LicenseId { + SMALL_LICENSE = "small_license", + BIG_LICENSE = "big_license" +} + +export type LicensePriceSet = { + [key in LicenseId]: string; +}; + +export const licensePriceSet: LicensePriceSet = { + // Free license matches the freeLimitSet + [LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", + [LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" +}; + +export const licensePriceSetSandbox: LicensePriceSet = { + // Free license matches the freeLimitSet + // when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value + [LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", + [LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" +}; + +export function getLicensePriceSet( + environment?: string, + sandbox_mode?: boolean +): LicensePriceSet { + if ( + (process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true") || + (environment === "prod" && sandbox_mode !== true) + ) { + // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE + return licensePriceSet; + } else { + return licensePriceSetSandbox; + } +} diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 820b121a..fdd077d9 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -40,7 +40,7 @@ export const subscribedLimitSet: LimitSet = { description: "Contact us to increase soft limit." }, // 12000 GB [FeatureId.DOMAINS]: { - value: 25, + value: 250, description: "Contact us to increase soft limit." }, [FeatureId.REMOTE_EXIT_NODES]: { diff --git a/server/lib/getEnvOrYaml.ts b/server/lib/getEnvOrYaml.ts new file mode 100644 index 00000000..62081cef --- /dev/null +++ b/server/lib/getEnvOrYaml.ts @@ -0,0 +1,3 @@ +export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { + return process.env[envVar] ?? valFromYaml; +}; diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 90ebdc89..362210ae 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -3,13 +3,10 @@ import yaml from "js-yaml"; import { configFilePath1, configFilePath2 } from "./consts"; import { z } from "zod"; import stoi from "./stoi"; +import { getEnvOrYaml } from "./getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); -const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { - return process.env[envVar] ?? valFromYaml; -}; - export const configSchema = z .object({ app: z @@ -311,7 +308,10 @@ export const configSchema = z .object({ smtp_host: z.string().optional(), smtp_port: portSchema.optional(), - smtp_user: z.string().optional(), + smtp_user: z + .string() + .optional() + .transform(getEnvOrYaml("EMAIL_SMTP_USER")), smtp_pass: z .string() .optional() diff --git a/server/license/license.ts b/server/license/license.ts index cfa45d7c..7c960984 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -12,6 +12,10 @@ export type LicenseStatus = { isLicenseValid: boolean; // Is the license key valid? hostId: string; // Host ID tier?: LicenseKeyTier; + maxSites?: number; + usedSites?: number; + maxUsers?: number; + usedUsers?: number; }; export type LicenseKeyCache = { @@ -22,12 +26,14 @@ export type LicenseKeyCache = { type?: LicenseKeyType; tier?: LicenseKeyTier; terminateAt?: Date; + quantity?: number; + quantity_2?: number; }; export class License { private serverSecret!: string; - constructor(private hostMeta: HostMeta) {} + constructor(private hostMeta: HostMeta) { } public async check(): Promise { return { diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index fbfb5cb0..adda2414 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -12,7 +12,7 @@ */ import { getTierPriceSet } from "@server/lib/billing/tiers"; -import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription"; +import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions"; import { build } from "@server/build"; export async function getOrgTierData( @@ -25,22 +25,32 @@ export async function getOrgTierData( return { tier, active }; } - const { subscription, items } = await getOrgSubscriptionData(orgId); + // TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM - if (items && items.length > 0) { - const tierPriceSet = getTierPriceSet(); - // Iterate through tiers in order (earlier keys are higher tiers) - for (const [tierId, priceId] of Object.entries(tierPriceSet)) { - // Check if any subscription item matches this tier's price ID - const matchingItem = items.find((item) => item.priceId === priceId); - if (matchingItem) { - tier = tierId; - break; + const subscriptionsWithItems = await getOrgSubscriptionsData(orgId); + + for (const { subscription, items } of subscriptionsWithItems) { + if (items && items.length > 0) { + const tierPriceSet = getTierPriceSet(); + // Iterate through tiers in order (earlier keys are higher tiers) + for (const [tierId, priceId] of Object.entries(tierPriceSet)) { + // Check if any subscription item matches this tier's price ID + const matchingItem = items.find((item) => item.priceId === priceId); + if (matchingItem) { + tier = tierId; + break; + } } } - } - if (subscription && subscription.status === "active") { - active = true; + + if (subscription && subscription.status === "active") { + active = true; + } + + // If we found a tier and active subscription, we can stop + if (tier && active) { + break; + } } return { tier, active }; } diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 06571cac..bc1dffcd 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -19,7 +19,6 @@ import * as fs from "fs"; import logger from "@server/logger"; import cache from "@server/lib/cache"; -let encryptionKeyPath = ""; let encryptionKeyHex = ""; let encryptionKey: Buffer; function loadEncryptData() { @@ -27,15 +26,7 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path; - - if (!fs.existsSync(encryptionKeyPath)) { - throw new Error( - "Encryption key file not found. Please generate one first." - ); - } - - encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim(); + encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key; encryptionKey = Buffer.from(encryptionKeyHex, "hex"); } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 374dee7c..0211a330 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -17,6 +17,7 @@ import { privateConfigFilePath1 } from "@server/lib/consts"; import { z } from "zod"; import { colorsSchema } from "@server/lib/colorsSchema"; import { build } from "@server/build"; +import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -32,19 +33,29 @@ export const privateConfigSchema = z.object({ }), server: z .object({ - encryption_key_path: z + encryption_key: z .string() .optional() - .default("./config/encryption.pem") - .pipe(z.string().min(8)), - resend_api_key: z.string().optional(), - reo_client_id: z.string().optional(), - fossorial_api_key: z.string().optional() + .transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")), + resend_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("RESEND_API_KEY")), + reo_client_id: z + .string() + .optional() + .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), + fossorial_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) }) .optional() - .default({ - encryption_key_path: "./config/encryption.pem" - }), + .prefault({}), redis: z .object({ host: z.string(), @@ -157,8 +168,14 @@ export const privateConfigSchema = z.object({ .optional(), stripe: z .object({ - secret_key: z.string(), - webhook_secret: z.string(), + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + webhook_secret: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), s3Bucket: z.string(), s3Region: z.string().default("us-east-1"), localFilePath: z.string() diff --git a/server/private/license/license.ts b/server/private/license/license.ts index f8f774c6..972dbc82 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -11,12 +11,12 @@ * This file is not licensed under the AGPLv3. */ -import { db, HostMeta } from "@server/db"; +import { db, HostMeta, sites, users } from "@server/db"; import { hostMeta, licenseKey } from "@server/db"; import logger from "@server/logger"; import NodeCache from "node-cache"; import { validateJWT } from "./licenseJwt"; -import { eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import moment from "moment"; import { encrypt, decrypt } from "@server/lib/crypto"; import { @@ -54,6 +54,7 @@ type TokenPayload = { type: LicenseKeyType; tier: LicenseKeyTier; quantity: number; + quantity_2: number; terminateAt: string; // ISO iat: number; // Issued at }; @@ -140,10 +141,20 @@ LQIDAQAB }; } + // Count used sites and users for license comparison + const [siteCountRes] = await db + .select({ value: count() }) + .from(sites); + const [userCountRes] = await db + .select({ value: count() }) + .from(users); + const status: LicenseStatus = { hostId: this.hostMeta.hostMetaId, isHostLicensed: true, - isLicenseValid: false + isLicenseValid: false, + usedSites: siteCountRes?.value ?? 0, + usedUsers: userCountRes?.value ?? 0 }; this.checkInProgress = true; @@ -151,6 +162,8 @@ LQIDAQAB try { if (!this.doRecheck && this.statusCache.has(this.statusKey)) { const res = this.statusCache.get("status") as LicenseStatus; + res.usedSites = status.usedSites; + res.usedUsers = status.usedUsers; return res; } logger.debug("Checking license status..."); @@ -193,7 +206,9 @@ LQIDAQAB type: payload.type, tier: payload.tier, iat: new Date(payload.iat * 1000), - terminateAt: new Date(payload.terminateAt) + terminateAt: new Date(payload.terminateAt), + quantity: payload.quantity, + quantity_2: payload.quantity_2 }); if (payload.type === "host") { @@ -292,6 +307,8 @@ LQIDAQAB cached.tier = payload.tier; cached.iat = new Date(payload.iat * 1000); cached.terminateAt = new Date(payload.terminateAt); + cached.quantity = payload.quantity; + cached.quantity_2 = payload.quantity_2; // Encrypt the updated token before storing const encryptedKey = encrypt( @@ -317,7 +334,7 @@ LQIDAQAB } } - // Compute host status + // Compute host status: quantity = users, quantity_2 = sites for (const key of keys) { const cached = newCache.get(key.licenseKey)!; @@ -329,6 +346,28 @@ LQIDAQAB if (!cached.valid) { continue; } + + // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) + if ( + cached.quantity_2 !== undefined && + cached.quantity_2 >= 0 + ) { + status.maxSites = + (status.maxSites ?? 0) + cached.quantity_2; + } + if (cached.quantity !== undefined && cached.quantity >= 0) { + status.maxUsers = (status.maxUsers ?? 0) + cached.quantity; + } + } + + // Invalidate license if over user or site limits + if ( + (status.maxSites !== undefined && + (status.usedSites ?? 0) > status.maxSites) || + (status.maxUsers !== undefined && + (status.usedUsers ?? 0) > status.maxUsers) + ) { + status.isLicenseValid = false; } // Invalidate old cache and set new cache @@ -502,7 +541,7 @@ LQIDAQAB // Calculate exponential backoff delay const retryDelay = Math.floor( initialRetryDelay * - Math.pow(exponentialFactor, attempt - 1) + Math.pow(exponentialFactor, attempt - 1) ); logger.debug( diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSessionSAAS.ts similarity index 96% rename from server/private/routers/billing/createCheckoutSession.ts rename to server/private/routers/billing/createCheckoutSessionSAAS.ts index a2d8080f..0f9b783e 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSessionSAAS.ts @@ -29,7 +29,7 @@ const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() }); -export async function createCheckoutSession( +export async function createCheckoutSessionSAAS( req: Request, res: Response, next: NextFunction @@ -87,7 +87,7 @@ export async function createCheckoutSession( data: session.url, success: true, error: false, - message: "Organization created successfully", + message: "Checkout session created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/private/routers/billing/getOrgSubscription.ts b/server/private/routers/billing/getOrgSubscriptions.ts similarity index 75% rename from server/private/routers/billing/getOrgSubscription.ts rename to server/private/routers/billing/getOrgSubscriptions.ts index e1f8316e..40b029e4 100644 --- a/server/private/routers/billing/getOrgSubscription.ts +++ b/server/private/routers/billing/getOrgSubscriptions.ts @@ -37,18 +37,7 @@ const getOrgSchema = z.strictObject({ orgId: z.string() }); -registry.registerPath({ - method: "get", - path: "/org/{orgId}/billing/subscription", - description: "Get an organization", - tags: [OpenAPITags.Org], - request: { - params: getOrgSchema - }, - responses: {} -}); - -export async function getOrgSubscription( +export async function getOrgSubscriptions( req: Request, res: Response, next: NextFunction @@ -66,12 +55,9 @@ export async function getOrgSubscription( const { orgId } = parsedParams.data; - let subscriptionData = null; - let itemsData: SubscriptionItem[] = []; + let subscriptions = null; try { - const { subscription, items } = await getOrgSubscriptionData(orgId); - subscriptionData = subscription; - itemsData = items; + subscriptions = await getOrgSubscriptionsData(orgId); } catch (err) { if ((err as Error).message === "Not found") { return next( @@ -86,8 +72,7 @@ export async function getOrgSubscription( return response(res, { data: { - subscription: subscriptionData, - items: itemsData + subscriptions }, success: true, error: false, @@ -102,9 +87,9 @@ export async function getOrgSubscription( } } -export async function getOrgSubscriptionData( +export async function getOrgSubscriptionsData( orgId: string -): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> { +): Promise> { const org = await db .select() .from(orgs) @@ -122,21 +107,21 @@ export async function getOrgSubscriptionData( .where(eq(customers.orgId, orgId)) .limit(1); - let subscription = null; - let items: SubscriptionItem[] = []; + const subscriptionsWithItems: Array<{ + subscription: Subscription; + items: SubscriptionItem[]; + }> = []; if (customer.length > 0) { - // Get subscription for customer + // Get all subscriptions for customer const subs = await db .select() .from(subscriptions) - .where(eq(subscriptions.customerId, customer[0].customerId)) - .limit(1); + .where(eq(subscriptions.customerId, customer[0].customerId)); - if (subs.length > 0) { - subscription = subs[0]; - // Get subscription items - items = await db + for (const subscription of subs) { + // Get subscription items for each subscription + const items = await db .select() .from(subscriptionItems) .where( @@ -145,8 +130,13 @@ export async function getOrgSubscriptionData( subscription.subscriptionId ) ); + + subscriptionsWithItems.push({ + subscription, + items + }); } } - return { subscription, items }; + return subscriptionsWithItems; } diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts new file mode 100644 index 00000000..8cd07713 --- /dev/null +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -0,0 +1,35 @@ +import { + getLicensePriceSet, +} from "@server/lib/billing/licenses"; +import { + getTierPriceSet, +} from "@server/lib/billing/tiers"; +import Stripe from "stripe"; + +export function getSubType(fullSubscription: Stripe.Response): "saas" | "license" { + // Determine subscription type by checking subscription items + let type: "saas" | "license" = "saas"; + if (Array.isArray(fullSubscription.items?.data)) { + for (const item of fullSubscription.items.data) { + const priceId = item.price.id; + + // Check if price ID matches any license price + const licensePrices = Object.values(getLicensePriceSet()); + + if (licensePrices.includes(priceId)) { + type = "license"; + break; + } + + // Check if price ID matches any tier price (saas) + const tierPrices = Object.values(getTierPriceSet()); + + if (tierPrices.includes(priceId)) { + type = "saas"; + break; + } + } + } + + return type; +} diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 223a2545..a51f825f 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -25,6 +25,12 @@ import logger from "@server/logger"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; +import { getSubType } from "./getSubType"; +import privateConfig from "#private/lib/config"; +import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; +import { sendEmail } from "@server/emails"; +import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated"; +import config from "@server/lib/config"; export async function handleSubscriptionCreated( subscription: Stripe.Subscription @@ -123,24 +129,142 @@ export async function handleSubscriptionCreated( return; } - await handleSubscriptionLifesycle(customer.orgId, subscription.status); + const type = getSubType(fullSubscription); + if (type === "saas") { + logger.debug( + `Handling SAAS subscription lifecycle for org ${customer.orgId}` + ); + // we only need to handle the limit lifecycle for saas subscriptions not for the licenses + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status + ); - const [orgUserRes] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, customer.orgId), - eq(userOrgs.isOwner, true) + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) ) - ) - .innerJoin(users, eq(userOrgs.userId, users.userId)); + .innerJoin(users, eq(userOrgs.userId, users.userId)); - if (orgUserRes) { - const email = orgUserRes.user.email; + if (orgUserRes) { + const email = orgUserRes.user.email; - if (email) { - moveEmailToAudience(email, AudienceIds.Subscribed); + if (email) { + moveEmailToAudience(email, AudienceIds.Subscribed); + } + } + } else if (type === "license") { + logger.debug( + `License subscription created for org ${customer.orgId}, no lifecycle handling needed.` + ); + + // Retrieve the client_reference_id from the checkout session + let licenseId: string | null = null; + + try { + const sessions = await stripe!.checkout.sessions.list({ + subscription: subscription.id, + limit: 1 + }); + if (sessions.data.length > 0) { + licenseId = sessions.data[0].client_reference_id || null; + } + + if (!licenseId) { + logger.error( + `No client_reference_id found for subscription ${subscription.id}` + ); + return; + } + + logger.debug( + `Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}` + ); + + // Determine users and sites based on license type + const priceSet = getLicensePriceSet(); + const subscriptionPriceId = + fullSubscription.items.data[0]?.price.id; + + let numUsers: number; + let numSites: number; + + if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) { + numUsers = 25; + numSites = 25; + } else if ( + subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] + ) { + numUsers = 50; + numSites = 50; + } else { + logger.error( + `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` + ); + return; + } + + logger.debug( + `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` + ); + + const response = await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseId: parseInt(licenseId), + paidFor: true, + users: numUsers, + sites: numSites + }) + } + ); + + const data = await response.json(); + + logger.debug(`Fossorial API response: ${JSON.stringify(data)}`); + + if (customer.email) { + logger.debug( + `Sending license key email to ${customer.email} for subscription ${subscription.id}` + ); + await sendEmail( + EnterpriseEditionKeyGenerated({ + keyValue: data.data.licenseKey, + personalUseOnly: false, + users: numUsers, + sites: numSites, + modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing` + }), + { + to: customer.email, + from: config.getNoReplyEmail(), + subject: + "Your Enterprise Edition license key is ready" + } + ); + } else { + logger.error( + `No email found for customer ${customer.customerId} to send license key.` + ); + } + + return data; + } catch (error) { + console.error("Error creating new license:", error); + throw error; } } } catch (error) { diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 7a7d9149..003110aa 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -24,11 +24,22 @@ import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; +import { getSubType } from "./getSubType"; +import stripe from "#private/lib/stripe"; +import privateConfig from "#private/lib/config"; export async function handleSubscriptionDeleted( subscription: Stripe.Subscription ): Promise { try { + // Fetch the subscription from Stripe with expanded price.tiers + const fullSubscription = await stripe!.subscriptions.retrieve( + subscription.id, + { + expand: ["items.data.price.tiers"] + } + ); + const [existingSubscription] = await db .select() .from(subscriptions) @@ -64,24 +75,62 @@ export async function handleSubscriptionDeleted( return; } - await handleSubscriptionLifesycle(customer.orgId, subscription.status); + const type = getSubType(fullSubscription); + if (type === "saas") { + logger.debug( + `Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` + ); - const [orgUserRes] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, customer.orgId), - eq(userOrgs.isOwner, true) + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status + ); + + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) ) - ) - .innerJoin(users, eq(userOrgs.userId, users.userId)); + .innerJoin(users, eq(userOrgs.userId, users.userId)); - if (orgUserRes) { - const email = orgUserRes.user.email; + if (orgUserRes) { + const email = orgUserRes.user.email; - if (email) { - moveEmailToAudience(email, AudienceIds.Churned); + if (email) { + moveEmailToAudience(email, AudienceIds.Churned); + } + } + } else if (type === "license") { + logger.debug( + `Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` + ); + try { + // WARNING: + // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId + await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + orgId: customer.orgId, + }) + } + ); + } catch (error) { + logger.error( + `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, + error + ); } } } catch (error) { diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 01086054..21943354 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -26,6 +26,8 @@ import logger from "@server/logger"; import { getFeatureIdByMetricId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; +import { getSubType } from "./getSubType"; +import privateConfig from "#private/lib/config"; export async function handleSubscriptionUpdated( subscription: Stripe.Subscription, @@ -56,7 +58,7 @@ export async function handleSubscriptionUpdated( } // get the customer - const [existingCustomer] = await db + const [customer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) @@ -74,11 +76,6 @@ export async function handleSubscriptionUpdated( }) .where(eq(subscriptions.subscriptionId, subscription.id)); - await handleSubscriptionLifesycle( - existingCustomer.orgId, - subscription.status - ); - // Upsert subscription items if (Array.isArray(fullSubscription.items?.data)) { const itemsToUpsert = fullSubscription.items.data.map((item) => ({ @@ -141,20 +138,20 @@ export async function handleSubscriptionUpdated( // This item has cycled const meterId = item.plan.meter; if (!meterId) { - logger.warn( + logger.debug( `No meterId found for subscription item ${item.id}. Skipping usage reset.` ); continue; } const featureId = getFeatureIdByMetricId(meterId); if (!featureId) { - logger.warn( + logger.debug( `No featureId found for meterId ${meterId}. Skipping usage reset.` ); continue; } - const orgId = existingCustomer.orgId; + const orgId = customer.orgId; if (!orgId) { logger.warn( @@ -236,6 +233,45 @@ export async function handleSubscriptionUpdated( } } // --- end usage update --- + + const type = getSubType(fullSubscription); + if (type === "saas") { + logger.debug( + `Handling SAAS subscription lifecycle for org ${customer.orgId}` + ); + // we only need to handle the limit lifecycle for saas subscriptions not for the licenses + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status + ); + } else { + if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { + try { + // WARNING: + // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId + await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig() + .server.fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + orgId: customer.orgId + }) + } + ); + } catch (error) { + logger.error( + `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, + error + ); + } + } + } } } catch (error) { logger.error( diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index 59fce8d6..e7770ec2 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -11,8 +11,8 @@ * This file is not licensed under the AGPLv3. */ -export * from "./createCheckoutSession"; +export * from "./createCheckoutSessionSAAS"; export * from "./createPortalSession"; -export * from "./getOrgSubscription"; +export * from "./getOrgSubscriptions"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index cf6e58bc..ddc2afe0 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -159,11 +159,11 @@ if (build === "saas") { ); authenticated.post( - "/org/:orgId/billing/create-checkout-session", + "/org/:orgId/billing/create-checkout-session-saas", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), - billing.createCheckoutSession + billing.createCheckoutSessionSAAS ); authenticated.post( @@ -175,10 +175,10 @@ if (build === "saas") { ); authenticated.get( - "/org/:orgId/billing/subscription", + "/org/:orgId/billing/subscriptions", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.getOrgSubscription + billing.getOrgSubscriptions ); authenticated.get( @@ -200,6 +200,14 @@ if (build === "saas") { generateLicense.generateNewLicense ); + authenticated.put( + "/org/:orgId/license/enterprise", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + generateLicense.generateNewEnterpriseLicense + ); + authenticated.post( "/send-support-request", rateLimit({ diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts new file mode 100644 index 00000000..7cffb9d7 --- /dev/null +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -0,0 +1,149 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import privateConfig from "#private/lib/config"; +import { createNewLicense } from "./generateNewLicense"; +import config from "@server/lib/config"; +import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; +import stripe from "#private/lib/stripe"; +import { customers, db } from "@server/db"; +import { fromError } from "zod-validation-error"; +import z from "zod"; +import { eq } from "drizzle-orm"; +import { log } from "winston"; + +const generateNewEnterpriseLicenseParamsSchema = z.strictObject({ + orgId: z.string() +}); + +export async function generateNewEnterpriseLicense( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + + const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (!orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization ID is required" + ) + ); + } + + logger.debug(`Generating new license for orgId: ${orgId}`); + + const licenseData = req.body; + + if (licenseData.tier != "big_license" && licenseData.tier != "small_license") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid tier specified. Must be either 'big_license' or 'small_license'." + ) + ); + } + + const apiResponse = await createNewLicense(orgId, licenseData); + + // Check if the API call was successful + if (!apiResponse.success || apiResponse.error) { + return next( + createHttpError( + apiResponse.status || HttpCode.BAD_REQUEST, + apiResponse.message || "Failed to create license from Fossorial API" + ) + ); + } + + const keyId = apiResponse?.data?.licenseKey?.id; + if (!keyId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Fossorial API did not return a valid license key ID" + ) + ); + } + + // check if we already have a customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + // If we don't have a customer, create one + if (!customer) { + // error + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } + + const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; + const tierPrice = getLicensePriceSet()[tier] + + const session = await stripe!.checkout.sessions.create({ + client_reference_id: keyId.toString(), + billing_address_collection: "required", + line_items: [ + { + price: tierPrice, // Use the standard tier + quantity: 1 + }, + ], // Start with the standard feature set that matches the free limits + customer: customer.customerId, + mode: "subscription", + success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` + }); + + return sendResponse(res, { + data: session.url, + success: true, + error: false, + message: "License and checkout session created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while generating new license." + ) + ); + } +} diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index 2c0c4420..9835f40a 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; -async function createNewLicense(orgId: string, licenseData: any): Promise { +export interface CreateNewLicenseResponse { + data: Data + success: boolean + error: boolean + message: string + status: number +} + +export interface Data { + licenseKey: LicenseKey +} + +export interface LicenseKey { + id: number + instanceName: any + instanceId: string + licenseKey: string + tier: string + type: string + quantity: number + quantity_2: number + isValid: boolean + updatedAt: string + createdAt: string + expiresAt: string + paidFor: boolean + orgId: string + metadata: string +} + +export async function createNewLicense(orgId: string, licenseData: any): Promise { try { const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`, + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both { method: "PUT", headers: { @@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise { } ); - const data = await response.json(); + const data: CreateNewLicenseResponse = await response.json(); - logger.debug("Fossorial API response:", { data }); return data; } catch (error) { console.error("Error creating new license:", error); diff --git a/server/private/routers/generatedLicense/index.ts b/server/private/routers/generatedLicense/index.ts index 83d88634..70b9b001 100644 --- a/server/private/routers/generatedLicense/index.ts +++ b/server/private/routers/generatedLicense/index.ts @@ -13,3 +13,4 @@ export * from "./listGeneratedLicenses"; export * from "./generateNewLicense"; +export * from "./generateNewEnterpriseLicense"; diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index fb54c763..cb930882 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -25,7 +25,7 @@ import { async function fetchLicenseKeys(orgId: string): Promise { try { const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`, + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`, { method: "GET", headers: { diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index a398dfe6..0e5d1ec2 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -186,7 +186,7 @@ export type ResourceWithAuth = { password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; - org: Org + org: Org; }; export type UserSessionWithUser = { @@ -270,7 +270,6 @@ hybridRouter.get( } ); -let encryptionKeyPath = ""; let encryptionKeyHex = ""; let encryptionKey: Buffer; function loadEncryptData() { @@ -278,16 +277,8 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = - privateConfig.getRawPrivateConfig().server.encryption_key_path; - - if (!fs.existsSync(encryptionKeyPath)) { - throw new Error( - "Encryption key file not found. Please generate one first." - ); - } - - encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim(); + encryptionKeyHex = + privateConfig.getRawPrivateConfig().server.encryption_key; encryptionKey = Buffer.from(encryptionKeyHex, "hex"); } diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 026ee4bb..5975d8f3 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -1,6 +1,6 @@ import { db, orgs, requestAuditLog } from "@server/db"; import logger from "@server/logger"; -import { and, eq, lt } from "drizzle-orm"; +import { and, eq, lt, sql } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; @@ -67,17 +67,27 @@ async function flushAuditLogs() { const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); try { - // Batch insert logs in groups of 25 to avoid overwhelming the database - const BATCH_DB_SIZE = 25; - for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { - const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); - await db.insert(requestAuditLog).values(batch); - } + // Use a transaction to ensure all inserts succeed or fail together + // This prevents index corruption from partial writes + await db.transaction(async (tx) => { + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await tx.insert(requestAuditLog).values(batch); + } + }); logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); } catch (error) { logger.error("Error flushing audit logs:", error); - // On error, we lose these logs - consider a fallback strategy if needed - // (e.g., write to file, or put back in buffer with retry limit) + // On transaction error, put logs back at the front of the buffer to retry + // but only if buffer isn't too large + if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { + auditLogBuffer.unshift(...logsToWrite); + logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); + } else { + logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); + } } finally { isFlushInProgress = false; // If buffer filled up while we were flushing, flush again diff --git a/server/routers/billing/types.ts b/server/routers/billing/types.ts index 4e0aab52..f29b7e14 100644 --- a/server/routers/billing/types.ts +++ b/server/routers/billing/types.ts @@ -1,8 +1,7 @@ import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db"; export type GetOrgSubscriptionResponse = { - subscription: Subscription | null; - items: SubscriptionItem[]; + subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>; }; export type GetOrgUsageResponse = { diff --git a/server/routers/generatedLicense/types.ts b/server/routers/generatedLicense/types.ts index 76e86265..d78f2332 100644 --- a/server/routers/generatedLicense/types.ts +++ b/server/routers/generatedLicense/types.ts @@ -6,6 +6,8 @@ export type GeneratedLicenseKey = { createdAt: string; tier: string; type: string; + users: number; + sites: number; }; export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[]; @@ -19,6 +21,7 @@ export type NewLicenseKey = { tier: string; type: string; quantity: number; + quantity_2: number; isValid: boolean; updatedAt: string; createdAt: string; diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index c307efcb..3d4b6054 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -18,6 +18,7 @@ import { build } from "@server/build"; import OrgPolicyResult from "@app/components/OrgPolicyResult"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; +import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -70,6 +71,7 @@ export default async function OrgLayout(props: { } catch (e) {} return ( + internal.get>( - `/org/${orgId}/billing/subscription`, + `/org/${orgId}/billing/subscriptions`, cookie ) ); @@ -104,6 +106,7 @@ export default async function OrgLayout(props: { env={env.app.environment} sandbox_mode={env.app.sandbox_mode} > + {props.children} diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 1ed5c094..e1879aa6 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -43,15 +43,18 @@ import Link from "next/link"; export default function GeneralPage() { const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); + const envContext = useEnvContext(); + const api = createApiClient(envContext); const t = useTranslations(); - // Subscription state - const [subscription, setSubscription] = - useState(null); - const [subscriptionItems, setSubscriptionItems] = useState< - GetOrgSubscriptionResponse["items"] + // Subscription state - now handling multiple subscriptions + const [allSubscriptions, setAllSubscriptions] = useState< + GetOrgSubscriptionResponse["subscriptions"] >([]); + const [tierSubscription, setTierSubscription] = + useState(null); + const [licenseSubscription, setLicenseSubscription] = + useState(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); // Example usage data (replace with real usage data if available) @@ -68,12 +71,41 @@ export default function GeneralPage() { try { const res = await api.get< AxiosResponse - >(`/org/${org.org.orgId}/billing/subscription`); - const { subscription, items } = res.data.data; - setSubscription(subscription); - setSubscriptionItems(items); + >(`/org/${org.org.orgId}/billing/subscriptions`); + const { subscriptions } = res.data.data; + setAllSubscriptions(subscriptions); + + // Import tier and license price sets + const { getTierPriceSet } = await import("@server/lib/billing/tiers"); + const { getLicensePriceSet } = await import("@server/lib/billing/licenses"); + + const tierPriceSet = getTierPriceSet( + envContext.env.app.environment, + envContext.env.app.sandbox_mode + ); + const licensePriceSet = getLicensePriceSet( + envContext.env.app.environment, + envContext.env.app.sandbox_mode + ); + + // Find tier subscription (subscription with items matching tier prices) + const tierSub = subscriptions.find(({ items }) => + items.some((item) => + item.priceId && Object.values(tierPriceSet).includes(item.priceId) + ) + ); + setTierSubscription(tierSub || null); + + // Find license subscription (subscription with items matching license prices) + const licenseSub = subscriptions.find(({ items }) => + items.some((item) => + item.priceId && Object.values(licensePriceSet).includes(item.priceId) + ) + ); + setLicenseSubscription(licenseSub || null); + setHasSubscription( - !!subscription && subscription.status === "active" + !!tierSub?.subscription && tierSub.subscription.status === "active" ); } catch (error) { toast({ @@ -121,7 +153,7 @@ export default function GeneralPage() { setIsLoading(true); try { const response = await api.post>( - `/org/${org.org.orgId}/billing/create-checkout-session`, + `/org/${org.org.orgId}/billing/create-checkout-session-saas`, {} ); console.log("Checkout session response:", response.data); @@ -302,6 +334,10 @@ export default function GeneralPage() { return { usage: usage ?? 0, item, limit }; } + // Get tier subscription items + const tierSubscriptionItems = tierSubscription?.items || []; + const tierSubscriptionData = tierSubscription?.subscription || null; + // Helper to check if usage exceeds limit function isOverLimit(usage: any, limit: any, usageType: any) { if (!limit || !usage) return false; @@ -388,15 +424,15 @@ export default function GeneralPage() {
- {subscription?.status === "active" && ( + {tierSubscriptionData?.status === "active" && ( )} - {subscription - ? subscription.status.charAt(0).toUpperCase() + - subscription.status.slice(1) + {tierSubscriptionData + ? tierSubscriptionData.status.charAt(0).toUpperCase() + + tierSubscriptionData.status.slice(1) : t("billingFreeTier")} { const { usage, limit } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -441,7 +477,7 @@ export default function GeneralPage() { {usageTypes.map((type) => { const { usage, limit } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -530,7 +566,7 @@ export default function GeneralPage() { {usageTypes.map((type) => { const { item, limit } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -614,7 +650,7 @@ export default function GeneralPage() { const { usage, item } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -636,7 +672,7 @@ export default function GeneralPage() { ); })} {/* Show recurring charges (items with unitAmount but no tiers/meterId) */} - {subscriptionItems + {tierSubscriptionItems .filter( (item) => item.unitAmount && @@ -672,7 +708,7 @@ export default function GeneralPage() { const { usage, item } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -687,7 +723,7 @@ export default function GeneralPage() { return sum + cost; }, 0) + // Add recurring charges - subscriptionItems + tierSubscriptionItems .filter( (item) => item.unitAmount && @@ -749,6 +785,56 @@ export default function GeneralPage() { )} + + {/* License Keys Section */} + {licenseSubscription && ( + + + + {t("billingLicenseKeys") || "License Keys"} + + + {t("billingLicenseKeysDescription") || "Manage your license key subscriptions"} + + + +
+
+ + + {t("billingLicenseSubscription") || "License Subscription"} + +
+ + {licenseSubscription.subscription?.status === "active" && ( + + )} + {licenseSubscription.subscription?.status + ? licenseSubscription.subscription.status + .charAt(0) + .toUpperCase() + + licenseSubscription.subscription.status.slice(1) + : t("billingInactive") || "Inactive"} + +
+ + + +
+
+ )} ); } diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx index 9b6b2bd2..7d2ed4e3 100644 --- a/src/app/auth/login/device/page.tsx +++ b/src/app/auth/login/device/page.tsx @@ -7,22 +7,35 @@ import { cache } from "react"; export const dynamic = "force-dynamic"; type Props = { - searchParams: Promise<{ code?: string }>; + searchParams: Promise<{ code?: string; user?: string }>; }; +function deviceRedirectSearchParams(params: { + code?: string; + user?: string; +}): string { + const search = new URLSearchParams(); + if (params.code) search.set("code", params.code); + if (params.user) search.set("user", params.user); + const q = search.toString(); + return q ? `?${q}` : ""; +} + export default async function DeviceLoginPage({ searchParams }: Props) { const user = await verifySession({ forceLogin: true }); const params = await searchParams; const code = params.code || ""; + const defaultUser = params.user; if (!user) { - const redirectDestination = code - ? `/auth/login/device?code=${encodeURIComponent(code)}` - : "/auth/login/device"; - redirect( - `/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}` - ); + const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`; + const loginUrl = new URL("/auth/login", "http://x"); + loginUrl.searchParams.set("forceLogin", "true"); + loginUrl.searchParams.set("redirect", redirectDestination); + if (defaultUser) loginUrl.searchParams.set("user", defaultUser); + console.log("loginUrl", loginUrl.pathname + loginUrl.search); + redirect(loginUrl.pathname + loginUrl.search); } const userName = user @@ -37,6 +50,7 @@ export default async function DeviceLoginPage({ searchParams }: Props) { userEmail={user?.email || ""} userName={userName} initialCode={code} + userQueryParam={defaultUser} /> ); } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 071020cd..2ba4d7f8 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -72,6 +72,8 @@ export default async function Page(props: { searchParams.redirect = redirectUrl; } + const defaultUser = searchParams.user as string | undefined; + // Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled) const useSmartLogin = build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp); @@ -151,6 +153,7 @@ export default async function Page(props: { @@ -165,6 +168,7 @@ export default async function Page(props: { (build === "saas" || env.flags.useOrgOnlyIdp) } searchParams={searchParams} + defaultUser={defaultUser} /> )} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 203dd778..ed7635e3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,6 +23,7 @@ import Script from "next/script"; import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; +import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -79,6 +80,7 @@ export default async function RootLayout({ return ( + {build === "saas" && (