mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-16 09:56:36 +00:00
Merge branch 'dev' into refactor/paginated-tables
This commit is contained in:
@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
|
||||
You must have a connection string in your config file, as shown above.
|
||||
|
||||
```bash
|
||||
npm run db:pg:generate
|
||||
npm run db:pg:push
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```bash
|
||||
npm run db:sqlite:generate
|
||||
npm run db:sqlite:push
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
## Build Time
|
||||
|
||||
3
server/db/migrate.ts
Normal file
3
server/db/migrate.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { runMigrations } from "./";
|
||||
|
||||
await runMigrations();
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./driver";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
export const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
await migrate(db as any, {
|
||||
@@ -17,5 +17,3 @@ const runMigrations = async () => {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -82,11 +82,14 @@ export const subscriptions = pgTable("subscriptions", {
|
||||
canceledAt: bigint("canceledAt", { mode: "number" }),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }),
|
||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" })
|
||||
version: integer("version"),
|
||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
||||
});
|
||||
|
||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
||||
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }),
|
||||
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
@@ -94,6 +97,7 @@ export const subscriptionItems = pgTable("subscriptionItems", {
|
||||
}),
|
||||
planId: varchar("planId", { length: 255 }).notNull(),
|
||||
priceId: varchar("priceId", { length: 255 }),
|
||||
featureId: varchar("featureId", { length: 255 }),
|
||||
meterId: varchar("meterId", { length: 255 }),
|
||||
unitAmount: real("unitAmount"),
|
||||
tiers: text("tiers"),
|
||||
@@ -136,6 +140,7 @@ export const limits = pgTable("limits", {
|
||||
})
|
||||
.notNull(),
|
||||
value: real("value"),
|
||||
override: boolean("override").default(false),
|
||||
description: text("description")
|
||||
});
|
||||
|
||||
|
||||
@@ -142,7 +142,8 @@ export const resources = pgTable("resources", {
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath")
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./driver";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
export const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
migrate(db as any, {
|
||||
@@ -16,5 +16,3 @@ const runMigrations = async () => {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -70,13 +70,16 @@ export const subscriptions = sqliteTable("subscriptions", {
|
||||
canceledAt: integer("canceledAt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt"),
|
||||
billingCycleAnchor: integer("billingCycleAnchor")
|
||||
version: integer("version"),
|
||||
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||
type: text("type") // tier1, tier2, tier3, or license
|
||||
});
|
||||
|
||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||
subscriptionItemId: integer("subscriptionItemId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
stripeSubscriptionItemId: text("stripeSubscriptionItemId"),
|
||||
subscriptionId: text("subscriptionId")
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
@@ -84,6 +87,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||
}),
|
||||
planId: text("planId").notNull(),
|
||||
priceId: text("priceId"),
|
||||
featureId: text("featureId"),
|
||||
meterId: text("meterId"),
|
||||
unitAmount: real("unitAmount"),
|
||||
tiers: text("tiers"),
|
||||
@@ -126,6 +130,7 @@ export const limits = sqliteTable("limits", {
|
||||
})
|
||||
.notNull(),
|
||||
value: real("value"),
|
||||
override: integer("override", { mode: "boolean" }).default(false),
|
||||
description: text("description")
|
||||
});
|
||||
|
||||
|
||||
@@ -162,7 +162,8 @@ export const resources = sqliteTable("resources", {
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath")
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
||||
@@ -105,11 +105,13 @@ function getOpenApiDocumentation() {
|
||||
servers: [{ url: "/v1" }]
|
||||
});
|
||||
|
||||
// convert to yaml and save to file
|
||||
const outputPath = path.join(APP_PATH, "openapi.yaml");
|
||||
const yamlOutput = yaml.dump(generated);
|
||||
fs.writeFileSync(outputPath, yamlOutput, "utf8");
|
||||
logger.info(`OpenAPI documentation saved to ${outputPath}`);
|
||||
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||
// convert to yaml and save to file
|
||||
const outputPath = path.join(APP_PATH, "openapi.yaml");
|
||||
const yamlOutput = yaml.dump(generated);
|
||||
fs.writeFileSync(outputPath, yamlOutput, "utf8");
|
||||
logger.info(`OpenAPI documentation saved to ${outputPath}`);
|
||||
}
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
export enum FeatureId {
|
||||
SITE_UPTIME = "siteUptime",
|
||||
USERS = "users",
|
||||
SITES = "sites",
|
||||
EGRESS_DATA_MB = "egressDataMb",
|
||||
DOMAINS = "domains",
|
||||
REMOTE_EXIT_NODES = "remoteExitNodes"
|
||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||
TIER1 = "tier1"
|
||||
}
|
||||
|
||||
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 async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
|
||||
switch (featureId) {
|
||||
case FeatureId.USERS:
|
||||
return "Users";
|
||||
case FeatureId.SITES:
|
||||
return "Sites";
|
||||
case FeatureId.EGRESS_DATA_MB:
|
||||
return "Egress Data (MB)";
|
||||
case FeatureId.DOMAINS:
|
||||
return "Domains";
|
||||
case FeatureId.REMOTE_EXIT_NODES:
|
||||
return "Remote Exit Nodes";
|
||||
case FeatureId.TIER1:
|
||||
return "Home Lab";
|
||||
default:
|
||||
return featureId;
|
||||
}
|
||||
}
|
||||
|
||||
// this is from the old system
|
||||
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
|
||||
// [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,45 +54,81 @@ 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 tier1FeaturePriceSet: FeaturePriceSet = {
|
||||
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
||||
};
|
||||
|
||||
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 tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||
};
|
||||
|
||||
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 getTier1FeaturePriceSet(): FeaturePriceSet {
|
||||
if (
|
||||
process.env.ENVIRONMENT == "prod" &&
|
||||
process.env.SANDBOX_MODE !== "true"
|
||||
) {
|
||||
return standardFeaturePriceSet;
|
||||
return tier1FeaturePriceSet;
|
||||
} else {
|
||||
return standardFeaturePriceSetSandbox;
|
||||
return tier1FeaturePriceSetSandbox;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLineItems(
|
||||
featurePriceSet: FeaturePriceSet
|
||||
): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({
|
||||
price: priceId
|
||||
}));
|
||||
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
||||
};
|
||||
|
||||
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||
};
|
||||
|
||||
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
||||
if (
|
||||
process.env.ENVIRONMENT == "prod" &&
|
||||
process.env.SANDBOX_MODE !== "true"
|
||||
) {
|
||||
return tier2FeaturePriceSet;
|
||||
} else {
|
||||
return tier2FeaturePriceSetSandbox;
|
||||
}
|
||||
}
|
||||
|
||||
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
||||
};
|
||||
|
||||
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||
};
|
||||
|
||||
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
||||
if (
|
||||
process.env.ENVIRONMENT == "prod" &&
|
||||
process.env.SANDBOX_MODE !== "true"
|
||||
) {
|
||||
return tier3FeaturePriceSet;
|
||||
} else {
|
||||
return tier3FeaturePriceSetSandbox;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
|
||||
// Check all feature price sets
|
||||
const allPriceSets = [
|
||||
getTier1FeaturePriceSet(),
|
||||
getTier2FeaturePriceSet(),
|
||||
getTier3FeaturePriceSet()
|
||||
];
|
||||
|
||||
for (const priceSet of allPriceSets) {
|
||||
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
|
||||
([_, price]) => price === priceId
|
||||
);
|
||||
if (entry) {
|
||||
return entry[0];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
25
server/lib/billing/getLineItems.ts
Normal file
25
server/lib/billing/getLineItems.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Stripe from "stripe";
|
||||
import { FeatureId, FeaturePriceSet } from "./features";
|
||||
import { usageService } from "./usageService";
|
||||
|
||||
export async function getLineItems(
|
||||
featurePriceSet: FeaturePriceSet,
|
||||
orgId: string,
|
||||
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
||||
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||
|
||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
||||
let quantity: number | undefined;
|
||||
|
||||
if (featureId === FeatureId.USERS) {
|
||||
quantity = users?.instantaneousValue || 1;
|
||||
} else if (featureId === FeatureId.TIER1) {
|
||||
quantity = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
price: priceId,
|
||||
quantity: quantity
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,50 +1,67 @@
|
||||
import { FeatureId } from "./features";
|
||||
|
||||
export type LimitSet = {
|
||||
export type LimitSet = Partial<{
|
||||
[key in FeatureId]: {
|
||||
value: number | null; // null indicates no limit
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export const sandboxLimitSet: LimitSet = {
|
||||
[FeatureId.SITE_UPTIME]: { value: 2880, 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.SITES]: { value: 1, description: "Sandbox limit" },
|
||||
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" }
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" },
|
||||
};
|
||||
|
||||
export const freeLimitSet: LimitSet = {
|
||||
[FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days
|
||||
[FeatureId.USERS]: { value: 3, description: "Free tier limit" },
|
||||
[FeatureId.EGRESS_DATA_MB]: {
|
||||
value: 25000,
|
||||
description: "Free tier limit"
|
||||
}, // 25 GB
|
||||
[FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
|
||||
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
|
||||
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
|
||||
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
|
||||
};
|
||||
|
||||
export const subscribedLimitSet: LimitSet = {
|
||||
[FeatureId.SITE_UPTIME]: {
|
||||
value: 2232000,
|
||||
description: "Contact us to increase soft limit."
|
||||
}, // 50 sites up for 31 days
|
||||
export const tier1LimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
||||
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||
};
|
||||
|
||||
export const tier2LimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: {
|
||||
value: 150,
|
||||
description: "Contact us to increase soft limit."
|
||||
value: 100,
|
||||
description: "Team limit"
|
||||
},
|
||||
[FeatureId.SITES]: {
|
||||
value: 50,
|
||||
description: "Team 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."
|
||||
value: 50,
|
||||
description: "Team limit"
|
||||
},
|
||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||
value: 5,
|
||||
description: "Contact us to increase soft limit."
|
||||
}
|
||||
value: 3,
|
||||
description: "Team limit"
|
||||
},
|
||||
};
|
||||
|
||||
export const tier3LimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: {
|
||||
value: 500,
|
||||
description: "Business limit"
|
||||
},
|
||||
[FeatureId.SITES]: {
|
||||
value: 250,
|
||||
description: "Business limit"
|
||||
},
|
||||
[FeatureId.DOMAINS]: {
|
||||
value: 100,
|
||||
description: "Business limit"
|
||||
},
|
||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||
value: 20,
|
||||
description: "Business limit"
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db, limits } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { LimitSet } from "./limitSet";
|
||||
import { FeatureId } from "./features";
|
||||
import logger from "@server/logger";
|
||||
|
||||
class LimitService {
|
||||
async applyLimitSetToOrg(orgId: string, limitSet: LimitSet): Promise<void> {
|
||||
@@ -13,6 +14,21 @@ class LimitService {
|
||||
for (const [featureId, entry] of limitEntries) {
|
||||
const limitId = `${orgId}-${featureId}`;
|
||||
const { value, description } = entry;
|
||||
// get the limit first
|
||||
const [limit] = await trx
|
||||
.select()
|
||||
.from(limits)
|
||||
.where(eq(limits.limitId, limitId))
|
||||
.limit(1);
|
||||
|
||||
// check if its overriden
|
||||
if (limit && limit.override) {
|
||||
logger.debug(
|
||||
`Skipping limit ${limitId} for org ${orgId} since it is overridden...`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await trx
|
||||
.insert(limits)
|
||||
.values({ limitId, orgId, featureId, value, description });
|
||||
|
||||
50
server/lib/billing/tierMatrix.ts
Normal file
50
server/lib/billing/tierMatrix.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export enum TierFeature {
|
||||
OrgOidc = "orgOidc",
|
||||
LoginPageDomain = "loginPageDomain", // handle downgrade by removing custom domain
|
||||
DeviceApprovals = "deviceApprovals", // handle downgrade by disabling device approvals
|
||||
LoginPageBranding = "loginPageBranding", // handle downgrade by setting to default branding
|
||||
LogExport = "logExport",
|
||||
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||
RotateCredentials = "rotateCredentials",
|
||||
MaintencePage = "maintencePage", // handle downgrade
|
||||
DevicePosture = "devicePosture",
|
||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.LoginPageBranding]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.LogExport]: ["tier3", "enterprise"],
|
||||
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.TwoFactorEnforcement]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.SessionDurationPolicies]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.PasswordExpirationPolicies]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"]
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { eq, sql, and } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import {
|
||||
db,
|
||||
usage,
|
||||
@@ -32,11 +30,7 @@ interface StripeEvent {
|
||||
}
|
||||
|
||||
export function noop() {
|
||||
if (
|
||||
build !== "saas" ||
|
||||
!process.env.S3_BUCKET ||
|
||||
!process.env.LOCAL_FILE_PATH
|
||||
) {
|
||||
if (build !== "saas") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -44,31 +38,40 @@ export function noop() {
|
||||
|
||||
export class UsageService {
|
||||
private bucketName: string | undefined;
|
||||
private currentEventFile: string | null = null;
|
||||
private currentFileStartTime: number = 0;
|
||||
private eventsDir: string | undefined;
|
||||
private uploadingFiles: Set<string> = new Set();
|
||||
private events: StripeEvent[] = [];
|
||||
private lastUploadTime: number = Date.now();
|
||||
private isUploading: boolean = false;
|
||||
|
||||
constructor() {
|
||||
if (noop()) {
|
||||
return;
|
||||
}
|
||||
// this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket;
|
||||
// this.eventsDir = privateConfig.getRawPrivateConfig().stripe?.localFilePath;
|
||||
this.bucketName = process.env.S3_BUCKET || undefined;
|
||||
this.eventsDir = process.env.LOCAL_FILE_PATH || undefined;
|
||||
|
||||
// Ensure events directory exists
|
||||
this.initializeEventsDirectory().then(() => {
|
||||
this.uploadPendingEventFilesOnStartup();
|
||||
});
|
||||
// this.bucketName = process.env.S3_BUCKET || undefined;
|
||||
|
||||
// Periodically check for old event files to upload
|
||||
setInterval(() => {
|
||||
this.uploadOldEventFiles().catch((err) => {
|
||||
logger.error("Error in periodic event file upload:", err);
|
||||
});
|
||||
}, 30000); // every 30 seconds
|
||||
// // Periodically check and upload events
|
||||
// setInterval(() => {
|
||||
// this.checkAndUploadEvents().catch((err) => {
|
||||
// logger.error("Error in periodic event upload:", err);
|
||||
// });
|
||||
// }, 30000); // every 30 seconds
|
||||
|
||||
// // Handle graceful shutdown on SIGTERM
|
||||
// process.on("SIGTERM", async () => {
|
||||
// logger.info(
|
||||
// "SIGTERM received, uploading events before shutdown..."
|
||||
// );
|
||||
// await this.forceUpload();
|
||||
// logger.info("Events uploaded, proceeding with shutdown");
|
||||
// });
|
||||
|
||||
// // Handle SIGINT as well (Ctrl+C)
|
||||
// process.on("SIGINT", async () => {
|
||||
// logger.info("SIGINT received, uploading events before shutdown...");
|
||||
// await this.forceUpload();
|
||||
// logger.info("Events uploaded, proceeding with shutdown");
|
||||
// process.exit(0);
|
||||
// });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,85 +81,6 @@ export class UsageService {
|
||||
return Math.round(value * 100000000000) / 100000000000; // 11 decimal places
|
||||
}
|
||||
|
||||
private async initializeEventsDirectory(): Promise<void> {
|
||||
if (!this.eventsDir) {
|
||||
logger.warn(
|
||||
"Stripe local file path is not configured, skipping events directory initialization."
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(this.eventsDir, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error("Failed to create events directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadPendingEventFilesOnStartup(): Promise<void> {
|
||||
if (!this.eventsDir || !this.bucketName) {
|
||||
logger.warn(
|
||||
"Stripe local file path or bucket name is not configured, skipping leftover event file upload."
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files = await fs.readdir(this.eventsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const filePath = path.join(this.eventsDir, file);
|
||||
try {
|
||||
const fileContent = await fs.readFile(
|
||||
filePath,
|
||||
"utf-8"
|
||||
);
|
||||
const events = JSON.parse(fileContent);
|
||||
if (Array.isArray(events) && events.length > 0) {
|
||||
// Upload to S3
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: file,
|
||||
Body: fileContent,
|
||||
ContentType: "application/json"
|
||||
});
|
||||
await s3Client.send(uploadCommand);
|
||||
|
||||
// Check if file still exists before unlinking
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
logger.debug(
|
||||
`Startup file ${file} was already deleted`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Uploaded leftover event file ${file} to S3 with ${events.length} events`
|
||||
);
|
||||
} else {
|
||||
// Remove empty file
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
logger.debug(
|
||||
`Empty startup file ${file} was already deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error processing leftover event file ${file}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to scan for leftover event files");
|
||||
}
|
||||
}
|
||||
|
||||
public async add(
|
||||
orgId: string,
|
||||
featureId: FeatureId,
|
||||
@@ -206,7 +130,9 @@ export class UsageService {
|
||||
}
|
||||
|
||||
// Log event for Stripe
|
||||
await this.logStripeEvent(featureId, value, customerId);
|
||||
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
|
||||
// await this.logStripeEvent(featureId, value, customerId);
|
||||
// }
|
||||
|
||||
return usage || null;
|
||||
} catch (error: any) {
|
||||
@@ -286,7 +212,7 @@ export class UsageService {
|
||||
return new Date(date * 1000).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
async updateDaily(
|
||||
async updateCount(
|
||||
orgId: string,
|
||||
featureId: FeatureId,
|
||||
value?: number,
|
||||
@@ -312,8 +238,6 @@ export class UsageService {
|
||||
value = this.truncateValue(value);
|
||||
}
|
||||
|
||||
const today = this.getTodayDateString();
|
||||
|
||||
let currentUsage: Usage | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -327,66 +251,34 @@ export class UsageService {
|
||||
.limit(1);
|
||||
|
||||
if (currentUsage) {
|
||||
const lastUpdateDate = this.getDateString(
|
||||
currentUsage.updatedAt
|
||||
);
|
||||
const currentRunningTotal = currentUsage.latestValue;
|
||||
const lastDailyValue = currentUsage.instantaneousValue || 0;
|
||||
|
||||
if (value == undefined || value === null) {
|
||||
value = currentUsage.instantaneousValue || 0;
|
||||
}
|
||||
|
||||
if (lastUpdateDate === today) {
|
||||
// Same day update: replace the daily value
|
||||
// Remove old daily value from running total, add new value
|
||||
const newRunningTotal = this.truncateValue(
|
||||
currentRunningTotal - lastDailyValue + value
|
||||
);
|
||||
|
||||
await trx
|
||||
.update(usage)
|
||||
.set({
|
||||
latestValue: newRunningTotal,
|
||||
instantaneousValue: value,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(usage.usageId, usageId));
|
||||
} else {
|
||||
// New day: add to running total
|
||||
const newRunningTotal = this.truncateValue(
|
||||
currentRunningTotal + value
|
||||
);
|
||||
|
||||
await trx
|
||||
.update(usage)
|
||||
.set({
|
||||
latestValue: newRunningTotal,
|
||||
instantaneousValue: value,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(usage.usageId, usageId));
|
||||
}
|
||||
await trx
|
||||
.update(usage)
|
||||
.set({
|
||||
instantaneousValue: value,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(usage.usageId, usageId));
|
||||
} else {
|
||||
// First record for this meter
|
||||
const meterId = getFeatureMeterId(featureId);
|
||||
const truncatedValue = this.truncateValue(value || 0);
|
||||
await trx.insert(usage).values({
|
||||
usageId,
|
||||
featureId,
|
||||
orgId,
|
||||
meterId,
|
||||
instantaneousValue: truncatedValue,
|
||||
latestValue: truncatedValue,
|
||||
instantaneousValue: value || 0,
|
||||
latestValue: value || 0,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.logStripeEvent(featureId, value || 0, customerId);
|
||||
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
|
||||
// await this.logStripeEvent(featureId, value || 0, customerId);
|
||||
// }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to update daily usage for ${orgId}/${featureId}:`,
|
||||
`Failed to update count usage for ${orgId}/${featureId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
@@ -450,121 +342,58 @@ export class UsageService {
|
||||
}
|
||||
};
|
||||
|
||||
await this.writeEventToFile(event);
|
||||
await this.checkAndUploadFile();
|
||||
this.addEventToMemory(event);
|
||||
await this.checkAndUploadEvents();
|
||||
}
|
||||
|
||||
private async writeEventToFile(event: StripeEvent): Promise<void> {
|
||||
if (!this.eventsDir || !this.bucketName) {
|
||||
private addEventToMemory(event: StripeEvent): void {
|
||||
if (!this.bucketName) {
|
||||
logger.warn(
|
||||
"Stripe local file path or bucket name is not configured, skipping event file write."
|
||||
"S3 bucket name is not configured, skipping event storage."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.currentEventFile) {
|
||||
this.currentEventFile = this.generateEventFileName();
|
||||
this.currentFileStartTime = Date.now();
|
||||
}
|
||||
|
||||
const filePath = path.join(this.eventsDir, this.currentEventFile);
|
||||
|
||||
try {
|
||||
let events: StripeEvent[] = [];
|
||||
|
||||
// Try to read existing file
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
||||
events = JSON.parse(fileContent);
|
||||
} catch (error) {
|
||||
// File doesn't exist or is empty, start with empty array
|
||||
events = [];
|
||||
}
|
||||
|
||||
// Add new event
|
||||
events.push(event);
|
||||
|
||||
// Write back to file
|
||||
await fs.writeFile(filePath, JSON.stringify(events, null, 2));
|
||||
} catch (error) {
|
||||
logger.error("Failed to write event to file:", error);
|
||||
}
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
private async checkAndUploadFile(): Promise<void> {
|
||||
if (!this.currentEventFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
private async checkAndUploadEvents(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const fileAge = now - this.currentFileStartTime;
|
||||
const timeSinceLastUpload = now - this.lastUploadTime;
|
||||
|
||||
// Check if file is at least 1 minute old
|
||||
if (fileAge >= 60000) {
|
||||
// 60 seconds
|
||||
await this.uploadFileToS3();
|
||||
// Check if at least 1 minute has passed since last upload
|
||||
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
|
||||
await this.uploadEventsToS3();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFileToS3(): Promise<void> {
|
||||
if (!this.bucketName || !this.eventsDir) {
|
||||
private async uploadEventsToS3(): Promise<void> {
|
||||
if (!this.bucketName) {
|
||||
logger.warn(
|
||||
"Stripe local file path or bucket name is not configured, skipping S3 upload."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.currentEventFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = this.currentEventFile;
|
||||
const filePath = path.join(this.eventsDir, fileName);
|
||||
|
||||
// Check if this file is already being uploaded
|
||||
if (this.uploadingFiles.has(fileName)) {
|
||||
logger.debug(
|
||||
`File ${fileName} is already being uploaded, skipping`
|
||||
"S3 bucket name is not configured, skipping S3 upload."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark file as being uploaded
|
||||
this.uploadingFiles.add(fileName);
|
||||
if (this.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already uploading
|
||||
if (this.isUploading) {
|
||||
logger.debug("Already uploading events, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading = true;
|
||||
|
||||
try {
|
||||
// Check if file exists before trying to read it
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`File ${fileName} does not exist, may have been already processed`
|
||||
);
|
||||
this.uploadingFiles.delete(fileName);
|
||||
// Reset current file if it was this file
|
||||
if (this.currentEventFile === fileName) {
|
||||
this.currentEventFile = null;
|
||||
this.currentFileStartTime = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Take a snapshot of current events and clear the array
|
||||
const eventsToUpload = [...this.events];
|
||||
this.events = [];
|
||||
this.lastUploadTime = Date.now();
|
||||
|
||||
// Check if file exists and has content
|
||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
||||
const events = JSON.parse(fileContent);
|
||||
|
||||
if (events.length === 0) {
|
||||
// No events to upload, just clean up
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
// File may have been already deleted
|
||||
logger.debug(
|
||||
`File ${fileName} was already deleted during cleanup`
|
||||
);
|
||||
}
|
||||
this.currentEventFile = null;
|
||||
this.uploadingFiles.delete(fileName);
|
||||
return;
|
||||
}
|
||||
const fileName = this.generateEventFileName();
|
||||
const fileContent = JSON.stringify(eventsToUpload, null, 2);
|
||||
|
||||
// Upload to S3
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
@@ -576,29 +405,15 @@ export class UsageService {
|
||||
|
||||
await s3Client.send(uploadCommand);
|
||||
|
||||
// Clean up local file - check if it still exists before unlinking
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
// File may have been already deleted by another process
|
||||
logger.debug(
|
||||
`File ${fileName} was already deleted during upload`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Uploaded ${fileName} to S3 with ${events.length} events`
|
||||
`Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
|
||||
);
|
||||
|
||||
// Reset for next file
|
||||
this.currentEventFile = null;
|
||||
this.currentFileStartTime = 0;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload ${fileName} to S3:`, error);
|
||||
logger.error("Failed to upload events to S3:", error);
|
||||
// Note: Events are lost if upload fails. In a production system,
|
||||
// you might want to add the events back to the array or implement retry logic
|
||||
} finally {
|
||||
// Always remove from uploading set
|
||||
this.uploadingFiles.delete(fileName);
|
||||
this.isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,129 +498,16 @@ export class UsageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getUsageDaily(
|
||||
orgId: string,
|
||||
featureId: FeatureId
|
||||
): Promise<Usage | null> {
|
||||
if (noop()) {
|
||||
return null;
|
||||
}
|
||||
await this.updateDaily(orgId, featureId); // Ensure daily usage is updated
|
||||
return this.getUsage(orgId, featureId);
|
||||
}
|
||||
|
||||
public async forceUpload(): Promise<void> {
|
||||
await this.uploadFileToS3();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the events directory for files older than 1 minute and upload them if not empty.
|
||||
*/
|
||||
private async uploadOldEventFiles(): Promise<void> {
|
||||
if (!this.eventsDir || !this.bucketName) {
|
||||
logger.warn(
|
||||
"Stripe local file path or bucket name is not configured, skipping old event file upload."
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files = await fs.readdir(this.eventsDir);
|
||||
const now = Date.now();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
|
||||
// Skip files that are already being uploaded
|
||||
if (this.uploadingFiles.has(file)) {
|
||||
logger.debug(
|
||||
`Skipping file ${file} as it's already being uploaded`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(this.eventsDir, file);
|
||||
|
||||
try {
|
||||
// Check if file still exists before processing
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (accessError) {
|
||||
logger.debug(`File ${file} does not exist, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
const age = now - stat.mtimeMs;
|
||||
if (age >= 90000) {
|
||||
// 1.5 minutes - Mark as being uploaded
|
||||
this.uploadingFiles.add(file);
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(
|
||||
filePath,
|
||||
"utf-8"
|
||||
);
|
||||
const events = JSON.parse(fileContent);
|
||||
if (Array.isArray(events) && events.length > 0) {
|
||||
// Upload to S3
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: file,
|
||||
Body: fileContent,
|
||||
ContentType: "application/json"
|
||||
});
|
||||
await s3Client.send(uploadCommand);
|
||||
|
||||
// Check if file still exists before unlinking
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
logger.debug(
|
||||
`File ${file} was already deleted during interval upload`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Interval: Uploaded event file ${file} to S3 with ${events.length} events`
|
||||
);
|
||||
// If this was the current event file, reset it
|
||||
if (this.currentEventFile === file) {
|
||||
this.currentEventFile = null;
|
||||
this.currentFileStartTime = 0;
|
||||
}
|
||||
} else {
|
||||
// Remove empty file
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
logger.debug(
|
||||
`Empty file ${file} was already deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always remove from uploading set
|
||||
this.uploadingFiles.delete(file);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Interval: Error processing event file ${file}:`,
|
||||
err
|
||||
);
|
||||
// Remove from uploading set on error
|
||||
this.uploadingFiles.delete(file);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Interval: Failed to scan for event files:", err);
|
||||
if (this.events.length > 0) {
|
||||
// Force upload regardless of time
|
||||
this.lastUploadTime = 0; // Reset to force upload
|
||||
await this.uploadEventsToS3();
|
||||
}
|
||||
}
|
||||
|
||||
public async checkLimitSet(
|
||||
orgId: string,
|
||||
kickSites = false,
|
||||
featureId?: FeatureId,
|
||||
usage?: Usage,
|
||||
trx: Transaction | typeof db = db
|
||||
@@ -879,58 +581,6 @@ export class UsageService {
|
||||
break; // Exit early if any limit is exceeded
|
||||
}
|
||||
}
|
||||
|
||||
// If any limits are exceeded, disconnect all sites for this organization
|
||||
if (hasExceededLimits && kickSites) {
|
||||
logger.warn(
|
||||
`Disconnecting all sites for org ${orgId} due to exceeded limits`
|
||||
);
|
||||
|
||||
// Get all sites for this organization
|
||||
const orgSites = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.orgId, orgId));
|
||||
|
||||
// Mark all sites as offline and send termination messages
|
||||
const siteUpdates = orgSites.map((site) => site.siteId);
|
||||
|
||||
if (siteUpdates.length > 0) {
|
||||
// Send termination messages to newt sites
|
||||
for (const site of orgSites) {
|
||||
if (site.type === "newt") {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
const payload = {
|
||||
type: `newt/wg/terminate`,
|
||||
data: {
|
||||
reason: "Usage limits exceeded"
|
||||
}
|
||||
};
|
||||
|
||||
// Don't await to prevent blocking
|
||||
await sendToClient(newt.newtId, payload).catch(
|
||||
(error: any) => {
|
||||
logger.error(
|
||||
`Failed to send termination message to newt ${newt.newtId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking limits for org ${orgId}:`, error);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { resourcePassword } from "@server/db";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
|
||||
export type ProxyResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
@@ -212,7 +212,7 @@ export async function updateProxyResources(
|
||||
} else {
|
||||
// Update existing resource
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
}
|
||||
@@ -648,7 +648,7 @@ export async function updateProxyResources(
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
|
||||
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
|
||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||
import { tierMatrix } from "./billing/tierMatrix";
|
||||
|
||||
export async function calculateUserClientsForOrgs(
|
||||
userId: string,
|
||||
@@ -189,7 +190,8 @@ export async function calculateUserClientsForOrgs(
|
||||
const niceId = await getUniqueClientName(orgId);
|
||||
|
||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||
userOrg.orgId
|
||||
userOrg.orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
const requireApproval =
|
||||
build !== "oss" &&
|
||||
|
||||
@@ -107,6 +107,11 @@ export class Config {
|
||||
process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path;
|
||||
}
|
||||
|
||||
process.env.DISABLE_ENTERPRISE_FEATURES = parsedConfig.flags
|
||||
?.disable_enterprise_features
|
||||
? "true"
|
||||
: "false";
|
||||
|
||||
this.rawConfig = parsedConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.15.0";
|
||||
export const APP_VERSION = "1.15.4";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function createUserAccountOrg(
|
||||
const customerId = await createCustomer(orgId, userEmail);
|
||||
|
||||
if (customerId) {
|
||||
await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
|
||||
await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export async function isLicensedOrSubscribed(
|
||||
orgId: string,
|
||||
tiers: Tier[]
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
8
server/lib/isSubscribed.ts
Normal file
8
server/lib/isSubscribed.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export async function isSubscribed(
|
||||
orgId: string,
|
||||
tiers: Tier[]
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
18
server/lib/normalizePostAuthPath.ts
Normal file
18
server/lib/normalizePostAuthPath.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Normalizes a post-authentication path for safe use when building redirect URLs.
|
||||
* Returns a path that starts with / and does not allow open redirects (no //, no :).
|
||||
*/
|
||||
export function normalizePostAuthPath(path: string | null | undefined): string | null {
|
||||
if (path == null || typeof path !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = path.trim();
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
// Reject protocol-relative (//) or scheme (:) to avoid open redirect
|
||||
if (trimmed.includes("//") || trimmed.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
@@ -331,7 +331,8 @@ export const configSchema = z
|
||||
disable_local_sites: z.boolean().optional(),
|
||||
disable_basic_wireguard_sites: z.boolean().optional(),
|
||||
disable_config_managed_domains: z.boolean().optional(),
|
||||
disable_product_help_banners: z.boolean().optional()
|
||||
disable_product_help_banners: z.boolean().optional(),
|
||||
disable_enterprise_features: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
dns: z
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from "./verifyUserIsOrgOwner";
|
||||
export * from "./verifySiteResourceAccess";
|
||||
export * from "./logActionAudit";
|
||||
export * from "./verifyOlmAccess";
|
||||
export * from "./verifyLimits";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function verifyApiKeyOrgAccess(
|
||||
req: Request,
|
||||
|
||||
43
server/middlewares/verifyLimits.ts
Normal file
43
server/middlewares/verifyLimits.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export async function verifyLimits(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
if (build != "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
||||
}
|
||||
|
||||
try {
|
||||
const reject = await usageService.checkLimitSet(orgId);
|
||||
|
||||
if (reject) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.PAYMENT_REQUIRED,
|
||||
"Organization has exceeded its usage limits. Please upgrade your plan or contact support."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error checking limits"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,46 +11,59 @@
|
||||
* 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 { db, customers, subscriptions } from "@server/db";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
|
||||
export async function getOrgTierData(
|
||||
orgId: string
|
||||
): Promise<{ tier: string | null; active: boolean }> {
|
||||
let tier = null;
|
||||
): Promise<{ tier: Tier | null; active: boolean }> {
|
||||
let tier: Tier | null = null;
|
||||
let active = false;
|
||||
|
||||
if (build !== "saas") {
|
||||
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 (items && items.length > 0) {
|
||||
const tierPriceSet = getTierPriceSet();
|
||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
||||
// Check if any subscription item matches this tier's price ID
|
||||
const matchingItem = items.find((item) => item.priceId === priceId);
|
||||
if (matchingItem) {
|
||||
tier = tierId;
|
||||
break;
|
||||
if (subscription) {
|
||||
// Validate that subscription.type is one of the expected tier values
|
||||
if (
|
||||
subscription.type === "tier1" ||
|
||||
subscription.type === "tier2" ||
|
||||
subscription.type === "tier3"
|
||||
) {
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subscription && subscription.status === "active") {
|
||||
active = true;
|
||||
}
|
||||
|
||||
// If we found a tier and active subscription, we can stop
|
||||
if (tier && active) {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If org not found or error occurs, return null tier and inactive
|
||||
// This is acceptable behavior as per the function signature
|
||||
}
|
||||
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
|
||||
import { build } from "@server/build";
|
||||
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 { eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -80,6 +78,8 @@ export async function checkOrgAccessPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: check that the org is subscribed
|
||||
|
||||
// get the needed data
|
||||
|
||||
if (!props.org) {
|
||||
|
||||
@@ -65,6 +65,11 @@ export class PrivateConfig {
|
||||
this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
|
||||
}
|
||||
|
||||
if (this.rawPrivateConfig.app.identity_provider_mode) {
|
||||
process.env.IDENTITY_PROVIDER_MODE =
|
||||
this.rawPrivateConfig.app.identity_provider_mode;
|
||||
}
|
||||
|
||||
process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
|
||||
?.logo?.auth_page?.width
|
||||
? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
|
||||
@@ -125,24 +130,10 @@ export class PrivateConfig {
|
||||
this.rawPrivateConfig.server.reo_client_id;
|
||||
}
|
||||
|
||||
if (this.rawPrivateConfig.stripe?.s3Bucket) {
|
||||
process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket;
|
||||
}
|
||||
if (this.rawPrivateConfig.stripe?.localFilePath) {
|
||||
process.env.LOCAL_FILE_PATH =
|
||||
this.rawPrivateConfig.stripe.localFilePath;
|
||||
}
|
||||
if (this.rawPrivateConfig.stripe?.s3Region) {
|
||||
process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region;
|
||||
}
|
||||
if (this.rawPrivateConfig.flags.use_pangolin_dns) {
|
||||
process.env.USE_PANGOLIN_DNS =
|
||||
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
||||
}
|
||||
if (this.rawPrivateConfig.flags.use_org_only_idp) {
|
||||
process.env.USE_ORG_ONLY_IDP =
|
||||
this.rawPrivateConfig.flags.use_org_only_idp.toString();
|
||||
}
|
||||
}
|
||||
|
||||
public getRawPrivateConfig() {
|
||||
|
||||
@@ -13,18 +13,20 @@
|
||||
|
||||
import { build } from "@server/build";
|
||||
import license from "#private/license/license";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
||||
export async function isLicensedOrSubscribed(
|
||||
orgId: string,
|
||||
tiers: Tier[]
|
||||
): Promise<boolean> {
|
||||
if (build === "enterprise") {
|
||||
return await license.isUnlocked();
|
||||
}
|
||||
|
||||
if (build === "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
return tier === TierId.STANDARD;
|
||||
return isSubscribed(orgId, tiers);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
29
server/private/lib/isSubscribed.ts
Normal file
29
server/private/lib/isSubscribed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export async function isSubscribed(
|
||||
orgId: string,
|
||||
tiers: Tier[]
|
||||
): Promise<boolean> {
|
||||
if (build === "saas") {
|
||||
const { tier, active } = await getOrgTierData(orgId);
|
||||
const isTier = (tier && tiers.includes(tier)) || false;
|
||||
return active && isTier;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -25,7 +25,8 @@ export const privateConfigSchema = z.object({
|
||||
app: z
|
||||
.object({
|
||||
region: z.string().optional().default("default"),
|
||||
base_domain: z.string().optional()
|
||||
base_domain: z.string().optional(),
|
||||
identity_provider_mode: z.enum(["global", "org"]).optional()
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
@@ -95,7 +96,7 @@ export const privateConfigSchema = z.object({
|
||||
.object({
|
||||
enable_redis: z.boolean().optional().default(false),
|
||||
use_pangolin_dns: z.boolean().optional().default(false),
|
||||
use_org_only_idp: z.boolean().optional().default(false)
|
||||
use_org_only_idp: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
@@ -176,12 +177,34 @@ export const privateConfigSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
|
||||
s3Bucket: z.string(),
|
||||
s3Region: z.string().default("us-east-1"),
|
||||
localFilePath: z.string()
|
||||
// s3Bucket: z.string(),
|
||||
// s3Region: z.string().default("us-east-1"),
|
||||
// localFilePath: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
})
|
||||
.transform((data) => {
|
||||
// this to maintain backwards compatibility with the old config file
|
||||
const identityProviderMode = data.app?.identity_provider_mode;
|
||||
const useOrgOnlyIdp = data.flags?.use_org_only_idp;
|
||||
|
||||
if (identityProviderMode !== undefined) {
|
||||
return data;
|
||||
}
|
||||
if (useOrgOnlyIdp === true) {
|
||||
return {
|
||||
...data,
|
||||
app: { ...data.app, identity_provider_mode: "org" as const }
|
||||
};
|
||||
}
|
||||
if (useOrgOnlyIdp === false) {
|
||||
return {
|
||||
...data,
|
||||
app: { ...data.app, identity_provider_mode: "global" as const }
|
||||
};
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
export function readPrivateConfigFile() {
|
||||
if (build == "oss") {
|
||||
|
||||
@@ -16,46 +16,61 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { build } from "@server/build";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export function verifyValidSubscription(tiers: Tier[]) {
|
||||
return async function (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (build != "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId =
|
||||
req.params.orgId ||
|
||||
req.body.orgId ||
|
||||
req.query.orgId ||
|
||||
req.userOrgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization ID is required to verify subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { tier, active } = await getOrgTierData(orgId);
|
||||
const isTier = tiers.includes(tier as Tier);
|
||||
if (!active) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization does not have an active subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!isTier) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization subscription tier does not have access to this feature"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyValidSubscription(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
if (build != "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId = req.params.orgId || req.body.orgId || req.query.orgId || req.userOrgId;
|
||||
|
||||
if (!orgId) {
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization ID is required to verify subscription"
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const tier = await getOrgTierData(orgId);
|
||||
|
||||
if (!tier.active) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization does not have an active subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import { fromError } from "zod-validation-error";
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { build } from "@server/build";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import {
|
||||
approvals,
|
||||
clients,
|
||||
@@ -273,19 +271,6 @@ export async function listApprovals(
|
||||
|
||||
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, nextCursorPending, nextCursorTimestamp } =
|
||||
await queryApprovals({
|
||||
orgId: orgId.toString(),
|
||||
|
||||
@@ -17,10 +17,7 @@ import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
import { build } from "@server/build";
|
||||
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 { and, eq, type InferInsertModel } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
@@ -64,20 +61,6 @@ export async function processPendingApproval(
|
||||
}
|
||||
|
||||
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 approval = await db
|
||||
|
||||
@@ -13,4 +13,3 @@
|
||||
|
||||
export * from "./transferSession";
|
||||
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}`
|
||||
};
|
||||
}
|
||||
};
|
||||
268
server/private/routers/billing/changeTier.ts
Normal file
268
server/private/routers/billing/changeTier.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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 {
|
||||
getTier1FeaturePriceSet,
|
||||
getTier3FeaturePriceSet,
|
||||
getTier2FeaturePriceSet,
|
||||
FeatureId,
|
||||
type FeaturePriceSet
|
||||
} from "@server/lib/billing";
|
||||
import { getLineItems } from "@server/lib/billing/getLineItems";
|
||||
|
||||
const changeTierSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const changeTierBodySchema = z.strictObject({
|
||||
tier: z.enum(["tier1", "tier2", "tier3"])
|
||||
});
|
||||
|
||||
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, "tier1"),
|
||||
eq(subscriptions.type, "tier2"),
|
||||
eq(subscriptions.type, "tier3")
|
||||
)
|
||||
)
|
||||
)
|
||||
.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 === "tier1") {
|
||||
targetPriceSet = getTier1FeaturePriceSet();
|
||||
} else if (tier === "tier2") {
|
||||
targetPriceSet = getTier2FeaturePriceSet();
|
||||
} else if (tier === "tier3") {
|
||||
targetPriceSet = getTier3FeaturePriceSet();
|
||||
} 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
|
||||
// tier1 uses TIER1 product, tier2/tier3 use USERS product
|
||||
const currentTier = subscription.type;
|
||||
const switchingProducts =
|
||||
(currentTier === "tier1" &&
|
||||
(tier === "tier2" || tier === "tier3")) ||
|
||||
((currentTier === "tier2" || currentTier === "tier3") &&
|
||||
tier === "tier1");
|
||||
|
||||
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
|
||||
const newLineItems = await getLineItems(targetPriceSet, orgId);
|
||||
for (const lineItem of newLineItems) {
|
||||
itemsToUpdate.push(lineItem);
|
||||
}
|
||||
|
||||
updatedSubscription = await stripe!.subscriptions.update(
|
||||
subscription.subscriptionId,
|
||||
{
|
||||
items: itemsToUpdate,
|
||||
proration_behavior: "create_prorations"
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Same product, different price tier (tier2 <-> tier3)
|
||||
// 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,
|
||||
quantity: stripeItem.quantity
|
||||
};
|
||||
}
|
||||
|
||||
// Map to the corresponding feature in the new tier
|
||||
const newPriceId = targetPriceSet[FeatureId.USERS];
|
||||
|
||||
if (newPriceId) {
|
||||
return {
|
||||
id: stripeItem.id,
|
||||
price: newPriceId,
|
||||
quantity: stripeItem.quantity
|
||||
};
|
||||
}
|
||||
|
||||
// If no mapping found, keep existing
|
||||
return {
|
||||
id: stripeItem.id,
|
||||
price: stripeItem.price.id,
|
||||
quantity: stripeItem.quantity
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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,23 @@ import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing";
|
||||
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
|
||||
import {
|
||||
getTier1FeaturePriceSet,
|
||||
getTier3FeaturePriceSet,
|
||||
getTier2FeaturePriceSet
|
||||
} from "@server/lib/billing";
|
||||
import { getLineItems } from "@server/lib/billing/getLineItems";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const createCheckoutSessionSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export async function createCheckoutSessionSAAS(
|
||||
const createCheckoutSessionBodySchema = z.strictObject({
|
||||
tier: z.enum(["tier1", "tier2", "tier3"])
|
||||
});
|
||||
|
||||
export async function createCheckoutSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@@ -47,6 +56,18 @@ export async function createCheckoutSessionSAAS(
|
||||
|
||||
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
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -65,20 +86,26 @@ export async function createCheckoutSessionSAAS(
|
||||
);
|
||||
}
|
||||
|
||||
const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
|
||||
let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
||||
if (tier === "tier1") {
|
||||
lineItems = await getLineItems(getTier1FeaturePriceSet(), orgId);
|
||||
} else if (tier === "tier2") {
|
||||
lineItems = await getLineItems(getTier2FeaturePriceSet(), orgId);
|
||||
} else if (tier === "tier3") {
|
||||
lineItems = await getLineItems(getTier3FeaturePriceSet(), orgId);
|
||||
} else {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid plan"));
|
||||
}
|
||||
|
||||
logger.debug(`Line items: ${JSON.stringify(lineItems)}`);
|
||||
|
||||
const session = await stripe!.checkout.sessions.create({
|
||||
client_reference_id: orgId, // So we can look it up the org later on the webhook
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price: standardTierPrice, // Use the standard tier
|
||||
quantity: 1
|
||||
},
|
||||
...getLineItems(getStandardFeaturePriceSet())
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
line_items: lineItems,
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true`
|
||||
});
|
||||
410
server/private/routers/billing/featureLifecycle.ts
Normal file
410
server/private/routers/billing/featureLifecycle.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* 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 { SubscriptionType } from "./hooks/getSubType";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import logger from "@server/logger";
|
||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get the maximum allowed retention days for a given tier
|
||||
* Returns null for enterprise tier (unlimited)
|
||||
*/
|
||||
function getMaxRetentionDaysForTier(tier: Tier | null): number | null {
|
||||
if (!tier) {
|
||||
return 3; // Free tier
|
||||
}
|
||||
|
||||
switch (tier) {
|
||||
case "tier1":
|
||||
return 7;
|
||||
case "tier2":
|
||||
return 30;
|
||||
case "tier3":
|
||||
return 90;
|
||||
case "enterprise":
|
||||
return null; // No limit
|
||||
default:
|
||||
return 3; // Default to free tier limit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap retention days to the maximum allowed for the given tier
|
||||
*/
|
||||
async function capRetentionDays(
|
||||
orgId: string,
|
||||
tier: Tier | null
|
||||
): Promise<void> {
|
||||
const maxRetentionDays = getMaxRetentionDaysForTier(tier);
|
||||
|
||||
// If there's no limit (enterprise tier), no capping needed
|
||||
if (maxRetentionDays === null) {
|
||||
logger.debug(
|
||||
`No retention day limit for org ${orgId} on tier ${tier || "free"}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current org settings
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org) {
|
||||
logger.warn(`Org ${orgId} not found when capping retention days`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<typeof orgs.$inferInsert> = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
// Cap request log retention if it exceeds the limit
|
||||
if (
|
||||
org.settingsLogRetentionDaysRequest !== null &&
|
||||
org.settingsLogRetentionDaysRequest > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysRequest = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Cap access log retention if it exceeds the limit
|
||||
if (
|
||||
org.settingsLogRetentionDaysAccess !== null &&
|
||||
org.settingsLogRetentionDaysAccess > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysAccess = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Cap action log retention if it exceeds the limit
|
||||
if (
|
||||
org.settingsLogRetentionDaysAction !== null &&
|
||||
org.settingsLogRetentionDaysAction > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysAction = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Apply updates if needed
|
||||
if (needsUpdate) {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set(updates)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`No retention day capping needed for org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTierChange(
|
||||
orgId: string,
|
||||
newTier: SubscriptionType | null,
|
||||
previousTier?: SubscriptionType | null
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||
);
|
||||
|
||||
// License subscriptions are handled separately and don't use the tier matrix
|
||||
if (newTier === "license") {
|
||||
logger.debug(
|
||||
`New tier is license for org ${orgId}, no feature lifecycle handling needed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If newTier is null, treat as free tier - disable all features
|
||||
if (newTier === null) {
|
||||
logger.info(
|
||||
`Org ${orgId} is reverting to free tier, disabling all paid features`
|
||||
);
|
||||
// Cap retention days to free tier limits
|
||||
await capRetentionDays(orgId, null);
|
||||
|
||||
// Disable all features in the tier matrix
|
||||
for (const [featureKey] of Object.entries(tierMatrix)) {
|
||||
const feature = featureKey as TierFeature;
|
||||
logger.info(
|
||||
`Feature ${feature} is not available in free tier for org ${orgId}. Disabling...`
|
||||
);
|
||||
await disableFeature(orgId, feature);
|
||||
}
|
||||
logger.info(
|
||||
`Completed free tier feature lifecycle handling for org ${orgId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the tier (cast as Tier since we've ruled out "license" and null)
|
||||
const tier = newTier as Tier;
|
||||
|
||||
// Cap retention days to the new tier's limits
|
||||
await capRetentionDays(orgId, tier);
|
||||
|
||||
// Check each feature in the tier matrix
|
||||
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
||||
const feature = featureKey as TierFeature;
|
||||
const isFeatureAvailable = allowedTiers.includes(tier);
|
||||
|
||||
if (!isFeatureAvailable) {
|
||||
logger.info(
|
||||
`Feature ${feature} is not available in tier ${tier} for org ${orgId}. Disabling...`
|
||||
);
|
||||
await disableFeature(orgId, feature);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Feature ${feature} is available in tier ${tier} for org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed tier change feature lifecycle handling for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function disableFeature(
|
||||
orgId: string,
|
||||
feature: TierFeature
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (feature) {
|
||||
case TierFeature.OrgOidc:
|
||||
await disableOrgOidc(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.LoginPageDomain:
|
||||
await disableLoginPageDomain(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.DeviceApprovals:
|
||||
await disableDeviceApprovals(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.LoginPageBranding:
|
||||
await disableLoginPageBranding(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.LogExport:
|
||||
await disableLogExport(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AccessLogs:
|
||||
await disableAccessLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.ActionLogs:
|
||||
await disableActionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.RotateCredentials:
|
||||
await disableRotateCredentials(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.MaintencePage:
|
||||
await disableMaintencePage(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.DevicePosture:
|
||||
await disableDevicePosture(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.TwoFactorEnforcement:
|
||||
await disableTwoFactorEnforcement(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.SessionDurationPolicies:
|
||||
await disableSessionDurationPolicies(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.PasswordExpirationPolicies:
|
||||
await disablePasswordExpirationPolicies(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AutoProvisioning:
|
||||
await disableAutoProvisioning(orgId);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(
|
||||
`Unknown feature ${feature} for org ${orgId}, skipping`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully disabled feature ${feature} for org ${orgId}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error disabling feature ${feature} for org ${orgId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableOrgOidc(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableDeviceApprovals(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(roles)
|
||||
.set({ requireDeviceApproval: false })
|
||||
.where(eq(roles.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableLoginPageBranding(orgId: string): Promise<void> {
|
||||
const [existingBranding] = await db
|
||||
.select()
|
||||
.from(loginPageBrandingOrg)
|
||||
.where(eq(loginPageBrandingOrg.orgId, orgId));
|
||||
|
||||
if (existingBranding) {
|
||||
await db
|
||||
.delete(loginPageBranding)
|
||||
.where(
|
||||
eq(
|
||||
loginPageBranding.loginPageBrandingId,
|
||||
existingBranding.loginPageBrandingId
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(`Disabled login page branding for org ${orgId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableLoginPageDomain(orgId: string): Promise<void> {
|
||||
const [existingLoginPage] = await db
|
||||
.select()
|
||||
.from(loginPageOrg)
|
||||
.where(eq(loginPageOrg.orgId, orgId))
|
||||
.innerJoin(
|
||||
loginPage,
|
||||
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
|
||||
);
|
||||
|
||||
if (existingLoginPage) {
|
||||
await db
|
||||
.delete(loginPageOrg)
|
||||
.where(eq(loginPageOrg.orgId, orgId));
|
||||
|
||||
await db
|
||||
.delete(loginPage)
|
||||
.where(
|
||||
eq(
|
||||
loginPage.loginPageId,
|
||||
existingLoginPage.loginPageOrg.loginPageId
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(`Disabled login page domain for org ${orgId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableLogExport(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableAccessLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAccess: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled access logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableActionLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAction: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled action logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableRotateCredentials(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableMaintencePage(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(resources)
|
||||
.set({
|
||||
maintenanceModeEnabled: false
|
||||
})
|
||||
.where(eq(resources.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled maintenance page on all resources for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableDevicePosture(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableTwoFactorEnforcement(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ requireTwoFactor: false })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled two-factor enforcement for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableSessionDurationPolicies(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ maxSessionLengthHours: null })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled session duration policies for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ passwordExpiryDays: null })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled password expiration policies for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAutoProvisioning(orgId: string): Promise<void> {
|
||||
// Get all IDP IDs for this org through the idpOrg join table
|
||||
const orgIdps = await db
|
||||
.select({ idpId: idpOrg.idpId })
|
||||
.from(idpOrg)
|
||||
.where(eq(idpOrg.orgId, orgId));
|
||||
|
||||
// Update autoProvision to false for all IDPs in this org
|
||||
for (const { idpId } of orgIdps) {
|
||||
await db
|
||||
.update(idp)
|
||||
.set({ autoProvision: false })
|
||||
.where(eq(idp.idpId, idpId));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Import tables for billing
|
||||
import {
|
||||
@@ -70,9 +72,19 @@ export async function getOrgSubscriptions(
|
||||
throw err;
|
||||
}
|
||||
|
||||
let limitsExceeded = false;
|
||||
if (build === "saas") {
|
||||
try {
|
||||
limitsExceeded = await usageService.checkLimitSet(orgId);
|
||||
} catch (err) {
|
||||
logger.error("Error checking limits for org %s: %s", orgId, err);
|
||||
}
|
||||
}
|
||||
|
||||
return response<GetOrgSubscriptionResponse>(res, {
|
||||
data: {
|
||||
subscriptions
|
||||
subscriptions,
|
||||
...(build === "saas" ? { limitsExceeded } : {})
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -78,16 +78,10 @@ export async function getOrgUsage(
|
||||
// Get usage for org
|
||||
const usageData = [];
|
||||
|
||||
const siteUptime = await usageService.getUsage(
|
||||
orgId,
|
||||
FeatureId.SITE_UPTIME
|
||||
);
|
||||
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
|
||||
const domains = await usageService.getUsageDaily(
|
||||
orgId,
|
||||
FeatureId.DOMAINS
|
||||
);
|
||||
const remoteExitNodes = await usageService.getUsageDaily(
|
||||
const sites = await usageService.getUsage(orgId, FeatureId.SITES);
|
||||
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||
const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS);
|
||||
const remoteExitNodes = await usageService.getUsage(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES
|
||||
);
|
||||
@@ -96,8 +90,8 @@ export async function getOrgUsage(
|
||||
FeatureId.EGRESS_DATA_MB
|
||||
);
|
||||
|
||||
if (siteUptime) {
|
||||
usageData.push(siteUptime);
|
||||
if (sites) {
|
||||
usageData.push(sites);
|
||||
}
|
||||
if (users) {
|
||||
usageData.push(users);
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
/*
|
||||
* 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 {
|
||||
getLicensePriceSet,
|
||||
} from "@server/lib/billing/licenses";
|
||||
import {
|
||||
getTierPriceSet,
|
||||
} from "@server/lib/billing/tiers";
|
||||
getTier1FeaturePriceSet,
|
||||
getTier2FeaturePriceSet,
|
||||
getTier3FeaturePriceSet,
|
||||
} from "@server/lib/billing/features";
|
||||
import Stripe from "stripe";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): "saas" | "license" {
|
||||
export type SubscriptionType = Tier | "license";
|
||||
|
||||
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null {
|
||||
// Determine subscription type by checking subscription items
|
||||
let type: "saas" | "license" = "saas";
|
||||
if (Array.isArray(fullSubscription.items?.data)) {
|
||||
for (const item of fullSubscription.items.data) {
|
||||
const priceId = item.price.id;
|
||||
if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if price ID matches any license price
|
||||
const licensePrices = Object.values(getLicensePriceSet());
|
||||
for (const item of fullSubscription.items.data) {
|
||||
const priceId = item.price.id;
|
||||
|
||||
if (licensePrices.includes(priceId)) {
|
||||
type = "license";
|
||||
break;
|
||||
}
|
||||
// Check if price ID matches any license price
|
||||
const licensePrices = Object.values(getLicensePriceSet());
|
||||
if (licensePrices.includes(priceId)) {
|
||||
return "license";
|
||||
}
|
||||
|
||||
// Check if price ID matches any tier price (saas)
|
||||
const tierPrices = Object.values(getTierPriceSet());
|
||||
// Check if price ID matches home lab tier
|
||||
const homeLabPrices = Object.values(getTier1FeaturePriceSet());
|
||||
if (homeLabPrices.includes(priceId)) {
|
||||
return "tier1";
|
||||
}
|
||||
|
||||
if (tierPrices.includes(priceId)) {
|
||||
type = "saas";
|
||||
break;
|
||||
}
|
||||
// Check if price ID matches tier2 tier
|
||||
const tier2Prices = Object.values(getTier2FeaturePriceSet());
|
||||
if (tier2Prices.includes(priceId)) {
|
||||
return "tier2";
|
||||
}
|
||||
|
||||
// Check if price ID matches tier3 tier
|
||||
const tier3Prices = Object.values(getTier3FeaturePriceSet());
|
||||
if (tier3Prices.includes(priceId)) {
|
||||
return "tier3";
|
||||
}
|
||||
}
|
||||
|
||||
return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||
import { sendEmail } from "@server/emails";
|
||||
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
|
||||
import config from "@server/lib/config";
|
||||
import { getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import { handleTierChange } from "../featureLifecycle";
|
||||
|
||||
export async function handleSubscriptionCreated(
|
||||
subscription: Stripe.Subscription
|
||||
@@ -59,6 +61,8 @@ export async function handleSubscriptionCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
|
||||
const newSubscription = {
|
||||
subscriptionId: subscription.id,
|
||||
customerId: subscription.customer as string,
|
||||
@@ -66,7 +70,9 @@ export async function handleSubscriptionCreated(
|
||||
canceledAt: subscription.canceled_at
|
||||
? subscription.canceled_at
|
||||
: null,
|
||||
createdAt: subscription.created
|
||||
createdAt: subscription.created,
|
||||
type: type,
|
||||
version: 1 // we are hardcoding the initial version when the subscription is created, and then we will increment it on every update
|
||||
};
|
||||
|
||||
await db.insert(subscriptions).values(newSubscription);
|
||||
@@ -87,10 +93,15 @@ export async function handleSubscriptionCreated(
|
||||
name = product.name || null;
|
||||
}
|
||||
|
||||
// Get the feature ID from the price ID
|
||||
const featureId = getFeatureIdByPriceId(item.price.id);
|
||||
|
||||
return {
|
||||
stripeSubscriptionItemId: item.id,
|
||||
subscriptionId: subscription.id,
|
||||
planId: item.plan.id,
|
||||
priceId: item.price.id,
|
||||
featureId: featureId || null,
|
||||
meterId: item.plan.meter,
|
||||
unitAmount: item.price.unit_amount || 0,
|
||||
currentPeriodStart: item.current_period_start,
|
||||
@@ -129,17 +140,23 @@ export async function handleSubscriptionCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
if (type === "tier1" || type === "tier2" || type === "tier3") {
|
||||
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
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
subscription.status,
|
||||
type
|
||||
);
|
||||
|
||||
// Handle initial tier setup - disable features not available in this tier
|
||||
logger.info(
|
||||
`Setting up initial tier features for org ${customer.orgId} with type ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||
import { getSubType } from "./getSubType";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { handleTierChange } from "../featureLifecycle";
|
||||
|
||||
export async function handleSubscriptionDeleted(
|
||||
subscription: Stripe.Subscription
|
||||
@@ -76,16 +77,23 @@ export async function handleSubscriptionDeleted(
|
||||
}
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
if (type == "tier1" || type == "tier2" || type == "tier3") {
|
||||
logger.debug(
|
||||
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
subscription.status,
|
||||
type
|
||||
);
|
||||
|
||||
// Handle feature lifecycle for cancellation - disable all tier-specific features
|
||||
logger.info(
|
||||
`Disabling tier-specific features for org ${customer.orgId} due to subscription deletion`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, type);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
|
||||
@@ -23,11 +23,12 @@ import {
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId } from "@server/lib/billing/features";
|
||||
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType } from "./getSubType";
|
||||
import { getSubType, SubscriptionType } from "./getSubType";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { handleTierChange } from "../featureLifecycle";
|
||||
|
||||
export async function handleSubscriptionUpdated(
|
||||
subscription: Stripe.Subscription,
|
||||
@@ -64,6 +65,9 @@ export async function handleSubscriptionUpdated(
|
||||
.where(eq(customers.customerId, subscription.customer as string))
|
||||
.limit(1);
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||
|
||||
await db
|
||||
.update(subscriptions)
|
||||
.set({
|
||||
@@ -72,25 +76,55 @@ export async function handleSubscriptionUpdated(
|
||||
? subscription.canceled_at
|
||||
: null,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
billingCycleAnchor: subscription.billing_cycle_anchor
|
||||
billingCycleAnchor: subscription.billing_cycle_anchor,
|
||||
type: type
|
||||
})
|
||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||
|
||||
// Handle tier change if the subscription type changed
|
||||
if (type && type !== previousType) {
|
||||
logger.info(
|
||||
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||
}
|
||||
|
||||
// Upsert subscription items
|
||||
if (Array.isArray(fullSubscription.items?.data)) {
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
|
||||
subscriptionId: subscription.id,
|
||||
planId: item.plan.id,
|
||||
priceId: item.price.id,
|
||||
meterId: item.plan.meter,
|
||||
unitAmount: item.price.unit_amount || 0,
|
||||
currentPeriodStart: item.current_period_start,
|
||||
currentPeriodEnd: item.current_period_end,
|
||||
tiers: item.price.tiers
|
||||
? JSON.stringify(item.price.tiers)
|
||||
: null,
|
||||
interval: item.plan.interval
|
||||
}));
|
||||
// First, get existing items to preserve featureId when there's no match
|
||||
const existingItems = await db
|
||||
.select()
|
||||
.from(subscriptionItems)
|
||||
.where(eq(subscriptionItems.subscriptionId, subscription.id));
|
||||
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
||||
// Try to get featureId from price
|
||||
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
|
||||
|
||||
// If no match, try to preserve existing featureId
|
||||
if (!featureId) {
|
||||
const existingItem = existingItems.find(
|
||||
(ei) => ei.stripeSubscriptionItemId === item.id
|
||||
);
|
||||
featureId = existingItem?.featureId || null;
|
||||
}
|
||||
|
||||
return {
|
||||
stripeSubscriptionItemId: item.id,
|
||||
subscriptionId: subscription.id,
|
||||
planId: item.plan.id,
|
||||
priceId: item.price.id,
|
||||
featureId: featureId,
|
||||
meterId: item.plan.meter,
|
||||
unitAmount: item.price.unit_amount || 0,
|
||||
currentPeriodStart: item.current_period_start,
|
||||
currentPeriodEnd: item.current_period_end,
|
||||
tiers: item.price.tiers
|
||||
? JSON.stringify(item.price.tiers)
|
||||
: null,
|
||||
interval: item.plan.interval
|
||||
};
|
||||
});
|
||||
if (itemsToUpsert.length > 0) {
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
@@ -154,7 +188,7 @@ export async function handleSubscriptionUpdated(
|
||||
const orgId = customer.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
|
||||
);
|
||||
continue;
|
||||
@@ -234,17 +268,29 @@ export async function handleSubscriptionUpdated(
|
||||
}
|
||||
// --- end usage update ---
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
if (type === "tier1" || type === "tier2" || type === "tier3") {
|
||||
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
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
subscription.status,
|
||||
type
|
||||
);
|
||||
} else {
|
||||
|
||||
// Handle feature lifecycle when subscription is canceled or becomes unpaid
|
||||
if (
|
||||
subscription.status === "canceled" ||
|
||||
subscription.status === "unpaid" ||
|
||||
subscription.status === "incomplete_expired"
|
||||
) {
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||
}
|
||||
} else if (type === "license") {
|
||||
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
||||
try {
|
||||
// WARNING:
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createCheckoutSessionSAAS";
|
||||
export * from "./createCheckoutSession";
|
||||
export * from "./createPortalSession";
|
||||
export * from "./getOrgSubscriptions";
|
||||
export * from "./getOrgUsage";
|
||||
export * from "./internalGetOrgTier";
|
||||
export * from "./changeTier";
|
||||
|
||||
@@ -13,38 +13,66 @@
|
||||
|
||||
import {
|
||||
freeLimitSet,
|
||||
tier1LimitSet,
|
||||
tier2LimitSet,
|
||||
tier3LimitSet,
|
||||
limitsService,
|
||||
subscribedLimitSet
|
||||
LimitSet
|
||||
} from "@server/lib/billing";
|
||||
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 "tier1":
|
||||
return tier1LimitSet;
|
||||
case "tier2":
|
||||
return tier2LimitSet;
|
||||
case "tier3":
|
||||
return tier3LimitSet;
|
||||
case "license":
|
||||
// License subscriptions use tier2 limits by default
|
||||
// This can be adjusted based on your business logic
|
||||
return tier2LimitSet;
|
||||
default:
|
||||
return freeLimitSet;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSubscriptionLifesycle(
|
||||
orgId: string,
|
||||
status: string
|
||||
status: string,
|
||||
subType: SubscriptionType | null
|
||||
) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet);
|
||||
await usageService.checkLimitSet(orgId, true);
|
||||
const activeLimitSet = getLimitSetForSubscriptionType(subType);
|
||||
await limitsService.applyLimitSetToOrg(orgId, activeLimitSet);
|
||||
await usageService.checkLimitSet(orgId);
|
||||
break;
|
||||
case "canceled":
|
||||
// Subscription canceled - revert to free tier
|
||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||
await usageService.checkLimitSet(orgId, true);
|
||||
await usageService.checkLimitSet(orgId);
|
||||
break;
|
||||
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;
|
||||
case "unpaid":
|
||||
// Subscription unpaid - revert to free tier
|
||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||
await usageService.checkLimitSet(orgId, true);
|
||||
await usageService.checkLimitSet(orgId);
|
||||
break;
|
||||
case "incomplete":
|
||||
// Optionally handle incomplete status, e.g., notify customer
|
||||
// Payment incomplete - give them time to complete payment
|
||||
break;
|
||||
case "incomplete_expired":
|
||||
// Payment never completed - revert to free tier
|
||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||
await usageService.checkLimitSet(orgId, true);
|
||||
await usageService.checkLimitSet(orgId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
verifyUserHasAction,
|
||||
verifyUserIsServerAdmin,
|
||||
verifySiteAccess,
|
||||
verifyClientAccess
|
||||
verifyClientAccess,
|
||||
verifyLimits
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
authenticated as a,
|
||||
authRouter as aa
|
||||
} from "@server/routers/external";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export const authenticated = a;
|
||||
export const unauthenticated = ua;
|
||||
@@ -76,7 +78,9 @@ unauthenticated.post(
|
||||
authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createIdp),
|
||||
logActionAudit(ActionsEnum.createIdp),
|
||||
orgIdp.createOrgOidcIdp
|
||||
@@ -85,8 +89,10 @@ authenticated.put(
|
||||
authenticated.post(
|
||||
"/org/:orgId/idp/:idpId/oidc",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
verifyOrgAccess,
|
||||
verifyIdpAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateIdp),
|
||||
logActionAudit(ActionsEnum.updateIdp),
|
||||
orgIdp.updateOrgOidcIdp
|
||||
@@ -135,35 +141,27 @@ authenticated.post(
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.restartCertificate),
|
||||
logActionAudit(ActionsEnum.restartCertificate),
|
||||
certificates.restartCertificate
|
||||
);
|
||||
|
||||
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(
|
||||
"/org/:orgId/billing/create-checkout-session-saas",
|
||||
"/org/:orgId/billing/create-checkout-session",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
billing.createCheckoutSessionSAAS
|
||||
billing.createCheckoutSession
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/billing/change-tier",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
billing.changeTier
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -243,6 +241,7 @@ authenticated.put(
|
||||
"/org/:orgId/remote-exit-node",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
|
||||
logActionAudit(ActionsEnum.createRemoteExitNode),
|
||||
remoteExitNode.createRemoteExitNode
|
||||
@@ -286,7 +285,9 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/org/:orgId/login-page",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.loginPageDomain),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createLoginPage),
|
||||
logActionAudit(ActionsEnum.createLoginPage),
|
||||
loginPage.createLoginPage
|
||||
@@ -295,8 +296,10 @@ authenticated.put(
|
||||
authenticated.post(
|
||||
"/org/:orgId/login-page/:loginPageId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.loginPageDomain),
|
||||
verifyOrgAccess,
|
||||
verifyLoginPageAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||
logActionAudit(ActionsEnum.updateLoginPage),
|
||||
loginPage.updateLoginPage
|
||||
@@ -323,6 +326,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/approvals",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.deviceApprovals),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listApprovals),
|
||||
logActionAudit(ActionsEnum.listApprovals),
|
||||
@@ -339,7 +343,9 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/approvals/:approvalId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.deviceApprovals),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateApprovals),
|
||||
logActionAudit(ActionsEnum.updateApprovals),
|
||||
approval.processPendingApproval
|
||||
@@ -348,6 +354,7 @@ authenticated.put(
|
||||
authenticated.get(
|
||||
"/org/:orgId/login-page-branding",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.loginPageBranding),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getLoginPage),
|
||||
logActionAudit(ActionsEnum.getLoginPage),
|
||||
@@ -357,7 +364,9 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/login-page-branding",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.loginPageBranding),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||
logActionAudit(ActionsEnum.updateLoginPage),
|
||||
loginPage.upsertLoginPageBranding
|
||||
@@ -433,7 +442,7 @@ authenticated.post(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.actionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logs.queryActionAuditLogs
|
||||
@@ -442,7 +451,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.logExport),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
@@ -452,7 +461,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/access",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.accessLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logs.queryAccessAuditLogs
|
||||
@@ -461,7 +470,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/access/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.logExport),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
@@ -470,18 +479,20 @@ authenticated.get(
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateClientSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:siteId/regenerate-site-secret",
|
||||
verifySiteAccess, // this is first to set the org id
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifySiteAccess, // this is first to set the org id
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateSiteSecret
|
||||
);
|
||||
@@ -489,8 +500,9 @@ authenticated.post(
|
||||
authenticated.put(
|
||||
"/re-key/:orgId/regenerate-remote-exit-node-secret",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function generateNewEnterpriseLicense(
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
|
||||
});
|
||||
|
||||
@@ -19,21 +19,20 @@ import {
|
||||
verifyApiKeyHasAction,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyIdpAccess
|
||||
verifyApiKeyIdpAccess,
|
||||
verifyLimits
|
||||
} from "@server/middlewares";
|
||||
import {
|
||||
verifyValidSubscription,
|
||||
verifyValidLicense
|
||||
} from "#private/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
||||
import {
|
||||
unauthenticated as ua,
|
||||
authenticated as a
|
||||
} from "@server/routers/integration";
|
||||
import { logActionAudit } from "#private/middlewares";
|
||||
import config from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export const unauthenticated = ua;
|
||||
export const authenticated = a;
|
||||
@@ -57,7 +56,7 @@ authenticated.delete(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.actionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logs.queryActionAuditLogs
|
||||
@@ -66,7 +65,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.logExport),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
@@ -76,7 +75,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/access",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.accessLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logs.queryAccessAuditLogs
|
||||
@@ -85,7 +84,7 @@ authenticated.get(
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/access/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyValidSubscription(tierMatrix.logExport),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
@@ -95,7 +94,9 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
||||
logActionAudit(ActionsEnum.createIdp),
|
||||
orgIdp.createOrgOidcIdp
|
||||
@@ -104,8 +105,10 @@ authenticated.put(
|
||||
authenticated.post(
|
||||
"/org/:orgId/idp/:idpId/oidc",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyIdpAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
||||
logActionAudit(ActionsEnum.updateIdp),
|
||||
orgIdp.updateOrgOidcIdp
|
||||
|
||||
@@ -30,9 +30,7 @@ import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -76,19 +74,6 @@ export async function createLoginPage(
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(loginPageOrg)
|
||||
|
||||
@@ -25,9 +25,7 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
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
|
||||
.object({
|
||||
@@ -53,18 +51,6 @@ export async function deleteLoginPageBranding(
|
||||
|
||||
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
|
||||
.select()
|
||||
|
||||
@@ -25,9 +25,7 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
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({
|
||||
orgId: z.string()
|
||||
@@ -51,19 +49,6 @@ export async function getLoginPageBranding(
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(loginPageBranding)
|
||||
|
||||
@@ -23,9 +23,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
const paramsSchema = z
|
||||
@@ -87,18 +85,6 @@ export async function updateLoginPage(
|
||||
|
||||
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
|
||||
.select()
|
||||
|
||||
@@ -25,10 +25,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
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 config from "@server/private/lib/config";
|
||||
import config from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -128,19 +126,6 @@ export async function upsertLoginPageBranding(
|
||||
|
||||
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<
|
||||
typeof loginPageBranding
|
||||
>;
|
||||
|
||||
@@ -24,10 +24,10 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
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 { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||
|
||||
@@ -93,6 +93,18 @@ export async function createOrgOidcIdp(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||
"org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -103,23 +115,19 @@ export async function createOrgOidcIdp(
|
||||
emailPath,
|
||||
namePath,
|
||||
name,
|
||||
autoProvision,
|
||||
variant,
|
||||
roleMapping,
|
||||
tags
|
||||
} = 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."
|
||||
)
|
||||
);
|
||||
}
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig, idpOrg } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -59,6 +60,18 @@ export async function deleteOrgIdp(
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||
"org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if IDP exists
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
|
||||
@@ -24,9 +24,9 @@ import { idp, idpOidcConfig } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
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 { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -98,6 +98,18 @@ export async function updateOrgOidcIdp(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||
"org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId, orgId } = parsedParams.data;
|
||||
const {
|
||||
clientId,
|
||||
@@ -109,22 +121,18 @@ export async function updateOrgOidcIdp(
|
||||
emailPath,
|
||||
namePath,
|
||||
name,
|
||||
autoProvision,
|
||||
roleMapping,
|
||||
tags
|
||||
} = 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."
|
||||
)
|
||||
);
|
||||
}
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
}
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
|
||||
@@ -85,7 +85,7 @@ export async function createRemoteExitNode(
|
||||
if (usage) {
|
||||
const rejectRemoteExitNodes = await usageService.checkLimitSet(
|
||||
orgId,
|
||||
false,
|
||||
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
{
|
||||
...usage,
|
||||
@@ -97,7 +97,7 @@ export async function createRemoteExitNode(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Remote exit node limit exceeded. Please upgrade your plan or contact us at support@pangolin.net"
|
||||
"Remote node limit exceeded. Please upgrade your plan."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -224,7 +224,7 @@ export async function createRemoteExitNode(
|
||||
});
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function deleteRemoteExitNode(
|
||||
});
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
ResourceHeaderAuthExtendedCompatibility,
|
||||
ResourcePassword,
|
||||
ResourcePincode,
|
||||
ResourceRule,
|
||||
resourceSessions
|
||||
ResourceRule
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||
@@ -32,7 +31,6 @@ import { fromError } from "zod-validation-error";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
import { getAsnForIp } from "@server/lib/asn";
|
||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import {
|
||||
checkOrgAccessPolicy,
|
||||
@@ -40,8 +38,9 @@ import {
|
||||
} from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { logRequestAudit } from "./logRequestAudit";
|
||||
import cache from "@server/lib/cache";
|
||||
import semver from "semver";
|
||||
import { APP_VERSION } from "@server/lib/consts";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const verifyResourceSessionSchema = z.object({
|
||||
sessions: z.record(z.string(), z.string()).optional(),
|
||||
@@ -798,8 +797,11 @@ async function notAllowed(
|
||||
) {
|
||||
let loginPage: LoginPage | null = null;
|
||||
if (orgId) {
|
||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
||||
if (tier === TierId.STANDARD) {
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.loginPageDomain
|
||||
);
|
||||
if (subscribed) {
|
||||
loginPage = await getOrgLoginPage(orgId);
|
||||
}
|
||||
}
|
||||
@@ -852,8 +854,8 @@ async function headerAuthChallenged(
|
||||
) {
|
||||
let loginPage: LoginPage | null = null;
|
||||
if (orgId) {
|
||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
||||
if (tier === TierId.STANDARD) {
|
||||
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
|
||||
if (subscribed) {
|
||||
loginPage = await getOrgLoginPage(orgId);
|
||||
}
|
||||
}
|
||||
@@ -1039,7 +1041,11 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
const MAX_RECURSION_DEPTH = 100;
|
||||
|
||||
// Recursive function to try different wildcard matches
|
||||
function matchSegments(patternIndex: number, pathIndex: number, depth: number = 0): boolean {
|
||||
function matchSegments(
|
||||
patternIndex: number,
|
||||
pathIndex: number,
|
||||
depth: number = 0
|
||||
): boolean {
|
||||
// Check recursion depth limit
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
logger.warn(
|
||||
@@ -1125,7 +1131,11 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||
);
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
|
||||
return matchSegments(
|
||||
patternIndex + 1,
|
||||
pathIndex + 1,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
|
||||
|
||||
export type GetOrgSubscriptionResponse = {
|
||||
subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>;
|
||||
/** When build === saas, true if org has exceeded plan limits (sites, users, etc.) */
|
||||
limitsExceeded?: boolean;
|
||||
};
|
||||
|
||||
export type GetOrgUsageResponse = {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { generateId } from "@server/auth/sessions/app";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
import { getUniqueClientName } from "@server/db/names";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const createClientParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -101,7 +102,7 @@ export async function createClient(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid subnet format. Please provide a valid CIDR notation."
|
||||
"Invalid subnet format. Please provide a valid IP."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -195,6 +196,12 @@ export async function createClient(
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
if (!randomExitNode) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
|
||||
);
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const getClientSchema = z.strictObject({
|
||||
clientId: z
|
||||
@@ -327,7 +328,8 @@ export async function getClient(
|
||||
client.currentFingerprint
|
||||
);
|
||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||
client.clients.orgId
|
||||
client.clients.orgId,
|
||||
tierMatrix.devicePosture
|
||||
);
|
||||
const postureData: PostureData | null = rawPosture
|
||||
? isOrgLicensed
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function createOrgDomain(
|
||||
}
|
||||
const rejectDomains = await usageService.checkLimitSet(
|
||||
orgId,
|
||||
false,
|
||||
|
||||
FeatureId.DOMAINS,
|
||||
{
|
||||
...usage,
|
||||
@@ -354,7 +354,7 @@ export async function createOrgDomain(
|
||||
});
|
||||
|
||||
if (numOrgDomains) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.DOMAINS,
|
||||
numOrgDomains.length
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function deleteAccountDomain(
|
||||
});
|
||||
|
||||
if (numOrgDomains) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.DOMAINS,
|
||||
numOrgDomains.length
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
verifyUserHasAction,
|
||||
verifyUserIsOrgOwner,
|
||||
verifySiteResourceAccess,
|
||||
verifyOlmAccess
|
||||
verifyOlmAccess,
|
||||
verifyLimits
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
@@ -79,6 +80,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateOrg),
|
||||
logActionAudit(ActionsEnum.updateOrg),
|
||||
org.updateOrg
|
||||
@@ -168,6 +170,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/client",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createClient),
|
||||
logActionAudit(ActionsEnum.createClient),
|
||||
client.createClient
|
||||
@@ -185,6 +188,7 @@ authenticated.delete(
|
||||
authenticated.post(
|
||||
"/client/:clientId/archive",
|
||||
verifyClientAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.archiveClient),
|
||||
logActionAudit(ActionsEnum.archiveClient),
|
||||
client.archiveClient
|
||||
@@ -193,6 +197,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId/unarchive",
|
||||
verifyClientAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.unarchiveClient),
|
||||
logActionAudit(ActionsEnum.unarchiveClient),
|
||||
client.unarchiveClient
|
||||
@@ -201,6 +206,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId/block",
|
||||
verifyClientAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.blockClient),
|
||||
logActionAudit(ActionsEnum.blockClient),
|
||||
client.blockClient
|
||||
@@ -209,6 +215,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId/unblock",
|
||||
verifyClientAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.unblockClient),
|
||||
logActionAudit(ActionsEnum.unblockClient),
|
||||
client.unblockClient
|
||||
@@ -217,6 +224,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId",
|
||||
verifyClientAccess, // this will check if the user has access to the client
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
|
||||
logActionAudit(ActionsEnum.updateClient),
|
||||
client.updateClient
|
||||
@@ -231,6 +239,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/site/:siteId",
|
||||
verifySiteAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateSite),
|
||||
logActionAudit(ActionsEnum.updateSite),
|
||||
site.updateSite
|
||||
@@ -280,6 +289,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/site-resource",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createSiteResource),
|
||||
logActionAudit(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource
|
||||
@@ -310,6 +320,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/site-resource/:siteResourceId",
|
||||
verifySiteResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
||||
logActionAudit(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource
|
||||
@@ -348,6 +359,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/roles",
|
||||
verifySiteResourceAccess,
|
||||
verifyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
siteResource.setSiteResourceRoles
|
||||
@@ -357,6 +369,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/users",
|
||||
verifySiteResourceAccess,
|
||||
verifySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.setSiteResourceUsers
|
||||
@@ -366,6 +379,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/clients",
|
||||
verifySiteResourceAccess,
|
||||
verifySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.setSiteResourceClients
|
||||
@@ -375,6 +389,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/clients/add",
|
||||
verifySiteResourceAccess,
|
||||
verifySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.addClientToSiteResource
|
||||
@@ -384,6 +399,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/clients/remove",
|
||||
verifySiteResourceAccess,
|
||||
verifySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.removeClientFromSiteResource
|
||||
@@ -392,6 +408,7 @@ authenticated.post(
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createResource),
|
||||
logActionAudit(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
@@ -506,6 +523,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateResource),
|
||||
logActionAudit(ActionsEnum.updateResource),
|
||||
resource.updateResource
|
||||
@@ -521,6 +539,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/target",
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createTarget),
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
@@ -535,6 +554,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/rule",
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||
logActionAudit(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule
|
||||
@@ -548,6 +568,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||
logActionAudit(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule
|
||||
@@ -569,6 +590,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/target/:targetId",
|
||||
verifyTargetAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateTarget),
|
||||
logActionAudit(ActionsEnum.updateTarget),
|
||||
target.updateTarget
|
||||
@@ -584,6 +606,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/org/:orgId/role",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createRole),
|
||||
logActionAudit(ActionsEnum.createRole),
|
||||
role.createRole
|
||||
@@ -598,6 +621,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/role/:roleId",
|
||||
verifyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateRole),
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
@@ -626,6 +650,7 @@ authenticated.post(
|
||||
"/role/:roleId/add/:userId",
|
||||
verifyRoleAccess,
|
||||
verifyUserAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.addUserRole),
|
||||
logActionAudit(ActionsEnum.addUserRole),
|
||||
user.addUserRole
|
||||
@@ -635,6 +660,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/roles",
|
||||
verifyResourceAccess,
|
||||
verifyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
resource.setResourceRoles
|
||||
@@ -644,6 +670,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/users",
|
||||
verifyResourceAccess,
|
||||
verifySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
resource.setResourceUsers
|
||||
@@ -652,6 +679,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
||||
logActionAudit(ActionsEnum.setResourcePassword),
|
||||
resource.setResourcePassword
|
||||
@@ -660,6 +688,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/pincode`,
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
||||
logActionAudit(ActionsEnum.setResourcePincode),
|
||||
resource.setResourcePincode
|
||||
@@ -668,6 +697,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/header-auth`,
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||
resource.setResourceHeaderAuth
|
||||
@@ -676,6 +706,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/whitelist`,
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||
resource.setResourceWhitelist
|
||||
@@ -691,6 +722,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
||||
logActionAudit(ActionsEnum.generateAccessToken),
|
||||
accessToken.generateAccessToken
|
||||
@@ -781,6 +813,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createOrgUser),
|
||||
logActionAudit(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
@@ -790,6 +823,7 @@ authenticated.post(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyOrgAccess,
|
||||
verifyUserAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
||||
logActionAudit(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
@@ -862,6 +896,7 @@ authenticated.post(
|
||||
"/user/:userId/olm/:olmId/archive",
|
||||
verifyIsLoggedInUser,
|
||||
verifyOlmAccess,
|
||||
verifyLimits,
|
||||
olm.archiveUserOlm
|
||||
);
|
||||
|
||||
@@ -976,6 +1011,7 @@ authenticated.post(
|
||||
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||
verifyOrgAccess,
|
||||
verifyApiKeyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||
apiKeys.setApiKeyActions
|
||||
@@ -992,6 +1028,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
`/org/:orgId/api-key`,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createApiKey),
|
||||
logActionAudit(ActionsEnum.createApiKey),
|
||||
apiKeys.createOrgApiKey
|
||||
@@ -1017,6 +1054,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
`/org/:orgId/domain`,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
||||
logActionAudit(ActionsEnum.createOrgDomain),
|
||||
domain.createOrgDomain
|
||||
@@ -1026,6 +1064,7 @@ authenticated.post(
|
||||
`/org/:orgId/domain/:domainId/restart`,
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
||||
logActionAudit(ActionsEnum.restartOrgDomain),
|
||||
domain.restartOrgDomain
|
||||
@@ -1072,6 +1111,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/blueprint",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.applyBlueprint),
|
||||
blueprints.applyYAMLBlueprint
|
||||
);
|
||||
|
||||
@@ -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,11 +178,9 @@ 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()])
|
||||
].sort();
|
||||
const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort();
|
||||
|
||||
for (const orgId of allOrgIds) {
|
||||
try {
|
||||
@@ -208,7 +197,7 @@ export async function updateSiteBandwidth(
|
||||
usageService
|
||||
.checkLimitSet(
|
||||
orgId,
|
||||
true,
|
||||
|
||||
FeatureId.EGRESS_DATA_MB,
|
||||
bandwidthUsage
|
||||
)
|
||||
@@ -220,32 +209,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
|
||||
|
||||
@@ -93,7 +93,9 @@ export async function createOidcIdp(
|
||||
name,
|
||||
autoProvision,
|
||||
type: "oidc",
|
||||
tags
|
||||
tags,
|
||||
defaultOrgMapping: `'{{orgId}}'`,
|
||||
defaultRoleMapping: `'Member'`
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import jsonwebtoken from "jsonwebtoken";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import { build } from "@server/build";
|
||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -113,8 +113,10 @@ export async function generateOidcUrl(
|
||||
}
|
||||
|
||||
if (build === "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
const subscribed = tier === TierId.STANDARD;
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.orgOidc
|
||||
);
|
||||
if (!subscribed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -34,6 +34,8 @@ import { FeatureId } from "@server/lib/billing";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { build } from "@server/build";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const ensureTrailingSlash = (url: string): string => {
|
||||
return url;
|
||||
@@ -326,6 +328,33 @@ export async function validateOidcCallback(
|
||||
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
||||
|
||||
// TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1
|
||||
if (allOrgs.length > 1) {
|
||||
// for some reason there is more than one org
|
||||
logger.error(
|
||||
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
|
||||
);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Multiple organizations linked to this IdP. Please contact support."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
allOrgs[0].orgId,
|
||||
tierMatrix.autoProvisioning
|
||||
);
|
||||
if (!subscribed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"This organization's current plan does not support this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
allOrgs = await db.select().from(orgs);
|
||||
}
|
||||
@@ -587,7 +616,7 @@ export async function validateOidcCallback(
|
||||
});
|
||||
|
||||
for (const orgCount of orgUserCounts) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgCount.orgId,
|
||||
FeatureId.USERS,
|
||||
orgCount.userCount
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyClientAccess,
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
@@ -74,6 +75,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/org/:orgId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateOrg),
|
||||
logActionAudit(ActionsEnum.updateOrg),
|
||||
org.updateOrg
|
||||
@@ -90,6 +92,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/org/:orgId/site",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createSite),
|
||||
logActionAudit(ActionsEnum.createSite),
|
||||
site.createSite
|
||||
@@ -126,6 +129,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/site/:siteId",
|
||||
verifyApiKeySiteAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateSite),
|
||||
logActionAudit(ActionsEnum.updateSite),
|
||||
site.updateSite
|
||||
@@ -146,8 +150,9 @@ authenticated.get(
|
||||
);
|
||||
// Site Resource endpoints
|
||||
authenticated.put(
|
||||
"/org/:orgId/private-resource",
|
||||
"/org/:orgId/site-resource",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
|
||||
logActionAudit(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource
|
||||
@@ -178,6 +183,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/site-resource/:siteResourceId",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
|
||||
logActionAudit(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource
|
||||
@@ -216,6 +222,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/roles",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
siteResource.setSiteResourceRoles
|
||||
@@ -225,6 +232,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/users",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.setSiteResourceUsers
|
||||
@@ -234,6 +242,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/roles/add",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
siteResource.addRoleToSiteResource
|
||||
@@ -243,6 +252,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/roles/remove",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
siteResource.removeRoleFromSiteResource
|
||||
@@ -252,6 +262,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/users/add",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.addUserToSiteResource
|
||||
@@ -261,6 +272,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/users/remove",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.removeUserFromSiteResource
|
||||
@@ -270,6 +282,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/clients",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.setSiteResourceClients
|
||||
@@ -279,6 +292,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/clients/add",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.addClientToSiteResource
|
||||
@@ -288,6 +302,7 @@ authenticated.post(
|
||||
"/site-resource/:siteResourceId/clients/remove",
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
siteResource.removeClientFromSiteResource
|
||||
@@ -296,6 +311,7 @@ authenticated.post(
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||
logActionAudit(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
@@ -304,6 +320,7 @@ authenticated.put(
|
||||
authenticated.put(
|
||||
"/org/:orgId/site/:siteId/resource",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||
logActionAudit(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
@@ -340,6 +357,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.inviteUser),
|
||||
logActionAudit(ActionsEnum.inviteUser),
|
||||
user.inviteUser
|
||||
@@ -377,6 +395,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
||||
logActionAudit(ActionsEnum.updateResource),
|
||||
resource.updateResource
|
||||
@@ -393,6 +412,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/target",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
@@ -408,6 +428,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/rule",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
|
||||
logActionAudit(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule
|
||||
@@ -423,6 +444,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
|
||||
logActionAudit(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule
|
||||
@@ -446,6 +468,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/target/:targetId",
|
||||
verifyApiKeyTargetAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateTarget),
|
||||
logActionAudit(ActionsEnum.updateTarget),
|
||||
target.updateTarget
|
||||
@@ -462,6 +485,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/org/:orgId/role",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createRole),
|
||||
logActionAudit(ActionsEnum.createRole),
|
||||
role.createRole
|
||||
@@ -470,6 +494,7 @@ authenticated.put(
|
||||
authenticated.post(
|
||||
"/role/:roleId",
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateRole),
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
@@ -501,6 +526,7 @@ authenticated.post(
|
||||
"/role/:roleId/add/:userId",
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.addUserRole),
|
||||
logActionAudit(ActionsEnum.addUserRole),
|
||||
user.addUserRole
|
||||
@@ -510,6 +536,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/roles",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
resource.setResourceRoles
|
||||
@@ -519,6 +546,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/users",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
resource.setResourceUsers
|
||||
@@ -528,6 +556,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/roles/add",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
resource.addRoleToResource
|
||||
@@ -537,6 +566,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/roles/remove",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
resource.removeRoleFromResource
|
||||
@@ -546,6 +576,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/users/add",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
resource.addUserToResource
|
||||
@@ -555,6 +586,7 @@ authenticated.post(
|
||||
"/resource/:resourceId/users/remove",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
resource.removeUserFromResource
|
||||
@@ -563,6 +595,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
|
||||
logActionAudit(ActionsEnum.setResourcePassword),
|
||||
resource.setResourcePassword
|
||||
@@ -571,6 +604,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/pincode`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
|
||||
logActionAudit(ActionsEnum.setResourcePincode),
|
||||
resource.setResourcePincode
|
||||
@@ -579,6 +613,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/header-auth`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||
resource.setResourceHeaderAuth
|
||||
@@ -587,6 +622,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/whitelist`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||
resource.setResourceWhitelist
|
||||
@@ -595,6 +631,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/whitelist/add`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||
resource.addEmailToResourceWhitelist
|
||||
);
|
||||
@@ -602,6 +639,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/whitelist/remove`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||
resource.removeEmailFromResourceWhitelist
|
||||
);
|
||||
@@ -616,6 +654,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
|
||||
logActionAudit(ActionsEnum.generateAccessToken),
|
||||
accessToken.generateAccessToken
|
||||
@@ -653,6 +692,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
"/user/:userId/2fa",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
||||
logActionAudit(ActionsEnum.updateUser),
|
||||
user.updateUser2FA
|
||||
@@ -675,6 +715,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
|
||||
logActionAudit(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
@@ -684,6 +725,7 @@ authenticated.post(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
|
||||
logActionAudit(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
@@ -714,6 +756,7 @@ authenticated.get(
|
||||
authenticated.post(
|
||||
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
|
||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||
apiKeys.setApiKeyActions
|
||||
@@ -729,6 +772,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
`/org/:orgId/api-key`,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createApiKey),
|
||||
logActionAudit(ActionsEnum.createApiKey),
|
||||
apiKeys.createOrgApiKey
|
||||
@@ -745,6 +789,7 @@ authenticated.delete(
|
||||
authenticated.put(
|
||||
"/idp/oidc",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
||||
logActionAudit(ActionsEnum.createIdp),
|
||||
idp.createOidcIdp
|
||||
@@ -753,6 +798,7 @@ authenticated.put(
|
||||
authenticated.post(
|
||||
"/idp/:idpId/oidc",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
||||
logActionAudit(ActionsEnum.updateIdp),
|
||||
idp.updateOidcIdp
|
||||
@@ -776,6 +822,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
|
||||
logActionAudit(ActionsEnum.createIdpOrg),
|
||||
idp.createIdpOrgPolicy
|
||||
@@ -784,6 +831,7 @@ authenticated.put(
|
||||
authenticated.post(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
|
||||
logActionAudit(ActionsEnum.updateIdpOrg),
|
||||
idp.updateIdpOrgPolicy
|
||||
@@ -835,6 +883,7 @@ authenticated.get(
|
||||
authenticated.put(
|
||||
"/org/:orgId/client",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createClient),
|
||||
logActionAudit(ActionsEnum.createClient),
|
||||
client.createClient
|
||||
@@ -861,6 +910,7 @@ authenticated.delete(
|
||||
authenticated.post(
|
||||
"/client/:clientId/archive",
|
||||
verifyApiKeyClientAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.archiveClient),
|
||||
logActionAudit(ActionsEnum.archiveClient),
|
||||
client.archiveClient
|
||||
@@ -869,6 +919,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId/unarchive",
|
||||
verifyApiKeyClientAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.unarchiveClient),
|
||||
logActionAudit(ActionsEnum.unarchiveClient),
|
||||
client.unarchiveClient
|
||||
@@ -877,6 +928,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId/block",
|
||||
verifyApiKeyClientAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.blockClient),
|
||||
logActionAudit(ActionsEnum.blockClient),
|
||||
client.blockClient
|
||||
@@ -885,6 +937,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId/unblock",
|
||||
verifyApiKeyClientAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.unblockClient),
|
||||
logActionAudit(ActionsEnum.unblockClient),
|
||||
client.unblockClient
|
||||
@@ -893,6 +946,7 @@ authenticated.post(
|
||||
authenticated.post(
|
||||
"/client/:clientId",
|
||||
verifyApiKeyClientAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
||||
logActionAudit(ActionsEnum.updateClient),
|
||||
client.updateClient
|
||||
@@ -901,6 +955,7 @@ authenticated.post(
|
||||
authenticated.put(
|
||||
"/org/:orgId/blueprint",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
||||
logActionAudit(ActionsEnum.applyBlueprint),
|
||||
blueprints.applyJSONBlueprint
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { db, ExitNode, exitNodeOrgs, newts, Transaction } from "@server/db";
|
||||
import { db, ExitNode, newts, Transaction } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
|
||||
import { targetHealthCheck } from "@server/db";
|
||||
import { eq, and, sql, inArray, ne } from "drizzle-orm";
|
||||
import { exitNodes, Newt, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
findNextAvailableCidr,
|
||||
getNextAvailableClientSubnet
|
||||
} from "@server/lib/ip";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import {
|
||||
selectBestExitNode,
|
||||
verifyExitNodeOrgAccess
|
||||
@@ -30,8 +26,6 @@ export type ExitNodePingResult = {
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
const numTimesLimitExceededForId: Record<string, number> = {};
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
@@ -96,42 +90,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
fetchContainers(newt.newtId);
|
||||
}
|
||||
|
||||
const rejectSiteUptime = await usageService.checkLimitSet(
|
||||
oldSite.orgId,
|
||||
false,
|
||||
FeatureId.SITE_UPTIME
|
||||
);
|
||||
const rejectEgressDataMb = await usageService.checkLimitSet(
|
||||
oldSite.orgId,
|
||||
false,
|
||||
FeatureId.EGRESS_DATA_MB
|
||||
);
|
||||
|
||||
// Do we need to check the users and domains daily limits here?
|
||||
// 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) {
|
||||
logger.info(
|
||||
`Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.`
|
||||
);
|
||||
|
||||
// PREVENT FURTHER REGISTRATION ATTEMPTS SO WE DON'T SPAM
|
||||
|
||||
// Increment the limit exceeded count for this site
|
||||
numTimesLimitExceededForId[newt.newtId] =
|
||||
(numTimesLimitExceededForId[newt.newtId] || 0) + 1;
|
||||
|
||||
if (numTimesLimitExceededForId[newt.newtId] > 15) {
|
||||
logger.debug(
|
||||
`Newt ${newt.newtId} has exceeded usage limits 15 times. Terminating...`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let siteSubnet = oldSite.subnet;
|
||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||
|
||||
@@ -271,7 +271,7 @@ export async function createOrg(
|
||||
// make sure we have the stripe customer
|
||||
const customerId = await createCustomer(orgId, req.user?.email);
|
||||
if (customerId) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
1,
|
||||
|
||||
@@ -10,10 +10,10 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||
|
||||
const updateOrgParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -88,26 +88,83 @@ export async function updateOrg(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (!isLicensed) {
|
||||
// Check 2FA enforcement feature
|
||||
const has2FAFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.TwoFactorEnforcement]
|
||||
);
|
||||
if (!has2FAFeature) {
|
||||
parsedBody.data.requireTwoFactor = undefined;
|
||||
parsedBody.data.maxSessionLengthHours = undefined;
|
||||
parsedBody.data.passwordExpiryDays = undefined;
|
||||
}
|
||||
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
if (
|
||||
build == "saas" &&
|
||||
tier != TierId.STANDARD &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest > 30
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You are not allowed to set log retention days greater than 30 with your current subscription"
|
||||
)
|
||||
);
|
||||
// Check session duration policies feature
|
||||
const hasSessionDurationFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.SessionDurationPolicies]
|
||||
);
|
||||
if (!hasSessionDurationFeature) {
|
||||
parsedBody.data.maxSessionLengthHours = undefined;
|
||||
}
|
||||
|
||||
// Check password expiration policies feature
|
||||
const hasPasswordExpirationFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.PasswordExpirationPolicies]
|
||||
);
|
||||
if (!hasPasswordExpirationFeature) {
|
||||
parsedBody.data.passwordExpiryDays = undefined;
|
||||
}
|
||||
if (build == "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
|
||||
// Determine max allowed retention days based on tier
|
||||
let maxRetentionDays: number | null = null;
|
||||
if (!tier) {
|
||||
maxRetentionDays = 3;
|
||||
} else if (tier === "tier1") {
|
||||
maxRetentionDays = 7;
|
||||
} else if (tier === "tier2") {
|
||||
maxRetentionDays = 30;
|
||||
} else if (tier === "tier3") {
|
||||
maxRetentionDays = 90;
|
||||
}
|
||||
// For enterprise tier, no check (maxRetentionDays remains null)
|
||||
|
||||
if (maxRetentionDays !== null) {
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysRequest !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysAccess !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysAction !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedOrg = await db
|
||||
|
||||
@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
|
||||
import config from "@server/lib/config";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
||||
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||
|
||||
const authWithAccessTokenBodySchema = z.strictObject({
|
||||
accessToken: z.string(),
|
||||
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
|
||||
if (postAuthPath) {
|
||||
redirectUrl = redirectUrl + postAuthPath;
|
||||
}
|
||||
|
||||
return response<AuthWithAccessTokenResponse>(res, {
|
||||
data: {
|
||||
session: token,
|
||||
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
redirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
domainId: z.string(),
|
||||
stickySession: z.boolean().optional()
|
||||
stickySession: z.boolean().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -188,7 +189,7 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { name, domainId } = parsedBody.data;
|
||||
const { name, domainId, postAuthPath } = parsedBody.data;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
@@ -255,7 +256,8 @@ async function createHttpResource(
|
||||
http: true,
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
stickySession: stickySession
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
|
||||
whitelist: boolean;
|
||||
skipToIdpId: number | null;
|
||||
orgId: string;
|
||||
postAuthPath: string | null;
|
||||
};
|
||||
|
||||
export async function getResourceAuthInfo(
|
||||
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
|
||||
url,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId
|
||||
orgId: resource.orgId,
|
||||
postAuthPath: resource.postAuthPath ?? null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const updateResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -54,7 +55,8 @@ const updateHttpResourceBodySchema = z
|
||||
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
|
||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional()
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -341,7 +343,7 @@ async function updateHttpResource(
|
||||
headers = null;
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(resource.orgId);
|
||||
const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
|
||||
if (!isLicensed) {
|
||||
updateData.maintenanceModeEnabled = undefined;
|
||||
updateData.maintenanceModeType = undefined;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const createRoleParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -100,7 +101,7 @@ export async function createRole(
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
|
||||
if (!isLicensed) {
|
||||
roleData.requireDeviceApproval = undefined;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const updateRoleParamsSchema = z.strictObject({
|
||||
roleId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -110,7 +111,7 @@ export async function updateRole(
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
|
||||
if (!isLicensed) {
|
||||
updateData.requireDeviceApproval = undefined;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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()
|
||||
@@ -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,
|
||||
|
||||
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,
|
||||
"Site limit exceeded. Please upgrade your plan."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let updatedAddress = null;
|
||||
if (address) {
|
||||
if (!org.subnet) {
|
||||
@@ -256,10 +287,22 @@ 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 == "wireguard" || type == "newt") {
|
||||
if (type == "newt") {
|
||||
[newSite] = await trx
|
||||
.insert(sites)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
type,
|
||||
dockerSocketEnabled: true
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "wireguard") {
|
||||
// we are creating a site with an exit node (tunneled)
|
||||
if (!subnet) {
|
||||
return next(
|
||||
@@ -311,11 +354,9 @@ export async function createSite(
|
||||
exitNodeId,
|
||||
name,
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
subnet,
|
||||
type,
|
||||
dockerSocketEnabled: type == "newt",
|
||||
...(pubKey && type == "wireguard" && { pubKey })
|
||||
pubKey: pubKey || null
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "local") {
|
||||
@@ -402,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.updateCount(
|
||||
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);
|
||||
|
||||
@@ -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.updateCount(
|
||||
site.orgId,
|
||||
FeatureId.SITES,
|
||||
numSites.length
|
||||
);
|
||||
}
|
||||
// Send termination message outside of transaction to prevent blocking
|
||||
if (deletedNewtId) {
|
||||
const payload = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const acceptInviteBodySchema = z.strictObject({
|
||||
token: z.string(),
|
||||
@@ -92,6 +93,38 @@ export async function acceptInvite(
|
||||
);
|
||||
}
|
||||
|
||||
if (build == "saas") {
|
||||
const usage = await usageService.getUsage(
|
||||
existingInvite.orgId,
|
||||
FeatureId.USERS
|
||||
);
|
||||
if (!usage) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"No usage data found for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
const rejectUsers = await usageService.checkLimitSet(
|
||||
existingInvite.orgId,
|
||||
|
||||
FeatureId.USERS,
|
||||
{
|
||||
...usage,
|
||||
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
||||
} // We need to add one to know if we are violating the limit
|
||||
);
|
||||
if (rejectUsers) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Can not accept because this org's user limit is exceeded. Please contact your administrator to upgrade their plan."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let roleId: number;
|
||||
let totalUsers: UserOrg[] | undefined;
|
||||
// get the role to make sure it exists
|
||||
@@ -125,17 +158,21 @@ export async function acceptInvite(
|
||||
.delete(userInvites)
|
||||
.where(eq(userInvites.inviteId, inviteId));
|
||||
|
||||
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
|
||||
|
||||
// Get the total number of users in the org now
|
||||
totalUsers = await db
|
||||
totalUsers = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, existingInvite.orgId));
|
||||
|
||||
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
|
||||
logger.debug(
|
||||
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`
|
||||
);
|
||||
});
|
||||
|
||||
if (totalUsers) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
existingInvite.orgId,
|
||||
FeatureId.USERS,
|
||||
totalUsers.length
|
||||
|
||||
@@ -13,20 +13,16 @@ import { generateId } from "@server/auth/sessions/app";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
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 { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.optional(),
|
||||
email: z.string().email().toLowerCase().optional(),
|
||||
username: z.string().nonempty().toLowerCase(),
|
||||
name: z.string().optional(),
|
||||
type: z.enum(["internal", "oidc"]).optional(),
|
||||
@@ -95,7 +91,7 @@ export async function createOrgUser(
|
||||
}
|
||||
const rejectUsers = await usageService.checkLimitSet(
|
||||
orgId,
|
||||
false,
|
||||
|
||||
FeatureId.USERS,
|
||||
{
|
||||
...usage,
|
||||
@@ -132,8 +128,10 @@ export async function createOrgUser(
|
||||
);
|
||||
} else if (type === "oidc") {
|
||||
if (build === "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
const subscribed = tier === TierId.STANDARD;
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.orgOidc
|
||||
);
|
||||
if (!subscribed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -256,7 +254,7 @@ export async function createOrgUser(
|
||||
});
|
||||
|
||||
if (orgUsers) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
orgUsers.length
|
||||
|
||||
@@ -133,7 +133,6 @@ export async function inviteUser(
|
||||
}
|
||||
const rejectUsers = await usageService.checkLimitSet(
|
||||
orgId,
|
||||
false,
|
||||
FeatureId.USERS,
|
||||
{
|
||||
...usage,
|
||||
|
||||
@@ -140,7 +140,7 @@ export async function removeUserOrg(
|
||||
});
|
||||
|
||||
if (userCount) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
userCount.length
|
||||
|
||||
1
server/setup/.gitignore
vendored
Normal file
1
server/setup/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
migrations.ts
|
||||
@@ -17,6 +17,8 @@ import m9 from "./scriptsPg/1.12.0";
|
||||
import m10 from "./scriptsPg/1.13.0";
|
||||
import m11 from "./scriptsPg/1.14.0";
|
||||
import m12 from "./scriptsPg/1.15.0";
|
||||
import m13 from "./scriptsPg/1.15.3";
|
||||
import m14 from "./scriptsPg/1.15.4";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -34,7 +36,9 @@ const migrations = [
|
||||
{ version: "1.12.0", run: m9 },
|
||||
{ version: "1.13.0", run: m10 },
|
||||
{ version: "1.14.0", run: m11 },
|
||||
{ version: "1.15.0", run: m12 }
|
||||
{ version: "1.15.0", run: m12 },
|
||||
{ version: "1.15.3", run: m13 },
|
||||
{ version: "1.15.4", run: m14 }
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
||||
@@ -35,6 +35,8 @@ import m30 from "./scriptsSqlite/1.12.0";
|
||||
import m31 from "./scriptsSqlite/1.13.0";
|
||||
import m32 from "./scriptsSqlite/1.14.0";
|
||||
import m33 from "./scriptsSqlite/1.15.0";
|
||||
import m34 from "./scriptsSqlite/1.15.3";
|
||||
import m35 from "./scriptsSqlite/1.15.4";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -68,7 +70,9 @@ const migrations = [
|
||||
{ version: "1.12.0", run: m30 },
|
||||
{ version: "1.13.0", run: m31 },
|
||||
{ version: "1.14.0", run: m32 },
|
||||
{ version: "1.15.0", run: m33 }
|
||||
{ version: "1.15.0", run: m33 },
|
||||
{ version: "1.15.3", run: m34 },
|
||||
{ version: "1.15.4", run: m35 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
||||
39
server/setup/scriptsPg/1.15.3.ts
Normal file
39
server/setup/scriptsPg/1.15.3.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { db } from "@server/db/pg/driver";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
||||
const version = "1.15.3";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "limits" ADD COLUMN "override" boolean DEFAULT false;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "subscriptionItems" ADD COLUMN "stripeSubscriptionItemId" varchar(255);`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "subscriptionItems" ADD COLUMN "featureId" varchar(255);`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "subscriptions" ADD COLUMN "version" integer;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "subscriptions" ADD COLUMN "type" varchar(50);`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
await db.execute(sql`ROLLBACK`);
|
||||
console.log("Unable to migrate database");
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
27
server/setup/scriptsPg/1.15.4.ts
Normal file
27
server/setup/scriptsPg/1.15.4.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { db } from "@server/db/pg/driver";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
||||
const version = "1.15.4";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "postAuthPath" text;`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
await db.execute(sql`ROLLBACK`);
|
||||
console.log("Unable to migrate database");
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
29
server/setup/scriptsSqlite/1.15.3.ts
Normal file
29
server/setup/scriptsSqlite/1.15.3.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.15.3";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare(`ALTER TABLE 'limits' ADD 'override' integer DEFAULT false;`).run();
|
||||
db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'featureId' text;`).run();
|
||||
db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'stripeSubscriptionItemId' text;`).run();
|
||||
db.prepare(`ALTER TABLE 'subscriptions' ADD 'version' integer;`).run();
|
||||
db.prepare(`ALTER TABLE 'subscriptions' ADD 'type' text;`).run();
|
||||
})();
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
27
server/setup/scriptsSqlite/1.15.4.ts
Normal file
27
server/setup/scriptsSqlite/1.15.4.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.15.4";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD 'postAuthPath' text;`
|
||||
).run();
|
||||
})();
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
1
server/types/Tiers.ts
Normal file
1
server/types/Tiers.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Tier = "tier1" | "tier2" | "tier3" | "enterprise";
|
||||
Reference in New Issue
Block a user