mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-27 03:02:30 +00:00
Compare commits
4 Commits
1.15.2
...
6cfc7b7c69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cfc7b7c69 | ||
|
|
34cced872f | ||
|
|
ac09e3aaf9 | ||
|
|
f899326189 |
@@ -1404,7 +1404,7 @@
|
|||||||
"billingUsageLimitsOverview": "Usage Limits Overview",
|
"billingUsageLimitsOverview": "Usage Limits Overview",
|
||||||
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
|
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
|
||||||
"billingDataUsage": "Data Usage",
|
"billingDataUsage": "Data Usage",
|
||||||
"billingOnlineTime": "Site Online Time",
|
"billingSites": "Sites",
|
||||||
"billingUsers": "Active Users",
|
"billingUsers": "Active Users",
|
||||||
"billingDomains": "Active Domains",
|
"billingDomains": "Active Domains",
|
||||||
"billingRemoteExitNodes": "Active Self-hosted Nodes",
|
"billingRemoteExitNodes": "Active Self-hosted Nodes",
|
||||||
@@ -1432,10 +1432,10 @@
|
|||||||
"billingFailedToGetPortalUrl": "Failed to get portal URL",
|
"billingFailedToGetPortalUrl": "Failed to get portal URL",
|
||||||
"billingPortalError": "Portal Error",
|
"billingPortalError": "Portal Error",
|
||||||
"billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.",
|
"billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.",
|
||||||
"billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.",
|
"billingSInfo": "How many sites you can use",
|
||||||
"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.",
|
"billingUsersInfo": "How many users you can use",
|
||||||
"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.",
|
"billingDomainInfo": "How many domains you can use",
|
||||||
"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.",
|
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
||||||
"billingLicenseKeys": "License Keys",
|
"billingLicenseKeys": "License Keys",
|
||||||
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
||||||
"billingLicenseSubscription": "License Subscription",
|
"billingLicenseSubscription": "License Subscription",
|
||||||
@@ -1444,7 +1444,6 @@
|
|||||||
"billingQuantity": "Quantity",
|
"billingQuantity": "Quantity",
|
||||||
"billingTotal": "total",
|
"billingTotal": "total",
|
||||||
"billingModifyLicenses": "Modify License Subscription",
|
"billingModifyLicenses": "Modify License Subscription",
|
||||||
"billingPricingCalculatorLink": "View Pricing Calculator",
|
|
||||||
"domainNotFound": "Domain Not Found",
|
"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.",
|
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ export const subscriptions = pgTable("subscriptions", {
|
|||||||
canceledAt: bigint("canceledAt", { mode: "number" }),
|
canceledAt: bigint("canceledAt", { mode: "number" }),
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
updatedAt: bigint("updatedAt", { mode: "number" }),
|
updatedAt: bigint("updatedAt", { mode: "number" }),
|
||||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" })
|
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||||
|
type: varchar("type", { length: 50 }) // home_lab, starter, scale, or license
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
canceledAt: integer("canceledAt"),
|
canceledAt: integer("canceledAt"),
|
||||||
createdAt: integer("createdAt").notNull(),
|
createdAt: integer("createdAt").notNull(),
|
||||||
updatedAt: integer("updatedAt"),
|
updatedAt: integer("updatedAt"),
|
||||||
billingCycleAnchor: integer("billingCycleAnchor")
|
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||||
|
type: text("type") // home_lab, starter, scale, or license
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
export enum FeatureId {
|
export enum FeatureId {
|
||||||
SITE_UPTIME = "siteUptime",
|
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
|
SITES = "sites",
|
||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes"
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
|
HOME_LAB = "home_lab"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeatureMeterIds: Record<FeatureId, string> = {
|
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
|
||||||
[FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU",
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
||||||
[FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW",
|
|
||||||
[FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureMeterIdsSandbox: Record<FeatureId, string> = {
|
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
|
||||||
[FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu",
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
||||||
[FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ",
|
|
||||||
[FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFeatureMeterId(featureId: FeatureId): string {
|
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
@@ -43,38 +36,62 @@ export function getFeatureIdByMetricId(
|
|||||||
)?.[0];
|
)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeaturePriceSet = {
|
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
||||||
[key in Exclude<FeatureId, FeatureId.DOMAINS>]: string;
|
|
||||||
} & {
|
export const homeLabFeaturePriceSet: FeaturePriceSet = {
|
||||||
[FeatureId.DOMAINS]?: string; // Optional since domains are not billed
|
[FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const standardFeaturePriceSet: FeaturePriceSet = {
|
export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
// Free tier matches the freeLimitSet
|
[FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
[FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF",
|
|
||||||
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk",
|
|
||||||
// [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const standardFeaturePriceSetSandbox: FeaturePriceSet = {
|
export function getHomeLabFeaturePriceSet(): FeaturePriceSet {
|
||||||
// Free tier matches the freeLimitSet
|
|
||||||
[FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU",
|
|
||||||
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0",
|
|
||||||
// [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getStandardFeaturePriceSet(): FeaturePriceSet {
|
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
) {
|
) {
|
||||||
return standardFeaturePriceSet;
|
return homeLabFeaturePriceSet;
|
||||||
} else {
|
} else {
|
||||||
return standardFeaturePriceSetSandbox;
|
return homeLabFeaturePriceSetSandbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const starterFeaturePriceSet: FeaturePriceSet = {
|
||||||
|
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const starterFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
|
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStarterFeaturePriceSet(): FeaturePriceSet {
|
||||||
|
if (
|
||||||
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
|
process.env.SANDBOX_MODE !== "true"
|
||||||
|
) {
|
||||||
|
return starterFeaturePriceSet;
|
||||||
|
} else {
|
||||||
|
return starterFeaturePriceSetSandbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scaleFeaturePriceSet: FeaturePriceSet = {
|
||||||
|
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scaleFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
|
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getScaleFeaturePriceSet(): FeaturePriceSet {
|
||||||
|
if (
|
||||||
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
|
process.env.SANDBOX_MODE !== "true"
|
||||||
|
) {
|
||||||
|
return scaleFeaturePriceSet;
|
||||||
|
} else {
|
||||||
|
return scaleFeaturePriceSetSandbox;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { FeatureId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
|
|
||||||
export type LimitSet = {
|
export type LimitSet = Partial<{
|
||||||
[key in FeatureId]: {
|
[key in FeatureId]: {
|
||||||
value: number | null; // null indicates no limit
|
value: number | null; // null indicates no limit
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export const sandboxLimitSet: LimitSet = {
|
export const sandboxLimitSet: LimitSet = {
|
||||||
[FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days
|
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days
|
||||||
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
|
[FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
|
||||||
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
||||||
@@ -16,7 +16,7 @@ export const sandboxLimitSet: LimitSet = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days
|
[FeatureId.SITES]: { value: 3, description: "Free tier limit" }, // 1 site up for 32 days
|
||||||
[FeatureId.USERS]: { value: 3, description: "Free tier limit" },
|
[FeatureId.USERS]: { value: 3, description: "Free tier limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
[FeatureId.EGRESS_DATA_MB]: {
|
||||||
value: 25000,
|
value: 25000,
|
||||||
@@ -26,25 +26,59 @@ export const freeLimitSet: LimitSet = {
|
|||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subscribedLimitSet: LimitSet = {
|
export const homeLabLimitSet: LimitSet = {
|
||||||
[FeatureId.SITE_UPTIME]: {
|
[FeatureId.SITES]: { value: 3, description: "Home lab limit" }, // 1 site up for 32 days
|
||||||
value: 2232000,
|
[FeatureId.USERS]: { value: 3, description: "Home lab limit" },
|
||||||
description: "Contact us to increase soft limit."
|
[FeatureId.EGRESS_DATA_MB]: {
|
||||||
|
value: 25000,
|
||||||
|
description: "Home lab limit"
|
||||||
|
}, // 25 GB
|
||||||
|
[FeatureId.DOMAINS]: { value: 3, description: "Home lab limit" },
|
||||||
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const starterLimitSet: LimitSet = {
|
||||||
|
[FeatureId.SITES]: {
|
||||||
|
value: 10,
|
||||||
|
description: "Starter limit"
|
||||||
}, // 50 sites up for 31 days
|
}, // 50 sites up for 31 days
|
||||||
[FeatureId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 150,
|
value: 150,
|
||||||
description: "Contact us to increase soft limit."
|
description: "Starter limit"
|
||||||
},
|
},
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
[FeatureId.EGRESS_DATA_MB]: {
|
||||||
value: 12000000,
|
value: 12000000,
|
||||||
description: "Contact us to increase soft limit."
|
description: "Starter limit"
|
||||||
}, // 12000 GB
|
}, // 12000 GB
|
||||||
[FeatureId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Contact us to increase soft limit."
|
description: "Starter limit"
|
||||||
},
|
},
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 5,
|
value: 5,
|
||||||
description: "Contact us to increase soft limit."
|
description: "Starter limit"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scaleLimitSet: LimitSet = {
|
||||||
|
[FeatureId.SITES]: {
|
||||||
|
value: 10,
|
||||||
|
description: "Scale limit"
|
||||||
|
}, // 50 sites up for 31 days
|
||||||
|
[FeatureId.USERS]: {
|
||||||
|
value: 150,
|
||||||
|
description: "Scale limit"
|
||||||
|
},
|
||||||
|
[FeatureId.EGRESS_DATA_MB]: {
|
||||||
|
value: 12000000,
|
||||||
|
description: "Scale limit"
|
||||||
|
}, // 12000 GB
|
||||||
|
[FeatureId.DOMAINS]: {
|
||||||
|
value: 250,
|
||||||
|
description: "Scale limit"
|
||||||
|
},
|
||||||
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
|
value: 5,
|
||||||
|
description: "Scale limit"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
export enum TierId {
|
|
||||||
STANDARD = "standard"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TierPriceSet = {
|
|
||||||
[key in TierId]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tierPriceSet: TierPriceSet = {
|
|
||||||
// Free tier matches the freeLimitSet
|
|
||||||
[TierId.STANDARD]: "price_1RrQ9cD3Ee2Ir7Wmqdy3KBa0"
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tierPriceSetSandbox: TierPriceSet = {
|
|
||||||
// Free tier matches the freeLimitSet
|
|
||||||
// when matching tier the keys closer to 0 index are matched first so list the tiers in descending order of value
|
|
||||||
[TierId.STANDARD]: "price_1RrAYJDCpkOb237By2s1P32m"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getTierPriceSet(
|
|
||||||
environment?: string,
|
|
||||||
sandbox_mode?: boolean
|
|
||||||
): TierPriceSet {
|
|
||||||
if (
|
|
||||||
(process.env.ENVIRONMENT == "prod" &&
|
|
||||||
process.env.SANDBOX_MODE !== "true") ||
|
|
||||||
(environment === "prod" && sandbox_mode !== true)
|
|
||||||
) {
|
|
||||||
// THIS GETS LOADED CLIENT SIDE AND SERVER SIDE
|
|
||||||
return tierPriceSet;
|
|
||||||
} else {
|
|
||||||
return tierPriceSetSandbox;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,46 +11,58 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getTierPriceSet } from "@server/lib/billing/tiers";
|
|
||||||
import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { db, customers, subscriptions } from "@server/db";
|
||||||
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
|
|
||||||
export async function getOrgTierData(
|
export async function getOrgTierData(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<{ tier: string | null; active: boolean }> {
|
): Promise<{ tier: "home_lab" | "starter" | "scale" | null; active: boolean }> {
|
||||||
let tier = null;
|
let tier: "home_lab" | "starter" | "scale" | null = null;
|
||||||
let active = false;
|
let active = false;
|
||||||
|
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
return { tier, active };
|
return { tier, active };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM
|
try {
|
||||||
|
// Get customer for org
|
||||||
|
const [customer] = await db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(eq(customers.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const subscriptionsWithItems = await getOrgSubscriptionsData(orgId);
|
if (customer) {
|
||||||
|
// Query for active subscriptions that are not license type
|
||||||
|
const [subscription] = await db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(subscriptions.customerId, customer.customerId),
|
||||||
|
eq(subscriptions.status, "active"),
|
||||||
|
ne(subscriptions.type, "license")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
for (const { subscription, items } of subscriptionsWithItems) {
|
if (subscription) {
|
||||||
if (items && items.length > 0) {
|
// Validate that subscription.type is one of the expected tier values
|
||||||
const tierPriceSet = getTierPriceSet();
|
if (
|
||||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
subscription.type === "home_lab" ||
|
||||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
subscription.type === "starter" ||
|
||||||
// Check if any subscription item matches this tier's price ID
|
subscription.type === "scale"
|
||||||
const matchingItem = items.find((item) => item.priceId === priceId);
|
) {
|
||||||
if (matchingItem) {
|
tier = subscription.type;
|
||||||
tier = tierId;
|
active = true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
if (subscription && subscription.status === "active") {
|
// If org not found or error occurs, return null tier and inactive
|
||||||
active = true;
|
// This is acceptable behavior as per the function signature
|
||||||
}
|
|
||||||
|
|
||||||
// If we found a tier and active subscription, we can stop
|
|
||||||
if (tier && active) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tier, active };
|
return { tier, active };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db";
|
import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import license from "#private/license/license";
|
import license from "#private/license/license";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import license from "#private/license/license";
|
import license from "#private/license/license";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
|
|
||||||
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
||||||
if (build === "enterprise") {
|
if (build === "enterprise") {
|
||||||
@@ -22,9 +21,9 @@ export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const { tier, active } = await getOrgTierData(orgId);
|
||||||
return tier === TierId.STANDARD;
|
return (tier == "home_lab" || tier == "starter" || tier == "scale") && active;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
24
server/private/lib/isSubscribed.ts
Normal file
24
server/private/lib/isSubscribed.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* 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 { build } from "@server/build";
|
||||||
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
|
|
||||||
|
export async function isSubscribed(orgId: string): Promise<boolean> {
|
||||||
|
if (build === "saas") {
|
||||||
|
const { tier, active } = await getOrgTierData(orgId);
|
||||||
|
return (tier == "home_lab" || tier == "starter" || tier == "scale") && active;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -38,9 +38,8 @@ export async function verifyValidSubscription(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = await getOrgTierData(orgId);
|
const { tier, active } = await getOrgTierData(orgId);
|
||||||
|
if ((tier == "home_lab" || tier == "starter" || tier == "scale") && active) {
|
||||||
if (!tier.active) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import {
|
import {
|
||||||
approvals,
|
approvals,
|
||||||
clients,
|
clients,
|
||||||
@@ -33,6 +31,7 @@ import {
|
|||||||
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
import { isLicensedOrSubscribed } from "@server/private/lib/isLicencedOrSubscribed";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -221,19 +220,6 @@ export async function listApprovals(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const approvalsList = await queryApprovals(
|
const approvalsList = await queryApprovals(
|
||||||
orgId.toString(),
|
orgId.toString(),
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import createHttpError from "http-errors";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
@@ -64,20 +61,6 @@ export async function processPendingApproval(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId, approvalId } = parsedParams.data;
|
const { orgId, approvalId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
const approval = await db
|
const approval = await db
|
||||||
|
|||||||
@@ -13,4 +13,3 @@
|
|||||||
|
|
||||||
export * from "./transferSession";
|
export * from "./transferSession";
|
||||||
export * from "./getSessionTransferToken";
|
export * from "./getSessionTransferToken";
|
||||||
export * from "./quickStart";
|
|
||||||
|
|||||||
@@ -1,585 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 { NextFunction, Request, Response } from "express";
|
|
||||||
import {
|
|
||||||
account,
|
|
||||||
db,
|
|
||||||
domainNamespaces,
|
|
||||||
domains,
|
|
||||||
exitNodes,
|
|
||||||
newts,
|
|
||||||
newtSessions,
|
|
||||||
orgs,
|
|
||||||
passwordResetTokens,
|
|
||||||
Resource,
|
|
||||||
resourcePassword,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
resourceWhitelist,
|
|
||||||
roleResources,
|
|
||||||
roles,
|
|
||||||
roleSites,
|
|
||||||
sites,
|
|
||||||
targetHealthCheck,
|
|
||||||
targets,
|
|
||||||
userResources,
|
|
||||||
userSites
|
|
||||||
} from "@server/db";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { users } from "@server/db";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import { SqliteError } from "better-sqlite3";
|
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
|
||||||
import moment from "moment";
|
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
|
||||||
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
|
|
||||||
import { sendEmail } from "@server/emails";
|
|
||||||
import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart";
|
|
||||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
|
||||||
import { createDate, TimeSpan } from "oslo";
|
|
||||||
import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names";
|
|
||||||
import { pickPort } from "@server/routers/target/helpers";
|
|
||||||
import { addTargets } from "@server/routers/newt/targets";
|
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
|
||||||
import { listExitNodes } from "#private/lib/exitNodes";
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
email: z.email().toLowerCase(),
|
|
||||||
ip: z.string().refine(isTargetValid),
|
|
||||||
method: z.enum(["http", "https"]),
|
|
||||||
port: z.int().min(1).max(65535),
|
|
||||||
pincode: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{6}$/)
|
|
||||||
.optional(),
|
|
||||||
password: z.string().min(4).max(100).optional(),
|
|
||||||
enableWhitelist: z.boolean().optional().default(true),
|
|
||||||
animalId: z.string() // This is actually the secret key for the backend
|
|
||||||
});
|
|
||||||
|
|
||||||
export type QuickStartBody = z.infer<typeof bodySchema>;
|
|
||||||
|
|
||||||
export type QuickStartResponse = {
|
|
||||||
newtId: string;
|
|
||||||
newtSecret: string;
|
|
||||||
resourceUrl: string;
|
|
||||||
completeSignUpLink: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22";
|
|
||||||
|
|
||||||
export async function quickStart(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
email,
|
|
||||||
ip,
|
|
||||||
method,
|
|
||||||
port,
|
|
||||||
pincode,
|
|
||||||
password,
|
|
||||||
enableWhitelist,
|
|
||||||
animalId
|
|
||||||
} = parsedBody.data;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tokenValidation = validateTokenOnApi(animalId);
|
|
||||||
|
|
||||||
if (!tokenValidation.isValid) {
|
|
||||||
logger.warn(
|
|
||||||
`Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}`
|
|
||||||
);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid or expired token"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (animalId === DEMO_UBO_KEY) {
|
|
||||||
if (email !== "mehrdad@getubo.com") {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid email for demo Ubo key"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(users.email, email),
|
|
||||||
eq(users.type, UserType.Internal)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// delete the user if it already exists
|
|
||||||
await db.delete(users).where(eq(users.userId, existing.userId));
|
|
||||||
const orgId = `org_${existing.userId}`;
|
|
||||||
await db.delete(orgs).where(eq(orgs.orgId, orgId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPassword = generateId(15);
|
|
||||||
const passwordHash = await hashPassword(tempPassword);
|
|
||||||
const userId = generateId(15);
|
|
||||||
|
|
||||||
// TODO: see if that user already exists?
|
|
||||||
|
|
||||||
// Create the sandbox user
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(
|
|
||||||
and(eq(users.email, email), eq(users.type, UserType.Internal))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing && existing.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"A user with that email address already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let newtId: string;
|
|
||||||
let secret: string;
|
|
||||||
let fullDomain: string;
|
|
||||||
let resource: Resource;
|
|
||||||
let completeSignUpLink: string;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
await trx.insert(users).values({
|
|
||||||
userId: userId,
|
|
||||||
type: UserType.Internal,
|
|
||||||
username: email,
|
|
||||||
email: email,
|
|
||||||
passwordHash,
|
|
||||||
dateCreated: moment().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// create user"s account
|
|
||||||
await trx.insert(account).values({
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success, error, org } = await createUserAccountOrg(
|
|
||||||
userId,
|
|
||||||
email
|
|
||||||
);
|
|
||||||
if (!success) {
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
throw new Error("Failed to create user account and organization");
|
|
||||||
}
|
|
||||||
if (!org) {
|
|
||||||
throw new Error("Failed to create user account and organization");
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgId = org.orgId;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const token = generateRandomString(
|
|
||||||
8,
|
|
||||||
alphabet("0-9", "A-Z", "a-z")
|
|
||||||
);
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(passwordResetTokens)
|
|
||||||
.where(eq(passwordResetTokens.userId, userId));
|
|
||||||
|
|
||||||
const tokenHash = await hashPassword(token);
|
|
||||||
|
|
||||||
await trx.insert(passwordResetTokens).values({
|
|
||||||
userId: userId,
|
|
||||||
email: email,
|
|
||||||
tokenHash,
|
|
||||||
expiresAt: createDate(new TimeSpan(7, "d")).getTime()
|
|
||||||
});
|
|
||||||
|
|
||||||
// // Create the sandbox newt
|
|
||||||
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
|
||||||
// if (!newClientAddress) {
|
|
||||||
// throw new Error("No available subnet found");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const clientAddress = newClientAddress.split("/")[0];
|
|
||||||
|
|
||||||
newtId = generateId(15);
|
|
||||||
secret = generateId(48);
|
|
||||||
|
|
||||||
// Create the sandbox site
|
|
||||||
const siteNiceId = await getUniqueSiteName(orgId);
|
|
||||||
const siteName = `First Site`;
|
|
||||||
|
|
||||||
// pick a random exit node
|
|
||||||
const exitNodesList = await listExitNodes(orgId);
|
|
||||||
|
|
||||||
// select a random exit node
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
if (!randomExitNode) {
|
|
||||||
throw new Error("No exit nodes available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [newSite] = await trx
|
|
||||||
.insert(sites)
|
|
||||||
.values({
|
|
||||||
orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: siteName,
|
|
||||||
niceId: siteNiceId,
|
|
||||||
// address: clientAddress,
|
|
||||||
type: "newt",
|
|
||||||
dockerSocketEnabled: true
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const siteId = newSite.siteId;
|
|
||||||
|
|
||||||
const adminRole = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (adminRole.length === 0) {
|
|
||||||
throw new Error("Admin role not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(roleSites).values({
|
|
||||||
roleId: adminRole[0].roleId,
|
|
||||||
siteId: newSite.siteId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
|
||||||
// make sure the user can access the site
|
|
||||||
await trx.insert(userSites).values({
|
|
||||||
userId: req.user?.userId!,
|
|
||||||
siteId: newSite.siteId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// add the peer to the exit node
|
|
||||||
const secretHash = await hashPassword(secret!);
|
|
||||||
|
|
||||||
await trx.insert(newts).values({
|
|
||||||
newtId: newtId!,
|
|
||||||
secretHash,
|
|
||||||
siteId: newSite.siteId,
|
|
||||||
dateCreated: moment().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
const [randomNamespace] = await trx
|
|
||||||
.select()
|
|
||||||
.from(domainNamespaces)
|
|
||||||
.orderBy(sql`RANDOM()`)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!randomNamespace) {
|
|
||||||
throw new Error("No domain namespace available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [randomNamespaceDomain] = await trx
|
|
||||||
.select()
|
|
||||||
.from(domains)
|
|
||||||
.where(eq(domains.domainId, randomNamespace.domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!randomNamespaceDomain) {
|
|
||||||
throw new Error("No domain found for the namespace");
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceNiceId = await getUniqueResourceName(orgId);
|
|
||||||
|
|
||||||
// Create sandbox resource
|
|
||||||
const subdomain = `${resourceNiceId}-${generateId(5)}`;
|
|
||||||
fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`;
|
|
||||||
|
|
||||||
const resourceName = `First Resource`;
|
|
||||||
|
|
||||||
const newResource = await trx
|
|
||||||
.insert(resources)
|
|
||||||
.values({
|
|
||||||
niceId: resourceNiceId,
|
|
||||||
fullDomain,
|
|
||||||
domainId: randomNamespaceDomain.domainId,
|
|
||||||
orgId,
|
|
||||||
name: resourceName,
|
|
||||||
subdomain,
|
|
||||||
http: true,
|
|
||||||
protocol: "tcp",
|
|
||||||
ssl: true,
|
|
||||||
sso: false,
|
|
||||||
emailWhitelistEnabled: enableWhitelist
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await trx.insert(roleResources).values({
|
|
||||||
roleId: adminRole[0].roleId,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
|
||||||
// make sure the user can access the resource
|
|
||||||
await trx.insert(userResources).values({
|
|
||||||
userId: req.user?.userId!,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resource = newResource[0];
|
|
||||||
|
|
||||||
// Create the sandbox target
|
|
||||||
const { internalPort, targetIps } = await pickPort(siteId!, trx);
|
|
||||||
|
|
||||||
if (!internalPort) {
|
|
||||||
throw new Error("No available internal port");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTarget = await trx
|
|
||||||
.insert(targets)
|
|
||||||
.values({
|
|
||||||
resourceId: resource.resourceId,
|
|
||||||
siteId: siteId!,
|
|
||||||
internalPort,
|
|
||||||
ip,
|
|
||||||
method,
|
|
||||||
port,
|
|
||||||
enabled: true
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const newHealthcheck = await trx
|
|
||||||
.insert(targetHealthCheck)
|
|
||||||
.values({
|
|
||||||
targetId: newTarget[0].targetId,
|
|
||||||
hcEnabled: false
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// add the new target to the targetIps array
|
|
||||||
targetIps.push(`${ip}/32`);
|
|
||||||
|
|
||||||
const [newt] = await trx
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, siteId!))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
await addTargets(
|
|
||||||
newt.newtId,
|
|
||||||
newTarget,
|
|
||||||
newHealthcheck,
|
|
||||||
resource.protocol
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set resource pincode if provided
|
|
||||||
if (pincode) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePincode)
|
|
||||||
.where(
|
|
||||||
eq(resourcePincode.resourceId, resource!.resourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const pincodeHash = await hashPassword(pincode);
|
|
||||||
|
|
||||||
await trx.insert(resourcePincode).values({
|
|
||||||
resourceId: resource!.resourceId,
|
|
||||||
pincodeHash,
|
|
||||||
digitLength: 6
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set resource password if provided
|
|
||||||
if (password) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePassword)
|
|
||||||
.where(
|
|
||||||
eq(resourcePassword.resourceId, resource!.resourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
await trx.insert(resourcePassword).values({
|
|
||||||
resourceId: resource!.resourceId,
|
|
||||||
passwordHash
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set resource OTP if whitelist is enabled
|
|
||||||
if (enableWhitelist) {
|
|
||||||
await trx.insert(resourceWhitelist).values({
|
|
||||||
email,
|
|
||||||
resourceId: resource!.resourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`;
|
|
||||||
|
|
||||||
// Store token for email outside transaction
|
|
||||||
await sendEmail(
|
|
||||||
WelcomeQuickStart({
|
|
||||||
username: email,
|
|
||||||
link: completeSignUpLink,
|
|
||||||
fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`,
|
|
||||||
resourceMethod: method,
|
|
||||||
resourceHostname: ip,
|
|
||||||
resourcePort: port,
|
|
||||||
resourceUrl: `https://${fullDomain}`,
|
|
||||||
cliCommand: `newt --id ${newtId} --secret ${secret}`
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
to: email,
|
|
||||||
from: config.getNoReplyEmail(),
|
|
||||||
subject: `Access your Pangolin dashboard and resources`
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response<QuickStartResponse>(res, {
|
|
||||||
data: {
|
|
||||||
newtId: newtId!,
|
|
||||||
newtSecret: secret!,
|
|
||||||
resourceUrl: `https://${fullDomain!}`,
|
|
||||||
completeSignUpLink: completeSignUpLink!
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Quick start completed successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
|
||||||
logger.info(
|
|
||||||
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"A user with that email address already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error(e);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to do quick start"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a token received from the frontend.
|
|
||||||
* @param {string} token The validation token from the request.
|
|
||||||
* @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid.
|
|
||||||
*/
|
|
||||||
const validateTokenOnApi = (
|
|
||||||
token: string
|
|
||||||
): { isValid: boolean; message: string } => {
|
|
||||||
if (token === DEMO_UBO_KEY) {
|
|
||||||
// Special case for demo UBO key
|
|
||||||
return { isValid: true, message: "Demo UBO key is valid." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return { isValid: false, message: "Error: No token provided." };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Decode the base64 string
|
|
||||||
const decodedB64 = atob(token);
|
|
||||||
|
|
||||||
// 2. Reverse the character code manipulation
|
|
||||||
const deobfuscated = decodedB64
|
|
||||||
.split("")
|
|
||||||
.map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
// 3. Split the data to get the original secret and timestamp
|
|
||||||
const parts = deobfuscated.split("|");
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
throw new Error("Invalid token format.");
|
|
||||||
}
|
|
||||||
const receivedKey = parts[0];
|
|
||||||
const tokenTimestamp = parseInt(parts[1], 10);
|
|
||||||
|
|
||||||
// 4. Check if the secret key matches
|
|
||||||
if (receivedKey !== BACKEND_SECRET_KEY) {
|
|
||||||
return { isValid: false, message: "Invalid token: Key mismatch." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks
|
|
||||||
const now = Date.now();
|
|
||||||
const timeDifference = now - tokenTimestamp;
|
|
||||||
|
|
||||||
if (timeDifference > 30000) {
|
|
||||||
// 30 seconds
|
|
||||||
return { isValid: false, message: "Invalid token: Expired." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeDifference < 0) {
|
|
||||||
// Timestamp is in the future
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
message: "Invalid token: Timestamp is in the future."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all checks pass, the token is valid
|
|
||||||
return { isValid: true, message: "Token is valid!" };
|
|
||||||
} catch (error) {
|
|
||||||
// This will catch errors from atob (if not valid base64) or other issues.
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
message: `Error: ${(error as Error).message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
263
server/private/routers/billing/changeTier.ts
Normal file
263
server/private/routers/billing/changeTier.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* 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 { z } from "zod";
|
||||||
|
import { customers, db, subscriptions, subscriptionItems } from "@server/db";
|
||||||
|
import { eq, and, or } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import stripe from "#private/lib/stripe";
|
||||||
|
import {
|
||||||
|
getHomeLabFeaturePriceSet,
|
||||||
|
getScaleFeaturePriceSet,
|
||||||
|
getStarterFeaturePriceSet,
|
||||||
|
FeatureId,
|
||||||
|
type FeaturePriceSet
|
||||||
|
} from "@server/lib/billing";
|
||||||
|
|
||||||
|
const changeTierSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeTierBodySchema = z.strictObject({
|
||||||
|
tier: z.enum(["home_lab", "starter", "scale"])
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function changeTier(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = changeTierSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = changeTierBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tier } = parsedBody.data;
|
||||||
|
|
||||||
|
// Get the customer for this org
|
||||||
|
const [customer] = await db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(eq(customers.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No customer found for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the active subscription for this customer
|
||||||
|
const [subscription] = await db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(subscriptions.customerId, customer.customerId),
|
||||||
|
eq(subscriptions.status, "active"),
|
||||||
|
or(
|
||||||
|
eq(subscriptions.type, "home_lab"),
|
||||||
|
eq(subscriptions.type, "starter"),
|
||||||
|
eq(subscriptions.type, "scale")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No active subscription found for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the target tier's price set
|
||||||
|
let targetPriceSet: FeaturePriceSet;
|
||||||
|
if (tier === "home_lab") {
|
||||||
|
targetPriceSet = getHomeLabFeaturePriceSet();
|
||||||
|
} else if (tier === "starter") {
|
||||||
|
targetPriceSet = getStarterFeaturePriceSet();
|
||||||
|
} else if (tier === "scale") {
|
||||||
|
targetPriceSet = getScaleFeaturePriceSet();
|
||||||
|
} else {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current subscription items from our database
|
||||||
|
const currentItems = await db
|
||||||
|
.select()
|
||||||
|
.from(subscriptionItems)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
subscriptionItems.subscriptionId,
|
||||||
|
subscription.subscriptionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentItems.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No subscription items found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the full subscription from Stripe to get item IDs
|
||||||
|
const stripeSubscription = await stripe!.subscriptions.retrieve(
|
||||||
|
subscription.subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if we're switching between different products
|
||||||
|
// home_lab uses HOME_LAB product, starter/scale use USERS product
|
||||||
|
const currentTier = subscription.type;
|
||||||
|
const switchingProducts =
|
||||||
|
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) ||
|
||||||
|
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab");
|
||||||
|
|
||||||
|
let updatedSubscription;
|
||||||
|
|
||||||
|
if (switchingProducts) {
|
||||||
|
// When switching between different products, we need to:
|
||||||
|
// 1. Delete old subscription items
|
||||||
|
// 2. Add new subscription items
|
||||||
|
logger.info(
|
||||||
|
`Switching products from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build array to delete all existing items and add new ones
|
||||||
|
const itemsToUpdate: any[] = [];
|
||||||
|
|
||||||
|
// Mark all existing items for deletion
|
||||||
|
for (const stripeItem of stripeSubscription.items.data) {
|
||||||
|
itemsToUpdate.push({
|
||||||
|
id: stripeItem.id,
|
||||||
|
deleted: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new items for the target tier
|
||||||
|
for (const [featureId, priceId] of Object.entries(targetPriceSet)) {
|
||||||
|
itemsToUpdate.push({
|
||||||
|
price: priceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSubscription = await stripe!.subscriptions.update(
|
||||||
|
subscription.subscriptionId,
|
||||||
|
{
|
||||||
|
items: itemsToUpdate,
|
||||||
|
proration_behavior: "create_prorations"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Same product, different price tier (starter <-> scale)
|
||||||
|
// We can simply update the price
|
||||||
|
logger.info(
|
||||||
|
`Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemsToUpdate = stripeSubscription.items.data.map(
|
||||||
|
(stripeItem) => {
|
||||||
|
// Find the corresponding item in our database
|
||||||
|
const dbItem = currentItems.find(
|
||||||
|
(item) => item.priceId === stripeItem.price.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dbItem) {
|
||||||
|
// Keep the existing item unchanged if we can't find it
|
||||||
|
return {
|
||||||
|
id: stripeItem.id,
|
||||||
|
price: stripeItem.price.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to the corresponding feature in the new tier
|
||||||
|
const newPriceId = targetPriceSet[FeatureId.USERS];
|
||||||
|
|
||||||
|
if (newPriceId) {
|
||||||
|
return {
|
||||||
|
id: stripeItem.id,
|
||||||
|
price: newPriceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no mapping found, keep existing
|
||||||
|
return {
|
||||||
|
id: stripeItem.id,
|
||||||
|
price: stripeItem.price.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedSubscription = await stripe!.subscriptions.update(
|
||||||
|
subscription.subscriptionId,
|
||||||
|
{
|
||||||
|
items: itemsToUpdate,
|
||||||
|
proration_behavior: "create_prorations"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Successfully changed tier to ${tier} for org ${orgId}, subscription ${subscription.subscriptionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<{ subscriptionId: string; newTier: string }>(res, {
|
||||||
|
data: {
|
||||||
|
subscriptionId: updatedSubscription.id,
|
||||||
|
newTier: tier
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Tier change successful",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error changing tier:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred while changing tier"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,14 +22,17 @@ import logger from "@server/logger";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing";
|
import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing";
|
||||||
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
|
|
||||||
|
|
||||||
const createCheckoutSessionSchema = z.strictObject({
|
const createCheckoutSessionSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createCheckoutSessionSAAS(
|
const createCheckoutSessionBodySchema = z.strictObject({
|
||||||
|
tier: z.enum(["home_lab", "starter", "scale"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createCheckoutSession(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
@@ -47,6 +50,18 @@ export async function createCheckoutSessionSAAS(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tier } = parsedBody.data;
|
||||||
|
|
||||||
// check if we already have a customer for this org
|
// check if we already have a customer for this org
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -65,18 +80,23 @@ export async function createCheckoutSessionSAAS(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
|
let lineItems;
|
||||||
|
if (tier === "home_lab") {
|
||||||
|
lineItems = getLineItems(getHomeLabFeaturePriceSet());
|
||||||
|
} else if (tier === "starter") {
|
||||||
|
lineItems = getLineItems(getStarterFeaturePriceSet());
|
||||||
|
} else if (tier === "scale") {
|
||||||
|
lineItems = getLineItems(getScaleFeaturePriceSet());
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const session = await stripe!.checkout.sessions.create({
|
const session = await stripe!.checkout.sessions.create({
|
||||||
client_reference_id: orgId, // So we can look it up the org later on the webhook
|
client_reference_id: orgId, // So we can look it up the org later on the webhook
|
||||||
billing_address_collection: "required",
|
billing_address_collection: "required",
|
||||||
line_items: [
|
line_items: lineItems,
|
||||||
{
|
|
||||||
price: standardTierPrice, // Use the standard tier
|
|
||||||
quantity: 1
|
|
||||||
},
|
|
||||||
...getLineItems(getStandardFeaturePriceSet())
|
|
||||||
], // Start with the standard feature set that matches the free limits
|
|
||||||
customer: customer.customerId,
|
customer: customer.customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
@@ -78,9 +78,9 @@ export async function getOrgUsage(
|
|||||||
// Get usage for org
|
// Get usage for org
|
||||||
const usageData = [];
|
const usageData = [];
|
||||||
|
|
||||||
const siteUptime = await usageService.getUsage(
|
const sites = await usageService.getUsage(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.SITE_UPTIME
|
FeatureId.SITES
|
||||||
);
|
);
|
||||||
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
|
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
|
||||||
const domains = await usageService.getUsageDaily(
|
const domains = await usageService.getUsageDaily(
|
||||||
@@ -96,8 +96,8 @@ export async function getOrgUsage(
|
|||||||
FeatureId.EGRESS_DATA_MB
|
FeatureId.EGRESS_DATA_MB
|
||||||
);
|
);
|
||||||
|
|
||||||
if (siteUptime) {
|
if (sites) {
|
||||||
usageData.push(siteUptime);
|
usageData.push(sites);
|
||||||
}
|
}
|
||||||
if (users) {
|
if (users) {
|
||||||
usageData.push(users);
|
usageData.push(users);
|
||||||
|
|||||||
@@ -1,35 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* 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 {
|
import {
|
||||||
getLicensePriceSet,
|
getLicensePriceSet,
|
||||||
} from "@server/lib/billing/licenses";
|
} from "@server/lib/billing/licenses";
|
||||||
import {
|
import {
|
||||||
getTierPriceSet,
|
getHomeLabFeaturePriceSet,
|
||||||
} from "@server/lib/billing/tiers";
|
getStarterFeaturePriceSet,
|
||||||
|
getScaleFeaturePriceSet,
|
||||||
|
} from "@server/lib/billing/features";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): "saas" | "license" {
|
export type SubscriptionType = "home_lab" | "starter" | "scale" | "license";
|
||||||
|
|
||||||
|
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null {
|
||||||
// Determine subscription type by checking subscription items
|
// Determine subscription type by checking subscription items
|
||||||
let type: "saas" | "license" = "saas";
|
if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) {
|
||||||
if (Array.isArray(fullSubscription.items?.data)) {
|
return null;
|
||||||
for (const item of fullSubscription.items.data) {
|
}
|
||||||
const priceId = item.price.id;
|
|
||||||
|
|
||||||
// Check if price ID matches any license price
|
for (const item of fullSubscription.items.data) {
|
||||||
const licensePrices = Object.values(getLicensePriceSet());
|
const priceId = item.price.id;
|
||||||
|
|
||||||
if (licensePrices.includes(priceId)) {
|
// Check if price ID matches any license price
|
||||||
type = "license";
|
const licensePrices = Object.values(getLicensePriceSet());
|
||||||
break;
|
if (licensePrices.includes(priceId)) {
|
||||||
}
|
return "license";
|
||||||
|
}
|
||||||
|
|
||||||
// Check if price ID matches any tier price (saas)
|
// Check if price ID matches home lab tier
|
||||||
const tierPrices = Object.values(getTierPriceSet());
|
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
|
||||||
|
if (homeLabPrices.includes(priceId)) {
|
||||||
|
return "home_lab";
|
||||||
|
}
|
||||||
|
|
||||||
if (tierPrices.includes(priceId)) {
|
// Check if price ID matches starter tier
|
||||||
type = "saas";
|
const starterPrices = Object.values(getStarterFeaturePriceSet());
|
||||||
break;
|
if (starterPrices.includes(priceId)) {
|
||||||
}
|
return "starter";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if price ID matches scale tier
|
||||||
|
const scalePrices = Object.values(getScaleFeaturePriceSet());
|
||||||
|
if (scalePrices.includes(priceId)) {
|
||||||
|
return "scale";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return type;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,8 @@ export async function handleSubscriptionCreated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const type = getSubType(fullSubscription);
|
||||||
|
|
||||||
const newSubscription = {
|
const newSubscription = {
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
customerId: subscription.customer as string,
|
customerId: subscription.customer as string,
|
||||||
@@ -66,7 +68,8 @@ export async function handleSubscriptionCreated(
|
|||||||
canceledAt: subscription.canceled_at
|
canceledAt: subscription.canceled_at
|
||||||
? subscription.canceled_at
|
? subscription.canceled_at
|
||||||
: null,
|
: null,
|
||||||
createdAt: subscription.created
|
createdAt: subscription.created,
|
||||||
|
type: type
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(subscriptions).values(newSubscription);
|
await db.insert(subscriptions).values(newSubscription);
|
||||||
@@ -129,15 +132,15 @@ export async function handleSubscriptionCreated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = getSubType(fullSubscription);
|
if (type === "home_lab" || type === "starter" || type === "scale") {
|
||||||
if (type === "saas") {
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
|
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
|
||||||
);
|
);
|
||||||
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
||||||
await handleSubscriptionLifesycle(
|
await handleSubscriptionLifesycle(
|
||||||
customer.orgId,
|
customer.orgId,
|
||||||
subscription.status
|
subscription.status,
|
||||||
|
type
|
||||||
);
|
);
|
||||||
|
|
||||||
const [orgUserRes] = await db
|
const [orgUserRes] = await db
|
||||||
|
|||||||
@@ -76,14 +76,15 @@ export async function handleSubscriptionDeleted(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const type = getSubType(fullSubscription);
|
const type = getSubType(fullSubscription);
|
||||||
if (type === "saas") {
|
if (type == "home_lab" || type == "starter" || type == "scale") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
await handleSubscriptionLifesycle(
|
await handleSubscriptionLifesycle(
|
||||||
customer.orgId,
|
customer.orgId,
|
||||||
subscription.status
|
subscription.status,
|
||||||
|
type
|
||||||
);
|
);
|
||||||
|
|
||||||
const [orgUserRes] = await db
|
const [orgUserRes] = await db
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export async function handleSubscriptionUpdated(
|
|||||||
.where(eq(customers.customerId, subscription.customer as string))
|
.where(eq(customers.customerId, subscription.customer as string))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
const type = getSubType(fullSubscription);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(subscriptions)
|
.update(subscriptions)
|
||||||
.set({
|
.set({
|
||||||
@@ -72,7 +74,8 @@ export async function handleSubscriptionUpdated(
|
|||||||
? subscription.canceled_at
|
? subscription.canceled_at
|
||||||
: null,
|
: null,
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
billingCycleAnchor: subscription.billing_cycle_anchor
|
billingCycleAnchor: subscription.billing_cycle_anchor,
|
||||||
|
type: type
|
||||||
})
|
})
|
||||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||||
|
|
||||||
@@ -234,17 +237,17 @@ export async function handleSubscriptionUpdated(
|
|||||||
}
|
}
|
||||||
// --- end usage update ---
|
// --- end usage update ---
|
||||||
|
|
||||||
const type = getSubType(fullSubscription);
|
if (type === "home_lab" || type === "starter" || type === "scale") {
|
||||||
if (type === "saas") {
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
|
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
|
||||||
);
|
);
|
||||||
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
||||||
await handleSubscriptionLifesycle(
|
await handleSubscriptionLifesycle(
|
||||||
customer.orgId,
|
customer.orgId,
|
||||||
subscription.status
|
subscription.status,
|
||||||
|
type
|
||||||
);
|
);
|
||||||
} else {
|
} else if (type === "license") {
|
||||||
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
||||||
try {
|
try {
|
||||||
// WARNING:
|
// WARNING:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./createCheckoutSessionSAAS";
|
export * from "./createCheckoutSession";
|
||||||
export * from "./createPortalSession";
|
export * from "./createPortalSession";
|
||||||
export * from "./getOrgSubscriptions";
|
export * from "./getOrgSubscriptions";
|
||||||
export * from "./getOrgUsage";
|
export * from "./getOrgUsage";
|
||||||
|
|||||||
@@ -13,36 +13,62 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
freeLimitSet,
|
freeLimitSet,
|
||||||
|
homeLabLimitSet,
|
||||||
|
starterLimitSet,
|
||||||
|
scaleLimitSet,
|
||||||
limitsService,
|
limitsService,
|
||||||
subscribedLimitSet
|
LimitSet
|
||||||
} from "@server/lib/billing";
|
} from "@server/lib/billing";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import logger from "@server/logger";
|
import { SubscriptionType } from "./hooks/getSubType";
|
||||||
|
|
||||||
|
function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet {
|
||||||
|
switch (subType) {
|
||||||
|
case "home_lab":
|
||||||
|
return homeLabLimitSet;
|
||||||
|
case "starter":
|
||||||
|
return starterLimitSet;
|
||||||
|
case "scale":
|
||||||
|
return scaleLimitSet;
|
||||||
|
case "license":
|
||||||
|
// License subscriptions use starter limits by default
|
||||||
|
// This can be adjusted based on your business logic
|
||||||
|
return starterLimitSet;
|
||||||
|
default:
|
||||||
|
return freeLimitSet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleSubscriptionLifesycle(
|
export async function handleSubscriptionLifesycle(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
status: string
|
status: string,
|
||||||
|
subType: SubscriptionType | null
|
||||||
) {
|
) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet);
|
const activeLimitSet = getLimitSetForSubscriptionType(subType);
|
||||||
|
await limitsService.applyLimitSetToOrg(orgId, activeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId, true);
|
||||||
break;
|
break;
|
||||||
case "canceled":
|
case "canceled":
|
||||||
|
// Subscription canceled - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId, true);
|
||||||
break;
|
break;
|
||||||
case "past_due":
|
case "past_due":
|
||||||
// Optionally handle past due status, e.g., notify customer
|
// Payment past due - keep current limits but notify customer
|
||||||
|
// Limits will revert to free tier if it becomes unpaid
|
||||||
break;
|
break;
|
||||||
case "unpaid":
|
case "unpaid":
|
||||||
|
// Subscription unpaid - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId, true);
|
||||||
break;
|
break;
|
||||||
case "incomplete":
|
case "incomplete":
|
||||||
// Optionally handle incomplete status, e.g., notify customer
|
// Payment incomplete - give them time to complete payment
|
||||||
break;
|
break;
|
||||||
case "incomplete_expired":
|
case "incomplete_expired":
|
||||||
|
// Payment never completed - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId, true);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ unauthenticated.post(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/idp/oidc",
|
"/org/:orgId/idp/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createIdp),
|
verifyUserHasAction(ActionsEnum.createIdp),
|
||||||
logActionAudit(ActionsEnum.createIdp),
|
logActionAudit(ActionsEnum.createIdp),
|
||||||
@@ -85,6 +86,7 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/idp/:idpId/oidc",
|
"/org/:orgId/idp/:idpId/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyIdpAccess,
|
verifyIdpAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateIdp),
|
verifyUserHasAction(ActionsEnum.updateIdp),
|
||||||
@@ -141,29 +143,12 @@ authenticated.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
unauthenticated.post(
|
|
||||||
"/quick-start",
|
|
||||||
rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
max: 100,
|
|
||||||
keyGenerator: (req) => req.path,
|
|
||||||
handler: (req, res, next) => {
|
|
||||||
const message = `We're too busy right now. Please try again later.`;
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
store: createStore()
|
|
||||||
}),
|
|
||||||
auth.quickStart
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/billing/create-checkout-session-saas",
|
"/org/:orgId/billing/create-checkout-session",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.billing),
|
verifyUserHasAction(ActionsEnum.billing),
|
||||||
logActionAudit(ActionsEnum.billing),
|
logActionAudit(ActionsEnum.billing),
|
||||||
billing.createCheckoutSessionSAAS
|
billing.createCheckoutSession
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -286,6 +271,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/login-page",
|
"/org/:orgId/login-page",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createLoginPage),
|
verifyUserHasAction(ActionsEnum.createLoginPage),
|
||||||
logActionAudit(ActionsEnum.createLoginPage),
|
logActionAudit(ActionsEnum.createLoginPage),
|
||||||
@@ -295,6 +281,7 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/login-page/:loginPageId",
|
"/org/:orgId/login-page/:loginPageId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyLoginPageAccess,
|
verifyLoginPageAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||||
@@ -323,6 +310,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/approvals",
|
"/org/:orgId/approvals",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.listApprovals),
|
verifyUserHasAction(ActionsEnum.listApprovals),
|
||||||
logActionAudit(ActionsEnum.listApprovals),
|
logActionAudit(ActionsEnum.listApprovals),
|
||||||
@@ -339,6 +327,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/approvals/:approvalId",
|
"/org/:orgId/approvals/:approvalId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateApprovals),
|
verifyUserHasAction(ActionsEnum.updateApprovals),
|
||||||
logActionAudit(ActionsEnum.updateApprovals),
|
logActionAudit(ActionsEnum.updateApprovals),
|
||||||
@@ -348,6 +337,7 @@ authenticated.put(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getLoginPage),
|
verifyUserHasAction(ActionsEnum.getLoginPage),
|
||||||
logActionAudit(ActionsEnum.getLoginPage),
|
logActionAudit(ActionsEnum.getLoginPage),
|
||||||
@@ -357,6 +347,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||||
logActionAudit(ActionsEnum.updateLoginPage),
|
logActionAudit(ActionsEnum.updateLoginPage),
|
||||||
@@ -366,6 +357,7 @@ authenticated.put(
|
|||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteLoginPage),
|
verifyUserHasAction(ActionsEnum.deleteLoginPage),
|
||||||
logActionAudit(ActionsEnum.deleteLoginPage),
|
logActionAudit(ActionsEnum.deleteLoginPage),
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -76,19 +74,6 @@ export async function createLoginPage(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(loginPageOrg)
|
.from(loginPageOrg)
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -53,18 +51,6 @@ export async function deleteLoginPageBranding(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingLoginPageBranding] = await db
|
const [existingLoginPageBranding] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -51,19 +49,6 @@ export async function getLoginPageBranding(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingLoginPageBranding] = await db
|
const [existingLoginPageBranding] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(loginPageBranding)
|
.from(loginPageBranding)
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { subdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
@@ -87,18 +85,6 @@ export async function updateLoginPage(
|
|||||||
|
|
||||||
const { loginPageId, orgId } = parsedParams.data;
|
const { loginPageId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingLoginPage] = await db
|
const [existingLoginPage] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import config from "@server/private/lib/config";
|
import config from "@server/private/lib/config";
|
||||||
|
|
||||||
@@ -128,19 +126,6 @@ export async function upsertLoginPageBranding(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let updateData = parsedBody.data satisfies InferInsertModel<
|
let updateData = parsedBody.data satisfies InferInsertModel<
|
||||||
typeof loginPageBranding
|
typeof loginPageBranding
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db";
|
|||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||||
@@ -109,19 +106,6 @@ export async function createOrgOidcIdp(
|
|||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier, active } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|
||||||
const encryptedSecret = encrypt(clientSecret, key);
|
const encryptedSecret = encrypt(clientSecret, key);
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ import { idp, idpOidcConfig } from "@server/db";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -114,19 +111,6 @@ export async function updateOrgOidcIdp(
|
|||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier, active } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
const [existingIdp] = await db
|
const [existingIdp] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db, orgs, requestAuditLog } from "@server/db";
|
import { db, orgs, requestAuditLog } from "@server/db";
|
||||||
import logger from "@server/logger";
|
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 cache from "@server/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
import { stripPortFromHost } from "@server/lib/ip";
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
@@ -67,17 +67,27 @@ async function flushAuditLogs() {
|
|||||||
const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length);
|
const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
// Use a transaction to ensure all inserts succeed or fail together
|
||||||
const BATCH_DB_SIZE = 25;
|
// This prevents index corruption from partial writes
|
||||||
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
await db.transaction(async (tx) => {
|
||||||
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
||||||
await db.insert(requestAuditLog).values(batch);
|
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`);
|
logger.debug(`Flushed ${logsToWrite.length} audit logs to database`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error flushing audit logs:", error);
|
logger.error("Error flushing audit logs:", error);
|
||||||
// On error, we lose these logs - consider a fallback strategy if needed
|
// On transaction error, put logs back at the front of the buffer to retry
|
||||||
// (e.g., write to file, or put back in buffer with retry limit)
|
// 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 {
|
} finally {
|
||||||
isFlushInProgress = false;
|
isFlushInProgress = false;
|
||||||
// If buffer filled up while we were flushing, flush again
|
// If buffer filled up while we were flushing, flush again
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
ResourcePassword,
|
ResourcePassword,
|
||||||
ResourcePincode,
|
ResourcePincode,
|
||||||
ResourceRule,
|
ResourceRule,
|
||||||
resourceSessions
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
@@ -32,7 +31,6 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
import { getAsnForIp } from "@server/lib/asn";
|
import { getAsnForIp } from "@server/lib/asn";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import {
|
import {
|
||||||
checkOrgAccessPolicy,
|
checkOrgAccessPolicy,
|
||||||
@@ -40,8 +38,8 @@ import {
|
|||||||
} from "#dynamic/lib/checkOrgAccessPolicy";
|
} from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { logRequestAudit } from "./logRequestAudit";
|
import { logRequestAudit } from "./logRequestAudit";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
import semver from "semver";
|
|
||||||
import { APP_VERSION } from "@server/lib/consts";
|
import { APP_VERSION } from "@server/lib/consts";
|
||||||
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string(), z.string()).optional(),
|
sessions: z.record(z.string(), z.string()).optional(),
|
||||||
@@ -798,8 +796,8 @@ async function notAllowed(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
const subscribed = await isSubscribed(orgId);
|
||||||
if (tier === TierId.STANDARD) {
|
if (subscribed) {
|
||||||
loginPage = await getOrgLoginPage(orgId);
|
loginPage = await getOrgLoginPage(orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,8 +850,8 @@ async function headerAuthChallenged(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
const subscribed = await isSubscribed(orgId);
|
||||||
if (tier === TierId.STANDARD) {
|
if (subscribed) {
|
||||||
loginPage = await getOrgLoginPage(orgId);
|
loginPage = await getOrgLoginPage(orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ export async function updateSiteBandwidth(
|
|||||||
|
|
||||||
// Aggregate usage data by organization (collected outside transaction)
|
// Aggregate usage data by organization (collected outside transaction)
|
||||||
const orgUsageMap = new Map<string, number>();
|
const orgUsageMap = new Map<string, number>();
|
||||||
const orgUptimeMap = new Map<string, number>();
|
|
||||||
|
|
||||||
if (activePeers.length > 0) {
|
if (activePeers.length > 0) {
|
||||||
// Remove any active peers from offline tracking since they're sending data
|
// Remove any active peers from offline tracking since they're sending data
|
||||||
@@ -166,14 +165,6 @@ export async function updateSiteBandwidth(
|
|||||||
updatedSite.orgId,
|
updatedSite.orgId,
|
||||||
currentOrgUsage + totalBandwidth
|
currentOrgUsage + totalBandwidth
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add 10 seconds of uptime for each active site
|
|
||||||
const currentOrgUptime =
|
|
||||||
orgUptimeMap.get(updatedSite.orgId) || 0;
|
|
||||||
orgUptimeMap.set(
|
|
||||||
updatedSite.orgId,
|
|
||||||
currentOrgUptime + 10 / 60
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -187,10 +178,10 @@ export async function updateSiteBandwidth(
|
|||||||
|
|
||||||
// Process usage updates outside of site update transactions
|
// Process usage updates outside of site update transactions
|
||||||
// This separates the concerns and reduces lock contention
|
// This separates the concerns and reduces lock contention
|
||||||
if (calcUsageAndLimits && (orgUsageMap.size > 0 || orgUptimeMap.size > 0)) {
|
if (calcUsageAndLimits && (orgUsageMap.size > 0)) {
|
||||||
// Sort org IDs to ensure consistent lock ordering
|
// Sort org IDs to ensure consistent lock ordering
|
||||||
const allOrgIds = [
|
const allOrgIds = [
|
||||||
...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()])
|
...new Set([...orgUsageMap.keys()])
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
for (const orgId of allOrgIds) {
|
for (const orgId of allOrgIds) {
|
||||||
@@ -220,32 +211,6 @@ export async function updateSiteBandwidth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process uptime usage for this org
|
|
||||||
const totalUptime = orgUptimeMap.get(orgId);
|
|
||||||
if (totalUptime) {
|
|
||||||
const uptimeUsage = await usageService.add(
|
|
||||||
orgId,
|
|
||||||
FeatureId.SITE_UPTIME,
|
|
||||||
totalUptime
|
|
||||||
);
|
|
||||||
if (uptimeUsage) {
|
|
||||||
// Fire and forget - don't block on limit checking
|
|
||||||
usageService
|
|
||||||
.checkLimitSet(
|
|
||||||
orgId,
|
|
||||||
true,
|
|
||||||
FeatureId.SITE_UPTIME,
|
|
||||||
uptimeUsage
|
|
||||||
)
|
|
||||||
.catch((error: any) => {
|
|
||||||
logger.error(
|
|
||||||
`Error checking uptime limits for org ${orgId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error processing usage for org ${orgId}:`, error);
|
logger.error(`Error processing usage for org ${orgId}:`, error);
|
||||||
// Continue with other orgs
|
// Continue with other orgs
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import jsonwebtoken from "jsonwebtoken";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
import { isSubscribed } from "@server/private/lib/isSubscribed";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -113,8 +112,7 @@ export async function generateOidcUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const subscribed = await isSubscribed(orgId);
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
fetchContainers(newt.newtId);
|
fetchContainers(newt.newtId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectSiteUptime = await usageService.checkLimitSet(
|
const rejectSites = await usageService.checkLimitSet(
|
||||||
oldSite.orgId,
|
oldSite.orgId,
|
||||||
false,
|
false,
|
||||||
FeatureId.SITE_UPTIME
|
FeatureId.SITES
|
||||||
);
|
);
|
||||||
const rejectEgressDataMb = await usageService.checkLimitSet(
|
const rejectEgressDataMb = await usageService.checkLimitSet(
|
||||||
oldSite.orgId,
|
oldSite.orgId,
|
||||||
@@ -111,8 +111,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
// const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS);
|
// const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS);
|
||||||
// const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);
|
// const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);
|
||||||
|
|
||||||
// if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) {
|
// if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) {
|
||||||
if (rejectEgressDataMb || rejectSiteUptime) {
|
if (rejectEgressDataMb || rejectSites) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.`
|
`Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { cache } from "@server/lib/cache";
|
import { cache } from "@server/lib/cache";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { subscribe } from "node:diagnostics_channel";
|
||||||
|
import { isSubscribed } from "@server/private/lib/isSubscribed";
|
||||||
|
|
||||||
const updateOrgParamsSchema = z.strictObject({
|
const updateOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -95,10 +95,10 @@ export async function updateOrg(
|
|||||||
parsedBody.data.passwordExpiryDays = undefined;
|
parsedBody.data.passwordExpiryDays = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const subscribed = await isSubscribed(orgId);
|
||||||
if (
|
if (
|
||||||
build == "saas" &&
|
build == "saas" &&
|
||||||
tier != TierId.STANDARD &&
|
subscribed &&
|
||||||
parsedBody.data.settingsLogRetentionDaysRequest &&
|
parsedBody.data.settingsLogRetentionDaysRequest &&
|
||||||
parsedBody.data.settingsLogRetentionDaysRequest > 30
|
parsedBody.data.settingsLogRetentionDaysRequest > 30
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { isValidIP } from "@server/lib/validators";
|
|||||||
import { isIpInCidr } from "@server/lib/ip";
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
|
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
|
import { FeatureId } from "@server/lib/billing";
|
||||||
|
|
||||||
const createSiteParamsSchema = z.strictObject({
|
const createSiteParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -126,6 +128,35 @@ export async function createSite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (build == "saas") {
|
||||||
|
const usage = await usageService.getUsage(orgId, FeatureId.SITES);
|
||||||
|
if (!usage) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"No usage data found for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rejectSites = await usageService.checkLimitSet(
|
||||||
|
orgId,
|
||||||
|
false,
|
||||||
|
FeatureId.SITES,
|
||||||
|
{
|
||||||
|
...usage,
|
||||||
|
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
||||||
|
} // We need to add one to know if we are violating the limit
|
||||||
|
);
|
||||||
|
if (rejectSites) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Sites limit exceeded. Please upgrade your plan."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let updatedAddress = null;
|
let updatedAddress = null;
|
||||||
if (address) {
|
if (address) {
|
||||||
if (!org.subnet) {
|
if (!org.subnet) {
|
||||||
@@ -256,8 +287,8 @@ export async function createSite(
|
|||||||
|
|
||||||
const niceId = await getUniqueSiteName(orgId);
|
const niceId = await getUniqueSiteName(orgId);
|
||||||
|
|
||||||
let newSite: Site;
|
let newSite: Site | undefined;
|
||||||
|
let numSites: Site[] | undefined;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (type == "wireguard" || type == "newt") {
|
if (type == "wireguard" || type == "newt") {
|
||||||
// we are creating a site with an exit node (tunneled)
|
// we are creating a site with an exit node (tunneled)
|
||||||
@@ -402,13 +433,35 @@ export async function createSite(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response<CreateSiteResponse>(res, {
|
numSites = await trx
|
||||||
data: newSite,
|
.select()
|
||||||
success: true,
|
.from(sites)
|
||||||
error: false,
|
.where(eq(sites.orgId, orgId));
|
||||||
message: "Site created successfully",
|
});
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
if (numSites) {
|
||||||
|
await usageService.updateDaily(
|
||||||
|
orgId,
|
||||||
|
FeatureId.SITES,
|
||||||
|
numSites.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newSite) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create site"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<CreateSiteResponse>(res, {
|
||||||
|
data: newSite,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Site created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, siteResources } from "@server/db";
|
import { db, Site, siteResources } from "@server/db";
|
||||||
import { newts, newtSessions, sites } from "@server/db";
|
import { newts, newtSessions, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
|
import { FeatureId } from "@server/lib/billing";
|
||||||
|
|
||||||
const deleteSiteSchema = z.strictObject({
|
const deleteSiteSchema = z.strictObject({
|
||||||
siteId: z.string().transform(Number).pipe(z.int().positive())
|
siteId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -62,6 +64,7 @@ export async function deleteSite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let deletedNewtId: string | null = null;
|
let deletedNewtId: string | null = null;
|
||||||
|
let numSites: Site[] | undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
@@ -99,8 +102,20 @@ export async function deleteSite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
|
|
||||||
|
numSites = await trx
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.orgId, site.orgId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (numSites) {
|
||||||
|
await usageService.updateDaily(
|
||||||
|
site.orgId,
|
||||||
|
FeatureId.SITES,
|
||||||
|
numSites.length
|
||||||
|
);
|
||||||
|
}
|
||||||
// Send termination message outside of transaction to prevent blocking
|
// Send termination message outside of transaction to prevent blocking
|
||||||
if (deletedNewtId) {
|
if (deletedNewtId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import { generateId } from "@server/auth/sessions/app";
|
|||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
|
import { isSubscribed } from "@server/private/lib/isSubscribed";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -132,9 +131,8 @@ export async function createOrgUser(
|
|||||||
);
|
);
|
||||||
} else if (type === "oidc") {
|
} else if (type === "oidc") {
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const subscribed = await isSubscribed(orgId);
|
||||||
const subscribed = tier === TierId.STANDARD;
|
if (subscribed) {
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export default function GeneralPage() {
|
|||||||
setAllSubscriptions(subscriptions);
|
setAllSubscriptions(subscriptions);
|
||||||
|
|
||||||
// Import tier and license price sets
|
// Import tier and license price sets
|
||||||
const { getTierPriceSet } = await import("@server/lib/billing/tiers");
|
|
||||||
const { getLicensePriceSet } = await import("@server/lib/billing/licenses");
|
const { getLicensePriceSet } = await import("@server/lib/billing/licenses");
|
||||||
|
|
||||||
const tierPriceSet = getTierPriceSet(
|
const tierPriceSet = getTierPriceSet(
|
||||||
@@ -153,7 +152,7 @@ export default function GeneralPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.post<AxiosResponse<string>>(
|
const response = await api.post<AxiosResponse<string>>(
|
||||||
`/org/${org.org.orgId}/billing/create-checkout-session-saas`,
|
`/org/${org.org.orgId}/billing/create-checkout-session`,
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
console.log("Checkout session response:", response.data);
|
console.log("Checkout session response:", response.data);
|
||||||
@@ -207,7 +206,7 @@ export default function GeneralPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Usage IDs
|
// Usage IDs
|
||||||
const SITE_UPTIME = "siteUptime";
|
const SITES = "sites";
|
||||||
const USERS = "users";
|
const USERS = "users";
|
||||||
const EGRESS_DATA_MB = "egressDataMb";
|
const EGRESS_DATA_MB = "egressDataMb";
|
||||||
const DOMAINS = "domains";
|
const DOMAINS = "domains";
|
||||||
@@ -362,12 +361,11 @@ export default function GeneralPage() {
|
|||||||
getLimitUsage: (v: any) => v.latestValue
|
getLimitUsage: (v: any) => v.latestValue
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: SITE_UPTIME,
|
id: SITES,
|
||||||
label: t("billingOnlineTime"),
|
label: t("billingSites"),
|
||||||
icon: <Clock className="h-4 w-4 text-green-500" />,
|
icon: <Clock className="h-4 w-4 text-green-500" />,
|
||||||
unit: "min",
|
unit: "",
|
||||||
info: t("billingOnlineTimeInfo"),
|
info: t("billingSitesInfo"),
|
||||||
note: "Not counted on self-hosted nodes",
|
|
||||||
getDisplay: (v: any) => v.latestValue,
|
getDisplay: (v: any) => v.latestValue,
|
||||||
getLimitDisplay: (v: any) => v.value,
|
getLimitDisplay: (v: any) => v.value,
|
||||||
getUsage: (v: any) => v.latestValue,
|
getUsage: (v: any) => v.latestValue,
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import { useTranslations } from "next-intl";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
|
|
||||||
type UserType = "internal" | "oidc";
|
type UserType = "internal" | "oidc";
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import type {
|
|||||||
LoadLoginPageBrandingResponse,
|
LoadLoginPageBrandingResponse,
|
||||||
LoadLoginPageResponse
|
LoadLoginPageResponse
|
||||||
} from "@server/routers/loginPage/types";
|
} from "@server/routers/loginPage/types";
|
||||||
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ type SubscriptionStatusContextType = {
|
|||||||
subscriptionStatus: GetOrgSubscriptionResponse | null;
|
subscriptionStatus: GetOrgSubscriptionResponse | null;
|
||||||
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
|
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
|
||||||
isActive: () => boolean;
|
isActive: () => boolean;
|
||||||
getTier: () => string | null;
|
getTier: () => { tier: string | null; active: boolean };
|
||||||
isSubscribed: () => boolean;
|
isSubscribed: () => boolean;
|
||||||
subscribed: boolean;
|
subscribed: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { getCachedSubscription } from "./getCachedSubscription";
|
import { getCachedSubscription } from "./getCachedSubscription";
|
||||||
import { priv } from ".";
|
import { priv } from ".";
|
||||||
@@ -21,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
|
|||||||
try {
|
try {
|
||||||
const subRes = await getCachedSubscription(orgId);
|
const subRes = await getCachedSubscription(orgId);
|
||||||
subscribed =
|
subscribed =
|
||||||
subRes.data.data.tier === TierId.STANDARD &&
|
(subRes.data.data.tier == "home_lab" || subRes.data.data.tier == "starter" || subRes.data.data.tier == "scale") &&
|
||||||
subRes.data.data.active;
|
subRes.data.data.active;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext";
|
import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext";
|
||||||
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -43,34 +42,37 @@ export function SubscriptionStatusProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTier = () => {
|
const getTier = () => {
|
||||||
const tierPriceSet = getTierPriceSet(env, sandbox_mode);
|
|
||||||
|
|
||||||
if (subscriptionStatus?.subscriptions) {
|
if (subscriptionStatus?.subscriptions) {
|
||||||
// Iterate through all subscriptions
|
// Iterate through all subscriptions
|
||||||
for (const { subscription, items } of subscriptionStatus.subscriptions) {
|
for (const { subscription } of subscriptionStatus.subscriptions) {
|
||||||
if (items && items.length > 0) {
|
if (
|
||||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
subscription.type == "home_lab" ||
|
||||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
subscription.type == "starter" ||
|
||||||
// Check if any subscription item matches this tier's price ID
|
subscription.type == "scale"
|
||||||
const matchingItem = items.find(
|
) {
|
||||||
(item) => item.priceId === priceId
|
return {
|
||||||
);
|
tier: subscription.type,
|
||||||
if (matchingItem) {
|
active: subscription.status === "active"
|
||||||
return tierId;
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return {
|
||||||
|
tier: null,
|
||||||
|
active: false
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSubscribed = () => {
|
const isSubscribed = () => {
|
||||||
if (build === "enterprise") {
|
if (build === "enterprise") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return getTier() === TierId.STANDARD;
|
const { tier, active } = getTier();
|
||||||
|
return (
|
||||||
|
(tier == "home_lab" || tier == "starter" || tier == "scale") &&
|
||||||
|
active
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [subscribed, setSubscribed] = useState<boolean>(isSubscribed());
|
const [subscribed, setSubscribed] = useState<boolean>(isSubscribed());
|
||||||
@@ -91,4 +93,4 @@ export function SubscriptionStatusProvider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SubscriptionStatusProvider;
|
export default SubscriptionStatusProvider;
|
||||||
|
|||||||
Reference in New Issue
Block a user