Change features, remove site uptime

This commit is contained in:
Owen
2026-02-11 10:05:53 -08:00
parent b4c01349d1
commit 7ffb260d7c
9 changed files with 161 additions and 109 deletions

View File

@@ -1,30 +1,22 @@
import Stripe from "stripe";
export enum FeatureId {
SITE_UPTIME = "siteUptime",
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes"
}
export const FeatureMeterIds: Record<FeatureId, string> = {
[FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU",
[FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro",
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW",
[FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU",
[FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE"
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = {
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
};
export const FeatureMeterIdsSandbox: Record<FeatureId, string> = {
[FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu",
[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 const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
[FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: FeatureId): string {
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -43,38 +35,43 @@ export function getFeatureIdByMetricId(
)?.[0];
}
export type FeaturePriceSet = {
[key in Exclude<FeatureId, FeatureId.DOMAINS>]: string;
} & {
[FeatureId.DOMAINS]?: string; // Optional since domains are not billed
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export const starterFeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea"
};
export const standardFeaturePriceSet: FeaturePriceSet = {
// Free tier matches the freeLimitSet
[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 starterFeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF"
};
export const standardFeaturePriceSetSandbox: 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 {
export function getStarterFeaturePriceSet(): FeaturePriceSet {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
) {
return standardFeaturePriceSet;
return starterFeaturePriceSet;
} else {
return standardFeaturePriceSetSandbox;
return starterFeaturePriceSetSandbox;
}
}
export const scaleFeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea"
};
export const scaleFeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF"
};
export function getScaleFeaturePriceSet(): FeaturePriceSet {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
) {
return scaleFeaturePriceSet;
} else {
return scaleFeaturePriceSetSandbox;
}
}

View File

@@ -8,7 +8,7 @@ export type 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.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
@@ -16,7 +16,7 @@ export const sandboxLimitSet: 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.EGRESS_DATA_MB]: {
value: 25000,
@@ -26,9 +26,32 @@ export const freeLimitSet: LimitSet = {
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
};
export const subscribedLimitSet: LimitSet = {
[FeatureId.SITE_UPTIME]: {
value: 2232000,
export const starterLimitSet: LimitSet = {
[FeatureId.SITES]: {
value: 10,
description: "Contact us to increase soft limit."
}, // 50 sites up for 31 days
[FeatureId.USERS]: {
value: 150,
description: "Contact us to increase soft limit."
},
[FeatureId.EGRESS_DATA_MB]: {
value: 12000000,
description: "Contact us to increase soft limit."
}, // 12000 GB
[FeatureId.DOMAINS]: {
value: 250,
description: "Contact us to increase soft limit."
},
[FeatureId.REMOTE_EXIT_NODES]: {
value: 5,
description: "Contact us to increase soft limit."
}
};
export const scaleLimitSet: LimitSet = {
[FeatureId.SITES]: {
value: 10,
description: "Contact us to increase soft limit."
}, // 50 sites up for 31 days
[FeatureId.USERS]: {

View File

@@ -78,9 +78,9 @@ export async function getOrgUsage(
// Get usage for org
const usageData = [];
const siteUptime = await usageService.getUsage(
const sites = await usageService.getUsage(
orgId,
FeatureId.SITE_UPTIME
FeatureId.SITES
);
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
const domains = await usageService.getUsageDaily(
@@ -96,8 +96,8 @@ export async function getOrgUsage(
FeatureId.EGRESS_DATA_MB
);
if (siteUptime) {
usageData.push(siteUptime);
if (sites) {
usageData.push(sites);
}
if (users) {
usageData.push(users);

View File

@@ -114,7 +114,6 @@ export async function updateSiteBandwidth(
// Aggregate usage data by organization (collected outside transaction)
const orgUsageMap = new Map<string, number>();
const orgUptimeMap = new Map<string, number>();
if (activePeers.length > 0) {
// Remove any active peers from offline tracking since they're sending data
@@ -166,14 +165,6 @@ export async function updateSiteBandwidth(
updatedSite.orgId,
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) {
logger.error(
@@ -187,10 +178,10 @@ export async function updateSiteBandwidth(
// Process usage updates outside of site update transactions
// 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
const allOrgIds = [
...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()])
...new Set([...orgUsageMap.keys()])
].sort();
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) {
logger.error(`Error processing usage for org ${orgId}:`, error);
// Continue with other orgs

View File

@@ -96,10 +96,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
fetchContainers(newt.newtId);
}
const rejectSiteUptime = await usageService.checkLimitSet(
const rejectSites = await usageService.checkLimitSet(
oldSite.orgId,
false,
FeatureId.SITE_UPTIME
FeatureId.SITES
);
const rejectEgressDataMb = await usageService.checkLimitSet(
oldSite.orgId,
@@ -111,8 +111,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
// const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS);
// const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);
// if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) {
if (rejectEgressDataMb || rejectSiteUptime) {
// if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) {
if (rejectEgressDataMb || rejectSites) {
logger.info(
`Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.`
);

View File

@@ -17,6 +17,9 @@ import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const createSiteParamsSchema = z.strictObject({
orgId: z.string()
@@ -125,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;
if (address) {
if (!org.subnet) {
@@ -255,8 +287,8 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId);
let newSite: Site;
let newSite: Site | undefined;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => {
if (type == "newt") {
[newSite] = await trx
@@ -411,13 +443,35 @@ export async function createSite(
});
}
return response<CreateSiteResponse>(res, {
data: newSite,
success: true,
error: false,
message: "Site created successfully",
status: HttpCode.CREATED
});
numSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, orgId));
});
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) {
logger.error(error);

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
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 { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const deleteSiteSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive())
@@ -62,6 +64,7 @@ export async function deleteSite(
}
let deletedNewtId: string | null = null;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => {
if (site.type == "wireguard") {
@@ -99,8 +102,20 @@ export async function deleteSite(
}
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
if (deletedNewtId) {
const payload = {